repomap-cli 1.0.0__py3-none-any.whl
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.
- repomap/__init__.py +320 -0
- repomap/ai.py +1108 -0
- repomap/check.py +1212 -0
- repomap/cli/__init__.py +3 -0
- repomap/cli/__main__.py +12 -0
- repomap/cli/cli.py +2475 -0
- repomap/core.py +730 -0
- repomap/lsp.py +753 -0
- repomap/parser.py +1697 -0
- repomap/ranking.py +639 -0
- repomap/resolver.py +906 -0
- repomap/toolkit.py +850 -0
- repomap/topic.py +600 -0
- repomap_cli-1.0.0.dist-info/METADATA +284 -0
- repomap_cli-1.0.0.dist-info/RECORD +18 -0
- repomap_cli-1.0.0.dist-info/WHEEL +4 -0
- repomap_cli-1.0.0.dist-info/entry_points.txt +2 -0
- repomap_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
repomap/lsp.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LspServerSpec:
|
|
17
|
+
language: str
|
|
18
|
+
server_name: str
|
|
19
|
+
command_names: tuple[str, ...]
|
|
20
|
+
args: tuple[str, ...] = ()
|
|
21
|
+
file_suffixes: tuple[str, ...] = ()
|
|
22
|
+
root_markers: tuple[str, ...] = ()
|
|
23
|
+
project_relative_candidates: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LspServerDetection:
|
|
28
|
+
language: str
|
|
29
|
+
server_name: str
|
|
30
|
+
status: str
|
|
31
|
+
command: list[str] = field(default_factory=list)
|
|
32
|
+
source: str = ""
|
|
33
|
+
workspace_root: str = ""
|
|
34
|
+
reason: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LspDiagnostic:
|
|
39
|
+
file: str
|
|
40
|
+
line: int
|
|
41
|
+
col: int
|
|
42
|
+
end_line: int
|
|
43
|
+
end_col: int
|
|
44
|
+
severity: str
|
|
45
|
+
code: str
|
|
46
|
+
message: str
|
|
47
|
+
source: str = "lsp"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class LspLocation:
|
|
52
|
+
file: str
|
|
53
|
+
line: int
|
|
54
|
+
col: int
|
|
55
|
+
end_line: int
|
|
56
|
+
end_col: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class LspRunResult:
|
|
61
|
+
server: str
|
|
62
|
+
language: str
|
|
63
|
+
status: str
|
|
64
|
+
diagnostics: list[LspDiagnostic] = field(default_factory=list)
|
|
65
|
+
definitions: list[LspLocation] = field(default_factory=list)
|
|
66
|
+
references: list[LspLocation] = field(default_factory=list)
|
|
67
|
+
command: list[str] = field(default_factory=list)
|
|
68
|
+
workspace_root: str = ""
|
|
69
|
+
reason: str = ""
|
|
70
|
+
duration_ms: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
LSP_SPECS: tuple[LspServerSpec, ...] = (
|
|
74
|
+
LspServerSpec(
|
|
75
|
+
language="typescript",
|
|
76
|
+
server_name="typescript-language-server",
|
|
77
|
+
command_names=("typescript-language-server",),
|
|
78
|
+
args=("--stdio",),
|
|
79
|
+
file_suffixes=(".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"),
|
|
80
|
+
root_markers=("package.json", "tsconfig.json", "jsconfig.json"),
|
|
81
|
+
project_relative_candidates=("node_modules/.bin/typescript-language-server",),
|
|
82
|
+
),
|
|
83
|
+
LspServerSpec(
|
|
84
|
+
language="python",
|
|
85
|
+
server_name="pyright-langserver",
|
|
86
|
+
command_names=("pyright-langserver",),
|
|
87
|
+
args=("--stdio",),
|
|
88
|
+
file_suffixes=(".py",),
|
|
89
|
+
root_markers=("pyproject.toml", "setup.py", "setup.cfg", ".venv"),
|
|
90
|
+
project_relative_candidates=(".venv/bin/pyright-langserver",),
|
|
91
|
+
),
|
|
92
|
+
LspServerSpec(
|
|
93
|
+
language="python",
|
|
94
|
+
server_name="pylsp",
|
|
95
|
+
command_names=("pylsp",),
|
|
96
|
+
file_suffixes=(".py",),
|
|
97
|
+
root_markers=("pyproject.toml", "setup.py", "setup.cfg", ".venv"),
|
|
98
|
+
project_relative_candidates=(".venv/bin/pylsp",),
|
|
99
|
+
),
|
|
100
|
+
LspServerSpec(
|
|
101
|
+
language="rust",
|
|
102
|
+
server_name="rust-analyzer",
|
|
103
|
+
command_names=("rust-analyzer",),
|
|
104
|
+
file_suffixes=(".rs",),
|
|
105
|
+
root_markers=("Cargo.toml",),
|
|
106
|
+
),
|
|
107
|
+
LspServerSpec(
|
|
108
|
+
language="go",
|
|
109
|
+
server_name="gopls",
|
|
110
|
+
command_names=("gopls",),
|
|
111
|
+
file_suffixes=(".go",),
|
|
112
|
+
root_markers=("go.mod", "go.work"),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def language_for_file(file_path: str | Path) -> str | None:
|
|
118
|
+
suffix = Path(file_path).suffix.lower()
|
|
119
|
+
if suffix in {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}:
|
|
120
|
+
return "typescript"
|
|
121
|
+
if suffix == ".py":
|
|
122
|
+
return "python"
|
|
123
|
+
if suffix == ".rs":
|
|
124
|
+
return "rust"
|
|
125
|
+
if suffix == ".go":
|
|
126
|
+
return "go"
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def specs_for_language(language: str) -> list[LspServerSpec]:
|
|
131
|
+
return [spec for spec in LSP_SPECS if spec.language == language]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def detect_project_languages(project_root: str | Path, max_files: int = 2000) -> list[str]:
|
|
135
|
+
root = Path(project_root).resolve()
|
|
136
|
+
languages: set[str] = set()
|
|
137
|
+
skip_dirs = {".git", "node_modules", "dist", "build", ".venv", "venv", "target", "__pycache__"}
|
|
138
|
+
seen = 0
|
|
139
|
+
for current_root, dir_names, file_names in os.walk(root):
|
|
140
|
+
dir_names[:] = [name for name in dir_names if name not in skip_dirs]
|
|
141
|
+
for file_name in file_names:
|
|
142
|
+
language = language_for_file(file_name)
|
|
143
|
+
if language:
|
|
144
|
+
languages.add(language)
|
|
145
|
+
seen += 1
|
|
146
|
+
if seen >= max_files:
|
|
147
|
+
return sorted(languages)
|
|
148
|
+
return sorted(languages)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def detect_lsp_workspace_root(project_root: str | Path, file_path: str | Path | None, language: str) -> Path:
|
|
152
|
+
root = Path(project_root).resolve()
|
|
153
|
+
specs = specs_for_language(language)
|
|
154
|
+
markers: tuple[str, ...] = tuple(dict.fromkeys(marker for spec in specs for marker in spec.root_markers))
|
|
155
|
+
if not file_path:
|
|
156
|
+
return root
|
|
157
|
+
path = Path(file_path)
|
|
158
|
+
abs_path = path if path.is_absolute() else root / path
|
|
159
|
+
abs_path = abs_path.resolve()
|
|
160
|
+
current = abs_path if abs_path.is_dir() else abs_path.parent
|
|
161
|
+
while True:
|
|
162
|
+
if current == root or root in current.parents:
|
|
163
|
+
if any((current / marker).exists() for marker in markers):
|
|
164
|
+
return current
|
|
165
|
+
if current == root:
|
|
166
|
+
break
|
|
167
|
+
current = current.parent
|
|
168
|
+
continue
|
|
169
|
+
break
|
|
170
|
+
return root
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _candidate_is_executable(path: Path) -> bool:
|
|
174
|
+
return path.exists() and os.access(path, os.X_OK)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _dedupe_paths(paths: list[Path]) -> list[Path]:
|
|
178
|
+
result: list[Path] = []
|
|
179
|
+
seen: set[str] = set()
|
|
180
|
+
for path in paths:
|
|
181
|
+
key = str(path.expanduser())
|
|
182
|
+
if key in seen:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(key)
|
|
185
|
+
result.append(path)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _npm_prefix_bin(command_name: str) -> list[Path]:
|
|
190
|
+
try:
|
|
191
|
+
completed = subprocess.run(
|
|
192
|
+
["npm", "config", "get", "prefix"],
|
|
193
|
+
check=False,
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
timeout=2,
|
|
197
|
+
)
|
|
198
|
+
except (OSError, subprocess.SubprocessError):
|
|
199
|
+
return []
|
|
200
|
+
if completed.returncode != 0:
|
|
201
|
+
return []
|
|
202
|
+
prefix = completed.stdout.strip().splitlines()[0] if completed.stdout.strip() else ""
|
|
203
|
+
if not prefix or prefix.lower() == "undefined":
|
|
204
|
+
return []
|
|
205
|
+
prefix_path = Path(prefix).expanduser()
|
|
206
|
+
return [prefix_path / "bin" / command_name]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _trusted_user_lsp_candidates(command_name: str) -> list[Path]:
|
|
210
|
+
home = Path.home()
|
|
211
|
+
candidates: list[Path] = [
|
|
212
|
+
home / ".local" / "bin" / command_name,
|
|
213
|
+
home / ".npm-global" / "bin" / command_name,
|
|
214
|
+
home / ".cargo" / "bin" / command_name,
|
|
215
|
+
home / "go" / "bin" / command_name,
|
|
216
|
+
home / ".bun" / "bin" / command_name,
|
|
217
|
+
home / ".yarn" / "bin" / command_name,
|
|
218
|
+
home / ".config" / "yarn" / "global" / "node_modules" / ".bin" / command_name,
|
|
219
|
+
home / ".local" / "share" / "pnpm" / command_name,
|
|
220
|
+
home / ".local" / "share" / "nvim" / "mason" / "bin" / command_name,
|
|
221
|
+
]
|
|
222
|
+
for base in (
|
|
223
|
+
home / ".local" / "share" / "pnpm" / "global",
|
|
224
|
+
home / ".local" / "share" / "pipx" / "venvs",
|
|
225
|
+
home / ".local" / "share" / "uv" / "tools",
|
|
226
|
+
):
|
|
227
|
+
if not base.is_dir():
|
|
228
|
+
continue
|
|
229
|
+
for child in sorted(base.iterdir()):
|
|
230
|
+
candidate = child / "node_modules" / ".bin" / command_name
|
|
231
|
+
if candidate.exists():
|
|
232
|
+
candidates.append(candidate)
|
|
233
|
+
candidate = child / "bin" / command_name
|
|
234
|
+
if candidate.exists():
|
|
235
|
+
candidates.append(candidate)
|
|
236
|
+
candidates.extend(_npm_prefix_bin(command_name))
|
|
237
|
+
return _dedupe_paths(candidates)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def detect_lsp_server(project_root: str | Path, language: str, file_path: str | Path | None = None) -> LspServerDetection:
|
|
241
|
+
root = Path(project_root).resolve()
|
|
242
|
+
workspace_root = detect_lsp_workspace_root(root, file_path, language)
|
|
243
|
+
specs = specs_for_language(language)
|
|
244
|
+
if not specs:
|
|
245
|
+
return LspServerDetection(language, "", "missing", workspace_root=str(workspace_root), reason="unsupported language")
|
|
246
|
+
for spec in specs:
|
|
247
|
+
for candidate in spec.project_relative_candidates:
|
|
248
|
+
candidate_path = workspace_root / candidate
|
|
249
|
+
if _candidate_is_executable(candidate_path):
|
|
250
|
+
return LspServerDetection(
|
|
251
|
+
language=language,
|
|
252
|
+
server_name=spec.server_name,
|
|
253
|
+
status="available",
|
|
254
|
+
command=[str(candidate_path), *spec.args],
|
|
255
|
+
source="project",
|
|
256
|
+
workspace_root=str(workspace_root),
|
|
257
|
+
)
|
|
258
|
+
for command_name in spec.command_names:
|
|
259
|
+
resolved = shutil.which(command_name)
|
|
260
|
+
if resolved:
|
|
261
|
+
return LspServerDetection(
|
|
262
|
+
language=language,
|
|
263
|
+
server_name=spec.server_name,
|
|
264
|
+
status="available",
|
|
265
|
+
command=[resolved, *spec.args],
|
|
266
|
+
source="path",
|
|
267
|
+
workspace_root=str(workspace_root),
|
|
268
|
+
)
|
|
269
|
+
for candidate_path in _trusted_user_lsp_candidates(command_name):
|
|
270
|
+
if _candidate_is_executable(candidate_path):
|
|
271
|
+
return LspServerDetection(
|
|
272
|
+
language=language,
|
|
273
|
+
server_name=spec.server_name,
|
|
274
|
+
status="available",
|
|
275
|
+
command=[str(candidate_path), *spec.args],
|
|
276
|
+
source="user",
|
|
277
|
+
workspace_root=str(workspace_root),
|
|
278
|
+
)
|
|
279
|
+
return LspServerDetection(
|
|
280
|
+
language=language,
|
|
281
|
+
server_name=specs[0].server_name,
|
|
282
|
+
status="missing",
|
|
283
|
+
workspace_root=str(workspace_root),
|
|
284
|
+
reason="local LSP server executable not found",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def detect_lsp_servers(project_root: str | Path, languages: list[str] | None = None) -> list[LspServerDetection]:
|
|
289
|
+
detected_languages = languages or detect_project_languages(project_root)
|
|
290
|
+
return [detect_lsp_server(project_root, language) for language in detected_languages]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _path_to_uri(path: Path) -> str:
|
|
294
|
+
return path.resolve().as_uri()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _uri_to_path(uri: str) -> Path:
|
|
298
|
+
if uri.startswith("file://"):
|
|
299
|
+
from urllib.parse import unquote, urlparse
|
|
300
|
+
return Path(unquote(urlparse(uri).path))
|
|
301
|
+
return Path(uri)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _json_rpc_frame(payload: dict[str, Any]) -> bytes:
|
|
305
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
306
|
+
return b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _read_lsp_message(stream: Any) -> dict[str, Any] | None:
|
|
310
|
+
headers: dict[str, str] = {}
|
|
311
|
+
while True:
|
|
312
|
+
line = stream.readline()
|
|
313
|
+
if not line:
|
|
314
|
+
return None
|
|
315
|
+
if line in (b"\r\n", b"\n"):
|
|
316
|
+
break
|
|
317
|
+
text = line.decode("ascii", errors="replace").strip()
|
|
318
|
+
if ":" in text:
|
|
319
|
+
key, value = text.split(":", 1)
|
|
320
|
+
headers[key.lower()] = value.strip()
|
|
321
|
+
length = int(headers.get("content-length", "0"))
|
|
322
|
+
if length <= 0:
|
|
323
|
+
return None
|
|
324
|
+
body = stream.read(length)
|
|
325
|
+
if not body:
|
|
326
|
+
return None
|
|
327
|
+
return json.loads(body.decode("utf-8"))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class StdioLspClient:
|
|
331
|
+
def __init__(self, command: list[str], workspace_root: Path, timeout: float = 8.0):
|
|
332
|
+
self.command = command
|
|
333
|
+
self.workspace_root = workspace_root
|
|
334
|
+
self.timeout = timeout
|
|
335
|
+
self.process: subprocess.Popen[bytes] | None = None
|
|
336
|
+
self._next_id = 1
|
|
337
|
+
self._messages: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
338
|
+
self._reader: threading.Thread | None = None
|
|
339
|
+
|
|
340
|
+
def __enter__(self) -> "StdioLspClient":
|
|
341
|
+
self.start()
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
345
|
+
self.close()
|
|
346
|
+
|
|
347
|
+
def start(self) -> None:
|
|
348
|
+
self.process = subprocess.Popen(
|
|
349
|
+
self.command,
|
|
350
|
+
cwd=self.workspace_root,
|
|
351
|
+
stdin=subprocess.PIPE,
|
|
352
|
+
stdout=subprocess.PIPE,
|
|
353
|
+
stderr=subprocess.PIPE,
|
|
354
|
+
)
|
|
355
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
356
|
+
self._reader.start()
|
|
357
|
+
|
|
358
|
+
def _read_loop(self) -> None:
|
|
359
|
+
assert self.process is not None and self.process.stdout is not None
|
|
360
|
+
while True:
|
|
361
|
+
try:
|
|
362
|
+
message = _read_lsp_message(self.process.stdout)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
self._messages.put({"method": "$/repomapReadError", "params": {"message": str(exc)}})
|
|
365
|
+
return
|
|
366
|
+
if message is None:
|
|
367
|
+
return
|
|
368
|
+
self._messages.put(message)
|
|
369
|
+
|
|
370
|
+
def send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
371
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
372
|
+
|
|
373
|
+
def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
374
|
+
request_id = self._next_id
|
|
375
|
+
self._next_id += 1
|
|
376
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
|
|
377
|
+
deadline = time.time() + self.timeout
|
|
378
|
+
while time.time() < deadline:
|
|
379
|
+
try:
|
|
380
|
+
message = self._messages.get(timeout=max(0.05, deadline - time.time()))
|
|
381
|
+
except queue.Empty:
|
|
382
|
+
break
|
|
383
|
+
if message.get("id") == request_id:
|
|
384
|
+
return message
|
|
385
|
+
# 请求期间可能收到 diagnostics 等通知;这里丢弃非目标消息,
|
|
386
|
+
# 避免同一通知被反复放回队列导致请求超时。
|
|
387
|
+
if "id" not in message:
|
|
388
|
+
continue
|
|
389
|
+
time.sleep(0.01)
|
|
390
|
+
raise TimeoutError(f"LSP request timed out: {method}")
|
|
391
|
+
|
|
392
|
+
def _send(self, payload: dict[str, Any]) -> None:
|
|
393
|
+
assert self.process is not None and self.process.stdin is not None
|
|
394
|
+
self.process.stdin.write(_json_rpc_frame(payload))
|
|
395
|
+
self.process.stdin.flush()
|
|
396
|
+
|
|
397
|
+
def initialize(self) -> None:
|
|
398
|
+
self.request(
|
|
399
|
+
"initialize",
|
|
400
|
+
{
|
|
401
|
+
"processId": os.getpid(),
|
|
402
|
+
"rootUri": _path_to_uri(self.workspace_root),
|
|
403
|
+
"capabilities": {
|
|
404
|
+
"textDocument": {
|
|
405
|
+
"publishDiagnostics": {},
|
|
406
|
+
"synchronization": {},
|
|
407
|
+
"definition": {},
|
|
408
|
+
"references": {},
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
self.send_notification("initialized", {})
|
|
414
|
+
|
|
415
|
+
def did_open(self, file_path: Path, language: str, text: str) -> None:
|
|
416
|
+
self.send_notification(
|
|
417
|
+
"textDocument/didOpen",
|
|
418
|
+
{
|
|
419
|
+
"textDocument": {
|
|
420
|
+
"uri": _path_to_uri(file_path),
|
|
421
|
+
"languageId": _lsp_language_id(language, file_path),
|
|
422
|
+
"version": 1,
|
|
423
|
+
"text": text,
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def _position_params(self, file_path: Path, line: int, character: int) -> dict[str, Any]:
|
|
429
|
+
return {
|
|
430
|
+
"textDocument": {"uri": _path_to_uri(file_path)},
|
|
431
|
+
"position": {"line": line, "character": character},
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
def definition(self, file_path: Path, line: int, character: int) -> Any:
|
|
435
|
+
response = self.request("textDocument/definition", self._position_params(file_path, line, character))
|
|
436
|
+
if "error" in response:
|
|
437
|
+
raise RuntimeError(str(response["error"]))
|
|
438
|
+
return response.get("result")
|
|
439
|
+
|
|
440
|
+
def references(self, file_path: Path, line: int, character: int) -> Any:
|
|
441
|
+
params = self._position_params(file_path, line, character)
|
|
442
|
+
params["context"] = {"includeDeclaration": True}
|
|
443
|
+
response = self.request("textDocument/references", params)
|
|
444
|
+
if "error" in response:
|
|
445
|
+
raise RuntimeError(str(response["error"]))
|
|
446
|
+
return response.get("result")
|
|
447
|
+
|
|
448
|
+
def collect_diagnostics(self, file_paths: list[Path], language: str) -> list[dict[str, Any]]:
|
|
449
|
+
expected_uris = {_path_to_uri(path) for path in file_paths}
|
|
450
|
+
diagnostics: list[dict[str, Any]] = []
|
|
451
|
+
deadline = time.time() + self.timeout
|
|
452
|
+
while time.time() < deadline and expected_uris:
|
|
453
|
+
try:
|
|
454
|
+
message = self._messages.get(timeout=max(0.05, deadline - time.time()))
|
|
455
|
+
except queue.Empty:
|
|
456
|
+
break
|
|
457
|
+
if message.get("method") != "textDocument/publishDiagnostics":
|
|
458
|
+
continue
|
|
459
|
+
params = message.get("params", {})
|
|
460
|
+
uri = params.get("uri", "")
|
|
461
|
+
if uri in expected_uris:
|
|
462
|
+
diagnostics.append(params)
|
|
463
|
+
expected_uris.remove(uri)
|
|
464
|
+
return diagnostics
|
|
465
|
+
|
|
466
|
+
def close(self) -> None:
|
|
467
|
+
if self.process is None:
|
|
468
|
+
return
|
|
469
|
+
process = self.process
|
|
470
|
+
try:
|
|
471
|
+
if process.poll() is None:
|
|
472
|
+
try:
|
|
473
|
+
self.request("shutdown", {})
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
try:
|
|
477
|
+
self.send_notification("exit", {})
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
try:
|
|
481
|
+
process.wait(timeout=2)
|
|
482
|
+
except subprocess.TimeoutExpired:
|
|
483
|
+
process.kill()
|
|
484
|
+
process.wait(timeout=2)
|
|
485
|
+
finally:
|
|
486
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
487
|
+
if stream is None:
|
|
488
|
+
continue
|
|
489
|
+
try:
|
|
490
|
+
stream.close()
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
self.process = None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _lsp_language_id(language: str, file_path: Path) -> str:
|
|
497
|
+
suffix = file_path.suffix.lower()
|
|
498
|
+
if language == "typescript":
|
|
499
|
+
if suffix == ".tsx":
|
|
500
|
+
return "typescriptreact"
|
|
501
|
+
if suffix in {".js", ".jsx", ".mjs", ".cjs"}:
|
|
502
|
+
return "javascriptreact" if suffix == ".jsx" else "javascript"
|
|
503
|
+
return "typescript"
|
|
504
|
+
return language
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _severity_name(value: int | None) -> str:
|
|
508
|
+
return {1: "error", 2: "warning", 3: "info", 4: "info"}.get(value or 3, "info")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _diagnostic_from_lsp(project_root: Path, params: dict[str, Any], item: dict[str, Any]) -> LspDiagnostic:
|
|
512
|
+
file_path = _uri_to_path(params.get("uri", ""))
|
|
513
|
+
try:
|
|
514
|
+
rel_file = file_path.resolve().relative_to(project_root).as_posix()
|
|
515
|
+
except ValueError:
|
|
516
|
+
rel_file = file_path.as_posix()
|
|
517
|
+
range_row = item.get("range", {})
|
|
518
|
+
start = range_row.get("start", {})
|
|
519
|
+
end = range_row.get("end", {})
|
|
520
|
+
code = item.get("code", "")
|
|
521
|
+
return LspDiagnostic(
|
|
522
|
+
file=rel_file,
|
|
523
|
+
line=int(start.get("line", 0)) + 1,
|
|
524
|
+
col=int(start.get("character", 0)) + 1,
|
|
525
|
+
end_line=int(end.get("line", start.get("line", 0))) + 1,
|
|
526
|
+
end_col=int(end.get("character", start.get("character", 0))) + 1,
|
|
527
|
+
severity=_severity_name(item.get("severity")),
|
|
528
|
+
code=str(code) if code is not None else "",
|
|
529
|
+
message=str(item.get("message", "")),
|
|
530
|
+
source=str(item.get("source", "lsp")),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def collect_lsp_diagnostics(
|
|
535
|
+
project_root: str | Path,
|
|
536
|
+
files: list[str],
|
|
537
|
+
timeout: float = 8.0,
|
|
538
|
+
max_files: int = 20,
|
|
539
|
+
) -> list[LspRunResult]:
|
|
540
|
+
root = Path(project_root).resolve()
|
|
541
|
+
normalized_files = [Path(file) for file in files[:max_files]]
|
|
542
|
+
by_language: dict[str, list[Path]] = {}
|
|
543
|
+
for file_path in normalized_files:
|
|
544
|
+
language = language_for_file(file_path)
|
|
545
|
+
if not language:
|
|
546
|
+
continue
|
|
547
|
+
abs_path = file_path if file_path.is_absolute() else root / file_path
|
|
548
|
+
if abs_path.exists() and abs_path.is_file():
|
|
549
|
+
by_language.setdefault(language, []).append(abs_path.resolve())
|
|
550
|
+
results: list[LspRunResult] = []
|
|
551
|
+
for language, abs_files in sorted(by_language.items()):
|
|
552
|
+
detection = detect_lsp_server(root, language, abs_files[0])
|
|
553
|
+
if detection.status != "available":
|
|
554
|
+
results.append(LspRunResult(
|
|
555
|
+
server=detection.server_name or language,
|
|
556
|
+
language=language,
|
|
557
|
+
status="skipped",
|
|
558
|
+
workspace_root=detection.workspace_root,
|
|
559
|
+
reason=detection.reason,
|
|
560
|
+
))
|
|
561
|
+
continue
|
|
562
|
+
start = time.time()
|
|
563
|
+
try:
|
|
564
|
+
workspace_root = Path(detection.workspace_root)
|
|
565
|
+
with StdioLspClient(detection.command, workspace_root, timeout=timeout) as client:
|
|
566
|
+
client.initialize()
|
|
567
|
+
for abs_file in abs_files:
|
|
568
|
+
client.did_open(abs_file, language, abs_file.read_text(encoding="utf-8", errors="replace"))
|
|
569
|
+
raw_diagnostics = client.collect_diagnostics(abs_files, language)
|
|
570
|
+
diagnostics = [
|
|
571
|
+
_diagnostic_from_lsp(root, params, item)
|
|
572
|
+
for params in raw_diagnostics
|
|
573
|
+
for item in params.get("diagnostics", [])
|
|
574
|
+
]
|
|
575
|
+
exit_code_status = "ok"
|
|
576
|
+
results.append(LspRunResult(
|
|
577
|
+
server=detection.server_name,
|
|
578
|
+
language=language,
|
|
579
|
+
status=exit_code_status,
|
|
580
|
+
diagnostics=diagnostics,
|
|
581
|
+
command=detection.command,
|
|
582
|
+
workspace_root=detection.workspace_root,
|
|
583
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
584
|
+
))
|
|
585
|
+
except TimeoutError as exc:
|
|
586
|
+
results.append(LspRunResult(
|
|
587
|
+
server=detection.server_name,
|
|
588
|
+
language=language,
|
|
589
|
+
status="timeout",
|
|
590
|
+
command=detection.command,
|
|
591
|
+
workspace_root=detection.workspace_root,
|
|
592
|
+
reason=str(exc),
|
|
593
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
594
|
+
))
|
|
595
|
+
except Exception as exc:
|
|
596
|
+
results.append(LspRunResult(
|
|
597
|
+
server=detection.server_name,
|
|
598
|
+
language=language,
|
|
599
|
+
status="failed",
|
|
600
|
+
command=detection.command,
|
|
601
|
+
workspace_root=detection.workspace_root,
|
|
602
|
+
reason=str(exc),
|
|
603
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
604
|
+
))
|
|
605
|
+
if not results:
|
|
606
|
+
results.append(LspRunResult(server="lsp", language="unknown", status="skipped", reason="no supported files"))
|
|
607
|
+
return results
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _location_from_lsp(project_root: Path, item: dict[str, Any]) -> LspLocation | None:
|
|
611
|
+
uri = item.get("uri") or item.get("targetUri")
|
|
612
|
+
raw_range = item.get("range") or item.get("targetSelectionRange") or item.get("targetRange")
|
|
613
|
+
if not uri or not isinstance(raw_range, dict):
|
|
614
|
+
return None
|
|
615
|
+
file_path = _uri_to_path(str(uri))
|
|
616
|
+
try:
|
|
617
|
+
rel_file = file_path.resolve().relative_to(project_root).as_posix()
|
|
618
|
+
except ValueError:
|
|
619
|
+
rel_file = file_path.as_posix()
|
|
620
|
+
start = raw_range.get("start", {})
|
|
621
|
+
end = raw_range.get("end", {})
|
|
622
|
+
return LspLocation(
|
|
623
|
+
file=rel_file,
|
|
624
|
+
line=int(start.get("line", 0)) + 1,
|
|
625
|
+
col=int(start.get("character", 0)) + 1,
|
|
626
|
+
end_line=int(end.get("line", start.get("line", 0))) + 1,
|
|
627
|
+
end_col=int(end.get("character", start.get("character", 0))) + 1,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _normalize_lsp_locations(project_root: Path, value: Any) -> list[LspLocation]:
|
|
632
|
+
if value is None:
|
|
633
|
+
return []
|
|
634
|
+
raw_items = value if isinstance(value, list) else [value]
|
|
635
|
+
locations: list[LspLocation] = []
|
|
636
|
+
seen: set[tuple[str, int, int, int, int]] = set()
|
|
637
|
+
for raw_item in raw_items:
|
|
638
|
+
if not isinstance(raw_item, dict):
|
|
639
|
+
continue
|
|
640
|
+
location = _location_from_lsp(project_root, raw_item)
|
|
641
|
+
if location is None:
|
|
642
|
+
continue
|
|
643
|
+
key = (location.file, location.line, location.col, location.end_line, location.end_col)
|
|
644
|
+
if key in seen:
|
|
645
|
+
continue
|
|
646
|
+
seen.add(key)
|
|
647
|
+
locations.append(location)
|
|
648
|
+
return locations
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _symbol_position(project_root: Path, file_path: str, line: int, symbol_name: str) -> tuple[Path, int, int]:
|
|
652
|
+
abs_file = (project_root / file_path).resolve()
|
|
653
|
+
zero_based_line = max(0, line - 1)
|
|
654
|
+
character = 0
|
|
655
|
+
try:
|
|
656
|
+
lines = abs_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
657
|
+
if 0 <= zero_based_line < len(lines):
|
|
658
|
+
index = lines[zero_based_line].find(symbol_name)
|
|
659
|
+
if index >= 0:
|
|
660
|
+
character = index
|
|
661
|
+
except OSError:
|
|
662
|
+
pass
|
|
663
|
+
return abs_file, zero_based_line, character
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def collect_lsp_symbol_evidence(
|
|
667
|
+
project_root: str | Path,
|
|
668
|
+
file_path: str,
|
|
669
|
+
line: int,
|
|
670
|
+
symbol_name: str,
|
|
671
|
+
timeout: float = 8.0,
|
|
672
|
+
) -> LspRunResult:
|
|
673
|
+
root = Path(project_root).resolve()
|
|
674
|
+
language = language_for_file(file_path)
|
|
675
|
+
if not language:
|
|
676
|
+
return LspRunResult(server="lsp", language="unknown", status="skipped", reason="unsupported file type")
|
|
677
|
+
abs_file, line_index, character = _symbol_position(root, file_path, line, symbol_name)
|
|
678
|
+
if not abs_file.exists() or not abs_file.is_file():
|
|
679
|
+
return LspRunResult(server="lsp", language=language, status="skipped", reason="file not found")
|
|
680
|
+
detection = detect_lsp_server(root, language, abs_file)
|
|
681
|
+
if detection.status != "available":
|
|
682
|
+
return LspRunResult(
|
|
683
|
+
server=detection.server_name or language,
|
|
684
|
+
language=language,
|
|
685
|
+
status="skipped",
|
|
686
|
+
workspace_root=detection.workspace_root,
|
|
687
|
+
reason=detection.reason,
|
|
688
|
+
)
|
|
689
|
+
start = time.time()
|
|
690
|
+
try:
|
|
691
|
+
workspace_root = Path(detection.workspace_root)
|
|
692
|
+
with StdioLspClient(detection.command, workspace_root, timeout=timeout) as client:
|
|
693
|
+
client.initialize()
|
|
694
|
+
client.did_open(abs_file, language, abs_file.read_text(encoding="utf-8", errors="replace"))
|
|
695
|
+
definitions = _normalize_lsp_locations(root, client.definition(abs_file, line_index, character))
|
|
696
|
+
references = _normalize_lsp_locations(root, client.references(abs_file, line_index, character))
|
|
697
|
+
return LspRunResult(
|
|
698
|
+
server=detection.server_name,
|
|
699
|
+
language=language,
|
|
700
|
+
status="ok",
|
|
701
|
+
definitions=definitions,
|
|
702
|
+
references=references,
|
|
703
|
+
command=detection.command,
|
|
704
|
+
workspace_root=detection.workspace_root,
|
|
705
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
706
|
+
)
|
|
707
|
+
except TimeoutError as exc:
|
|
708
|
+
return LspRunResult(
|
|
709
|
+
server=detection.server_name,
|
|
710
|
+
language=language,
|
|
711
|
+
status="timeout",
|
|
712
|
+
command=detection.command,
|
|
713
|
+
workspace_root=detection.workspace_root,
|
|
714
|
+
reason=str(exc),
|
|
715
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
716
|
+
)
|
|
717
|
+
except Exception as exc:
|
|
718
|
+
return LspRunResult(
|
|
719
|
+
server=detection.server_name,
|
|
720
|
+
language=language,
|
|
721
|
+
status="failed",
|
|
722
|
+
command=detection.command,
|
|
723
|
+
workspace_root=detection.workspace_root,
|
|
724
|
+
reason=str(exc),
|
|
725
|
+
duration_ms=int((time.time() - start) * 1000),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def detection_to_dict(detection: LspServerDetection) -> dict[str, Any]:
|
|
730
|
+
return {
|
|
731
|
+
"language": detection.language,
|
|
732
|
+
"server": detection.server_name,
|
|
733
|
+
"status": detection.status,
|
|
734
|
+
"command": detection.command,
|
|
735
|
+
"source": detection.source,
|
|
736
|
+
"workspaceRoot": detection.workspace_root,
|
|
737
|
+
"reason": detection.reason,
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def run_result_to_dict(result: LspRunResult) -> dict[str, Any]:
|
|
742
|
+
return {
|
|
743
|
+
"server": result.server,
|
|
744
|
+
"language": result.language,
|
|
745
|
+
"status": result.status,
|
|
746
|
+
"command": result.command,
|
|
747
|
+
"workspaceRoot": result.workspace_root,
|
|
748
|
+
"reason": result.reason,
|
|
749
|
+
"durationMs": result.duration_ms,
|
|
750
|
+
"diagnostics": [diagnostic.__dict__ for diagnostic in result.diagnostics],
|
|
751
|
+
"definitions": [location.__dict__ for location in result.definitions],
|
|
752
|
+
"references": [location.__dict__ for location in result.references],
|
|
753
|
+
}
|