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/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
+ }