1010import contextlib
1111import json
1212import os
13+ from collections .abc import Callable
1314from pathlib import Path
1415from typing import Any
1516from urllib .parse import unquote , urlparse
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
8690def _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+
104218class 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