pylance-mcp-server 1.0.0
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/LICENSE +21 -0
- package/README.md +213 -0
- package/bin/pylance-mcp.js +68 -0
- package/mcp_server/__init__.py +13 -0
- package/mcp_server/__pycache__/__init__.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/__init__.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/mcp_server/__pycache__/ai_features.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/api_routes.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/auth.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/cloud_sync.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/logging_db.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/logging_db.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/pylance_bridge.cpython-314.pyc +0 -0
- package/mcp_server/__pycache__/resources.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/resources.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/tools.cpython-312.pyc +0 -0
- package/mcp_server/__pycache__/tools.cpython-313.pyc +0 -0
- package/mcp_server/__pycache__/tracing.cpython-313.pyc +0 -0
- package/mcp_server/ai_features.py +274 -0
- package/mcp_server/api_routes.py +429 -0
- package/mcp_server/auth.py +275 -0
- package/mcp_server/cloud_sync.py +427 -0
- package/mcp_server/logging_db.py +403 -0
- package/mcp_server/pylance_bridge.py +579 -0
- package/mcp_server/resources.py +174 -0
- package/mcp_server/tools.py +642 -0
- package/mcp_server/tracing.py +84 -0
- package/package.json +53 -0
- package/requirements.txt +29 -0
- package/scripts/check-python.js +57 -0
- package/server.py +1228 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tools Module
|
|
3
|
+
|
|
4
|
+
Implements all @mcp.tool decorators for Pylance/Pyright language server operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .pylance_bridge import PylanceBridge
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PylanceTools:
|
|
17
|
+
"""Collection of MCP tools for Pylance language server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, bridge: PylanceBridge):
|
|
20
|
+
"""
|
|
21
|
+
Initialize tools with a Pylance bridge instance.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
bridge: PylanceBridge instance for LSP communication
|
|
25
|
+
"""
|
|
26
|
+
self.bridge = bridge
|
|
27
|
+
|
|
28
|
+
def get_completions(
|
|
29
|
+
self, file_path: str, line: int, character: int, content: str
|
|
30
|
+
) -> List[Dict[str, Any]]:
|
|
31
|
+
"""
|
|
32
|
+
Return Pylance completions at exact position.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
file_path: Relative or absolute path to the Python file
|
|
36
|
+
line: Line number (0-indexed)
|
|
37
|
+
character: Character position (0-indexed)
|
|
38
|
+
content: Full content of the file
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of LSP completion items
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
# Validate path
|
|
45
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
46
|
+
|
|
47
|
+
# Open the document
|
|
48
|
+
self.bridge.open_document(str(validated_path), content)
|
|
49
|
+
|
|
50
|
+
# Request completions
|
|
51
|
+
params = {
|
|
52
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
53
|
+
"position": {"line": line, "character": character},
|
|
54
|
+
"context": {"triggerKind": 1}, # Invoked
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
response = self.bridge.send_request("textDocument/completion", params)
|
|
58
|
+
|
|
59
|
+
# Close the document
|
|
60
|
+
self.bridge.close_document(str(validated_path))
|
|
61
|
+
|
|
62
|
+
if "error" in response:
|
|
63
|
+
logger.error(f"Completion error: {response['error']}")
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
result = response.get("result", {})
|
|
67
|
+
if isinstance(result, dict):
|
|
68
|
+
items = result.get("items", [])
|
|
69
|
+
else:
|
|
70
|
+
items = result or []
|
|
71
|
+
|
|
72
|
+
return items
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"get_completions error: {e}")
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
def get_hover(
|
|
79
|
+
self, file_path: str, line: int, character: int, content: str
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Return hover information (type signature + docstring).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
file_path: Relative or absolute path to the Python file
|
|
86
|
+
line: Line number (0-indexed)
|
|
87
|
+
character: Character position (0-indexed)
|
|
88
|
+
content: Full content of the file
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Hover information as markdown string
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
95
|
+
self.bridge.open_document(str(validated_path), content)
|
|
96
|
+
|
|
97
|
+
params = {
|
|
98
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
99
|
+
"position": {"line": line, "character": character},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
response = self.bridge.send_request("textDocument/hover", params)
|
|
103
|
+
self.bridge.close_document(str(validated_path))
|
|
104
|
+
|
|
105
|
+
if "error" in response:
|
|
106
|
+
logger.error(f"Hover error: {response['error']}")
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
result = response.get("result", {})
|
|
110
|
+
if not result:
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
contents = result.get("contents", {})
|
|
114
|
+
if isinstance(contents, str):
|
|
115
|
+
return contents
|
|
116
|
+
elif isinstance(contents, dict):
|
|
117
|
+
return contents.get("value", "")
|
|
118
|
+
elif isinstance(contents, list) and len(contents) > 0:
|
|
119
|
+
if isinstance(contents[0], str):
|
|
120
|
+
return contents[0]
|
|
121
|
+
elif isinstance(contents[0], dict):
|
|
122
|
+
return contents[0].get("value", "")
|
|
123
|
+
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"get_hover error: {e}")
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
def get_definition(
|
|
131
|
+
self, file_path: str, line: int, character: int, content: str
|
|
132
|
+
) -> List[Dict[str, Any]]:
|
|
133
|
+
"""
|
|
134
|
+
Go-to definition - returns location(s).
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
file_path: Relative or absolute path to the Python file
|
|
138
|
+
line: Line number (0-indexed)
|
|
139
|
+
character: Character position (0-indexed)
|
|
140
|
+
content: Full content of the file
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of location dictionaries with uri and range
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
147
|
+
self.bridge.open_document(str(validated_path), content)
|
|
148
|
+
|
|
149
|
+
params = {
|
|
150
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
151
|
+
"position": {"line": line, "character": character},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
response = self.bridge.send_request("textDocument/definition", params)
|
|
155
|
+
self.bridge.close_document(str(validated_path))
|
|
156
|
+
|
|
157
|
+
if "error" in response:
|
|
158
|
+
logger.error(f"Definition error: {response['error']}")
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
result = response.get("result", [])
|
|
162
|
+
if isinstance(result, dict):
|
|
163
|
+
# Single location
|
|
164
|
+
return [result]
|
|
165
|
+
return result or []
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"get_definition error: {e}")
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
def get_references(
|
|
172
|
+
self, file_path: str, line: int, character: int, content: str, include_declaration: bool = True
|
|
173
|
+
) -> List[Dict[str, Any]]:
|
|
174
|
+
"""
|
|
175
|
+
Find all references across the entire workspace.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
file_path: Relative or absolute path to the Python file
|
|
179
|
+
line: Line number (0-indexed)
|
|
180
|
+
character: Character position (0-indexed)
|
|
181
|
+
content: Full content of the file
|
|
182
|
+
include_declaration: Whether to include the declaration
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of location dictionaries with uri and range
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
189
|
+
self.bridge.open_document(str(validated_path), content)
|
|
190
|
+
|
|
191
|
+
params = {
|
|
192
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
193
|
+
"position": {"line": line, "character": character},
|
|
194
|
+
"context": {"includeDeclaration": include_declaration},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
response = self.bridge.send_request("textDocument/references", params)
|
|
198
|
+
self.bridge.close_document(str(validated_path))
|
|
199
|
+
|
|
200
|
+
if "error" in response:
|
|
201
|
+
logger.error(f"References error: {response['error']}")
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
result = response.get("result", [])
|
|
205
|
+
return result or []
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"get_references error: {e}")
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
def rename_symbol(
|
|
212
|
+
self, file_path: str, line: int, character: int, new_name: str, content: str
|
|
213
|
+
) -> Dict[str, Any]:
|
|
214
|
+
"""
|
|
215
|
+
Returns workspace edit JSON for renaming a symbol.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
file_path: Relative or absolute path to the Python file
|
|
219
|
+
line: Line number (0-indexed)
|
|
220
|
+
character: Character position (0-indexed)
|
|
221
|
+
new_name: New name for the symbol
|
|
222
|
+
content: Full content of the file
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
LSP WorkspaceEdit dictionary
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
229
|
+
self.bridge.open_document(str(validated_path), content)
|
|
230
|
+
|
|
231
|
+
params = {
|
|
232
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
233
|
+
"position": {"line": line, "character": character},
|
|
234
|
+
"newName": new_name,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
response = self.bridge.send_request("textDocument/rename", params)
|
|
238
|
+
self.bridge.close_document(str(validated_path))
|
|
239
|
+
|
|
240
|
+
if "error" in response:
|
|
241
|
+
logger.error(f"Rename error: {response['error']}")
|
|
242
|
+
return {"changes": {}}
|
|
243
|
+
|
|
244
|
+
result = response.get("result", {})
|
|
245
|
+
return result or {"changes": {}}
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"rename_symbol error: {e}")
|
|
249
|
+
return {"changes": {}}
|
|
250
|
+
|
|
251
|
+
def get_diagnostics(self, file_path: str, content: str) -> List[Dict[str, Any]]:
|
|
252
|
+
"""
|
|
253
|
+
Return Pyright diagnostics (errors, warnings, suggestions).
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
file_path: Relative or absolute path to the Python file
|
|
257
|
+
content: Full content of the file
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of diagnostic dictionaries
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
264
|
+
|
|
265
|
+
# For diagnostics, we need to open and wait a bit
|
|
266
|
+
self.bridge.open_document(str(validated_path), content)
|
|
267
|
+
|
|
268
|
+
# Request diagnostics via publishDiagnostics is async
|
|
269
|
+
# So we use a different approach: request code actions which triggers diagnostics
|
|
270
|
+
# Or we can use the diagnostic pull model if supported
|
|
271
|
+
|
|
272
|
+
# Try using document diagnostic request (LSP 3.17+)
|
|
273
|
+
params = {
|
|
274
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
response = self.bridge.send_request("textDocument/diagnostic", params, timeout=10)
|
|
279
|
+
self.bridge.close_document(str(validated_path))
|
|
280
|
+
|
|
281
|
+
if "error" not in response:
|
|
282
|
+
result = response.get("result", {})
|
|
283
|
+
items = result.get("items", [])
|
|
284
|
+
return items
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
# Fallback: request code actions which will include diagnostics
|
|
289
|
+
# For now, return empty as pull diagnostics might not be supported
|
|
290
|
+
self.bridge.close_document(str(validated_path))
|
|
291
|
+
|
|
292
|
+
# Note: In production, you'd implement publishDiagnostics listener
|
|
293
|
+
# For this implementation, we'll return a placeholder
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"get_diagnostics error: {e}")
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
def format_document(self, file_path: str, content: str) -> str:
|
|
301
|
+
"""
|
|
302
|
+
Return fully formatted code.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
file_path: Relative or absolute path to the Python file
|
|
306
|
+
content: Full content of the file
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Formatted code as string
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
313
|
+
self.bridge.open_document(str(validated_path), content)
|
|
314
|
+
|
|
315
|
+
params = {
|
|
316
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))},
|
|
317
|
+
"options": {
|
|
318
|
+
"tabSize": 4,
|
|
319
|
+
"insertSpaces": True,
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
response = self.bridge.send_request("textDocument/formatting", params)
|
|
324
|
+
self.bridge.close_document(str(validated_path))
|
|
325
|
+
|
|
326
|
+
if "error" in response:
|
|
327
|
+
logger.error(f"Formatting error: {response['error']}")
|
|
328
|
+
return content
|
|
329
|
+
|
|
330
|
+
result = response.get("result", [])
|
|
331
|
+
if not result:
|
|
332
|
+
return content
|
|
333
|
+
|
|
334
|
+
# Apply text edits
|
|
335
|
+
lines = content.splitlines(keepends=True)
|
|
336
|
+
|
|
337
|
+
# Sort edits in reverse order to avoid offset issues
|
|
338
|
+
edits = sorted(result, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True)
|
|
339
|
+
|
|
340
|
+
for edit in edits:
|
|
341
|
+
start_line = edit["range"]["start"]["line"]
|
|
342
|
+
start_char = edit["range"]["start"]["character"]
|
|
343
|
+
end_line = edit["range"]["end"]["line"]
|
|
344
|
+
end_char = edit["range"]["end"]["character"]
|
|
345
|
+
new_text = edit["newText"]
|
|
346
|
+
|
|
347
|
+
# Apply edit
|
|
348
|
+
if start_line == end_line:
|
|
349
|
+
line = lines[start_line]
|
|
350
|
+
lines[start_line] = line[:start_char] + new_text + line[end_char:]
|
|
351
|
+
else:
|
|
352
|
+
# Multi-line edit
|
|
353
|
+
before = lines[start_line][:start_char]
|
|
354
|
+
after = lines[end_line][end_char:]
|
|
355
|
+
lines[start_line:end_line+1] = [before + new_text + after]
|
|
356
|
+
|
|
357
|
+
return "".join(lines)
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f"format_document error: {e}")
|
|
361
|
+
return content
|
|
362
|
+
|
|
363
|
+
def apply_workspace_edit(self, edit: Dict[str, Any]) -> Dict[str, Any]:
|
|
364
|
+
"""
|
|
365
|
+
Take LSP WorkspaceEdit JSON and return file write instructions.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
edit: LSP WorkspaceEdit dictionary
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Dictionary with file paths and their new content
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
result = {"success": True, "files": {}}
|
|
375
|
+
|
|
376
|
+
# Handle changes (uri -> list of text edits)
|
|
377
|
+
changes = edit.get("changes", {})
|
|
378
|
+
for uri, text_edits in changes.items():
|
|
379
|
+
# Convert URI to file path
|
|
380
|
+
file_path = Path(uri.replace("file://", "").replace("file:///", ""))
|
|
381
|
+
|
|
382
|
+
# Validate path
|
|
383
|
+
try:
|
|
384
|
+
validated_path = self.bridge._validate_path(str(file_path))
|
|
385
|
+
except ValueError:
|
|
386
|
+
logger.warning(f"Skipping file outside workspace: {file_path}")
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
# Read current content
|
|
390
|
+
if validated_path.exists():
|
|
391
|
+
content = validated_path.read_text(encoding="utf-8")
|
|
392
|
+
else:
|
|
393
|
+
content = ""
|
|
394
|
+
|
|
395
|
+
# Apply edits
|
|
396
|
+
lines = content.splitlines(keepends=True)
|
|
397
|
+
|
|
398
|
+
# Sort in reverse order
|
|
399
|
+
sorted_edits = sorted(
|
|
400
|
+
text_edits,
|
|
401
|
+
key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]),
|
|
402
|
+
reverse=True
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
for text_edit in sorted_edits:
|
|
406
|
+
start_line = text_edit["range"]["start"]["line"]
|
|
407
|
+
start_char = text_edit["range"]["start"]["character"]
|
|
408
|
+
end_line = text_edit["range"]["end"]["line"]
|
|
409
|
+
end_char = text_edit["range"]["end"]["character"]
|
|
410
|
+
new_text = text_edit["newText"]
|
|
411
|
+
|
|
412
|
+
if start_line == end_line:
|
|
413
|
+
line = lines[start_line] if start_line < len(lines) else ""
|
|
414
|
+
lines[start_line] = line[:start_char] + new_text + line[end_char:]
|
|
415
|
+
else:
|
|
416
|
+
before = lines[start_line][:start_char] if start_line < len(lines) else ""
|
|
417
|
+
after = lines[end_line][end_char:] if end_line < len(lines) else ""
|
|
418
|
+
lines[start_line:end_line+1] = [before + new_text + after]
|
|
419
|
+
|
|
420
|
+
result["files"][str(validated_path)] = "".join(lines)
|
|
421
|
+
|
|
422
|
+
# Handle documentChanges (more advanced format)
|
|
423
|
+
doc_changes = edit.get("documentChanges", [])
|
|
424
|
+
for change in doc_changes:
|
|
425
|
+
if "textDocument" in change:
|
|
426
|
+
# TextDocumentEdit
|
|
427
|
+
uri = change["textDocument"]["uri"]
|
|
428
|
+
text_edits = change.get("edits", [])
|
|
429
|
+
|
|
430
|
+
file_path = Path(uri.replace("file://", "").replace("file:///", ""))
|
|
431
|
+
try:
|
|
432
|
+
validated_path = self.bridge._validate_path(str(file_path))
|
|
433
|
+
except ValueError:
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
if validated_path.exists():
|
|
437
|
+
content = validated_path.read_text(encoding="utf-8")
|
|
438
|
+
else:
|
|
439
|
+
content = ""
|
|
440
|
+
|
|
441
|
+
lines = content.splitlines(keepends=True)
|
|
442
|
+
sorted_edits = sorted(
|
|
443
|
+
text_edits,
|
|
444
|
+
key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]),
|
|
445
|
+
reverse=True
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
for text_edit in sorted_edits:
|
|
449
|
+
# Similar logic as above
|
|
450
|
+
pass # Already implemented above
|
|
451
|
+
|
|
452
|
+
result["files"][str(validated_path)] = "".join(lines)
|
|
453
|
+
|
|
454
|
+
return result
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.error(f"apply_workspace_edit error: {e}")
|
|
458
|
+
return {"success": False, "error": str(e)}
|
|
459
|
+
|
|
460
|
+
def type_check(
|
|
461
|
+
self, file_path: Optional[str] = None, content: Optional[str] = None
|
|
462
|
+
) -> Dict[str, Any]:
|
|
463
|
+
"""
|
|
464
|
+
Run Pyright type checking on a file or entire workspace.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
file_path: Optional path to specific file. If None, checks entire workspace.
|
|
468
|
+
content: Optional file content. If provided with file_path, checks this content.
|
|
469
|
+
If None, reads from disk.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
Dictionary with type checking results:
|
|
473
|
+
{
|
|
474
|
+
"summary": {
|
|
475
|
+
"total_errors": int,
|
|
476
|
+
"total_warnings": int,
|
|
477
|
+
"total_information": int,
|
|
478
|
+
"files_checked": int
|
|
479
|
+
},
|
|
480
|
+
"diagnostics": [
|
|
481
|
+
{
|
|
482
|
+
"file": str,
|
|
483
|
+
"line": int,
|
|
484
|
+
"character": int,
|
|
485
|
+
"severity": str, # "error", "warning", "information"
|
|
486
|
+
"message": str,
|
|
487
|
+
"code": str,
|
|
488
|
+
"source": "Pylance"
|
|
489
|
+
}
|
|
490
|
+
],
|
|
491
|
+
"success": bool
|
|
492
|
+
}
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
diagnostics_by_file = {}
|
|
496
|
+
|
|
497
|
+
if file_path:
|
|
498
|
+
# Type check specific file
|
|
499
|
+
validated_path = self.bridge._validate_path(file_path)
|
|
500
|
+
|
|
501
|
+
if content is None:
|
|
502
|
+
if validated_path.exists():
|
|
503
|
+
content = validated_path.read_text(encoding="utf-8")
|
|
504
|
+
else:
|
|
505
|
+
return {
|
|
506
|
+
"success": False,
|
|
507
|
+
"error": f"File not found: {file_path}"
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# Open document and get diagnostics
|
|
511
|
+
self.bridge.open_document(str(validated_path), content)
|
|
512
|
+
|
|
513
|
+
params = {
|
|
514
|
+
"textDocument": {"uri": self.bridge._file_uri(str(validated_path))}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Request diagnostics via publishDiagnostics notification
|
|
518
|
+
# Note: Pyright sends diagnostics automatically, but we can also
|
|
519
|
+
# request them explicitly
|
|
520
|
+
response = self.bridge.send_request("textDocument/diagnostic", params)
|
|
521
|
+
|
|
522
|
+
# Close document
|
|
523
|
+
self.bridge.close_document(str(validated_path))
|
|
524
|
+
|
|
525
|
+
if "error" not in response:
|
|
526
|
+
result = response.get("result", {})
|
|
527
|
+
items = result.get("items", [])
|
|
528
|
+
|
|
529
|
+
if items:
|
|
530
|
+
diagnostics_by_file[str(validated_path)] = items
|
|
531
|
+
|
|
532
|
+
else:
|
|
533
|
+
# Type check entire workspace
|
|
534
|
+
# List all Python files in workspace
|
|
535
|
+
workspace_root = self.bridge.workspace_root
|
|
536
|
+
python_files = list(workspace_root.rglob("*.py"))
|
|
537
|
+
|
|
538
|
+
# Filter out virtual environments and common ignore patterns
|
|
539
|
+
ignore_patterns = [
|
|
540
|
+
"venv", ".venv", "env", ".env",
|
|
541
|
+
"node_modules", ".git", "__pycache__",
|
|
542
|
+
"site-packages", "dist", "build"
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
python_files = [
|
|
546
|
+
f for f in python_files
|
|
547
|
+
if not any(pattern in f.parts for pattern in ignore_patterns)
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
# Check each file
|
|
551
|
+
for py_file in python_files[:100]: # Limit to 100 files for performance
|
|
552
|
+
try:
|
|
553
|
+
rel_path = py_file.relative_to(workspace_root)
|
|
554
|
+
content = py_file.read_text(encoding="utf-8")
|
|
555
|
+
|
|
556
|
+
self.bridge.open_document(str(py_file), content)
|
|
557
|
+
|
|
558
|
+
params = {
|
|
559
|
+
"textDocument": {"uri": self.bridge._file_uri(str(py_file))}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
response = self.bridge.send_request("textDocument/diagnostic", params)
|
|
563
|
+
|
|
564
|
+
self.bridge.close_document(str(py_file))
|
|
565
|
+
|
|
566
|
+
if "error" not in response:
|
|
567
|
+
result = response.get("result", {})
|
|
568
|
+
items = result.get("items", [])
|
|
569
|
+
|
|
570
|
+
if items:
|
|
571
|
+
diagnostics_by_file[str(rel_path)] = items
|
|
572
|
+
|
|
573
|
+
except Exception as e:
|
|
574
|
+
logger.warning(f"Failed to check {py_file}: {e}")
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
# Process diagnostics into standardized format
|
|
578
|
+
all_diagnostics = []
|
|
579
|
+
total_errors = 0
|
|
580
|
+
total_warnings = 0
|
|
581
|
+
total_information = 0
|
|
582
|
+
|
|
583
|
+
for file_path, diagnostics in diagnostics_by_file.items():
|
|
584
|
+
for diag in diagnostics:
|
|
585
|
+
severity_map = {
|
|
586
|
+
1: "error",
|
|
587
|
+
2: "warning",
|
|
588
|
+
3: "information",
|
|
589
|
+
4: "hint"
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
severity_num = diag.get("severity", 3)
|
|
593
|
+
severity = severity_map.get(severity_num, "information")
|
|
594
|
+
|
|
595
|
+
if severity == "error":
|
|
596
|
+
total_errors += 1
|
|
597
|
+
elif severity == "warning":
|
|
598
|
+
total_warnings += 1
|
|
599
|
+
else:
|
|
600
|
+
total_information += 1
|
|
601
|
+
|
|
602
|
+
range_info = diag.get("range", {})
|
|
603
|
+
start = range_info.get("start", {})
|
|
604
|
+
|
|
605
|
+
all_diagnostics.append({
|
|
606
|
+
"file": file_path,
|
|
607
|
+
"line": start.get("line", 0),
|
|
608
|
+
"character": start.get("character", 0),
|
|
609
|
+
"severity": severity,
|
|
610
|
+
"message": diag.get("message", ""),
|
|
611
|
+
"code": str(diag.get("code", "")),
|
|
612
|
+
"source": diag.get("source", "Pylance")
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
# Sort diagnostics by severity (errors first), then by file/line
|
|
616
|
+
severity_order = {"error": 0, "warning": 1, "information": 2, "hint": 3}
|
|
617
|
+
all_diagnostics.sort(
|
|
618
|
+
key=lambda d: (
|
|
619
|
+
severity_order.get(d["severity"], 4),
|
|
620
|
+
d["file"],
|
|
621
|
+
d["line"],
|
|
622
|
+
d["character"]
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
"success": True,
|
|
628
|
+
"summary": {
|
|
629
|
+
"total_errors": total_errors,
|
|
630
|
+
"total_warnings": total_warnings,
|
|
631
|
+
"total_information": total_information,
|
|
632
|
+
"files_checked": len(diagnostics_by_file)
|
|
633
|
+
},
|
|
634
|
+
"diagnostics": all_diagnostics
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
except Exception as e:
|
|
638
|
+
logger.error(f"type_check error: {e}")
|
|
639
|
+
return {
|
|
640
|
+
"success": False,
|
|
641
|
+
"error": str(e)
|
|
642
|
+
}
|