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,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pylance Bridge Module
|
|
3
|
+
|
|
4
|
+
Spawns and manages communication with the Pyright language server via subprocess.
|
|
5
|
+
Implements LSP JSON-RPC protocol over stdio.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
import threading
|
|
17
|
+
import queue
|
|
18
|
+
|
|
19
|
+
from opentelemetry import trace
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
tracer = trace.get_tracer(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PylanceBridge:
|
|
26
|
+
"""Manages a Pyright language server subprocess and handles LSP communication."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, workspace_root: str):
|
|
29
|
+
"""
|
|
30
|
+
Initialize the Pylance bridge.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
workspace_root: Absolute path to the workspace root directory
|
|
34
|
+
"""
|
|
35
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
36
|
+
self.process: Optional[subprocess.Popen] = None
|
|
37
|
+
self.message_id = 0
|
|
38
|
+
self.pending_responses: Dict[int, queue.Queue] = {}
|
|
39
|
+
self.reader_thread: Optional[threading.Thread] = None
|
|
40
|
+
self.running = False
|
|
41
|
+
self.initialized = False
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
# Detect Python virtual environment
|
|
45
|
+
self.python_path = self._detect_python_venv()
|
|
46
|
+
if self.python_path:
|
|
47
|
+
logger.info(f"Detected Python environment: {self.python_path}")
|
|
48
|
+
else:
|
|
49
|
+
logger.warning("No virtual environment detected, using system Python")
|
|
50
|
+
|
|
51
|
+
def _detect_python_venv(self) -> Optional[str]:
|
|
52
|
+
"""
|
|
53
|
+
Detect and return the Python executable path from virtual environment.
|
|
54
|
+
If no venv exists, creates one automatically.
|
|
55
|
+
|
|
56
|
+
Checks in order:
|
|
57
|
+
1. .venv/Scripts/python.exe (Windows) or .venv/bin/python (Unix)
|
|
58
|
+
2. venv/Scripts/python.exe (Windows) or venv/bin/python (Unix)
|
|
59
|
+
3. env/Scripts/python.exe (Windows) or env/bin/python (Unix)
|
|
60
|
+
4. .env/Scripts/python.exe (Windows) or .env/bin/python (Unix)
|
|
61
|
+
5. If none found, creates .venv automatically
|
|
62
|
+
6. System Python (fallback if creation fails)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Path to Python executable or None if not found
|
|
66
|
+
"""
|
|
67
|
+
venv_names = ['.venv', 'venv', 'env', '.env']
|
|
68
|
+
|
|
69
|
+
if sys.platform == 'win32':
|
|
70
|
+
python_names = ['python.exe', 'python3.exe']
|
|
71
|
+
venv_subdir = 'Scripts'
|
|
72
|
+
else:
|
|
73
|
+
python_names = ['python3', 'python']
|
|
74
|
+
venv_subdir = 'bin'
|
|
75
|
+
|
|
76
|
+
# Check for existing virtual environments in workspace
|
|
77
|
+
for venv_name in venv_names:
|
|
78
|
+
venv_path = self.workspace_root / venv_name
|
|
79
|
+
if venv_path.exists() and venv_path.is_dir():
|
|
80
|
+
for python_name in python_names:
|
|
81
|
+
python_exe = venv_path / venv_subdir / python_name
|
|
82
|
+
if python_exe.exists():
|
|
83
|
+
logger.info(f"Found existing virtual environment: {venv_path}")
|
|
84
|
+
return str(python_exe.resolve())
|
|
85
|
+
|
|
86
|
+
# No venv found - create one automatically
|
|
87
|
+
logger.info("No virtual environment found. Creating .venv automatically...")
|
|
88
|
+
created_python = self._create_venv()
|
|
89
|
+
if created_python:
|
|
90
|
+
return created_python
|
|
91
|
+
|
|
92
|
+
# Fallback to system Python
|
|
93
|
+
for python_name in python_names:
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
['which' if sys.platform != 'win32' else 'where', python_name],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=5
|
|
100
|
+
)
|
|
101
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
102
|
+
python_path = result.stdout.strip().split('\n')[0]
|
|
103
|
+
logger.info(f"Using system Python: {python_path}")
|
|
104
|
+
return python_path
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.debug(f"Error finding {python_name}: {e}")
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _create_venv(self) -> Optional[str]:
|
|
111
|
+
"""
|
|
112
|
+
Create a new virtual environment in .venv directory.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to created Python executable, or None if creation failed
|
|
116
|
+
"""
|
|
117
|
+
venv_path = self.workspace_root / '.venv'
|
|
118
|
+
|
|
119
|
+
# Find system Python to create venv
|
|
120
|
+
if sys.platform == 'win32':
|
|
121
|
+
python_names = ['python', 'python3', 'py']
|
|
122
|
+
else:
|
|
123
|
+
python_names = ['python3', 'python']
|
|
124
|
+
|
|
125
|
+
system_python = None
|
|
126
|
+
for python_name in python_names:
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
['which' if sys.platform != 'win32' else 'where', python_name],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=5
|
|
133
|
+
)
|
|
134
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
135
|
+
system_python = result.stdout.strip().split('\n')[0]
|
|
136
|
+
break
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug(f"Error finding {python_name}: {e}")
|
|
139
|
+
|
|
140
|
+
if not system_python:
|
|
141
|
+
logger.error("Cannot create venv: No Python executable found")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
logger.info(f"Creating virtual environment at {venv_path} using {system_python}")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Create venv using: python -m venv .venv
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
[system_python, '-m', 'venv', str(venv_path)],
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
timeout=60,
|
|
153
|
+
cwd=str(self.workspace_root)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
logger.error(f"Failed to create venv: {result.stderr}")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
# Verify venv was created
|
|
161
|
+
if sys.platform == 'win32':
|
|
162
|
+
python_exe = venv_path / 'Scripts' / 'python.exe'
|
|
163
|
+
else:
|
|
164
|
+
python_exe = venv_path / 'bin' / 'python3'
|
|
165
|
+
if not python_exe.exists():
|
|
166
|
+
python_exe = venv_path / 'bin' / 'python'
|
|
167
|
+
|
|
168
|
+
if python_exe.exists():
|
|
169
|
+
logger.info(f"✓ Successfully created virtual environment: {venv_path}")
|
|
170
|
+
|
|
171
|
+
# Install common packages if requirements.txt exists
|
|
172
|
+
requirements_file = self.workspace_root / 'requirements.txt'
|
|
173
|
+
if requirements_file.exists():
|
|
174
|
+
logger.info("Found requirements.txt, installing dependencies...")
|
|
175
|
+
self._install_requirements(str(python_exe), str(requirements_file))
|
|
176
|
+
else:
|
|
177
|
+
logger.info("No requirements.txt found. Virtual environment created with no packages.")
|
|
178
|
+
|
|
179
|
+
return str(python_exe.resolve())
|
|
180
|
+
else:
|
|
181
|
+
logger.error(f"Venv created but Python executable not found at {python_exe}")
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
except subprocess.TimeoutExpired:
|
|
185
|
+
logger.error("Timeout creating virtual environment (>60s)")
|
|
186
|
+
return None
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"Error creating virtual environment: {e}")
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def _install_requirements(self, python_exe: str, requirements_file: str) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Install packages from requirements.txt into the virtual environment.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
python_exe: Path to venv Python executable
|
|
197
|
+
requirements_file: Path to requirements.txt
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
logger.info(f"Installing packages from {requirements_file}...")
|
|
201
|
+
|
|
202
|
+
# Use pip from the venv: python -m pip install -r requirements.txt
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
[python_exe, '-m', 'pip', 'install', '-r', requirements_file],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=300, # 5 minutes timeout for package installation
|
|
208
|
+
cwd=str(self.workspace_root)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if result.returncode == 0:
|
|
212
|
+
logger.info("✓ Successfully installed dependencies from requirements.txt")
|
|
213
|
+
else:
|
|
214
|
+
logger.warning(f"Failed to install some dependencies: {result.stderr}")
|
|
215
|
+
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
logger.warning("Package installation timed out (>5 minutes). Some packages may not be installed.")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.warning(f"Error installing requirements: {e}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _create_pyrightconfig(self) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Create or update pyrightconfig.json to use detected Python environment.
|
|
225
|
+
"""
|
|
226
|
+
config_path = self.workspace_root / 'pyrightconfig.json'
|
|
227
|
+
|
|
228
|
+
config = {}
|
|
229
|
+
if config_path.exists():
|
|
230
|
+
try:
|
|
231
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
232
|
+
config = json.load(f)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.warning(f"Failed to read existing pyrightconfig.json: {e}")
|
|
235
|
+
|
|
236
|
+
# Update Python path if detected
|
|
237
|
+
if self.python_path:
|
|
238
|
+
config['pythonPath'] = self.python_path
|
|
239
|
+
|
|
240
|
+
# Set other useful defaults
|
|
241
|
+
if 'include' not in config:
|
|
242
|
+
config['include'] = ['.']
|
|
243
|
+
|
|
244
|
+
if 'exclude' not in config:
|
|
245
|
+
config['exclude'] = [
|
|
246
|
+
'**/node_modules',
|
|
247
|
+
'**/__pycache__',
|
|
248
|
+
'.git',
|
|
249
|
+
'.venv',
|
|
250
|
+
'venv',
|
|
251
|
+
'env',
|
|
252
|
+
'.env'
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
# Write back to file
|
|
256
|
+
try:
|
|
257
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
258
|
+
json.dump(config, f, indent=2)
|
|
259
|
+
logger.info(f"Created/updated pyrightconfig.json with Python path: {self.python_path}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Failed to write pyrightconfig.json: {e}")
|
|
262
|
+
|
|
263
|
+
def start(self) -> None:
|
|
264
|
+
"""Start the Pyright language server subprocess."""
|
|
265
|
+
if self.process and self.process.poll() is None:
|
|
266
|
+
logger.info("Pyright server already running")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# Create/update pyrightconfig.json with detected Python path
|
|
270
|
+
self._create_pyrightconfig()
|
|
271
|
+
|
|
272
|
+
# Find npx or pyright executable
|
|
273
|
+
npx_cmd = self._find_npx()
|
|
274
|
+
if not npx_cmd:
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
"npx not found. Please install Node.js: https://nodejs.org/"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Start the language server
|
|
280
|
+
cmd = [npx_cmd, "pyright-langserver", "--stdio"]
|
|
281
|
+
logger.info(f"Starting Pyright: {' '.join(cmd)}")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
self.process = subprocess.Popen(
|
|
285
|
+
cmd,
|
|
286
|
+
stdin=subprocess.PIPE,
|
|
287
|
+
stdout=subprocess.PIPE,
|
|
288
|
+
stderr=subprocess.PIPE,
|
|
289
|
+
cwd=str(self.workspace_root),
|
|
290
|
+
text=False, # Use binary mode for proper encoding
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise RuntimeError(f"Failed to start Pyright: {e}")
|
|
294
|
+
|
|
295
|
+
self.running = True
|
|
296
|
+
self.reader_thread = threading.Thread(target=self._read_messages, daemon=True)
|
|
297
|
+
self.reader_thread.start()
|
|
298
|
+
|
|
299
|
+
# Initialize the language server
|
|
300
|
+
self._initialize()
|
|
301
|
+
logger.info("Pyright language server started successfully")
|
|
302
|
+
|
|
303
|
+
def stop(self) -> None:
|
|
304
|
+
"""Stop the Pyright language server subprocess."""
|
|
305
|
+
self.running = False
|
|
306
|
+
if self.process:
|
|
307
|
+
try:
|
|
308
|
+
self.send_request("shutdown", {})
|
|
309
|
+
self.send_notification("exit", {})
|
|
310
|
+
self.process.wait(timeout=5)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning(f"Error during shutdown: {e}")
|
|
313
|
+
self.process.kill()
|
|
314
|
+
finally:
|
|
315
|
+
self.process = None
|
|
316
|
+
self.initialized = False
|
|
317
|
+
logger.info("Pyright language server stopped")
|
|
318
|
+
|
|
319
|
+
def _find_npx(self) -> Optional[str]:
|
|
320
|
+
"""Find the npx executable."""
|
|
321
|
+
# Check if npx is in PATH
|
|
322
|
+
if sys.platform == "win32":
|
|
323
|
+
npx_names = ["npx.cmd", "npx.exe", "npx"]
|
|
324
|
+
else:
|
|
325
|
+
npx_names = ["npx"]
|
|
326
|
+
|
|
327
|
+
for npx_name in npx_names:
|
|
328
|
+
try:
|
|
329
|
+
result = subprocess.run(
|
|
330
|
+
["where" if sys.platform == "win32" else "which", npx_name],
|
|
331
|
+
capture_output=True,
|
|
332
|
+
text=True,
|
|
333
|
+
)
|
|
334
|
+
if result.returncode == 0:
|
|
335
|
+
return npx_name
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Check common locations
|
|
340
|
+
if sys.platform == "win32":
|
|
341
|
+
common_paths = [
|
|
342
|
+
os.path.expandvars(r"%ProgramFiles%\nodejs"),
|
|
343
|
+
os.path.expandvars(r"%ProgramFiles(x86)%\nodejs"),
|
|
344
|
+
os.path.expandvars(r"%APPDATA%\npm"),
|
|
345
|
+
]
|
|
346
|
+
else:
|
|
347
|
+
common_paths = [
|
|
348
|
+
"/usr/local/bin",
|
|
349
|
+
"/usr/bin",
|
|
350
|
+
os.path.expanduser("~/.npm-global/bin"),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
for path in common_paths:
|
|
354
|
+
for npx_name in npx_names:
|
|
355
|
+
npx_path = os.path.join(path, npx_name)
|
|
356
|
+
if os.path.exists(npx_path):
|
|
357
|
+
return npx_path
|
|
358
|
+
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
def _initialize(self) -> None:
|
|
362
|
+
"""Send the initialize request to the language server."""
|
|
363
|
+
init_params = {
|
|
364
|
+
"processId": os.getpid(),
|
|
365
|
+
"rootUri": self.workspace_root.as_uri(),
|
|
366
|
+
"capabilities": {
|
|
367
|
+
"textDocument": {
|
|
368
|
+
"completion": {"completionItem": {"snippetSupport": True}},
|
|
369
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
370
|
+
"definition": {"linkSupport": True},
|
|
371
|
+
"references": {},
|
|
372
|
+
"rename": {"prepareSupport": True},
|
|
373
|
+
"diagnostic": {},
|
|
374
|
+
"formatting": {},
|
|
375
|
+
},
|
|
376
|
+
"workspace": {
|
|
377
|
+
"applyEdit": True,
|
|
378
|
+
"workspaceEdit": {"documentChanges": True},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
"workspaceFolders": [
|
|
382
|
+
{"uri": self.workspace_root.as_uri(), "name": self.workspace_root.name}
|
|
383
|
+
],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# Add Python path to initialization options if detected
|
|
387
|
+
if self.python_path:
|
|
388
|
+
init_params["initializationOptions"] = {
|
|
389
|
+
"pythonPath": self.python_path,
|
|
390
|
+
"python": {
|
|
391
|
+
"pythonPath": self.python_path
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
response = self.send_request("initialize", init_params)
|
|
396
|
+
if "error" in response:
|
|
397
|
+
raise RuntimeError(f"Failed to initialize: {response['error']}")
|
|
398
|
+
|
|
399
|
+
self.send_notification("initialized", {})
|
|
400
|
+
|
|
401
|
+
# Send workspace configuration with Python path
|
|
402
|
+
if self.python_path:
|
|
403
|
+
self.send_notification("workspace/didChangeConfiguration", {
|
|
404
|
+
"settings": {
|
|
405
|
+
"python": {
|
|
406
|
+
"pythonPath": self.python_path
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
self.initialized = True
|
|
412
|
+
logger.info("Pyright language server initialized")
|
|
413
|
+
|
|
414
|
+
def _read_messages(self) -> None:
|
|
415
|
+
"""Read messages from the language server stdout."""
|
|
416
|
+
buffer = b""
|
|
417
|
+
while self.running and self.process:
|
|
418
|
+
try:
|
|
419
|
+
chunk = self.process.stdout.read(1024)
|
|
420
|
+
if not chunk:
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
buffer += chunk
|
|
424
|
+
|
|
425
|
+
while b"\r\n\r\n" in buffer:
|
|
426
|
+
header_end = buffer.index(b"\r\n\r\n")
|
|
427
|
+
header = buffer[:header_end].decode("utf-8")
|
|
428
|
+
buffer = buffer[header_end + 4 :]
|
|
429
|
+
|
|
430
|
+
# Parse Content-Length
|
|
431
|
+
content_length = 0
|
|
432
|
+
for line in header.split("\r\n"):
|
|
433
|
+
if line.startswith("Content-Length:"):
|
|
434
|
+
content_length = int(line.split(":")[1].strip())
|
|
435
|
+
break
|
|
436
|
+
|
|
437
|
+
# Read the message body
|
|
438
|
+
while len(buffer) < content_length:
|
|
439
|
+
chunk = self.process.stdout.read(content_length - len(buffer))
|
|
440
|
+
if not chunk:
|
|
441
|
+
break
|
|
442
|
+
buffer += chunk
|
|
443
|
+
|
|
444
|
+
message_body = buffer[:content_length]
|
|
445
|
+
buffer = buffer[content_length:]
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
message = json.loads(message_body.decode("utf-8"))
|
|
449
|
+
self._handle_message(message)
|
|
450
|
+
except json.JSONDecodeError as e:
|
|
451
|
+
logger.error(f"Failed to decode message: {e}")
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
logger.error(f"Error reading messages: {e}")
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
def _handle_message(self, message: Dict[str, Any]) -> None:
|
|
458
|
+
"""Handle incoming message from the language server."""
|
|
459
|
+
if "id" in message:
|
|
460
|
+
# This is a response to a request
|
|
461
|
+
msg_id = message["id"]
|
|
462
|
+
if msg_id in self.pending_responses:
|
|
463
|
+
self.pending_responses[msg_id].put(message)
|
|
464
|
+
else:
|
|
465
|
+
# This is a notification - log it
|
|
466
|
+
logger.debug(f"Received notification: {message.get('method')}")
|
|
467
|
+
|
|
468
|
+
def send_notification(self, method: str, params: Dict[str, Any]) -> None:
|
|
469
|
+
"""Send a notification to the language server."""
|
|
470
|
+
message = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
471
|
+
self._send_message(message)
|
|
472
|
+
|
|
473
|
+
def send_request(self, method: str, params: Dict[str, Any], timeout: int = 30) -> Dict[str, Any]:
|
|
474
|
+
"""
|
|
475
|
+
Send a request to the language server and wait for response.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
method: LSP method name
|
|
479
|
+
params: Request parameters
|
|
480
|
+
timeout: Timeout in seconds
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Response dictionary
|
|
484
|
+
"""
|
|
485
|
+
with tracer.start_as_current_span(f"lsp.{method}") as span:
|
|
486
|
+
span.set_attribute("lsp.method", method)
|
|
487
|
+
span.set_attribute("lsp.timeout", timeout)
|
|
488
|
+
|
|
489
|
+
with self._lock:
|
|
490
|
+
self.message_id += 1
|
|
491
|
+
msg_id = self.message_id
|
|
492
|
+
|
|
493
|
+
response_queue: queue.Queue = queue.Queue()
|
|
494
|
+
self.pending_responses[msg_id] = response_queue
|
|
495
|
+
|
|
496
|
+
message = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params}
|
|
497
|
+
self._send_message(message)
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
response = response_queue.get(timeout=timeout)
|
|
501
|
+
span.set_attribute("lsp.success", True)
|
|
502
|
+
return response
|
|
503
|
+
except queue.Empty:
|
|
504
|
+
span.set_attribute("lsp.success", False)
|
|
505
|
+
span.set_attribute("lsp.error", "timeout")
|
|
506
|
+
raise TimeoutError(f"Request {method} timed out after {timeout}s")
|
|
507
|
+
finally:
|
|
508
|
+
self.pending_responses.pop(msg_id, None)
|
|
509
|
+
|
|
510
|
+
def _send_message(self, message: Dict[str, Any]) -> None:
|
|
511
|
+
"""Send a message to the language server."""
|
|
512
|
+
if not self.process or not self.process.stdin:
|
|
513
|
+
raise RuntimeError("Language server not running")
|
|
514
|
+
|
|
515
|
+
content = json.dumps(message).encode("utf-8")
|
|
516
|
+
header = f"Content-Length: {len(content)}\r\n\r\n".encode("utf-8")
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
self.process.stdin.write(header + content)
|
|
520
|
+
self.process.stdin.flush()
|
|
521
|
+
except Exception as e:
|
|
522
|
+
raise RuntimeError(f"Failed to send message: {e}")
|
|
523
|
+
|
|
524
|
+
def _file_uri(self, file_path: str) -> str:
|
|
525
|
+
"""Convert file path to URI."""
|
|
526
|
+
path = Path(file_path)
|
|
527
|
+
if not path.is_absolute():
|
|
528
|
+
path = (self.workspace_root / path).resolve()
|
|
529
|
+
return path.as_uri()
|
|
530
|
+
|
|
531
|
+
def _validate_path(self, file_path: str) -> Path:
|
|
532
|
+
"""Validate that file path is within workspace root."""
|
|
533
|
+
path = Path(file_path)
|
|
534
|
+
if not path.is_absolute():
|
|
535
|
+
path = (self.workspace_root / path).resolve()
|
|
536
|
+
else:
|
|
537
|
+
path = path.resolve()
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
path.relative_to(self.workspace_root)
|
|
541
|
+
except ValueError:
|
|
542
|
+
raise ValueError(f"File path {file_path} is outside workspace root")
|
|
543
|
+
|
|
544
|
+
return path
|
|
545
|
+
|
|
546
|
+
def open_document(self, file_path: str, content: str) -> None:
|
|
547
|
+
"""Open a document in the language server."""
|
|
548
|
+
with tracer.start_as_current_span("lsp.open_document") as span:
|
|
549
|
+
span.set_attribute("file_path", file_path)
|
|
550
|
+
span.set_attribute("content_length", len(content))
|
|
551
|
+
|
|
552
|
+
uri = self._file_uri(file_path)
|
|
553
|
+
params = {
|
|
554
|
+
"textDocument": {
|
|
555
|
+
"uri": uri,
|
|
556
|
+
"languageId": "python",
|
|
557
|
+
"version": 1,
|
|
558
|
+
"text": content,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
self.send_notification("textDocument/didOpen", params)
|
|
562
|
+
|
|
563
|
+
def close_document(self, file_path: str) -> None:
|
|
564
|
+
"""Close a document in the language server."""
|
|
565
|
+
with tracer.start_as_current_span("lsp.close_document") as span:
|
|
566
|
+
span.set_attribute("file_path", file_path)
|
|
567
|
+
|
|
568
|
+
uri = self._file_uri(file_path)
|
|
569
|
+
params = {"textDocument": {"uri": uri}}
|
|
570
|
+
self.send_notification("textDocument/didClose", params)
|
|
571
|
+
|
|
572
|
+
def __enter__(self) -> "PylanceBridge":
|
|
573
|
+
"""Context manager entry."""
|
|
574
|
+
self.start()
|
|
575
|
+
return self
|
|
576
|
+
|
|
577
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
578
|
+
"""Context manager exit."""
|
|
579
|
+
self.stop()
|