orchestrata_ultimate.tools.lsp_tool
Reference › orchestrata_ultimate › tools › lsp_tool
LSPTool (SPEC-065) gives volks code navigation and diagnostics for Python files. It is a BaseTool named lsp in the search category with priority P0, registered by agents.tool_registry and exported from the package __init__. tests/tools/test_lsp_tool.py covers it.
The module records a deliberate backend choice. No language-server binary is available, so it does not implement a JSON-RPC LSP client. Diagnostics come from PyrightDiagnosticsBackend, which runs the batch pyright command with JSON output. Definition, references, document symbols and hover come from PythonSymbolIndex, an AST index that resolves symbols by name, not by type inference. This is a stated trade-off.
execute takes an action, a file uri and, for navigation, a position. The actions are definition, references, diagnostics, hover and document_symbols. It converts the URI to a path, requires a Python file for non-diagnostic actions, finds the identifier at the position, and searches the paths resolved from the arguments. Data classes Position, Range, LSPLocation, DocumentSymbolInfo, LSPHover, LSPDiagnostic and LSPDiagnostics carry results, and LSPError with LSPErrorCode reports failures. execute never raises: errors become failed ToolResult values. It carries @traces for REQ-065-001 and related requirements.
At a glance
| Source | src/orchestrata_ultimate/tools/lsp_tool.py |
| Lines of code | 892 |
| Classes | 12 |
| Functions | 14 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.tools.base |
| Imported by | orchestrata_ultimate.agents.tool_registry, orchestrata_ultimate.tools |
| Tests that import it | tests/tools/test_lsp_tool.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
_LSP_TOOL_PARAMETERS |
dict[str, Any] |
{'type': 'object', 'properties': {'action': {'type': 'string', 'enum': sorted(_… |
Classes
LSPErrorCode
Line 78
Error codes for LSPTool (spec.md Error Conditions, ERR-00N).
LSPError
Bases: Exception · Line 101
Raised by the LSP pipeline on any LSPErrorCode condition.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(code: str, message: str) -> None |
|
to_result |
to_result(tool_name: str) -> ToolResult |
Render this error as a failed ToolResult for tool_name. |
Position (dataclass)
Decorators: dataclass(slots=True, frozen=True) · Line 129
A zero-based line/character cursor position (spec.md Position).
| Attribute | Type | Default |
|---|---|---|
line |
int |
|
character |
int |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, int] |
Range (dataclass)
Decorators: dataclass(slots=True, frozen=True) · Line 140
A start/end Position span (spec.md Range).
| Attribute | Type | Default |
|---|---|---|
start |
Position |
|
end |
Position |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
LSPLocation (dataclass)
Decorators: traces(spec='065', story='US-065-003', reqs=['REQ-065-001', 'REQ-065-002']), dataclass(slots=True, frozen=True) · Line 152
A URI + range reference (spec.md LSPLocation).
| Attribute | Type | Default |
|---|---|---|
uri |
str |
|
range |
Range |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
DocumentSymbolInfo (dataclass)
Decorators: traces(spec='065', story='US-065-003', reqs=['REQ-065-003']), dataclass(slots=True, frozen=True) · Line 165
One entry of textDocument/documentSymbol (FR-005 subset).
| Attribute | Type | Default |
|---|---|---|
name |
str |
|
kind |
str |
|
range |
Range |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
LSPHover (dataclass)
Decorators: traces(spec='065', story='US-065-004', reqs=['REQ-065-004']), dataclass(slots=True, frozen=True) · Line 179
Hover content + optional target range (spec.md LSPHover).
| Attribute | Type | Default |
|---|---|---|
contents |
str |
|
range |
Range | None |
None |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
LSPDiagnostic (dataclass)
Decorators: traces(spec='065', story='US-065-002', reqs=['REQ-065-006']), dataclass(slots=True, frozen=True) · Line 195
One diagnostic item (spec.md LSPDiagnostics.diagnostics[]).
| Attribute | Type | Default |
|---|---|---|
range |
Range |
|
message |
str |
|
severity |
int |
1 |
code |
str | None |
None |
source |
str | None |
None |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
LSPDiagnostics (dataclass)
Decorators: traces(spec='065', story='US-065-002', reqs=['REQ-065-006']), dataclass(slots=True) · Line 220
Diagnostics for one document (spec.md LSPDiagnostics).
| Attribute | Type | Default |
|---|---|---|
uri |
str |
|
diagnostics |
list[LSPDiagnostic] |
field(default_factory=list) |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
PythonSymbolIndex
Decorators: traces(spec='065', story='US-065-003', reqs=['REQ-065-001', 'REQ-065-002', 'REQ-065-003', 'REQ-065-004']) · Line 438
Token-precise, name-resolution navigation over Python source (MVP backend).
| Method | Signature | Summary |
|---|---|---|
identifier_at |
identifier_at(source: str, position: Position) -> str | None |
Return the identifier token spanning position in source. |
find_definition |
find_definition(name: str, search_paths: list[Path]) -> LSPLocation | None |
First ClassDef/FunctionDef/module-level assignment named name. |
find_references |
find_references(name: str, search_paths: list[Path]) -> list[LSPLocation] |
Every Name/Attribute/def-site use of name across search_paths. |
document_symbols |
document_symbols(path: Path) -> list[DocumentSymbolInfo] |
List every top-level class/function and method in path (FR-005). |
hover |
hover(name: str, search_paths: list[Path]) -> LSPHover | None |
Signature + docstring for the ClassDef/FunctionDef named name. |
PyrightDiagnosticsBackend
Decorators: traces(spec='065', story='US-065-002', reqs=['REQ-065-006', 'REQ-065-007']) · Line 601
Wraps the real pyright CLI (a declared dev dependency) for diagnostics.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(command: str = 'pyright') -> None |
|
available |
available() -> bool |
Whether the pyright executable can be found on PATH (FR-SEC-001). |
diagnostics |
diagnostics(path: Path, timeout: float = 30.0) -> LSPDiagnostics |
Run pyright against path and return its diagnostics (US-002). |
LSPTool
Bases: BaseTool · Decorators: traces(spec='065', story='US-065-003', reqs=['REQ-065-016']) · Line 774
LSPTool — code navigation + diagnostics for Python targets (SPEC-065).
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(symbol_index: PythonSymbolIndex | None = None, diagnostics_backend: PyrightDiagnosticsBackend | None = None) -> None |
|
metadata property |
metadata() -> ToolMetadata |
|
execute |
async execute(arguments: dict[str, Any]) -> ToolResult |
Dispatch one of definition/references/diagnostics/hover/ document_symbols from raw tool-call arguments. |
Functions
| Function | Signature | Summary |
|---|---|---|
_uri_to_path |
_uri_to_path(uri: str) -> Path |
Resolve a file:// URI or bare path to a :class:Path. |
_path_to_uri |
_path_to_uri(path: Path) -> str |
Render a filesystem path as a file:// URI. |
_ensure_python_file traces |
_ensure_python_file(path: Path) -> None |
Raise UNSUPPORTED_LANGUAGE (ERR-011) for any non-.py target. |
_read_source traces |
_read_source(path: Path) -> str |
Read path as text, raising FILE_READ_ERROR (ERR-012) on failure. |
_iter_python_files traces |
_iter_python_files(root: Path) -> list[Path] |
Every .py file under root (or [root] if it's a file). |
_func_signature traces |
_func_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str |
Render a Python function/method signature from its AST node. |
_class_signature traces |
_class_signature(node: ast.ClassDef) -> str |
Render a Python class signature (name + base list) from its AST node. |
_name_token_span traces |
_name_token_span(line_text: str, name: str, keyword: str) -> tuple[int, int] | None |
Column span of name immediately after keyword on line_text. |
_def_location traces |
_def_location(path: Path, lines: list[str], node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, name: str) -> LSPLocation |
Build an :class:LSPLocation for a ClassDef/FunctionDef name token. |
_name_node_location traces |
_name_node_location(path: Path, node: ast.Name) -> LSPLocation |
Build an :class:LSPLocation for an ast.Name reference node. |
_attribute_node_location traces |
_attribute_node_location(path: Path, node: ast.Attribute) -> LSPLocation |
Build an :class:LSPLocation for the .attr token of an Attribute node. |
_symbol_of traces |
_symbol_of(path: Path, node: ast.AST, kind: str, name: str) -> DocumentSymbolInfo |
Build a :class:DocumentSymbolInfo for a class/function/method AST node. |
_parse_position |
_parse_position(raw: Any) -> Position |
Parse a {"line": int, "character": int} mapping into a :class:Position. |
_resolve_search_paths traces |
_resolve_search_paths(arguments: dict[str, Any], default: Path) -> list[Path] |
Resolve arguments['search_paths'] into a flat, deduplicated file list. |
Source
"""LSPTool — code navigation for Python targets (SPEC-065).
Backend reality check (see the brief this module was built from) before any
line of implementation:
- ``pyright`` IS a declared dev dependency (``pyproject.toml``, "pyright>=1.1.410")
and IS runnable in this repo — ``scripts/pyright_ratchet.py`` already shells
out to it as ``pyright <source> --outputjson`` for the pyright-error-count
gate. That binary is a **batch type-checker**: one-shot ``pyright <path>
--outputjson`` produces a diagnostics report. It is NOT the same binary as
``pyright-langserver``, which speaks LSP JSON-RPC over stdio — that
long-lived, stateful server binary is not declared/installed here.
- Neither ``python-lsp-server`` (pylsp) nor ``jedi`` is declared in
``pyproject.toml``. No dependency was added to get either (per the brief:
"Do NOT add dependencies").
**Chosen backend (the documented fork):** a JSON-RPC LSP client (initialize/
textDocument-definition/references) was rejected for this MVP because there
is no LSP *server* binary available on PATH to talk to (only the batch
``pyright`` CLI, and no ``jedi``/``pylsp``). Building the client without a
compliant server to test it against would be untested vaporware. Instead:
- **Diagnostics** (US-002) uses the REAL ``pyright`` CLI already wired
elsewhere in this repo (:class:`PyrightDiagnosticsBackend`), batch mode
(``--outputjson``), not push/debounced streaming — a faithful but
synchronous subset of FR-004.
- **Definition / references / document-symbols / hover** (US-003, US-004,
and the navigation half of FR-005/FR-006) are implemented over this
repo's existing AST-based code-intelligence engine style (the same
token-precise approach used by ``scripts/serialize_codebase_ast.py``'s
``find_references``/``list_symbols`` and
``orchestrata_ultimate.tools.ast_query``), reimplemented locally in
:class:`PythonSymbolIndex` so this module has no dependency on the
9000+ line toolkit script's internals. It resolves symbols by NAME
match within the AST (not full type inference — no ``jedi``/pyright
language-server available to resolve by type), which is a stated,
intentional trade-off for the MVP.
- **Completion (FR-003), code actions (FR-007), document formatting
(FR-008), rename (FR-009), signature help, and persistent multi-server
lifecycle/session state (FR-001/FR-002)** are NOT implemented — they
need a live, stateful LSP session (edits, incremental sync, applying
workspace edits) that a one-shot AST read or a batch pyright invocation
cannot provide. The hardening path is a real stdio JSON-RPC client
against ``pyright-langserver`` (or ``jedi``/``python-lsp-server`` once
declared as a dependency), gated at runtime by
``shutil.which(...)`` exactly like :class:`PyrightDiagnosticsBackend`
below already gates ``pyright``.
Only Python targets are supported (SPEC-065's other listed servers —
typescript-language-server / rust-analyzer / gopls / clangd — are neither
installed nor declared here; requesting a non-``.py`` file raises
``LSPErrorCode.UNSUPPORTED_LANGUAGE``, ERR-011).
"""
from __future__ import annotations
import ast
import json
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.tools.base import (
BaseTool,
ToolCategory,
ToolMetadata,
ToolPriority,
ToolResult,
)
# ── error codes ──────────────────────────────────────────────────────────
class LSPErrorCode:
"""Error codes for LSPTool (spec.md Error Conditions, ERR-00N).
Only the subset reachable by this MVP's stateless, one-shot backend is
represented — spec.md conditions that require a persistent LSP server
process (crash/restart, memory limits, protocol-level JSON-RPC errors,
initialization timeout) do not apply here and are documented as
deferred in ``specs/065-lsp-tool/checklists/requirements.md``.
"""
SERVER_NOT_FOUND = "server_not_found" # ERR-001
REQUEST_TIMEOUT = "request_timeout" # ERR-004
INVALID_URI = "invalid_uri" # ERR-005
SERVER_ERROR = "server_error" # ERR-007
UNSUPPORTED_LANGUAGE = "unsupported_language" # ERR-011
FILE_READ_ERROR = "file_read_error" # ERR-012
# Not spec-numbered ERR-00N conditions — framework-level input
# validation / lookup-miss codes needed by any tool dispatch, same
# convention as ToolSearchTool's non-ERR codes (e.g. TOOL_NOT_FOUND).
INVALID_REQUEST = "invalid_request"
SYMBOL_NOT_FOUND = "symbol_not_found"
class LSPError(Exception):
"""Raised by the LSP pipeline on any ``LSPErrorCode`` condition.
Mirrors ``ToolSearchError`` (tools/search.py): carries a code + message
and knows how to render itself as a failed :class:`ToolResult` so
:meth:`LSPTool.execute` never has to translate error codes itself.
"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@traces(spec="065", story="US-065-003", reqs=["REQ-065-016"])
def to_result(self, tool_name: str) -> ToolResult:
"""Render this error as a failed ``ToolResult`` for ``tool_name``."""
return ToolResult(
tool_name=tool_name,
success=False,
output="",
error=f"{self.code}: {self.message}",
)
# ── data models (spec.md Data Models, trimmed to the implemented subset) ──
@dataclass(slots=True, frozen=True)
class Position:
"""A zero-based line/character cursor position (spec.md ``Position``)."""
line: int
character: int
def to_dict(self) -> dict[str, int]:
return {"line": self.line, "character": self.character}
@dataclass(slots=True, frozen=True)
class Range:
"""A start/end ``Position`` span (spec.md ``Range``)."""
start: Position
end: Position
def to_dict(self) -> dict[str, Any]:
return {"start": self.start.to_dict(), "end": self.end.to_dict()}
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001", "REQ-065-002"])
@dataclass(slots=True, frozen=True)
class LSPLocation:
"""A URI + range reference (spec.md ``LSPLocation``)."""
uri: str
range: Range
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001", "REQ-065-002"])
def to_dict(self) -> dict[str, Any]:
return {"uri": self.uri, "range": self.range.to_dict()}
@traces(spec="065", story="US-065-003", reqs=["REQ-065-003"])
@dataclass(slots=True, frozen=True)
class DocumentSymbolInfo:
"""One entry of ``textDocument/documentSymbol`` (FR-005 subset)."""
name: str
kind: str
range: Range
@traces(spec="065", story="US-065-003", reqs=["REQ-065-003"])
def to_dict(self) -> dict[str, Any]:
return {"name": self.name, "kind": self.kind, "range": self.range.to_dict()}
@traces(spec="065", story="US-065-004", reqs=["REQ-065-004"])
@dataclass(slots=True, frozen=True)
class LSPHover:
"""Hover content + optional target range (spec.md ``LSPHover``)."""
contents: str
range: Range | None = None
@traces(spec="065", story="US-065-004", reqs=["REQ-065-004"])
def to_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {"contents": self.contents}
if self.range is not None:
payload["range"] = self.range.to_dict()
return payload
@traces(spec="065", story="US-065-002", reqs=["REQ-065-006"])
@dataclass(slots=True, frozen=True)
class LSPDiagnostic:
"""One diagnostic item (spec.md ``LSPDiagnostics.diagnostics[]``)."""
range: Range
message: str
severity: int = 1
code: str | None = None
source: str | None = None
@traces(spec="065", story="US-065-002", reqs=["REQ-065-006"])
def to_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"range": self.range.to_dict(),
"message": self.message,
"severity": self.severity,
}
if self.code is not None:
payload["code"] = self.code
if self.source is not None:
payload["source"] = self.source
return payload
@traces(spec="065", story="US-065-002", reqs=["REQ-065-006"])
@dataclass(slots=True)
class LSPDiagnostics:
"""Diagnostics for one document (spec.md ``LSPDiagnostics``)."""
uri: str
diagnostics: list[LSPDiagnostic] = field(default_factory=list)
@traces(spec="065", story="US-065-002", reqs=["REQ-065-006"])
def to_dict(self) -> dict[str, Any]:
return {
"uri": self.uri,
"diagnostics": [d.to_dict() for d in self.diagnostics],
}
#: spec.md "1=Error, 2=Warning, 3=Info, 4=Hint" vs. pyright's own string
#: severities (``--outputjson``'s ``generalDiagnostics[].severity``).
_PYRIGHT_SEVERITY_MAP = {
"error": 1,
"warning": 2,
"information": 3,
"hint": 4,
}
_VALID_ACTIONS = frozenset(
{"definition", "references", "diagnostics", "hover", "document_symbols"}
)
# ── URI / path helpers ─────────────────────────────────────────────────────
def _uri_to_path(uri: str) -> Path:
"""Resolve a ``file://`` URI or bare path to a :class:`Path`.
Raises:
LSPError: ``INVALID_URI`` (ERR-005) on an empty or non-``file``
scheme URI.
"""
if not uri or not uri.strip():
raise LSPError(LSPErrorCode.INVALID_URI, "uri must not be blank")
if uri.startswith("file://"):
raw = uri[len("file://") :]
elif "://" in uri:
raise LSPError(LSPErrorCode.INVALID_URI, f"unsupported URI scheme in {uri!r}")
else:
raw = uri
if not raw:
raise LSPError(LSPErrorCode.INVALID_URI, f"malformed uri: {uri!r}")
return Path(raw)
def _path_to_uri(path: Path) -> str:
"""Render a filesystem path as a ``file://`` URI."""
return f"file://{path}"
@traces(spec="065", reqs=["REQ-065-012"])
def _ensure_python_file(path: Path) -> None:
"""Raise ``UNSUPPORTED_LANGUAGE`` (ERR-011) for any non-``.py`` target."""
if path.suffix != ".py":
raise LSPError(
LSPErrorCode.UNSUPPORTED_LANGUAGE,
f"only Python (.py) targets are supported; got {path.suffix or '<none>'!r}",
)
@traces(spec="065", reqs=["REQ-065-013"])
def _read_source(path: Path) -> str:
"""Read ``path`` as text, raising ``FILE_READ_ERROR`` (ERR-012) on failure."""
try:
return path.read_text()
except OSError as exc:
raise LSPError(
LSPErrorCode.FILE_READ_ERROR, f"cannot read {path}: {exc}"
) from exc
@traces(spec="065", story="US-065-003", reqs=["REQ-065-002"])
def _iter_python_files(root: Path) -> list[Path]:
"""Every ``.py`` file under ``root`` (or ``[root]`` if it's a file)."""
if root.is_file():
return [root]
if not root.is_dir():
return []
return sorted(
p
for p in root.rglob("*.py")
if "__pycache__" not in p.parts
and not any(part.startswith(".") for part in p.relative_to(root).parts)
)
# ── signature helpers (self-contained; no dependency on scripts/*) ────────
@traces(spec="065", story="US-065-004", reqs=["REQ-065-004"])
def _func_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
"""Render a Python function/method signature from its AST node."""
try:
args = ast.unparse(node.args)
except ValueError, TypeError:
args = "..."
ret = ""
if node.returns is not None:
try:
ret = f" -> {ast.unparse(node.returns)}"
except ValueError, TypeError:
ret = ""
prefix = "async def " if isinstance(node, ast.AsyncFunctionDef) else "def "
return f"{prefix}{node.name}({args}){ret}"
@traces(spec="065", story="US-065-004", reqs=["REQ-065-004"])
def _class_signature(node: ast.ClassDef) -> str:
"""Render a Python class signature (name + base list) from its AST node."""
bases = []
for base in node.bases:
try:
bases.append(ast.unparse(base))
except ValueError, TypeError:
continue
inner = f"({', '.join(bases)})" if bases else ""
return f"class {node.name}{inner}"
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001", "REQ-065-002"])
def _name_token_span(line_text: str, name: str, keyword: str) -> tuple[int, int] | None:
"""Column span of ``name`` immediately after ``keyword`` on ``line_text``."""
match = re.search(rf"\b{keyword}\s+({re.escape(name)})\b", line_text)
if not match:
return None
return match.start(1), match.end(1)
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001", "REQ-065-002"])
def _def_location(
path: Path,
lines: list[str],
node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
name: str,
) -> LSPLocation:
"""Build an :class:`LSPLocation` for a ``ClassDef``/``FunctionDef`` name token.
Falls back to the node's own ``col_offset`` (pointing at the ``class``/
``def`` keyword rather than the name) if the name can't be found via
regex on its source line — e.g. unusual formatting.
"""
lineno = node.lineno - 1
keyword = "class" if isinstance(node, ast.ClassDef) else "def"
line_text = lines[lineno] if 0 <= lineno < len(lines) else ""
span = _name_token_span(line_text, name, keyword)
if span is None:
start_col = node.col_offset
end_col = start_col + len(name)
else:
start_col, end_col = span
return LSPLocation(
uri=_path_to_uri(path),
range=Range(
start=Position(line=lineno, character=start_col),
end=Position(line=lineno, character=end_col),
),
)
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001", "REQ-065-002"])
def _name_node_location(path: Path, node: ast.Name) -> LSPLocation:
"""Build an :class:`LSPLocation` for an ``ast.Name`` reference node."""
return LSPLocation(
uri=_path_to_uri(path),
range=Range(
start=Position(line=node.lineno - 1, character=node.col_offset),
end=Position(
line=(node.end_lineno or node.lineno) - 1,
character=(
node.end_col_offset
if node.end_col_offset is not None
else node.col_offset + len(node.id)
),
),
),
)
@traces(spec="065", story="US-065-003", reqs=["REQ-065-002"])
def _attribute_node_location(path: Path, node: ast.Attribute) -> LSPLocation:
"""Build an :class:`LSPLocation` for the ``.attr`` token of an Attribute node.
``ast.Attribute.end_col_offset`` marks the end of the whole ``a.b.c``
expression, which — for the common single-line case — is exactly the end
of the trailing attribute name; the attribute's own start column is
derived by subtracting its length. Multi-line attribute chains are a
known imprecision of this heuristic (documented MVP trade-off).
"""
end_line = (node.end_lineno or node.lineno) - 1
end_col = (
node.end_col_offset
if node.end_col_offset is not None
else node.col_offset + len(node.attr)
)
start_col = max(0, end_col - len(node.attr))
return LSPLocation(
uri=_path_to_uri(path),
range=Range(
start=Position(line=end_line, character=start_col),
end=Position(line=end_line, character=end_col),
),
)
# ── AST-based navigation backend (definition / references / symbols / hover) ──
@traces(
spec="065",
story="US-065-003",
reqs=["REQ-065-001", "REQ-065-002", "REQ-065-003", "REQ-065-004"],
)
class PythonSymbolIndex:
"""Token-precise, name-resolution navigation over Python source (MVP backend).
See the module docstring for the full backend-choice rationale: this is
NOT a type-resolving language server — it walks the target file(s)' AST
and matches identifiers by NAME, the same approach as this repo's
existing ``scripts/serialize_codebase_ast.py`` navigation helpers,
reimplemented locally so this tool has no dependency on that script.
"""
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001"])
def identifier_at(self, source: str, position: Position) -> str | None:
"""Return the identifier token spanning ``position`` in ``source``.
Returns ``None`` when the position is out of range or does not fall
on a Python identifier (``[A-Za-z_][A-Za-z0-9_]*``).
"""
lines = source.splitlines()
if not (0 <= position.line < len(lines)):
return None
line_text = lines[position.line]
col = position.character
if not (0 <= col <= len(line_text)):
return None
for match in re.finditer(r"[A-Za-z_][A-Za-z0-9_]*", line_text):
if match.start() <= col <= match.end():
return match.group(0)
return None
@traces(spec="065", story="US-065-003", reqs=["REQ-065-001"])
def find_definition(
self, name: str, search_paths: list[Path]
) -> LSPLocation | None:
"""First ``ClassDef``/``FunctionDef``/module-level assignment named ``name``.
Searches ``search_paths`` in order and returns the first match; the
target must be a Python file (see :func:`_ensure_python_file`).
"""
for path in search_paths:
try:
source = _read_source(path)
tree = ast.parse(source, filename=str(path))
except LSPError, SyntaxError:
continue
lines = source.splitlines()
for node in ast.walk(tree):
if (
isinstance(
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
)
and node.name == name
):
return _def_location(path, lines, node, name)
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == name:
return _name_node_location(path, target)
return None
@traces(spec="065", story="US-065-003", reqs=["REQ-065-002"])
def find_references(self, name: str, search_paths: list[Path]) -> list[LSPLocation]:
"""Every ``Name``/``Attribute``/def-site use of ``name`` across ``search_paths``."""
locations: list[LSPLocation] = []
for path in search_paths:
try:
source = _read_source(path)
tree = ast.parse(source, filename=str(path))
except LSPError, SyntaxError:
continue
lines = source.splitlines()
for node in ast.walk(tree):
if isinstance(node, ast.Name) and node.id == name:
locations.append(_name_node_location(path, node))
elif isinstance(node, ast.Attribute) and node.attr == name:
locations.append(_attribute_node_location(path, node))
elif (
isinstance(
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
)
and node.name == name
):
locations.append(_def_location(path, lines, node, name))
return locations
@traces(spec="065", story="US-065-004", reqs=["REQ-065-003"])
def document_symbols(self, path: Path) -> list[DocumentSymbolInfo]:
"""List every top-level class/function and method in ``path`` (FR-005)."""
source = _read_source(path)
tree = ast.parse(source, filename=str(path))
symbols: list[DocumentSymbolInfo] = []
def _visit(node: ast.AST, class_name: str | None) -> None:
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.ClassDef):
symbols.append(_symbol_of(path, child, "class", child.name))
_visit(child, child.name)
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
qual = f"{class_name}.{child.name}" if class_name else child.name
kind = "method" if class_name else "function"
symbols.append(_symbol_of(path, child, kind, qual))
_visit(child, class_name)
else:
_visit(child, class_name)
_visit(tree, None)
return symbols
@traces(spec="065", story="US-065-004", reqs=["REQ-065-004"])
def hover(self, name: str, search_paths: list[Path]) -> LSPHover | None:
"""Signature + docstring for the ``ClassDef``/``FunctionDef`` named ``name``.
A lightweight subset of spec.md's hover content — no live type
inference (no jedi/pyright-langserver session), so only what the AST
itself carries: the declared signature and its docstring.
"""
for path in search_paths:
try:
source = _read_source(path)
tree = ast.parse(source, filename=str(path))
except LSPError, SyntaxError:
continue
lines = source.splitlines()
for node in ast.walk(tree):
if (
isinstance(
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
)
and node.name == name
):
if isinstance(node, ast.ClassDef):
signature = _class_signature(node)
else:
signature = _func_signature(node)
doc = ast.get_docstring(node) or ""
contents = f"```python\n{signature}\n```"
if doc:
contents += f"\n\n{doc}"
location = _def_location(path, lines, node, name)
return LSPHover(contents=contents, range=location.range)
return None
@traces(spec="065", story="US-065-003", reqs=["REQ-065-003"])
def _symbol_of(path: Path, node: ast.AST, kind: str, name: str) -> DocumentSymbolInfo:
"""Build a :class:`DocumentSymbolInfo` for a class/function/method AST node."""
lineno = getattr(node, "lineno", 1) - 1
end_lineno = getattr(node, "end_lineno", None) or (lineno + 1)
return DocumentSymbolInfo(
name=name,
kind=kind,
range=Range(
start=Position(line=lineno, character=getattr(node, "col_offset", 0)),
end=Position(
line=end_lineno - 1, character=getattr(node, "end_col_offset", 0) or 0
),
),
)
# ── diagnostics backend (real pyright CLI, batch mode) ─────────────────────
@traces(spec="065", story="US-065-002", reqs=["REQ-065-006", "REQ-065-007"])
class PyrightDiagnosticsBackend:
"""Wraps the real ``pyright`` CLI (a declared dev dependency) for diagnostics.
Runs ``pyright <path> --outputjson`` as a one-shot subprocess — exactly
the invocation ``scripts/pyright_ratchet.py`` already uses for its gate —
NOT a persistent ``pyright-langserver --stdio`` JSON-RPC session. See the
module docstring for the full backend-choice rationale.
"""
def __init__(self, command: str = "pyright") -> None:
self._command = command
@traces(spec="065", reqs=["REQ-065-014"])
def available(self) -> bool:
"""Whether the ``pyright`` executable can be found on ``PATH`` (FR-SEC-001)."""
return shutil.which(self._command) is not None
@traces(
spec="065",
story="US-065-002",
reqs=["REQ-065-006", "REQ-065-008", "REQ-065-009", "REQ-065-011"],
)
def diagnostics(self, path: Path, timeout: float = 30.0) -> LSPDiagnostics:
"""Run pyright against ``path`` and return its diagnostics (US-002).
Raises:
LSPError: ``SERVER_NOT_FOUND`` (ERR-001) if pyright isn't on
PATH, ``REQUEST_TIMEOUT`` (ERR-004) if the subprocess exceeds
``timeout``, or ``SERVER_ERROR`` (ERR-007) if its stdout
isn't parseable JSON.
"""
if not self.available():
raise LSPError(
LSPErrorCode.SERVER_NOT_FOUND,
f"{self._command!r} not found on PATH; install the 'pyright' "
"dev dependency (uv sync --dev)",
)
if not path.exists():
raise LSPError(LSPErrorCode.FILE_READ_ERROR, f"file not found: {path}")
try:
proc = subprocess.run(
[self._command, str(path), "--outputjson"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise LSPError(
LSPErrorCode.REQUEST_TIMEOUT,
f"pyright timed out after {timeout}s on {path}",
) from exc
try:
report = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise LSPError(
LSPErrorCode.SERVER_ERROR,
f"could not parse pyright --outputjson output: {exc}",
) from exc
items = []
for entry in report.get("generalDiagnostics", []):
rng = entry.get("range") or {}
start = rng.get("start") or {}
end = rng.get("end") or {}
items.append(
LSPDiagnostic(
range=Range(
start=Position(
line=start.get("line", 0),
character=start.get("character", 0),
),
end=Position(
line=end.get("line", 0),
character=end.get("character", 0),
),
),
message=entry.get("message", ""),
severity=_PYRIGHT_SEVERITY_MAP.get(entry.get("severity"), 1),
code=str(entry["rule"]) if entry.get("rule") else None,
source="pyright",
)
)
return LSPDiagnostics(uri=_path_to_uri(path), diagnostics=items)
# ── argument parsing ────────────────────────────────────────────────────
def _parse_position(raw: Any) -> Position:
"""Parse a ``{"line": int, "character": int}`` mapping into a :class:`Position`."""
if not isinstance(raw, dict):
raise LSPError(
LSPErrorCode.INVALID_REQUEST, "arguments['position'] is required"
)
try:
return Position(line=int(raw["line"]), character=int(raw["character"]))
except (KeyError, TypeError, ValueError) as exc:
raise LSPError(
LSPErrorCode.INVALID_REQUEST,
f"malformed position (need integer 'line'/'character'): {exc}",
) from exc
@traces(spec="065", story="US-065-003", reqs=["REQ-065-002"])
def _resolve_search_paths(arguments: dict[str, Any], default: Path) -> list[Path]:
"""Resolve ``arguments['search_paths']`` into a flat, deduplicated file list.
Defaults to ``[default]`` (the target document only) when omitted —
references/definition then only search within the single document,
matching a minimal single-file navigation request.
"""
raw_paths = arguments.get("search_paths")
if not raw_paths:
return [default]
if not isinstance(raw_paths, list):
raise LSPError(
LSPErrorCode.INVALID_REQUEST, "arguments['search_paths'] must be a list"
)
seen: list[Path] = []
for raw in raw_paths:
root = _uri_to_path(str(raw)) if isinstance(raw, str) else None
if root is None:
continue
for candidate in _iter_python_files(root):
if candidate not in seen:
seen.append(candidate)
return seen or [default]
# ── the tool itself ─────────────────────────────────────────────────────
_LSP_TOOL_PARAMETERS: dict[str, Any] = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": sorted(_VALID_ACTIONS),
"description": "The navigation operation to perform.",
},
"uri": {
"type": "string",
"description": "file:// URI or filesystem path to the target Python file.",
},
"position": {
"type": "object",
"properties": {
"line": {"type": "integer", "minimum": 0},
"character": {"type": "integer", "minimum": 0},
},
"required": ["line", "character"],
"description": (
"Zero-based cursor position; required for definition/"
"references/hover, ignored for diagnostics/document_symbols."
),
},
"search_paths": {
"type": "array",
"items": {"type": "string"},
"description": (
"Additional files/directories to search for definition/"
"references; defaults to the target file only."
),
},
},
"required": ["action", "uri"],
}
@traces(
spec="065",
story="US-065-003",
reqs=["REQ-065-016"],
)
class LSPTool(BaseTool):
"""LSPTool — code navigation + diagnostics for Python targets (SPEC-065).
Composes a :class:`PythonSymbolIndex` (definition/references/document
symbols/hover — the AST-based MVP navigation backend) and a
:class:`PyrightDiagnosticsBackend` (real ``pyright`` CLI diagnostics).
See the module docstring for the full backend-choice rationale and the
explicit list of spec.md capabilities NOT implemented in this MVP.
"""
def __init__(
self,
symbol_index: PythonSymbolIndex | None = None,
diagnostics_backend: PyrightDiagnosticsBackend | None = None,
) -> None:
self._symbols = symbol_index or PythonSymbolIndex()
self._diagnostics = diagnostics_backend or PyrightDiagnosticsBackend()
self._metadata = ToolMetadata(
name="lsp",
description=(
"Code navigation (go-to-definition, find-references, "
"document symbols, hover) and pyright diagnostics for "
"Python files."
),
category=ToolCategory.SEARCH,
parameters=_LSP_TOOL_PARAMETERS,
priority=ToolPriority.P0,
tags=("lsp", "navigation", "diagnostics", "python"),
)
@property
def metadata(self) -> ToolMetadata:
return self._metadata
@traces(
spec="065",
story="US-065-003",
reqs=[
"REQ-065-001",
"REQ-065-002",
"REQ-065-003",
"REQ-065-004",
"REQ-065-006",
"REQ-065-010",
"REQ-065-012",
"REQ-065-013",
],
)
async def execute(self, arguments: dict[str, Any]) -> ToolResult:
"""Dispatch one of ``definition``/``references``/``diagnostics``/``hover``/
``document_symbols`` from raw tool-call ``arguments``.
Never raises: any :class:`LSPError` (or unforeseen exception)
becomes a failed :class:`ToolResult` instead.
"""
try:
action = arguments.get("action")
if action not in _VALID_ACTIONS:
raise LSPError(
LSPErrorCode.INVALID_REQUEST,
f"unknown action {action!r}; valid: {sorted(_VALID_ACTIONS)}",
)
uri = arguments.get("uri")
if not isinstance(uri, str) or not uri.strip():
raise LSPError(LSPErrorCode.INVALID_URI, "arguments['uri'] is required")
path = _uri_to_path(uri)
if action == "diagnostics":
result = self._diagnostics.diagnostics(path)
return ToolResult(
tool_name=self.metadata.name,
success=True,
output=json.dumps(result.to_dict()),
)
_ensure_python_file(path)
if action == "document_symbols":
symbols = self._symbols.document_symbols(path)
return ToolResult(
tool_name=self.metadata.name,
success=True,
output=json.dumps([s.to_dict() for s in symbols]),
)
position = _parse_position(arguments.get("position"))
source = _read_source(path)
name = self._symbols.identifier_at(source, position)
if name is None:
raise LSPError(
LSPErrorCode.SYMBOL_NOT_FOUND,
f"no identifier at line {position.line}, character {position.character}",
)
search_paths = _resolve_search_paths(arguments, path)
if action == "definition":
location = self._symbols.find_definition(name, search_paths)
output = json.dumps(location.to_dict() if location else None)
return ToolResult(
tool_name=self.metadata.name, success=True, output=output
)
if action == "references":
locations = self._symbols.find_references(name, search_paths)
output = json.dumps([loc.to_dict() for loc in locations])
return ToolResult(
tool_name=self.metadata.name, success=True, output=output
)
# action == "hover"
hover = self._symbols.hover(name, search_paths)
output = json.dumps(hover.to_dict() if hover else None)
return ToolResult(tool_name=self.metadata.name, success=True, output=output)
except LSPError as exc:
return exc.to_result(self.metadata.name)
except Exception as exc: # noqa: BLE001 - never propagate; report as ToolResult
return ToolResult(
tool_name=self.metadata.name, success=False, output="", error=str(exc)
)