@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.
Files changed (76) hide show
  1. package/dist/bundled/pyright/dist/index.d.ts +2 -0
  2. package/dist/bundled/pyright/dist/index.js +1620 -0
  3. package/dist/bundled/pyright/dist/index.js.map +26 -0
  4. package/dist/bundled/pyright/dist/lsp/connection.d.ts +71 -0
  5. package/dist/bundled/pyright/dist/lsp/document-manager.d.ts +67 -0
  6. package/dist/bundled/pyright/dist/lsp/index.d.ts +3 -0
  7. package/dist/bundled/pyright/dist/lsp/types.d.ts +55 -0
  8. package/dist/bundled/pyright/dist/lsp-client.d.ts +55 -0
  9. package/dist/bundled/pyright/dist/tools/completions.d.ts +18 -0
  10. package/dist/bundled/pyright/dist/tools/definition.d.ts +16 -0
  11. package/dist/bundled/pyright/dist/tools/diagnostics.d.ts +12 -0
  12. package/dist/bundled/pyright/dist/tools/hover.d.ts +16 -0
  13. package/dist/bundled/pyright/dist/tools/references.d.ts +16 -0
  14. package/dist/bundled/pyright/dist/tools/rename.d.ts +18 -0
  15. package/dist/bundled/pyright/dist/tools/search.d.ts +20 -0
  16. package/dist/bundled/pyright/dist/tools/signature-help.d.ts +16 -0
  17. package/dist/bundled/pyright/dist/tools/status.d.ts +14 -0
  18. package/dist/bundled/pyright/dist/tools/symbols.d.ts +17 -0
  19. package/dist/bundled/pyright/dist/tools/update-document.d.ts +14 -0
  20. package/dist/bundled/pyright/dist/utils/position.d.ts +33 -0
  21. package/dist/bundled/pyright/package.json +54 -0
  22. package/dist/bundled/python/README.md +230 -0
  23. package/dist/bundled/python/pyproject.toml +61 -0
  24. package/dist/bundled/python/src/rope_mcp/__init__.py +3 -0
  25. package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  26. package/dist/bundled/python/src/rope_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-312.pyc +0 -0
  28. package/dist/bundled/python/src/rope_mcp/__pycache__/config.cpython-313.pyc +0 -0
  29. package/dist/bundled/python/src/rope_mcp/__pycache__/pyright_client.cpython-313.pyc +0 -0
  30. package/dist/bundled/python/src/rope_mcp/__pycache__/rope_client.cpython-313.pyc +0 -0
  31. package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-312.pyc +0 -0
  32. package/dist/bundled/python/src/rope_mcp/__pycache__/server.cpython-313.pyc +0 -0
  33. package/dist/bundled/python/src/rope_mcp/config.py +408 -0
  34. package/dist/bundled/python/src/rope_mcp/lsp/__init__.py +15 -0
  35. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/client.cpython-313.pyc +0 -0
  37. package/dist/bundled/python/src/rope_mcp/lsp/__pycache__/types.cpython-313.pyc +0 -0
  38. package/dist/bundled/python/src/rope_mcp/lsp/client.py +624 -0
  39. package/dist/bundled/python/src/rope_mcp/lsp/types.py +82 -0
  40. package/dist/bundled/python/src/rope_mcp/pyright_client.py +147 -0
  41. package/dist/bundled/python/src/rope_mcp/rope_client.py +198 -0
  42. package/dist/bundled/python/src/rope_mcp/server.py +963 -0
  43. package/dist/bundled/python/src/rope_mcp/tools/__init__.py +26 -0
  44. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  45. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/change_signature.cpython-313.pyc +0 -0
  46. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/completions.cpython-313.pyc +0 -0
  47. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/definition.cpython-313.pyc +0 -0
  48. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/diagnostics.cpython-313.pyc +0 -0
  49. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/hover.cpython-313.pyc +0 -0
  50. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/move.cpython-313.pyc +0 -0
  51. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/references.cpython-313.pyc +0 -0
  52. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/rename.cpython-313.pyc +0 -0
  53. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/search.cpython-313.pyc +0 -0
  54. package/dist/bundled/python/src/rope_mcp/tools/__pycache__/symbols.cpython-313.pyc +0 -0
  55. package/dist/bundled/python/src/rope_mcp/tools/change_signature.py +184 -0
  56. package/dist/bundled/python/src/rope_mcp/tools/completions.py +84 -0
  57. package/dist/bundled/python/src/rope_mcp/tools/definition.py +51 -0
  58. package/dist/bundled/python/src/rope_mcp/tools/diagnostics.py +18 -0
  59. package/dist/bundled/python/src/rope_mcp/tools/hover.py +49 -0
  60. package/dist/bundled/python/src/rope_mcp/tools/move.py +81 -0
  61. package/dist/bundled/python/src/rope_mcp/tools/references.py +60 -0
  62. package/dist/bundled/python/src/rope_mcp/tools/rename.py +61 -0
  63. package/dist/bundled/python/src/rope_mcp/tools/search.py +128 -0
  64. package/dist/bundled/python/src/rope_mcp/tools/symbols.py +118 -0
  65. package/dist/bundled/python/uv.lock +979 -0
  66. package/dist/bundled/typescript/dist/index.js +29534 -0
  67. package/dist/bundled/typescript/dist/index.js.map +211 -0
  68. package/dist/bundled/typescript/package.json +46 -0
  69. package/dist/bundled/vue/dist/index.d.ts +8 -0
  70. package/dist/bundled/vue/dist/index.js +21111 -0
  71. package/dist/bundled/vue/dist/ts-vue-service.d.ts +67 -0
  72. package/dist/bundled/vue/dist/vue-service.d.ts +144 -0
  73. package/dist/bundled/vue/package.json +45 -0
  74. package/dist/index.js +148 -58
  75. package/dist/index.js.map +4 -4
  76. 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"