@treedy/lsp-mcp 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundled/pyright/dist/index.d.ts +2 -0
- package/dist/bundled/pyright/dist/index.js +1620 -0
- package/dist/bundled/pyright/dist/index.js.map +26 -0
- package/dist/bundled/pyright/dist/lsp/connection.d.ts +71 -0
- package/dist/bundled/pyright/dist/lsp/document-manager.d.ts +67 -0
- package/dist/bundled/pyright/dist/lsp/index.d.ts +3 -0
- package/dist/bundled/pyright/dist/lsp/types.d.ts +55 -0
- package/dist/bundled/pyright/dist/lsp-client.d.ts +55 -0
- package/dist/bundled/pyright/dist/tools/completions.d.ts +18 -0
- package/dist/bundled/pyright/dist/tools/definition.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/diagnostics.d.ts +12 -0
- package/dist/bundled/pyright/dist/tools/hover.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/references.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/rename.d.ts +18 -0
- package/dist/bundled/pyright/dist/tools/search.d.ts +20 -0
- package/dist/bundled/pyright/dist/tools/signature-help.d.ts +16 -0
- package/dist/bundled/pyright/dist/tools/status.d.ts +14 -0
- package/dist/bundled/pyright/dist/tools/symbols.d.ts +17 -0
- package/dist/bundled/pyright/dist/tools/update-document.d.ts +14 -0
- package/dist/bundled/pyright/dist/utils/position.d.ts +33 -0
- package/dist/bundled/pyright/package.json +54 -0
- package/dist/bundled/python/README.md +230 -0
- package/dist/bundled/python/pyproject.toml +61 -0
- package/dist/bundled/python/src/rope_mcp/__init__.py +3 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/pyright_client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/rope_client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-312.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/config.py +408 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__init__.py +15 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/client.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/types.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/lsp/client.py +624 -0
- package/dist/bundled/python/src/rope_mcp/lsp/types.py +82 -0
- package/dist/bundled/python/src/rope_mcp/pyright_client.py +147 -0
- package/dist/bundled/python/src/rope_mcp/rope_client.py +198 -0
- package/dist/bundled/python/src/rope_mcp/server.py +963 -0
- package/dist/bundled/python/src/rope_mcp/tools/__init__.py +26 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/change_signature.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/completions.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/definition.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/diagnostics.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/hover.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/move.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/references.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/rename.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/search.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/__pycache__/symbols.cpython-313.pyc +0 -0
- package/dist/bundled/python/src/rope_mcp/tools/change_signature.py +184 -0
- package/dist/bundled/python/src/rope_mcp/tools/completions.py +84 -0
- package/dist/bundled/python/src/rope_mcp/tools/definition.py +51 -0
- package/dist/bundled/python/src/rope_mcp/tools/diagnostics.py +18 -0
- package/dist/bundled/python/src/rope_mcp/tools/hover.py +49 -0
- package/dist/bundled/python/src/rope_mcp/tools/move.py +81 -0
- package/dist/bundled/python/src/rope_mcp/tools/references.py +60 -0
- package/dist/bundled/python/src/rope_mcp/tools/rename.py +61 -0
- package/dist/bundled/python/src/rope_mcp/tools/search.py +128 -0
- package/dist/bundled/python/src/rope_mcp/tools/symbols.py +118 -0
- package/dist/bundled/python/uv.lock +979 -0
- package/dist/bundled/typescript/dist/index.js +29534 -0
- package/dist/bundled/typescript/dist/index.js.map +211 -0
- package/dist/bundled/typescript/package.json +46 -0
- package/dist/bundled/vue/dist/index.d.ts +8 -0
- package/dist/bundled/vue/dist/index.js +21111 -0
- package/dist/bundled/vue/dist/ts-vue-service.d.ts +67 -0
- package/dist/bundled/vue/dist/vue-service.d.ts +144 -0
- package/dist/bundled/vue/package.json +45 -0
- package/dist/index.js +148 -58
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""LSP client for connecting to Pyright language server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .types import DocumentState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LspClient:
|
|
15
|
+
"""Client for communicating with Pyright language server via JSON-RPC."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, workspace_root: str):
|
|
18
|
+
self.workspace_root = os.path.abspath(workspace_root)
|
|
19
|
+
self._process: Optional[subprocess.Popen] = None
|
|
20
|
+
self._request_id = 0
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._pending_requests: dict[int, threading.Event] = {}
|
|
23
|
+
self._responses: dict[int, Any] = {}
|
|
24
|
+
self._documents: dict[str, DocumentState] = {}
|
|
25
|
+
self._initialized = False
|
|
26
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
27
|
+
self._running = False
|
|
28
|
+
|
|
29
|
+
def _start_server(self) -> None:
|
|
30
|
+
"""Start the Pyright language server process."""
|
|
31
|
+
if self._process is not None:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Try to find pyright-langserver
|
|
35
|
+
# Use DEVNULL for stderr to prevent buffer blocking (like TS version)
|
|
36
|
+
try:
|
|
37
|
+
self._process = subprocess.Popen(
|
|
38
|
+
["pyright-langserver", "--stdio"],
|
|
39
|
+
stdin=subprocess.PIPE,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.DEVNULL,
|
|
42
|
+
cwd=self.workspace_root,
|
|
43
|
+
)
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
# Try npx
|
|
46
|
+
self._process = subprocess.Popen(
|
|
47
|
+
["npx", "pyright-langserver", "--stdio"],
|
|
48
|
+
stdin=subprocess.PIPE,
|
|
49
|
+
stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.DEVNULL,
|
|
51
|
+
cwd=self.workspace_root,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self._running = True
|
|
55
|
+
self._reader_thread = threading.Thread(target=self._read_responses, daemon=True)
|
|
56
|
+
self._reader_thread.start()
|
|
57
|
+
|
|
58
|
+
def _read_responses(self) -> None:
|
|
59
|
+
"""Background thread to read responses from the server."""
|
|
60
|
+
while self._running and self._process and self._process.stdout:
|
|
61
|
+
stdout = self._process.stdout
|
|
62
|
+
try:
|
|
63
|
+
# Read Content-Length header
|
|
64
|
+
header = b""
|
|
65
|
+
while not header.endswith(b"\r\n\r\n"):
|
|
66
|
+
char = stdout.read(1)
|
|
67
|
+
if not char:
|
|
68
|
+
return
|
|
69
|
+
header += char
|
|
70
|
+
|
|
71
|
+
# Parse Content-Length
|
|
72
|
+
content_length = 0
|
|
73
|
+
for line in header.decode().split("\r\n"):
|
|
74
|
+
if line.startswith("Content-Length:"):
|
|
75
|
+
content_length = int(line.split(":")[1].strip())
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
if content_length == 0:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Read content
|
|
82
|
+
content = stdout.read(content_length)
|
|
83
|
+
message = json.loads(content.decode())
|
|
84
|
+
|
|
85
|
+
# Handle response
|
|
86
|
+
if "id" in message and message["id"] in self._pending_requests:
|
|
87
|
+
req_id = message["id"]
|
|
88
|
+
self._responses[req_id] = message
|
|
89
|
+
self._pending_requests[req_id].set()
|
|
90
|
+
|
|
91
|
+
except Exception:
|
|
92
|
+
if self._running:
|
|
93
|
+
continue
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
def _send_message(self, message: dict) -> None:
|
|
97
|
+
"""Send a JSON-RPC message to the server."""
|
|
98
|
+
if not self._process or not self._process.stdin:
|
|
99
|
+
raise RuntimeError("Server not started")
|
|
100
|
+
|
|
101
|
+
content = json.dumps(message)
|
|
102
|
+
header = f"Content-Length: {len(content)}\r\n\r\n"
|
|
103
|
+
self._process.stdin.write(header.encode() + content.encode())
|
|
104
|
+
self._process.stdin.flush()
|
|
105
|
+
|
|
106
|
+
def _send_request(
|
|
107
|
+
self, method: str, params: Any = None, timeout: float = 30.0
|
|
108
|
+
) -> Any:
|
|
109
|
+
"""Send a request and wait for response."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._request_id += 1
|
|
112
|
+
req_id = self._request_id
|
|
113
|
+
|
|
114
|
+
message = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
115
|
+
if params is not None:
|
|
116
|
+
message["params"] = params
|
|
117
|
+
|
|
118
|
+
event = threading.Event()
|
|
119
|
+
self._pending_requests[req_id] = event
|
|
120
|
+
|
|
121
|
+
self._send_message(message)
|
|
122
|
+
|
|
123
|
+
if not event.wait(timeout):
|
|
124
|
+
del self._pending_requests[req_id]
|
|
125
|
+
raise TimeoutError(f"Request {method} timed out")
|
|
126
|
+
|
|
127
|
+
del self._pending_requests[req_id]
|
|
128
|
+
response = self._responses.pop(req_id)
|
|
129
|
+
|
|
130
|
+
if "error" in response:
|
|
131
|
+
raise RuntimeError(f"LSP error: {response['error']}")
|
|
132
|
+
|
|
133
|
+
return response.get("result")
|
|
134
|
+
|
|
135
|
+
def _send_notification(self, method: str, params: Any = None) -> None:
|
|
136
|
+
"""Send a notification (no response expected)."""
|
|
137
|
+
message = {"jsonrpc": "2.0", "method": method}
|
|
138
|
+
if params is not None:
|
|
139
|
+
message["params"] = params
|
|
140
|
+
self._send_message(message)
|
|
141
|
+
|
|
142
|
+
def initialize(self) -> None:
|
|
143
|
+
"""Initialize the LSP connection."""
|
|
144
|
+
if self._initialized:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
self._start_server()
|
|
148
|
+
|
|
149
|
+
# Send initialize request
|
|
150
|
+
# Note: Do NOT declare workspace.workspaceFolders capability
|
|
151
|
+
# pyright-langserver will send workspace/workspaceFolders requests
|
|
152
|
+
# that we don't handle, causing requests to hang
|
|
153
|
+
init_params = {
|
|
154
|
+
"processId": os.getpid(),
|
|
155
|
+
"rootUri": self._path_to_uri(self.workspace_root),
|
|
156
|
+
"capabilities": {
|
|
157
|
+
"textDocument": {
|
|
158
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
159
|
+
"completion": {
|
|
160
|
+
"completionItem": {
|
|
161
|
+
"snippetSupport": True,
|
|
162
|
+
"documentationFormat": ["markdown", "plaintext"],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"signatureHelp": {
|
|
166
|
+
"signatureInformation": {
|
|
167
|
+
"documentationFormat": ["markdown", "plaintext"],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
"definition": {"linkSupport": True},
|
|
171
|
+
"references": {},
|
|
172
|
+
"rename": {"prepareSupport": True},
|
|
173
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
174
|
+
"publishDiagnostics": {},
|
|
175
|
+
},
|
|
176
|
+
# Note: Do NOT include workspace.workspaceFolders here
|
|
177
|
+
},
|
|
178
|
+
"workspaceFolders": [
|
|
179
|
+
{
|
|
180
|
+
"uri": self._path_to_uri(self.workspace_root),
|
|
181
|
+
"name": Path(self.workspace_root).name,
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
self._send_request("initialize", init_params)
|
|
187
|
+
self._send_notification("initialized", {})
|
|
188
|
+
self._initialized = True
|
|
189
|
+
|
|
190
|
+
def shutdown(self) -> None:
|
|
191
|
+
"""Shutdown the LSP connection."""
|
|
192
|
+
if not self._initialized:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
self._running = False
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
self._send_request("shutdown", timeout=5.0)
|
|
199
|
+
self._send_notification("exit")
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
if self._process:
|
|
204
|
+
self._process.terminate()
|
|
205
|
+
self._process.wait(timeout=5)
|
|
206
|
+
self._process = None
|
|
207
|
+
|
|
208
|
+
self._initialized = False
|
|
209
|
+
|
|
210
|
+
def _path_to_uri(self, path: str) -> str:
|
|
211
|
+
"""Convert file path to URI."""
|
|
212
|
+
abs_path = os.path.abspath(path)
|
|
213
|
+
if sys.platform == "win32":
|
|
214
|
+
return f"file:///{abs_path.replace(os.sep, '/')}"
|
|
215
|
+
return f"file://{abs_path}"
|
|
216
|
+
|
|
217
|
+
def _uri_to_path(self, uri: str) -> str:
|
|
218
|
+
"""Convert URI to file path."""
|
|
219
|
+
if uri.startswith("file://"):
|
|
220
|
+
path = uri[7:]
|
|
221
|
+
if sys.platform == "win32" and path.startswith("/"):
|
|
222
|
+
path = path[1:]
|
|
223
|
+
return path
|
|
224
|
+
return uri
|
|
225
|
+
|
|
226
|
+
def open_document(self, file_path: str) -> None:
|
|
227
|
+
"""Open a document in the language server."""
|
|
228
|
+
self.initialize()
|
|
229
|
+
|
|
230
|
+
uri = self._path_to_uri(file_path)
|
|
231
|
+
if uri in self._documents:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
235
|
+
content = f.read()
|
|
236
|
+
|
|
237
|
+
self._documents[uri] = DocumentState(
|
|
238
|
+
uri=uri,
|
|
239
|
+
version=1,
|
|
240
|
+
content=content,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
self._send_notification(
|
|
244
|
+
"textDocument/didOpen",
|
|
245
|
+
{
|
|
246
|
+
"textDocument": {
|
|
247
|
+
"uri": uri,
|
|
248
|
+
"languageId": "python",
|
|
249
|
+
"version": 1,
|
|
250
|
+
"text": content,
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def update_document(self, file_path: str, content: str) -> None:
|
|
256
|
+
"""Update document content (incremental)."""
|
|
257
|
+
self.initialize()
|
|
258
|
+
|
|
259
|
+
uri = self._path_to_uri(file_path)
|
|
260
|
+
if uri not in self._documents:
|
|
261
|
+
self.open_document(file_path)
|
|
262
|
+
# Now update with new content
|
|
263
|
+
uri = self._path_to_uri(file_path)
|
|
264
|
+
|
|
265
|
+
doc = self._documents[uri]
|
|
266
|
+
doc.version += 1
|
|
267
|
+
doc.content = content
|
|
268
|
+
|
|
269
|
+
self._send_notification(
|
|
270
|
+
"textDocument/didChange",
|
|
271
|
+
{
|
|
272
|
+
"textDocument": {"uri": uri, "version": doc.version},
|
|
273
|
+
"contentChanges": [{"text": content}],
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def close_document(self, file_path: str) -> None:
|
|
278
|
+
"""Close a document."""
|
|
279
|
+
uri = self._path_to_uri(file_path)
|
|
280
|
+
if uri not in self._documents:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
self._send_notification(
|
|
284
|
+
"textDocument/didClose",
|
|
285
|
+
{"textDocument": {"uri": uri}},
|
|
286
|
+
)
|
|
287
|
+
del self._documents[uri]
|
|
288
|
+
|
|
289
|
+
def refresh_document(self, file_path: str) -> None:
|
|
290
|
+
"""Refresh a document after external modification.
|
|
291
|
+
|
|
292
|
+
If the document is already open, sends didChange with new content.
|
|
293
|
+
Otherwise, opens it fresh when needed later.
|
|
294
|
+
"""
|
|
295
|
+
if not os.path.exists(file_path):
|
|
296
|
+
# File was deleted, close if open
|
|
297
|
+
uri = self._path_to_uri(file_path)
|
|
298
|
+
if uri in self._documents:
|
|
299
|
+
self.close_document(file_path)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
uri = self._path_to_uri(file_path)
|
|
303
|
+
if uri in self._documents:
|
|
304
|
+
# Document is open - read new content and send didChange
|
|
305
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
306
|
+
new_content = f.read()
|
|
307
|
+
self.update_document(file_path, new_content)
|
|
308
|
+
# If not open, it will be opened fresh when needed
|
|
309
|
+
|
|
310
|
+
def refresh_documents(self, file_paths: list[str]) -> None:
|
|
311
|
+
"""Refresh multiple documents after external modification."""
|
|
312
|
+
for file_path in file_paths:
|
|
313
|
+
self.refresh_document(file_path)
|
|
314
|
+
|
|
315
|
+
def hover(self, file_path: str, line: int, column: int) -> Optional[dict]:
|
|
316
|
+
"""Get hover information at position."""
|
|
317
|
+
self.open_document(file_path)
|
|
318
|
+
uri = self._path_to_uri(file_path)
|
|
319
|
+
|
|
320
|
+
result = self._send_request(
|
|
321
|
+
"textDocument/hover",
|
|
322
|
+
{
|
|
323
|
+
"textDocument": {"uri": uri},
|
|
324
|
+
"position": {"line": line - 1, "character": column - 1},
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if not result:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
contents = result.get("contents", {})
|
|
332
|
+
if isinstance(contents, dict):
|
|
333
|
+
return {"contents": contents.get("value", "")}
|
|
334
|
+
elif isinstance(contents, str):
|
|
335
|
+
return {"contents": contents}
|
|
336
|
+
elif isinstance(contents, list):
|
|
337
|
+
return {
|
|
338
|
+
"contents": "\n".join(
|
|
339
|
+
c.get("value", c) if isinstance(c, dict) else c for c in contents
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def definition(self, file_path: str, line: int, column: int) -> list[dict]:
|
|
345
|
+
"""Get definition locations."""
|
|
346
|
+
self.open_document(file_path)
|
|
347
|
+
uri = self._path_to_uri(file_path)
|
|
348
|
+
|
|
349
|
+
result = self._send_request(
|
|
350
|
+
"textDocument/definition",
|
|
351
|
+
{
|
|
352
|
+
"textDocument": {"uri": uri},
|
|
353
|
+
"position": {"line": line - 1, "character": column - 1},
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if not result:
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
if isinstance(result, dict):
|
|
361
|
+
result = [result]
|
|
362
|
+
|
|
363
|
+
locations = []
|
|
364
|
+
for loc in result:
|
|
365
|
+
locations.append(
|
|
366
|
+
{
|
|
367
|
+
"file": self._uri_to_path(loc["uri"]),
|
|
368
|
+
"line": loc["range"]["start"]["line"] + 1,
|
|
369
|
+
"column": loc["range"]["start"]["character"] + 1,
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
return locations
|
|
373
|
+
|
|
374
|
+
def references(
|
|
375
|
+
self, file_path: str, line: int, column: int, include_declaration: bool = True
|
|
376
|
+
) -> list[dict]:
|
|
377
|
+
"""Find all references."""
|
|
378
|
+
self.open_document(file_path)
|
|
379
|
+
uri = self._path_to_uri(file_path)
|
|
380
|
+
|
|
381
|
+
result = self._send_request(
|
|
382
|
+
"textDocument/references",
|
|
383
|
+
{
|
|
384
|
+
"textDocument": {"uri": uri},
|
|
385
|
+
"position": {"line": line - 1, "character": column - 1},
|
|
386
|
+
"context": {"includeDeclaration": include_declaration},
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not result:
|
|
391
|
+
return []
|
|
392
|
+
|
|
393
|
+
references = []
|
|
394
|
+
for loc in result:
|
|
395
|
+
references.append(
|
|
396
|
+
{
|
|
397
|
+
"file": self._uri_to_path(loc["uri"]),
|
|
398
|
+
"line": loc["range"]["start"]["line"] + 1,
|
|
399
|
+
"column": loc["range"]["start"]["character"] + 1,
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
return references
|
|
403
|
+
|
|
404
|
+
def completions(self, file_path: str, line: int, column: int) -> list[dict]:
|
|
405
|
+
"""Get completion items."""
|
|
406
|
+
self.open_document(file_path)
|
|
407
|
+
uri = self._path_to_uri(file_path)
|
|
408
|
+
|
|
409
|
+
result = self._send_request(
|
|
410
|
+
"textDocument/completion",
|
|
411
|
+
{
|
|
412
|
+
"textDocument": {"uri": uri},
|
|
413
|
+
"position": {"line": line - 1, "character": column - 1},
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if not result:
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
items = result if isinstance(result, list) else result.get("items", [])
|
|
421
|
+
|
|
422
|
+
completions = []
|
|
423
|
+
for item in items:
|
|
424
|
+
completions.append(
|
|
425
|
+
{
|
|
426
|
+
"label": item.get("label", ""),
|
|
427
|
+
"kind": self._completion_kind_to_string(item.get("kind", 1)),
|
|
428
|
+
"detail": item.get("detail", ""),
|
|
429
|
+
"documentation": self._get_documentation(item.get("documentation")),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
return completions
|
|
433
|
+
|
|
434
|
+
def document_symbols(self, file_path: str) -> list[dict]:
|
|
435
|
+
"""Get document symbols."""
|
|
436
|
+
self.open_document(file_path)
|
|
437
|
+
uri = self._path_to_uri(file_path)
|
|
438
|
+
|
|
439
|
+
result = self._send_request(
|
|
440
|
+
"textDocument/documentSymbol",
|
|
441
|
+
{"textDocument": {"uri": uri}},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if not result:
|
|
445
|
+
return []
|
|
446
|
+
|
|
447
|
+
return self._flatten_symbols(result, file_path)
|
|
448
|
+
|
|
449
|
+
def signature_help(self, file_path: str, line: int, column: int) -> Optional[dict]:
|
|
450
|
+
"""Get signature help."""
|
|
451
|
+
self.open_document(file_path)
|
|
452
|
+
uri = self._path_to_uri(file_path)
|
|
453
|
+
|
|
454
|
+
result = self._send_request(
|
|
455
|
+
"textDocument/signatureHelp",
|
|
456
|
+
{
|
|
457
|
+
"textDocument": {"uri": uri},
|
|
458
|
+
"position": {"line": line - 1, "character": column - 1},
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if not result or not result.get("signatures"):
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
signatures = result.get("signatures", [])
|
|
466
|
+
active_signature = result.get("activeSignature", 0)
|
|
467
|
+
|
|
468
|
+
if not signatures:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
sig = (
|
|
472
|
+
signatures[active_signature]
|
|
473
|
+
if active_signature < len(signatures)
|
|
474
|
+
else signatures[0]
|
|
475
|
+
)
|
|
476
|
+
return {
|
|
477
|
+
"label": sig.get("label", ""),
|
|
478
|
+
"documentation": self._get_documentation(sig.get("documentation")),
|
|
479
|
+
"parameters": [
|
|
480
|
+
{
|
|
481
|
+
"label": p.get("label", ""),
|
|
482
|
+
"documentation": self._get_documentation(p.get("documentation")),
|
|
483
|
+
}
|
|
484
|
+
for p in sig.get("parameters", [])
|
|
485
|
+
],
|
|
486
|
+
"active_parameter": result.get("activeParameter", 0),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
def _flatten_symbols(
|
|
490
|
+
self, symbols: list, file_path: str, parent: str = ""
|
|
491
|
+
) -> list[dict]:
|
|
492
|
+
"""Flatten hierarchical symbols."""
|
|
493
|
+
result = []
|
|
494
|
+
for sym in symbols:
|
|
495
|
+
name = sym.get("name", "")
|
|
496
|
+
full_name = f"{parent}.{name}" if parent else name
|
|
497
|
+
result.append(
|
|
498
|
+
{
|
|
499
|
+
"name": name,
|
|
500
|
+
"kind": self._symbol_kind_to_string(sym.get("kind", 1)),
|
|
501
|
+
"line": sym.get("range", {}).get("start", {}).get("line", 0) + 1,
|
|
502
|
+
"column": sym.get("range", {}).get("start", {}).get("character", 0)
|
|
503
|
+
+ 1,
|
|
504
|
+
"file": file_path,
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
# Recurse into children
|
|
508
|
+
if "children" in sym:
|
|
509
|
+
result.extend(
|
|
510
|
+
self._flatten_symbols(sym["children"], file_path, full_name)
|
|
511
|
+
)
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
def _get_documentation(self, doc: Any) -> str:
|
|
515
|
+
"""Extract documentation string."""
|
|
516
|
+
if not doc:
|
|
517
|
+
return ""
|
|
518
|
+
if isinstance(doc, str):
|
|
519
|
+
return doc
|
|
520
|
+
if isinstance(doc, dict):
|
|
521
|
+
return doc.get("value", "")
|
|
522
|
+
return ""
|
|
523
|
+
|
|
524
|
+
def _completion_kind_to_string(self, kind: int) -> str:
|
|
525
|
+
"""Convert LSP CompletionItemKind to string."""
|
|
526
|
+
kinds = {
|
|
527
|
+
1: "Text",
|
|
528
|
+
2: "Method",
|
|
529
|
+
3: "Function",
|
|
530
|
+
4: "Constructor",
|
|
531
|
+
5: "Field",
|
|
532
|
+
6: "Variable",
|
|
533
|
+
7: "Class",
|
|
534
|
+
8: "Interface",
|
|
535
|
+
9: "Module",
|
|
536
|
+
10: "Property",
|
|
537
|
+
11: "Unit",
|
|
538
|
+
12: "Value",
|
|
539
|
+
13: "Enum",
|
|
540
|
+
14: "Keyword",
|
|
541
|
+
15: "Snippet",
|
|
542
|
+
16: "Color",
|
|
543
|
+
17: "File",
|
|
544
|
+
18: "Reference",
|
|
545
|
+
19: "Folder",
|
|
546
|
+
20: "EnumMember",
|
|
547
|
+
21: "Constant",
|
|
548
|
+
22: "Struct",
|
|
549
|
+
23: "Event",
|
|
550
|
+
24: "Operator",
|
|
551
|
+
25: "TypeParameter",
|
|
552
|
+
}
|
|
553
|
+
return kinds.get(kind, "Text")
|
|
554
|
+
|
|
555
|
+
def _symbol_kind_to_string(self, kind: int) -> str:
|
|
556
|
+
"""Convert LSP SymbolKind to string."""
|
|
557
|
+
kinds = {
|
|
558
|
+
1: "File",
|
|
559
|
+
2: "Module",
|
|
560
|
+
3: "Namespace",
|
|
561
|
+
4: "Package",
|
|
562
|
+
5: "Class",
|
|
563
|
+
6: "Method",
|
|
564
|
+
7: "Property",
|
|
565
|
+
8: "Field",
|
|
566
|
+
9: "Constructor",
|
|
567
|
+
10: "Enum",
|
|
568
|
+
11: "Interface",
|
|
569
|
+
12: "Function",
|
|
570
|
+
13: "Variable",
|
|
571
|
+
14: "Constant",
|
|
572
|
+
15: "String",
|
|
573
|
+
16: "Number",
|
|
574
|
+
17: "Boolean",
|
|
575
|
+
18: "Array",
|
|
576
|
+
19: "Object",
|
|
577
|
+
20: "Key",
|
|
578
|
+
21: "Null",
|
|
579
|
+
22: "EnumMember",
|
|
580
|
+
23: "Struct",
|
|
581
|
+
24: "Event",
|
|
582
|
+
25: "Operator",
|
|
583
|
+
26: "TypeParameter",
|
|
584
|
+
}
|
|
585
|
+
return kinds.get(kind, "Variable")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# Global client instances per workspace
|
|
589
|
+
_clients: dict[str, LspClient] = {}
|
|
590
|
+
_lock = threading.Lock()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def get_lsp_client(workspace_root: str) -> LspClient:
|
|
594
|
+
"""Get or create an LSP client for the workspace."""
|
|
595
|
+
workspace_root = os.path.abspath(workspace_root)
|
|
596
|
+
with _lock:
|
|
597
|
+
if workspace_root not in _clients:
|
|
598
|
+
_clients[workspace_root] = LspClient(workspace_root)
|
|
599
|
+
return _clients[workspace_root]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def close_all_clients() -> None:
|
|
603
|
+
"""Close all LSP clients."""
|
|
604
|
+
with _lock:
|
|
605
|
+
for client in _clients.values():
|
|
606
|
+
try:
|
|
607
|
+
client.shutdown()
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
_clients.clear()
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def refresh_lsp_documents(file_paths: list[str]) -> None:
|
|
614
|
+
"""Refresh documents in all LSP clients after external modification.
|
|
615
|
+
|
|
616
|
+
This should be called after Rope refactoring operations that modify
|
|
617
|
+
files on disk, so that Pyright picks up the changes.
|
|
618
|
+
"""
|
|
619
|
+
with _lock:
|
|
620
|
+
for client in _clients.values():
|
|
621
|
+
try:
|
|
622
|
+
client.refresh_documents(file_paths)
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""LSP protocol types."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Position:
|
|
8
|
+
"""Position in a text document (0-based)."""
|
|
9
|
+
|
|
10
|
+
line: int
|
|
11
|
+
character: int
|
|
12
|
+
|
|
13
|
+
def to_dict(self) -> dict:
|
|
14
|
+
return {"line": self.line, "character": self.character}
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, d: dict) -> "Position":
|
|
18
|
+
return cls(line=d["line"], character=d["character"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Range:
|
|
23
|
+
"""Range in a text document."""
|
|
24
|
+
|
|
25
|
+
start: Position
|
|
26
|
+
end: Position
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {"start": self.start.to_dict(), "end": self.end.to_dict()}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, d: dict) -> "Range":
|
|
33
|
+
return cls(
|
|
34
|
+
start=Position.from_dict(d["start"]),
|
|
35
|
+
end=Position.from_dict(d["end"]),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Location:
|
|
41
|
+
"""Location in a document."""
|
|
42
|
+
|
|
43
|
+
uri: str
|
|
44
|
+
range: Range
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, d: dict) -> "Location":
|
|
48
|
+
return cls(uri=d["uri"], range=Range.from_dict(d["range"]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class TextDocumentIdentifier:
|
|
53
|
+
"""Identifies a text document."""
|
|
54
|
+
|
|
55
|
+
uri: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {"uri": self.uri}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TextDocumentPositionParams:
|
|
63
|
+
"""Parameters for text document position requests."""
|
|
64
|
+
|
|
65
|
+
text_document: TextDocumentIdentifier
|
|
66
|
+
position: Position
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"textDocument": self.text_document.to_dict(),
|
|
71
|
+
"position": self.position.to_dict(),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DocumentState:
|
|
77
|
+
"""State of an open document."""
|
|
78
|
+
|
|
79
|
+
uri: str
|
|
80
|
+
version: int
|
|
81
|
+
content: str
|
|
82
|
+
language_id: str = "python"
|