Skip to content

Commit 956b5b9

Browse files
authored
Update lsp.py
1 parent 433d0ef commit 956b5b9

1 file changed

Lines changed: 123 additions & 93 deletions

File tree

  • python_agent_harness/tools

python_agent_harness/tools/lsp.py

Lines changed: 123 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import contextlib
1111
import json
1212
import os
13+
from collections.abc import Callable
1314
from pathlib import Path
1415
from typing import Any
1516
from urllib.parse import unquote, urlparse
@@ -82,6 +83,9 @@
8283
"required": ["operation", "file_path"],
8384
}
8485

86+
# Operations that require a position (line/character).
87+
_POSITION_OPS = frozenset(o for o in OPERATIONS if o != "workspaceSymbol")
88+
8589

8690
def _uri_to_path(uri: str) -> str:
8791
parsed = urlparse(uri)
@@ -101,6 +105,116 @@ def _jsonable(value: Any) -> Any:
101105
return value
102106

103107

108+
def _resolve_path(raw_path: str, cwd: str) -> str:
109+
if not os.path.isabs(raw_path):
110+
return os.path.realpath(os.path.abspath(os.path.join(cwd, raw_path)))
111+
return os.path.realpath(raw_path)
112+
113+
114+
def _to_lsp_position(lines: list[str], line: int, character: int, encoding: str) -> dict[str, int]:
115+
source_line = lines[line - 1].rstrip("\r\n")
116+
py_index = min(character - 1, len(source_line))
117+
if encoding == "utf-8":
118+
lsp_character = len(source_line[:py_index].encode("utf-8"))
119+
elif encoding == "utf-32":
120+
lsp_character = py_index
121+
else:
122+
lsp_character = len(source_line[:py_index].encode("utf-16-le")) // 2
123+
return {"line": line - 1, "character": lsp_character}
124+
125+
126+
def _format_result(result: Any, operation: str) -> str:
127+
if result is None:
128+
result = []
129+
if isinstance(result, (dict, list)) and not result:
130+
return f"No results found for {operation}"
131+
return json.dumps(_jsonable(result), ensure_ascii=False, indent=2)
132+
133+
134+
# --- Operation handlers -------------------------------------------------
135+
# Each handler receives (client, uri, position, args) and returns the raw
136+
# LSP response (or a str error message for early-exit cases like empty
137+
# call-hierarchy preparation).
138+
139+
140+
def _op_definition(client, uri, position, args) -> Any:
141+
return client.request(
142+
"textDocument/definition",
143+
{"textDocument": {"uri": uri}, "position": position},
144+
)
145+
146+
147+
def _op_references(client, uri, position, args) -> Any:
148+
return client.request(
149+
"textDocument/references",
150+
{
151+
"textDocument": {"uri": uri},
152+
"position": position,
153+
"context": {"includeDeclaration": True},
154+
},
155+
)
156+
157+
158+
def _op_hover(client, uri, position, args) -> Any:
159+
return client.request(
160+
"textDocument/hover", {"textDocument": {"uri": uri}, "position": position}
161+
)
162+
163+
164+
def _op_document_symbol(client, uri, position, args) -> Any:
165+
return client.request("textDocument/documentSymbol", {"textDocument": {"uri": uri}})
166+
167+
168+
def _op_workspace_symbol(client, uri, position, args) -> Any:
169+
return client.request("workspace/symbol", {"query": str(args.get("query", ""))})
170+
171+
172+
def _op_implementation(client, uri, position, args) -> Any:
173+
return client.request(
174+
"textDocument/implementation",
175+
{"textDocument": {"uri": uri}, "position": position},
176+
)
177+
178+
179+
def _op_prepare_call_hierarchy(client, uri, position, args) -> Any:
180+
return client.request(
181+
"textDocument/prepareCallHierarchy",
182+
{"textDocument": {"uri": uri}, "position": position},
183+
)
184+
185+
186+
def _op_call_hierarchy(direction: str) -> Callable[..., Any]:
187+
def handler(client, uri, position, args) -> Any:
188+
prepared = client.request(
189+
"textDocument/prepareCallHierarchy",
190+
{"textDocument": {"uri": uri}, "position": position},
191+
)
192+
if not isinstance(prepared, list) or not prepared:
193+
return "No call hierarchy item found at this position"
194+
item = prepared[0]
195+
method = (
196+
"callHierarchy/incomingCalls"
197+
if direction == "incoming"
198+
else "callHierarchy/outgoingCalls"
199+
)
200+
return client.request(method, {"item": item})
201+
202+
return handler
203+
204+
205+
_DISPATCH: dict[str, Callable[..., Any]] = {
206+
"goToDefinition": _op_definition,
207+
"findReferences": _op_references,
208+
"hover": _op_hover,
209+
"documentSymbol": _op_document_symbol,
210+
"workspaceSymbol": _op_workspace_symbol,
211+
"goToImplementation": _op_implementation,
212+
"prepareCallHierarchy": _op_prepare_call_hierarchy,
213+
"incomingCalls": _op_call_hierarchy("incoming"),
214+
"outgoingCalls": _op_call_hierarchy("outgoing"),
215+
}
216+
217+
104218
class LSP(Tool):
105219
name = "LSP"
106220
description = DESCRIPTION
@@ -115,20 +229,12 @@ def run(self, args: dict, ctx: ToolContext) -> str:
115229
raw_path = str(args.get("file_path", ""))
116230
if not raw_path:
117231
return "Error: file_path must not be empty"
118-
path = (
119-
os.path.realpath(os.path.abspath(os.path.join(ctx.cwd, raw_path)))
120-
if not os.path.isabs(raw_path)
121-
else os.path.realpath(raw_path)
122-
)
232+
path = _resolve_path(raw_path, ctx.cwd)
123233
if not os.path.isfile(path):
124234
return f"Error: File not found: {raw_path}"
125235

126-
# workspaceSymbol is project-wide: it uses file_path only to select
127-
# the workspace/server and ignores line/character, so it skips all
128-
# position validation and coordinate conversion below.
129-
needs_position = operation != "workspaceSymbol"
236+
needs_position = operation in _POSITION_OPS
130237

131-
# Keep the agent-facing contract 1-based, while the LSP protocol is 0-based.
132238
try:
133239
line = int(args.get("line", 1))
134240
character = int(args.get("character", 1))
@@ -137,8 +243,6 @@ def run(self, args: dict, ctx: ToolContext) -> str:
137243
if needs_position and (line < 1 or character < 1):
138244
return "Error: line and character must be >= 1"
139245

140-
# Validate the requested line before spinning up a server so a bad
141-
# position fails fast even when no LSP binary is installed.
142246
try:
143247
text = Path(path).read_text(encoding="utf-8", errors="replace")
144248
except (OSError, UnicodeError) as e:
@@ -152,92 +256,18 @@ def run(self, args: dict, ctx: ToolContext) -> str:
152256
uri = Path(path).as_uri()
153257
client.open_document(uri, text)
154258
try:
155-
# LSP positions are 0-based. Character conversion to the
156-
# negotiated encoding is done here using the source line.
157-
# Skipped entirely for workspaceSymbol, which sends no position.
158259
position: dict[str, int] | None = None
159260
if needs_position:
160-
source_line = lines[line - 1].rstrip("\r\n")
161-
py_index = min(character - 1, len(source_line))
162-
if client.position_encoding == "utf-8":
163-
lsp_character = len(source_line[:py_index].encode("utf-8"))
164-
elif client.position_encoding == "utf-32":
165-
lsp_character = py_index
166-
else:
167-
lsp_character = len(source_line[:py_index].encode("utf-16-le")) // 2
168-
position = {"line": line - 1, "character": lsp_character}
169-
170-
if operation == "goToDefinition":
171-
result = client.request(
172-
"textDocument/definition",
173-
{"textDocument": {"uri": uri}, "position": position},
174-
)
175-
elif operation == "findReferences":
176-
result = client.request(
177-
"textDocument/references",
178-
{
179-
"textDocument": {"uri": uri},
180-
"position": position,
181-
"context": {"includeDeclaration": True},
182-
},
183-
)
184-
elif operation == "hover":
185-
result = client.request(
186-
"textDocument/hover", {"textDocument": {"uri": uri}, "position": position}
187-
)
188-
elif operation == "documentSymbol":
189-
result = client.request(
190-
"textDocument/documentSymbol", {"textDocument": {"uri": uri}}
191-
)
192-
elif operation == "workspaceSymbol":
193-
result = client.request(
194-
"workspace/symbol", {"query": str(args.get("query", ""))}
195-
)
196-
elif operation == "goToImplementation":
197-
result = client.request(
198-
"textDocument/implementation",
199-
{"textDocument": {"uri": uri}, "position": position},
200-
)
201-
elif operation == "prepareCallHierarchy":
202-
result = client.request(
203-
"textDocument/prepareCallHierarchy",
204-
{"textDocument": {"uri": uri}, "position": position},
205-
)
206-
else:
207-
prepared = client.request(
208-
"textDocument/prepareCallHierarchy",
209-
{"textDocument": {"uri": uri}, "position": position},
210-
)
211-
# A spec-compliant server returns a list or null; guard
212-
# against a non-list (truthy but unsubscriptable) reply so
213-
# a misbehaving server cannot raise past the LSPError guard.
214-
if not isinstance(prepared, list) or not prepared:
215-
return "No call hierarchy item found at this position"
216-
item = prepared[0]
217-
method = (
218-
"callHierarchy/incomingCalls"
219-
if operation == "incomingCalls"
220-
else "callHierarchy/outgoingCalls"
221-
)
222-
result = client.request(method, {"item": item})
261+
position = _to_lsp_position(lines, line, character, client.position_encoding)
262+
263+
handler = _DISPATCH[operation]
264+
result = handler(client, uri, position, args)
223265
finally:
224-
# Close-after-use: keeping every inspected file open would grow
225-
# the (long-lived, cached) server's document set unbounded over a
226-
# session. Best-effort — a failed close must not mask a result
227-
# or a raised LSPError.
228266
with contextlib.suppress(Exception):
229267
client.close_document(uri)
230268
except (LSPError, ValueError) as e:
231-
# LSPError: no server / server failure. ValueError: a malformed
232-
# lsp.servers entry surfacing on this lazy get_client call (it is
233-
# also validated eagerly at session start, but a Session built
234-
# outside make_session may reach here first).
235269
return f"Error: {e}"
236270

237-
if result is None:
238-
result = []
239-
if isinstance(result, dict) and not result:
240-
return f"No results found for {operation}"
241-
if isinstance(result, list) and not result:
242-
return f"No results found for {operation}"
243-
return json.dumps(_jsonable(result), ensure_ascii=False, indent=2)
271+
if isinstance(result, str):
272+
return result
273+
return _format_result(result, operation)

0 commit comments

Comments
 (0)