@treedy/lsp-mcp 0.1.7 → 0.1.9

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 (54) 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/config.py +444 -0
  26. package/dist/bundled/python/src/rope_mcp/lsp/__init__.py +15 -0
  27. package/dist/bundled/python/src/rope_mcp/lsp/client.py +863 -0
  28. package/dist/bundled/python/src/rope_mcp/lsp/types.py +83 -0
  29. package/dist/bundled/python/src/rope_mcp/pyright_client.py +147 -0
  30. package/dist/bundled/python/src/rope_mcp/rope_client.py +198 -0
  31. package/dist/bundled/python/src/rope_mcp/server.py +1217 -0
  32. package/dist/bundled/python/src/rope_mcp/tools/__init__.py +24 -0
  33. package/dist/bundled/python/src/rope_mcp/tools/change_signature.py +184 -0
  34. package/dist/bundled/python/src/rope_mcp/tools/completions.py +84 -0
  35. package/dist/bundled/python/src/rope_mcp/tools/definition.py +51 -0
  36. package/dist/bundled/python/src/rope_mcp/tools/diagnostics.py +18 -0
  37. package/dist/bundled/python/src/rope_mcp/tools/hover.py +49 -0
  38. package/dist/bundled/python/src/rope_mcp/tools/move.py +81 -0
  39. package/dist/bundled/python/src/rope_mcp/tools/references.py +60 -0
  40. package/dist/bundled/python/src/rope_mcp/tools/rename.py +61 -0
  41. package/dist/bundled/python/src/rope_mcp/tools/search.py +128 -0
  42. package/dist/bundled/python/src/rope_mcp/tools/symbols.py +118 -0
  43. package/dist/bundled/python/uv.lock +979 -0
  44. package/dist/bundled/typescript/dist/index.js +29772 -0
  45. package/dist/bundled/typescript/dist/index.js.map +211 -0
  46. package/dist/bundled/typescript/package.json +46 -0
  47. package/dist/bundled/vue/dist/index.d.ts +8 -0
  48. package/dist/bundled/vue/dist/index.js +21176 -0
  49. package/dist/bundled/vue/dist/ts-vue-service.d.ts +67 -0
  50. package/dist/bundled/vue/dist/vue-service.d.ts +160 -0
  51. package/dist/bundled/vue/package.json +45 -0
  52. package/dist/index.js +695 -352
  53. package/dist/index.js.map +6 -6
  54. package/package.json +1 -1
@@ -0,0 +1,863 @@
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:
87
+ if "method" in message:
88
+ # Server Request
89
+ self._handle_server_request(message)
90
+ elif message["id"] in self._pending_requests:
91
+ # Server Response to our request
92
+ req_id = message["id"]
93
+ self._responses[req_id] = message
94
+ self._pending_requests[req_id].set()
95
+ elif "method" in message:
96
+ # Server Notification
97
+ self._handle_server_notification(message)
98
+
99
+ except Exception:
100
+ if self._running:
101
+ continue
102
+ break
103
+
104
+ def _handle_server_notification(self, message: dict) -> None:
105
+ """Handle a notification from the server."""
106
+ method = message.get("method")
107
+ params = message.get("params", {})
108
+
109
+ if method == "textDocument/publishDiagnostics":
110
+ uri = params.get("uri")
111
+ diagnostics = params.get("diagnostics", [])
112
+
113
+ # Normalize URI to match our internal map
114
+ # Pyright might send "file:///path" while we stored "file:///path"
115
+ # But we need to check if it's in our _documents
116
+ if uri in self._documents:
117
+ self._documents[uri].diagnostics = diagnostics
118
+ # Signal update if someone is waiting
119
+ if hasattr(self._documents[uri], "update_event"):
120
+ self._documents[uri].update_event.set()
121
+ else:
122
+ # Try decoding uri if keys don't match exactly
123
+ path = self._uri_to_path(uri)
124
+ uri_key = self._path_to_uri(path)
125
+ if uri_key in self._documents:
126
+ self._documents[uri_key].diagnostics = diagnostics
127
+ if hasattr(self._documents[uri_key], "update_event"):
128
+ self._documents[uri_key].update_event.set()
129
+
130
+ def get_diagnostics(self, file_path: str, timeout: float = 3.0) -> dict:
131
+ """Get diagnostics for a file."""
132
+ # Force re-open to trigger fresh diagnostics
133
+ # This ensures we get the latest state including any config changes
134
+ if self._path_to_uri(file_path) in self._documents:
135
+ self.close_document(file_path)
136
+
137
+ # Create event before opening
138
+ uri = self._path_to_uri(file_path)
139
+
140
+ # We need to access _documents but it's created in open_document
141
+ # So we can't attach event yet.
142
+
143
+ self.open_document(file_path)
144
+ doc = self._documents.get(uri)
145
+ if not doc:
146
+ return {"error": "Failed to open document"}
147
+
148
+ doc.update_event = threading.Event()
149
+
150
+ # Wait for diagnostics
151
+ doc.update_event.wait(timeout)
152
+
153
+ diags = doc.diagnostics or []
154
+
155
+ formatted = []
156
+ for d in diags:
157
+ formatted.append({
158
+ "file": file_path,
159
+ "line": d["range"]["start"]["line"] + 1,
160
+ "column": d["range"]["start"]["character"] + 1,
161
+ "end_line": d["range"]["end"]["line"] + 1,
162
+ "end_column": d["range"]["end"]["character"] + 1,
163
+ "severity": self._severity_to_string(d.get("severity", 1)),
164
+ "message": d["message"],
165
+ "code": d.get("code")
166
+ })
167
+
168
+ return {
169
+ "diagnostics": formatted,
170
+ "summary": {
171
+ "files_analyzed": 1,
172
+ "errors": len([d for d in formatted if d["severity"] == "error"]),
173
+ "warnings": len([d for d in formatted if d["severity"] == "warning"]),
174
+ "informations": len([d for d in formatted if d["severity"] == "information"]),
175
+ },
176
+ "backend": "pyright-lsp"
177
+ }
178
+
179
+ def _severity_to_string(self, severity: int) -> str:
180
+ mapping = {1: "error", 2: "warning", 3: "information", 4: "hint"}
181
+ return mapping.get(severity, "error")
182
+
183
+ def _handle_server_request(self, message: dict) -> None:
184
+ """Handle a request from the server."""
185
+ method = message.get("method")
186
+ req_id = message.get("id")
187
+
188
+ if method == "workspace/configuration":
189
+ # Pyright asking for config
190
+ items = message.get("params", {}).get("items", [])
191
+ result = []
192
+ analysis_config = {
193
+ "typeCheckingMode": "basic",
194
+ "reportUnusedImport": "warning",
195
+ "reportUnusedVariable": "warning",
196
+ "autoImportCompletions": True,
197
+ "autoSearchPaths": True,
198
+ "useLibraryCodeForTypes": True,
199
+ "diagnosticMode": "workspace"
200
+ }
201
+
202
+ for item in items:
203
+ section = item.get("section")
204
+ if section == "python.analysis":
205
+ result.append(analysis_config)
206
+ elif section == "python":
207
+ result.append({"analysis": analysis_config})
208
+ else:
209
+ # Return analysis config for everything to be safe
210
+ result.append(analysis_config)
211
+
212
+ self._send_response(req_id, result)
213
+
214
+ elif method == "client/registerCapability":
215
+ # Just acknowledge
216
+ self._send_response(req_id, None)
217
+
218
+ def _send_response(self, req_id: Any, result: Any) -> None:
219
+ """Send a JSON-RPC response."""
220
+ if not self._process or not self._process.stdin:
221
+ return
222
+
223
+ message = {"jsonrpc": "2.0", "id": req_id, "result": result}
224
+ content = json.dumps(message)
225
+ header = f"Content-Length: {len(content)}\r\n\r\n"
226
+
227
+ try:
228
+ with self._lock: # Protect stdin write
229
+ self._process.stdin.write(header.encode() + content.encode())
230
+ self._process.stdin.flush()
231
+ except Exception:
232
+ pass
233
+
234
+ def _send_message(self, message: dict) -> None:
235
+ """Send a JSON-RPC message to the server."""
236
+ if not self._process or not self._process.stdin:
237
+ raise RuntimeError("Server not started")
238
+
239
+ content = json.dumps(message)
240
+ header = f"Content-Length: {len(content)}\r\n\r\n"
241
+ self._process.stdin.write(header.encode() + content.encode())
242
+ self._process.stdin.flush()
243
+
244
+ def _send_request(
245
+ self, method: str, params: Any = None, timeout: float = 30.0
246
+ ) -> Any:
247
+ """Send a request and wait for response."""
248
+ with self._lock:
249
+ self._request_id += 1
250
+ req_id = self._request_id
251
+
252
+ message = {"jsonrpc": "2.0", "id": req_id, "method": method}
253
+ if params is not None:
254
+ message["params"] = params
255
+
256
+ event = threading.Event()
257
+ self._pending_requests[req_id] = event
258
+
259
+ self._send_message(message)
260
+
261
+ if not event.wait(timeout):
262
+ del self._pending_requests[req_id]
263
+ raise TimeoutError(f"Request {method} timed out")
264
+
265
+ del self._pending_requests[req_id]
266
+ response = self._responses.pop(req_id)
267
+
268
+ if "error" in response:
269
+ raise RuntimeError(f"LSP error: {response['error']}")
270
+
271
+ return response.get("result")
272
+
273
+ def _send_notification(self, method: str, params: Any = None) -> None:
274
+ """Send a notification (no response expected)."""
275
+ message = {"jsonrpc": "2.0", "method": method}
276
+ if params is not None:
277
+ message["params"] = params
278
+ self._send_message(message)
279
+
280
+ def initialize(self) -> None:
281
+ """Initialize the LSP connection."""
282
+ if self._initialized:
283
+ return
284
+
285
+ self._start_server()
286
+
287
+ # Send initialize request
288
+ # Note: Do NOT declare workspace.workspaceFolders capability
289
+ # pyright-langserver will send workspace/workspaceFolders requests
290
+ # that we don't handle, causing requests to hang
291
+ init_params = {
292
+ "processId": os.getpid(),
293
+ "rootUri": self._path_to_uri(self.workspace_root),
294
+ "capabilities": {
295
+ "textDocument": {
296
+ "hover": {"contentFormat": ["markdown", "plaintext"]},
297
+ "completion": {
298
+ "completionItem": {
299
+ "snippetSupport": True,
300
+ "documentationFormat": ["markdown", "plaintext"],
301
+ },
302
+ },
303
+ "signatureHelp": {
304
+ "signatureInformation": {
305
+ "documentationFormat": ["markdown", "plaintext"],
306
+ },
307
+ },
308
+ "definition": {"linkSupport": True},
309
+ "references": {},
310
+ "rename": {"prepareSupport": True},
311
+ "codeAction": {
312
+ "codeActionLiteralSupport": {
313
+ "codeActionKind": {
314
+ "valueSet": [
315
+ "quickfix",
316
+ "refactor",
317
+ "refactor.extract",
318
+ "refactor.inline",
319
+ "refactor.rewrite",
320
+ "source",
321
+ "source.organizeImports",
322
+ ]
323
+ }
324
+ }
325
+ },
326
+ "inlayHint": {"dynamicRegistration": False},
327
+ "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
328
+ "publishDiagnostics": {},
329
+ },
330
+ # Note: Do NOT include workspace.workspaceFolders here
331
+ },
332
+ "workspaceFolders": [
333
+ {
334
+ "uri": self._path_to_uri(self.workspace_root),
335
+ "name": Path(self.workspace_root).name,
336
+ }
337
+ ],
338
+ "initializationOptions": {
339
+ "python": {
340
+ "analysis": {
341
+ # Default to basic mode but enable useful hints
342
+ "typeCheckingMode": "basic",
343
+ "reportUnusedImport": "warning",
344
+ "reportUnusedVariable": "warning",
345
+ "autoImportCompletions": True,
346
+ "autoSearchPaths": True,
347
+ "useLibraryCodeForTypes": True
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ self._send_request("initialize", init_params)
354
+ self._send_notification("initialized", {})
355
+ self._initialized = True
356
+
357
+ def shutdown(self) -> None:
358
+ """Shutdown the LSP connection."""
359
+ if not self._initialized:
360
+ return
361
+
362
+ self._running = False
363
+
364
+ try:
365
+ self._send_request("shutdown", timeout=5.0)
366
+ self._send_notification("exit")
367
+ except Exception:
368
+ pass
369
+
370
+ if self._process:
371
+ self._process.terminate()
372
+ self._process.wait(timeout=5)
373
+ self._process = None
374
+
375
+ self._initialized = False
376
+
377
+ def _path_to_uri(self, path: str) -> str:
378
+ """Convert file path to URI."""
379
+ abs_path = os.path.abspath(path)
380
+ if sys.platform == "win32":
381
+ return f"file:///{abs_path.replace(os.sep, '/')}"
382
+ return f"file://{abs_path}"
383
+
384
+ def _uri_to_path(self, uri: str) -> str:
385
+ """Convert URI to file path."""
386
+ if uri.startswith("file://"):
387
+ path = uri[7:]
388
+ if sys.platform == "win32" and path.startswith("/"):
389
+ path = path[1:]
390
+ return path
391
+ return uri
392
+
393
+ def open_document(self, file_path: str) -> None:
394
+ """Open a document in the language server."""
395
+ self.initialize()
396
+
397
+ uri = self._path_to_uri(file_path)
398
+ if uri in self._documents:
399
+ return
400
+
401
+ with open(file_path, "r", encoding="utf-8") as f:
402
+ content = f.read()
403
+
404
+ self._documents[uri] = DocumentState(
405
+ uri=uri,
406
+ version=1,
407
+ content=content,
408
+ )
409
+
410
+ self._send_notification(
411
+ "textDocument/didOpen",
412
+ {
413
+ "textDocument": {
414
+ "uri": uri,
415
+ "languageId": "python",
416
+ "version": 1,
417
+ "text": content,
418
+ }
419
+ },
420
+ )
421
+
422
+ def update_document(self, file_path: str, content: str) -> None:
423
+ """Update document content (incremental)."""
424
+ self.initialize()
425
+
426
+ uri = self._path_to_uri(file_path)
427
+ if uri not in self._documents:
428
+ self.open_document(file_path)
429
+ # Now update with new content
430
+ uri = self._path_to_uri(file_path)
431
+
432
+ doc = self._documents[uri]
433
+ doc.version += 1
434
+ doc.content = content
435
+
436
+ self._send_notification(
437
+ "textDocument/didChange",
438
+ {
439
+ "textDocument": {"uri": uri, "version": doc.version},
440
+ "contentChanges": [{"text": content}],
441
+ },
442
+ )
443
+
444
+ def close_document(self, file_path: str) -> None:
445
+ """Close a document."""
446
+ uri = self._path_to_uri(file_path)
447
+ if uri not in self._documents:
448
+ return
449
+
450
+ self._send_notification(
451
+ "textDocument/didClose",
452
+ {"textDocument": {"uri": uri}},
453
+ )
454
+ del self._documents[uri]
455
+
456
+ def refresh_document(self, file_path: str) -> None:
457
+ """Refresh a document after external modification.
458
+
459
+ If the document is already open, sends didChange with new content.
460
+ Otherwise, opens it fresh when needed later.
461
+ """
462
+ if not os.path.exists(file_path):
463
+ # File was deleted, close if open
464
+ uri = self._path_to_uri(file_path)
465
+ if uri in self._documents:
466
+ self.close_document(file_path)
467
+ return
468
+
469
+ uri = self._path_to_uri(file_path)
470
+ if uri in self._documents:
471
+ # Document is open - read new content and send didChange
472
+ with open(file_path, "r", encoding="utf-8") as f:
473
+ new_content = f.read()
474
+ self.update_document(file_path, new_content)
475
+ # If not open, it will be opened fresh when needed
476
+
477
+ def refresh_documents(self, file_paths: list[str]) -> None:
478
+ """Refresh multiple documents after external modification."""
479
+ for file_path in file_paths:
480
+ self.refresh_document(file_path)
481
+
482
+ def hover(self, file_path: str, line: int, column: int) -> Optional[dict]:
483
+ """Get hover information at position."""
484
+ self.open_document(file_path)
485
+ uri = self._path_to_uri(file_path)
486
+
487
+ result = self._send_request(
488
+ "textDocument/hover",
489
+ {
490
+ "textDocument": {"uri": uri},
491
+ "position": {"line": line - 1, "character": column - 1},
492
+ },
493
+ )
494
+
495
+ if not result:
496
+ return None
497
+
498
+ contents = result.get("contents", {})
499
+ if isinstance(contents, dict):
500
+ return {"contents": contents.get("value", "")}
501
+ elif isinstance(contents, str):
502
+ return {"contents": contents}
503
+ elif isinstance(contents, list):
504
+ return {
505
+ "contents": "\n".join(
506
+ c.get("value", c) if isinstance(c, dict) else c for c in contents
507
+ )
508
+ }
509
+ return None
510
+
511
+ def definition(self, file_path: str, line: int, column: int) -> list[dict]:
512
+ """Get definition locations."""
513
+ self.open_document(file_path)
514
+ uri = self._path_to_uri(file_path)
515
+
516
+ result = self._send_request(
517
+ "textDocument/definition",
518
+ {
519
+ "textDocument": {"uri": uri},
520
+ "position": {"line": line - 1, "character": column - 1},
521
+ },
522
+ )
523
+
524
+ if not result:
525
+ return []
526
+
527
+ if isinstance(result, dict):
528
+ result = [result]
529
+
530
+ locations = []
531
+ for loc in result:
532
+ locations.append(
533
+ {
534
+ "file": self._uri_to_path(loc["uri"]),
535
+ "line": loc["range"]["start"]["line"] + 1,
536
+ "column": loc["range"]["start"]["character"] + 1,
537
+ }
538
+ )
539
+ return locations
540
+
541
+ def references(
542
+ self, file_path: str, line: int, column: int, include_declaration: bool = True
543
+ ) -> list[dict]:
544
+ """Find all references."""
545
+ self.open_document(file_path)
546
+ uri = self._path_to_uri(file_path)
547
+
548
+ result = self._send_request(
549
+ "textDocument/references",
550
+ {
551
+ "textDocument": {"uri": uri},
552
+ "position": {"line": line - 1, "character": column - 1},
553
+ "context": {"includeDeclaration": include_declaration},
554
+ },
555
+ )
556
+
557
+ if not result:
558
+ return []
559
+
560
+ references = []
561
+ for loc in result:
562
+ references.append(
563
+ {
564
+ "file": self._uri_to_path(loc["uri"]),
565
+ "line": loc["range"]["start"]["line"] + 1,
566
+ "column": loc["range"]["start"]["character"] + 1,
567
+ }
568
+ )
569
+ return references
570
+
571
+ def completions(self, file_path: str, line: int, column: int) -> list[dict]:
572
+ """Get completion items."""
573
+ self.open_document(file_path)
574
+ uri = self._path_to_uri(file_path)
575
+
576
+ result = self._send_request(
577
+ "textDocument/completion",
578
+ {
579
+ "textDocument": {"uri": uri},
580
+ "position": {"line": line - 1, "character": column - 1},
581
+ },
582
+ )
583
+
584
+ if not result:
585
+ return []
586
+
587
+ items = result if isinstance(result, list) else result.get("items", [])
588
+
589
+ completions = []
590
+ for item in items:
591
+ completions.append(
592
+ {
593
+ "label": item.get("label", ""),
594
+ "kind": self._completion_kind_to_string(item.get("kind", 1)),
595
+ "detail": item.get("detail", ""),
596
+ "documentation": self._get_documentation(item.get("documentation")),
597
+ }
598
+ )
599
+ return completions
600
+
601
+ def inlay_hint(
602
+ self,
603
+ file_path: str,
604
+ start_line: int,
605
+ start_col: int,
606
+ end_line: int,
607
+ end_col: int,
608
+ ) -> list[dict]:
609
+ """Get inlay hints for a range."""
610
+ self.open_document(file_path)
611
+ uri = self._path_to_uri(file_path)
612
+
613
+ params = {
614
+ "textDocument": {"uri": uri},
615
+ "range": {
616
+ "start": {"line": start_line - 1, "character": start_col - 1},
617
+ "end": {"line": end_line - 1, "character": end_col - 1},
618
+ },
619
+ }
620
+
621
+ result = self._send_request("textDocument/inlayHint", params)
622
+
623
+ if not result:
624
+ return []
625
+
626
+ return result
627
+
628
+ def code_action(
629
+ self,
630
+ file_path: str,
631
+ start_line: int,
632
+ start_col: int,
633
+ end_line: int,
634
+ end_col: int,
635
+ diagnostics: list = None,
636
+ ) -> list[dict]:
637
+ """Get available code actions."""
638
+ self.open_document(file_path)
639
+ uri = self._path_to_uri(file_path)
640
+
641
+ params = {
642
+ "textDocument": {"uri": uri},
643
+ "range": {
644
+ "start": {"line": start_line - 1, "character": start_col - 1},
645
+ "end": {"line": end_line - 1, "character": end_col - 1},
646
+ },
647
+ "context": {"diagnostics": diagnostics or []},
648
+ }
649
+
650
+ result = self._send_request("textDocument/codeAction", params)
651
+
652
+ if not result:
653
+ return []
654
+
655
+ return result
656
+
657
+ def rename(self, file_path: str, line: int, column: int, new_name: str) -> Optional[dict]:
658
+ """Rename symbol at position."""
659
+ self.open_document(file_path)
660
+ uri = self._path_to_uri(file_path)
661
+
662
+ result = self._send_request(
663
+ "textDocument/rename",
664
+ {
665
+ "textDocument": {"uri": uri},
666
+ "position": {"line": line - 1, "character": column - 1},
667
+ "newName": new_name,
668
+ },
669
+ )
670
+
671
+ return result
672
+
673
+ def document_symbols(self, file_path: str) -> list[dict]:
674
+ """Get document symbols."""
675
+ self.open_document(file_path)
676
+ uri = self._path_to_uri(file_path)
677
+
678
+ result = self._send_request(
679
+ "textDocument/documentSymbol",
680
+ {"textDocument": {"uri": uri}},
681
+ )
682
+
683
+ if not result:
684
+ return []
685
+
686
+ return self._flatten_symbols(result, file_path)
687
+
688
+ def signature_help(self, file_path: str, line: int, column: int) -> Optional[dict]:
689
+ """Get signature help."""
690
+ self.open_document(file_path)
691
+ uri = self._path_to_uri(file_path)
692
+
693
+ result = self._send_request(
694
+ "textDocument/signatureHelp",
695
+ {
696
+ "textDocument": {"uri": uri},
697
+ "position": {"line": line - 1, "character": column - 1},
698
+ },
699
+ )
700
+
701
+ if not result or not result.get("signatures"):
702
+ return None
703
+
704
+ signatures = result.get("signatures", [])
705
+ active_signature = result.get("activeSignature", 0)
706
+
707
+ if not signatures:
708
+ return None
709
+
710
+ sig = (
711
+ signatures[active_signature]
712
+ if active_signature < len(signatures)
713
+ else signatures[0]
714
+ )
715
+ return {
716
+ "label": sig.get("label", ""),
717
+ "documentation": self._get_documentation(sig.get("documentation")),
718
+ "parameters": [
719
+ {
720
+ "label": p.get("label", ""),
721
+ "documentation": self._get_documentation(p.get("documentation")),
722
+ }
723
+ for p in sig.get("parameters", [])
724
+ ],
725
+ "active_parameter": result.get("activeParameter", 0),
726
+ }
727
+
728
+ def _flatten_symbols(
729
+ self, symbols: list, file_path: str, parent: str = ""
730
+ ) -> list[dict]:
731
+ """Flatten hierarchical symbols."""
732
+ result = []
733
+ for sym in symbols:
734
+ name = sym.get("name", "")
735
+ full_name = f"{parent}.{name}" if parent else name
736
+ result.append(
737
+ {
738
+ "name": name,
739
+ "kind": self._symbol_kind_to_string(sym.get("kind", 1)),
740
+ "line": sym.get("range", {}).get("start", {}).get("line", 0) + 1,
741
+ "column": sym.get("range", {}).get("start", {}).get("character", 0)
742
+ + 1,
743
+ "file": file_path,
744
+ }
745
+ )
746
+ # Recurse into children
747
+ if "children" in sym:
748
+ result.extend(
749
+ self._flatten_symbols(sym["children"], file_path, full_name)
750
+ )
751
+ return result
752
+
753
+ def _get_documentation(self, doc: Any) -> str:
754
+ """Extract documentation string."""
755
+ if not doc:
756
+ return ""
757
+ if isinstance(doc, str):
758
+ return doc
759
+ if isinstance(doc, dict):
760
+ return doc.get("value", "")
761
+ return ""
762
+
763
+ def _completion_kind_to_string(self, kind: int) -> str:
764
+ """Convert LSP CompletionItemKind to string."""
765
+ kinds = {
766
+ 1: "Text",
767
+ 2: "Method",
768
+ 3: "Function",
769
+ 4: "Constructor",
770
+ 5: "Field",
771
+ 6: "Variable",
772
+ 7: "Class",
773
+ 8: "Interface",
774
+ 9: "Module",
775
+ 10: "Property",
776
+ 11: "Unit",
777
+ 12: "Value",
778
+ 13: "Enum",
779
+ 14: "Keyword",
780
+ 15: "Snippet",
781
+ 16: "Color",
782
+ 17: "File",
783
+ 18: "Reference",
784
+ 19: "Folder",
785
+ 20: "EnumMember",
786
+ 21: "Constant",
787
+ 22: "Struct",
788
+ 23: "Event",
789
+ 24: "Operator",
790
+ 25: "TypeParameter",
791
+ }
792
+ return kinds.get(kind, "Text")
793
+
794
+ def _symbol_kind_to_string(self, kind: int) -> str:
795
+ """Convert LSP SymbolKind to string."""
796
+ kinds = {
797
+ 1: "File",
798
+ 2: "Module",
799
+ 3: "Namespace",
800
+ 4: "Package",
801
+ 5: "Class",
802
+ 6: "Method",
803
+ 7: "Property",
804
+ 8: "Field",
805
+ 9: "Constructor",
806
+ 10: "Enum",
807
+ 11: "Interface",
808
+ 12: "Function",
809
+ 13: "Variable",
810
+ 14: "Constant",
811
+ 15: "String",
812
+ 16: "Number",
813
+ 17: "Boolean",
814
+ 18: "Array",
815
+ 19: "Object",
816
+ 20: "Key",
817
+ 21: "Null",
818
+ 22: "EnumMember",
819
+ 23: "Struct",
820
+ 24: "Event",
821
+ 25: "Operator",
822
+ 26: "TypeParameter",
823
+ }
824
+ return kinds.get(kind, "Variable")
825
+
826
+
827
+ # Global client instances per workspace
828
+ _clients: dict[str, LspClient] = {}
829
+ _lock = threading.Lock()
830
+
831
+
832
+ def get_lsp_client(workspace_root: str) -> LspClient:
833
+ """Get or create an LSP client for the workspace."""
834
+ workspace_root = os.path.abspath(workspace_root)
835
+ with _lock:
836
+ if workspace_root not in _clients:
837
+ _clients[workspace_root] = LspClient(workspace_root)
838
+ return _clients[workspace_root]
839
+
840
+
841
+ def close_all_clients() -> None:
842
+ """Close all LSP clients."""
843
+ with _lock:
844
+ for client in _clients.values():
845
+ try:
846
+ client.shutdown()
847
+ except Exception:
848
+ pass
849
+ _clients.clear()
850
+
851
+
852
+ def refresh_lsp_documents(file_paths: list[str]) -> None:
853
+ """Refresh documents in all LSP clients after external modification.
854
+
855
+ This should be called after Rope refactoring operations that modify
856
+ files on disk, so that Pyright picks up the changes.
857
+ """
858
+ with _lock:
859
+ for client in _clients.values():
860
+ try:
861
+ client.refresh_documents(file_paths)
862
+ except Exception:
863
+ pass