loomweave-plugin-python 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.
@@ -0,0 +1,1655 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import ctypes
5
+ import ctypes.util
6
+ import json
7
+ import math
8
+ import os
9
+ import select
10
+ import shutil
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ import tokenize
17
+ from dataclasses import dataclass
18
+ from io import StringIO
19
+ from pathlib import Path
20
+ from typing import IO, TYPE_CHECKING, Any, Literal, Self
21
+ from urllib.parse import unquote, urlparse
22
+
23
+ from loomweave_plugin_python import __version__
24
+ from loomweave_plugin_python.call_resolver import (
25
+ CallResolutionResult,
26
+ CallsRawEdge,
27
+ Finding,
28
+ UnresolvedCallSite,
29
+ )
30
+ from loomweave_plugin_python.entity_id import entity_id
31
+ from loomweave_plugin_python.extractor import module_dotted_name
32
+ from loomweave_plugin_python.qualname import reconstruct_qualname
33
+ from loomweave_plugin_python.reference_resolver import (
34
+ ReferenceResolutionResult,
35
+ ReferenceSite,
36
+ ReferencesRawEdge,
37
+ )
38
+
39
+ FINDING_PYRIGHT_RESTART = "LMWV-PY-PYRIGHT-RESTART"
40
+ FINDING_PYRIGHT_POISON_FRAME = "LMWV-PY-PYRIGHT-POISON-FRAME"
41
+ FINDING_PYRIGHT_INIT_TIMEOUT = "LMWV-PY-PYRIGHT-INIT-TIMEOUT"
42
+ FINDING_PYRIGHT_UNAVAILABLE = "LMWV-PY-PYRIGHT-UNAVAILABLE"
43
+ FINDING_PYRIGHT_INSTALL_FAILURE = "LMWV-PY-PYRIGHT-INSTALL-FAILURE"
44
+ FINDING_PYRIGHT_CALL_RESOLUTION_TIMEOUT = "LMWV-PY-CALL-RESOLUTION-TIMEOUT"
45
+ FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT = "LMWV-PY-REFERENCE-RESOLUTION-TIMEOUT"
46
+ FINDING_PYRIGHT_REFERENCE_SITE_CAP = "LMWV-PY-REFERENCE-SITE-CAP"
47
+
48
+
49
+ @dataclass
50
+ class PyrightRunState:
51
+ """Run-wide pyright health budget, shared across session recycles.
52
+
53
+ A ``PyrightSession`` is recycled every ``MAX_FILES_PER_PYRIGHT_SESSION``
54
+ files to bound memory growth. Without a shared budget the 3-restart cap
55
+ resets at every recycle boundary, letting a crash-looping pyright silently
56
+ consume ``ceil(N/25) * 3`` restarts instead of 3 for an entire analysis
57
+ run. Pass the same ``PyrightRunState`` instance to every successive
58
+ ``PyrightSession`` so the budget is enforced across the full run.
59
+ """
60
+
61
+ restart_count: int = 0
62
+ disabled: bool = False
63
+
64
+
65
+ MAX_UNRESOLVED_CALLEE_EXPR_BYTES = 512
66
+ MAX_PYRIGHT_RESTARTS_PER_RUN = 3
67
+ MAX_REFERENCE_SITES_PER_FILE = 2000
68
+ PYRIGHT_INIT_TIMEOUT_SECS = 30.0
69
+ PYRIGHT_CALL_TIMEOUT_SECS = 5.0
70
+ PYRIGHT_FILE_TIMEOUT_SECS = 3.0
71
+ STDERR_TAIL_LIMIT = 65536
72
+ PYRIGHT_EXCLUDE_PATTERNS = [
73
+ "**/.loomweave/**",
74
+ "**/.git/**",
75
+ "**/.hg/**",
76
+ "**/.svn/**",
77
+ "**/.jj/**",
78
+ "**/.venv/**",
79
+ "**/__pycache__/**",
80
+ "**/node_modules/**",
81
+ ]
82
+ PROJECT_LOCAL_EXTERNAL_DIRS = {".loomweave", ".git", ".hg", ".svn", ".jj", ".venv", "node_modules"}
83
+
84
+
85
+ if TYPE_CHECKING:
86
+ from collections.abc import Callable, Sequence
87
+
88
+
89
+ class LspTimeoutError(TimeoutError):
90
+ def __init__(self, method: str) -> None:
91
+ super().__init__(f"{method} timed out")
92
+ self.method = method
93
+
94
+
95
+ class LspTransportClosedError(RuntimeError):
96
+ pass
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class _CallSite:
101
+ line: int
102
+ character: int
103
+ end_line: int
104
+ end_character: int
105
+ callee_expr: str
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _FunctionInfo:
110
+ entity_id: str
111
+ qualified_name: str
112
+ name: str
113
+ line: int
114
+ character: int
115
+ end_line: int
116
+ end_character: int
117
+ call_sites: tuple[_CallSite, ...]
118
+ node: ast.FunctionDef | ast.AsyncFunctionDef
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _EntityInfo:
123
+ entity_id: str
124
+ line: int
125
+ character: int
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class _FunctionIndex:
130
+ source: str
131
+ line_starts: tuple[int, ...]
132
+ parse_latency_ms: int
133
+ module_id: str
134
+ by_id: dict[str, _FunctionInfo]
135
+ by_name_position: dict[tuple[int, int], _FunctionInfo]
136
+ entity_by_name_position: dict[tuple[int, int], str]
137
+ by_short_name: dict[str, str]
138
+ dunder_call_by_class: dict[str, str]
139
+ functions: tuple[_FunctionInfo, ...]
140
+ entities: tuple[_EntityInfo, ...]
141
+ tree: ast.Module
142
+ parse_status: Literal["ok", "syntax_error"] = "ok"
143
+
144
+
145
+ @dataclass
146
+ class _ReferenceEdgeAccumulator:
147
+ from_id: str
148
+ to_id: str
149
+ source_byte_start: int
150
+ source_byte_end: int
151
+ candidates: set[str]
152
+
153
+
154
+ class PyrightSession:
155
+ def __init__( # noqa: PLR0913 - knobs are tested lifecycle boundaries.
156
+ self,
157
+ project_root: str | Path,
158
+ *,
159
+ executable: str = "pyright-langserver",
160
+ env: dict[str, str] | None = None,
161
+ install_check: Callable[[str], bool] | None = None,
162
+ init_timeout_secs: float = PYRIGHT_INIT_TIMEOUT_SECS,
163
+ call_timeout_secs: float = PYRIGHT_CALL_TIMEOUT_SECS,
164
+ file_timeout_secs: float = PYRIGHT_FILE_TIMEOUT_SECS,
165
+ max_restarts_per_run: int = MAX_PYRIGHT_RESTARTS_PER_RUN,
166
+ max_reference_sites_per_file: int = MAX_REFERENCE_SITES_PER_FILE,
167
+ run_state: PyrightRunState | None = None,
168
+ ) -> None:
169
+ self.project_root = Path(project_root).resolve()
170
+ self.executable = executable
171
+ self.env = env
172
+ self.install_check = install_check
173
+ self.init_timeout_secs = init_timeout_secs
174
+ self.call_timeout_secs = call_timeout_secs
175
+ self.file_timeout_secs = file_timeout_secs
176
+ self.max_restarts_per_run = max_restarts_per_run
177
+ self.max_reference_sites_per_file = max_reference_sites_per_file
178
+ # Run-wide health budget: shared across session recycles when the caller
179
+ # passes an explicit ``run_state``; isolated (per-instance) otherwise,
180
+ # which preserves the existing contract for code that constructs
181
+ # ``PyrightSession`` directly without going through ``ServerState``.
182
+ self._run_state = run_state if run_state is not None else PyrightRunState()
183
+ self._process: subprocess.Popen[bytes] | None = None
184
+ self._stderr_thread: threading.Thread | None = None
185
+ self._stderr_tail = bytearray()
186
+ self._next_id = 1
187
+ self._findings: list[Finding] = []
188
+ self._function_indexes: dict[Path, _FunctionIndex] = {}
189
+ self._index_parse_latency_ms: list[int] = []
190
+ self._file_deadlines: dict[Path, float] = {}
191
+
192
+ def __enter__(self) -> Self:
193
+ return self
194
+
195
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
196
+ _ = (exc_type, exc, tb)
197
+ self.close()
198
+
199
+ @property
200
+ def stderr_thread_alive(self) -> bool:
201
+ return self._stderr_thread is not None and self._stderr_thread.is_alive()
202
+
203
+ def kill_for_test(self) -> None:
204
+ if self._process is None or self._process.poll() is not None:
205
+ return
206
+ self._process.kill()
207
+ self._process.wait(timeout=2)
208
+
209
+ def close(self) -> None:
210
+ process = self._process
211
+ if process is not None and process.poll() is None:
212
+ try:
213
+ self._request("shutdown", {}, self.call_timeout_secs)
214
+ self._notify("exit", {})
215
+ except (LspTimeoutError, LspTransportClosedError, BrokenPipeError, OSError):
216
+ process.kill()
217
+ try:
218
+ process.wait(timeout=2)
219
+ except subprocess.TimeoutExpired:
220
+ process.kill()
221
+ process.wait(timeout=2)
222
+ self._process = None
223
+ if self._stderr_thread is not None:
224
+ self._stderr_thread.join(timeout=2)
225
+
226
+ def resolve_calls(
227
+ self,
228
+ file_path: str | Path,
229
+ function_ids: Sequence[str],
230
+ ) -> CallResolutionResult:
231
+ path = Path(file_path).resolve()
232
+ index = self._function_index_for_path(path)
233
+ if index.parse_status == "syntax_error":
234
+ return CallResolutionResult(
235
+ unresolved_call_sites_total=len(function_ids),
236
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
237
+ findings=self._pop_findings(),
238
+ )
239
+ requested = [
240
+ index.by_id[function_id] for function_id in function_ids if function_id in index.by_id
241
+ ]
242
+ ast_call_sites_total = sum(len(function.call_sites) for function in requested)
243
+ if not requested:
244
+ return CallResolutionResult(
245
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
246
+ findings=self._pop_findings(),
247
+ )
248
+
249
+ if not self._ensure_process():
250
+ return CallResolutionResult(
251
+ unresolved_call_sites_total=ast_call_sites_total,
252
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
253
+ findings=self._pop_findings(),
254
+ )
255
+
256
+ deadline = self._deadline_for_file(path)
257
+ latency_started = time.perf_counter()
258
+ try:
259
+ edges, unresolved, unresolved_sites = self._resolve_with_pyright(
260
+ path,
261
+ index,
262
+ requested,
263
+ deadline,
264
+ )
265
+ except LspTimeoutError as exc:
266
+ self._record_finding(
267
+ FINDING_PYRIGHT_CALL_RESOLUTION_TIMEOUT,
268
+ f"pyright query timed out: {exc.method}",
269
+ method=exc.method,
270
+ )
271
+ edges = []
272
+ unresolved = ast_call_sites_total
273
+ unresolved_sites = []
274
+ except (LspTransportClosedError, BrokenPipeError, OSError) as exc:
275
+ self._record_restart_or_poison(str(exc))
276
+ edges = []
277
+ unresolved = ast_call_sites_total
278
+ unresolved_sites = []
279
+ latency_ms = max(1, math.ceil((time.perf_counter() - latency_started) * 1000))
280
+
281
+ return CallResolutionResult(
282
+ edges=edges,
283
+ unresolved_call_sites_total=unresolved,
284
+ unresolved_call_sites=unresolved_sites,
285
+ pyright_query_latency_ms=[latency_ms],
286
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
287
+ findings=self._pop_findings(),
288
+ )
289
+
290
+ def resolve_references(
291
+ self,
292
+ file_path: str | Path,
293
+ sites: Sequence[ReferenceSite],
294
+ ) -> ReferenceResolutionResult:
295
+ path = Path(file_path).resolve()
296
+ index = self._function_index_for_path(path)
297
+ reference_sites_total = len(sites)
298
+ if index.parse_status == "syntax_error":
299
+ return ReferenceResolutionResult(
300
+ reference_sites_total=reference_sites_total,
301
+ unresolved_reference_sites_total=reference_sites_total,
302
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
303
+ findings=self._pop_findings(),
304
+ )
305
+ if not sites:
306
+ return ReferenceResolutionResult(
307
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
308
+ findings=self._pop_findings(),
309
+ )
310
+ if reference_sites_total > self.max_reference_sites_per_file:
311
+ self._record_finding(
312
+ FINDING_PYRIGHT_REFERENCE_SITE_CAP,
313
+ "reference site cap exceeded; skipping reference resolution for file",
314
+ reference_sites_total=reference_sites_total,
315
+ max_reference_sites_per_file=self.max_reference_sites_per_file,
316
+ )
317
+ return ReferenceResolutionResult(
318
+ reference_sites_total=reference_sites_total,
319
+ references_skipped_cap_total=reference_sites_total,
320
+ unresolved_reference_sites_total=reference_sites_total,
321
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
322
+ findings=self._pop_findings(),
323
+ )
324
+ if not self._ensure_process():
325
+ return ReferenceResolutionResult(
326
+ reference_sites_total=reference_sites_total,
327
+ unresolved_reference_sites_total=reference_sites_total,
328
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
329
+ findings=self._pop_findings(),
330
+ )
331
+
332
+ deadline = self._deadline_for_file(path)
333
+ latency_started = time.perf_counter()
334
+ try:
335
+ edges, resolved, skipped_external, unresolved = self._resolve_references_with_pyright(
336
+ path,
337
+ index,
338
+ sites,
339
+ deadline,
340
+ )
341
+ except LspTimeoutError as exc:
342
+ self._record_finding(
343
+ FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT,
344
+ f"pyright reference query timed out: {exc.method}",
345
+ method=exc.method,
346
+ )
347
+ edges = []
348
+ resolved = 0
349
+ skipped_external = 0
350
+ unresolved = reference_sites_total
351
+ except (LspTransportClosedError, BrokenPipeError, OSError) as exc:
352
+ self._record_restart_or_poison(str(exc))
353
+ edges = []
354
+ resolved = 0
355
+ skipped_external = 0
356
+ unresolved = reference_sites_total
357
+ finally:
358
+ self._file_deadlines.pop(path, None)
359
+ latency_ms = max(1, math.ceil((time.perf_counter() - latency_started) * 1000))
360
+
361
+ return ReferenceResolutionResult(
362
+ edges=edges,
363
+ reference_sites_total=reference_sites_total,
364
+ references_resolved_total=resolved,
365
+ references_skipped_external_total=skipped_external,
366
+ unresolved_reference_sites_total=unresolved,
367
+ pyright_query_latency_ms=[latency_ms],
368
+ pyright_index_parse_latency_ms=self._pop_index_parse_latencies(),
369
+ findings=self._pop_findings(),
370
+ )
371
+
372
+ def _resolve_with_pyright(
373
+ self,
374
+ path: Path,
375
+ index: _FunctionIndex,
376
+ functions: Sequence[_FunctionInfo],
377
+ deadline: float,
378
+ ) -> tuple[list[CallsRawEdge], int, list[UnresolvedCallSite]]:
379
+ uri = path.as_uri()
380
+ self._notify(
381
+ "textDocument/didOpen",
382
+ {
383
+ "textDocument": {
384
+ "uri": uri,
385
+ "languageId": "python",
386
+ "version": 1,
387
+ "text": index.source,
388
+ },
389
+ },
390
+ )
391
+ try:
392
+ edges: list[CallsRawEdge] = []
393
+ unresolved_total = 0
394
+ unresolved_sites: list[UnresolvedCallSite] = []
395
+ for function in functions:
396
+ self._ensure_file_budget(deadline)
397
+ grouped: dict[tuple[int, int, int, int], set[str]] = {}
398
+ prepared = self._request(
399
+ "textDocument/prepareCallHierarchy",
400
+ {
401
+ "textDocument": {"uri": uri},
402
+ "position": {"line": function.line, "character": function.character},
403
+ },
404
+ self._budgeted_timeout(deadline),
405
+ )
406
+ items = prepared if isinstance(prepared, list) else []
407
+ for item in items:
408
+ self._ensure_file_budget(deadline)
409
+ outgoing = self._request(
410
+ "callHierarchy/outgoingCalls",
411
+ {"item": item},
412
+ self._budgeted_timeout(deadline),
413
+ )
414
+ calls = outgoing if isinstance(outgoing, list) else []
415
+ for call in calls:
416
+ if not isinstance(call, dict):
417
+ continue
418
+ to_id = self._target_id_from_call(call)
419
+ if to_id is None:
420
+ continue
421
+ from_ranges = call.get("fromRanges")
422
+ if not isinstance(from_ranges, list):
423
+ continue
424
+ for from_range in from_ranges:
425
+ key = _range_key(from_range)
426
+ if key is not None and _range_within_function(key, function):
427
+ grouped.setdefault(key, set()).add(to_id)
428
+
429
+ for range_key, candidates in _ambiguous_dict_dispatches(index, function).items():
430
+ grouped.setdefault(range_key, set()).update(candidates)
431
+ for range_key, candidates in _dunder_call_dispatches(index, function).items():
432
+ grouped.setdefault(range_key, set()).update(candidates)
433
+
434
+ for range_key in sorted(grouped):
435
+ candidate_ids = sorted(grouped[range_key])
436
+ if not candidate_ids:
437
+ continue
438
+ start_line, start_character, end_line, end_character = range_key
439
+ start_byte = _position_to_byte(index, start_line, start_character)
440
+ end_byte = _position_to_byte(index, end_line, end_character)
441
+ edge: CallsRawEdge = {
442
+ "kind": "calls",
443
+ "from_id": function.entity_id,
444
+ "to_id": candidate_ids[0],
445
+ "source_byte_start": start_byte,
446
+ "source_byte_end": end_byte,
447
+ "confidence": "resolved" if len(candidate_ids) == 1 else "ambiguous",
448
+ }
449
+ if len(candidate_ids) > 1:
450
+ edge["properties"] = {"candidates": candidate_ids}
451
+ edges.append(edge)
452
+
453
+ function_unresolved_sites = _unresolved_call_sites_for_function(
454
+ index,
455
+ function,
456
+ set(grouped),
457
+ )
458
+ unresolved_total += _unresolved_call_site_total_for_function(
459
+ function,
460
+ set(grouped),
461
+ )
462
+ unresolved_sites.extend(function_unresolved_sites)
463
+ return edges, unresolved_total, unresolved_sites
464
+ finally:
465
+ self._notify("textDocument/didClose", {"textDocument": {"uri": uri}})
466
+
467
+ def _resolve_references_with_pyright(
468
+ self,
469
+ path: Path,
470
+ index: _FunctionIndex,
471
+ sites: Sequence[ReferenceSite],
472
+ deadline: float,
473
+ ) -> tuple[list[ReferencesRawEdge], int, int, int]:
474
+ uri = path.as_uri()
475
+ self._notify(
476
+ "textDocument/didOpen",
477
+ {
478
+ "textDocument": {
479
+ "uri": uri,
480
+ "languageId": "python",
481
+ "version": 1,
482
+ "text": index.source,
483
+ },
484
+ },
485
+ )
486
+ try:
487
+ accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator] = {}
488
+ lookup_cache: dict[
489
+ tuple[str, str, str, int, int, int, int], tuple[list[str], bool]
490
+ ] = {}
491
+ source_bytes = index.source.encode("utf-8")
492
+ resolved_total = 0
493
+ skipped_external_total = 0
494
+ unresolved_total = 0
495
+ for site_index, site in enumerate(sites):
496
+ if self._file_budget_expired(deadline):
497
+ unresolved_total += len(sites) - site_index
498
+ self._record_finding(
499
+ FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT,
500
+ "pyright reference query timed out: analyze_file budget",
501
+ method="analyze_file budget",
502
+ )
503
+ break
504
+ cache_key = _reference_lookup_cache_key(site, source_bytes)
505
+ cached = lookup_cache.get(cache_key)
506
+ if cached is None:
507
+ try:
508
+ candidate_ids, saw_external = self._reference_target_ids(
509
+ uri,
510
+ site,
511
+ deadline=deadline,
512
+ )
513
+ if not candidate_ids and site.kind == "annotation" and not saw_external:
514
+ candidate_ids, fallback_external = self._reference_target_ids(
515
+ uri,
516
+ site,
517
+ method="textDocument/typeDefinition",
518
+ deadline=deadline,
519
+ )
520
+ saw_external = saw_external or fallback_external
521
+ except LspTimeoutError as exc:
522
+ self._record_finding(
523
+ FINDING_PYRIGHT_REFERENCE_RESOLUTION_TIMEOUT,
524
+ f"pyright reference query timed out: {exc.method}",
525
+ method=exc.method,
526
+ line=site.line,
527
+ character=site.character,
528
+ source_byte_start=site.source_byte_start,
529
+ source_byte_end=site.source_byte_end,
530
+ )
531
+ unresolved_total += 1
532
+ continue
533
+ lookup_cache[cache_key] = (candidate_ids, saw_external)
534
+ else:
535
+ candidate_ids, saw_external = cached
536
+ if not candidate_ids:
537
+ unresolved_total += 1
538
+ if saw_external:
539
+ skipped_external_total += 1
540
+ continue
541
+ resolved_total += 1
542
+ _merge_reference_site(accumulators, site, candidate_ids)
543
+ return (
544
+ [
545
+ _reference_accumulator_to_edge(acc)
546
+ for acc in _sorted_reference_accumulators(accumulators)
547
+ ],
548
+ resolved_total,
549
+ skipped_external_total,
550
+ unresolved_total,
551
+ )
552
+ finally:
553
+ self._notify("textDocument/didClose", {"textDocument": {"uri": uri}})
554
+
555
+ def _reference_target_ids(
556
+ self,
557
+ uri: str,
558
+ site: ReferenceSite,
559
+ *,
560
+ deadline: float,
561
+ method: str = "textDocument/definition",
562
+ ) -> tuple[list[str], bool]:
563
+ result = self._request(
564
+ method,
565
+ {
566
+ "textDocument": {"uri": uri},
567
+ "position": {"line": site.line, "character": site.character},
568
+ },
569
+ self._budgeted_timeout(deadline),
570
+ )
571
+ return self._target_ids_from_locations(result)
572
+
573
+ def _deadline_for_file(self, path: Path) -> float:
574
+ return self._file_deadlines.setdefault(
575
+ path,
576
+ time.monotonic() + self.file_timeout_secs,
577
+ )
578
+
579
+ def _budgeted_timeout(self, deadline: float) -> float:
580
+ remaining = deadline - time.monotonic()
581
+ if remaining <= 0:
582
+ method = "analyze_file budget"
583
+ raise LspTimeoutError(method)
584
+ return min(self.call_timeout_secs, remaining)
585
+
586
+ def _ensure_file_budget(self, deadline: float) -> None:
587
+ if self._file_budget_expired(deadline):
588
+ method = "analyze_file budget"
589
+ raise LspTimeoutError(method)
590
+
591
+ def _file_budget_expired(self, deadline: float) -> bool:
592
+ return deadline - time.monotonic() <= 0
593
+
594
+ def _target_ids_from_locations(self, result: object) -> tuple[list[str], bool]:
595
+ locations = result if isinstance(result, list) else [result]
596
+ candidate_ids: set[str] = set()
597
+ saw_external = False
598
+ for location in locations:
599
+ target_id, external = self._target_id_from_location(location)
600
+ if external:
601
+ saw_external = True
602
+ if target_id is not None:
603
+ candidate_ids.add(target_id)
604
+ return sorted(candidate_ids), saw_external
605
+
606
+ def _target_id_from_location(self, location: object) -> tuple[str | None, bool]:
607
+ if not isinstance(location, dict):
608
+ return None, False
609
+ raw_uri = location.get("uri")
610
+ raw_range = location.get("range")
611
+ if raw_uri is None:
612
+ raw_uri = location.get("targetUri")
613
+ if raw_range is None:
614
+ raw_range = location.get("targetSelectionRange") or location.get("targetRange")
615
+ if not isinstance(raw_uri, str) or not isinstance(raw_range, dict):
616
+ return None, False
617
+ target_path = _path_from_uri(raw_uri)
618
+ if target_path is None:
619
+ return None, False
620
+ if not self._is_internal_project_path(target_path):
621
+ return None, True
622
+ target_index = self._function_index_for_path(target_path)
623
+ if target_index.parse_status == "syntax_error":
624
+ return None, False
625
+ key = _range_start_key(raw_range)
626
+ if key is not None and key in target_index.entity_by_name_position:
627
+ return target_index.entity_by_name_position[key], False
628
+ return target_index.module_id, False
629
+
630
+ def _ensure_process(self) -> bool:
631
+ if self._run_state.disabled:
632
+ return False
633
+ if self._process is None:
634
+ return self._start_process()
635
+ if self._process.poll() is None:
636
+ return True
637
+ self._process = None
638
+ self._record_restart_or_poison("pyright subprocess exited")
639
+ if self._run_state.disabled:
640
+ return False
641
+ return self._start_process()
642
+
643
+ def _record_restart_or_poison(self, reason: str) -> None:
644
+ self._run_state.restart_count += 1
645
+ if self._run_state.restart_count > self.max_restarts_per_run:
646
+ self._run_state.disabled = True
647
+ self._record_finding(
648
+ FINDING_PYRIGHT_POISON_FRAME,
649
+ "pyright restart cap exceeded; skipping call resolution",
650
+ restart_count=self._run_state.restart_count,
651
+ reason=reason,
652
+ )
653
+ return
654
+ self._record_finding(
655
+ FINDING_PYRIGHT_RESTART,
656
+ "pyright subprocess died and was restarted",
657
+ restart_count=self._run_state.restart_count,
658
+ reason=reason,
659
+ )
660
+
661
+ def _start_process(self) -> bool:
662
+ executable = self._resolve_executable()
663
+ if executable is None:
664
+ self._run_state.disabled = True
665
+ self._record_finding(
666
+ FINDING_PYRIGHT_UNAVAILABLE,
667
+ "pyright-langserver is not available",
668
+ executable=self.executable,
669
+ )
670
+ return False
671
+ if self.install_check is not None and not self.install_check(executable):
672
+ self._run_state.disabled = True
673
+ self._record_finding(
674
+ FINDING_PYRIGHT_INSTALL_FAILURE,
675
+ "pyright-langserver executability check failed",
676
+ executable=executable,
677
+ )
678
+ return False
679
+
680
+ preexec_fn = None
681
+ if sys.platform == "linux":
682
+ libc_name = ctypes.util.find_library("c")
683
+ libc = None
684
+ if libc_name is not None:
685
+ try: # noqa: SIM105
686
+ libc = ctypes.CDLL(libc_name, use_errno=True)
687
+ except Exception: # noqa: BLE001, S110
688
+ pass
689
+
690
+ if libc is not None:
691
+
692
+ def set_pdeathsig() -> None:
693
+ try:
694
+ # PR_SET_PDEATHSIG is 1
695
+ libc.prctl(1, signal.SIGTERM, 0, 0, 0)
696
+ if os.getppid() == 1:
697
+ os._exit(0)
698
+ except Exception: # noqa: BLE001, S110
699
+ pass
700
+
701
+ preexec_fn = set_pdeathsig
702
+
703
+ try:
704
+ process = subprocess.Popen( # noqa: S603 - executable path comes from manifest/PATH.
705
+ [executable, "--stdio"],
706
+ cwd=self.project_root,
707
+ env=self._subprocess_env(),
708
+ stdin=subprocess.PIPE,
709
+ stdout=subprocess.PIPE,
710
+ stderr=subprocess.PIPE,
711
+ preexec_fn=preexec_fn, # noqa: PLW1509
712
+ )
713
+ except OSError as exc:
714
+ self._run_state.disabled = True
715
+ self._record_finding(
716
+ FINDING_PYRIGHT_INSTALL_FAILURE,
717
+ "pyright-langserver failed to start",
718
+ executable=executable,
719
+ error=str(exc),
720
+ )
721
+ return False
722
+
723
+ self._process = process
724
+ self._start_stderr_drain(process)
725
+ try:
726
+ self._initialize()
727
+ except LspTimeoutError:
728
+ self._run_state.disabled = True
729
+ self._record_finding(
730
+ FINDING_PYRIGHT_INIT_TIMEOUT,
731
+ "pyright initialize handshake timed out",
732
+ timeout_secs=self.init_timeout_secs,
733
+ )
734
+ process.kill()
735
+ process.wait(timeout=2)
736
+ return False
737
+ except (LspTransportClosedError, BrokenPipeError, OSError) as exc:
738
+ self._run_state.disabled = True
739
+ self._record_finding(
740
+ FINDING_PYRIGHT_UNAVAILABLE,
741
+ "pyright initialize handshake failed",
742
+ error=str(exc),
743
+ )
744
+ if process.poll() is None:
745
+ process.kill()
746
+ process.wait(timeout=2)
747
+ return False
748
+ return True
749
+
750
+ def _initialize(self) -> None:
751
+ result = self._request(
752
+ "initialize",
753
+ {
754
+ "processId": os.getpid(),
755
+ "rootUri": self.project_root.as_uri(),
756
+ "workspaceFolders": [
757
+ {"uri": self.project_root.as_uri(), "name": self.project_root.name},
758
+ ],
759
+ "capabilities": {"workspace": {"configuration": True}},
760
+ "clientInfo": {"name": "loomweave-plugin-python", "version": __version__},
761
+ },
762
+ self.init_timeout_secs,
763
+ )
764
+ _ = result
765
+ self._notify("initialized", {})
766
+
767
+ def _resolve_executable(self) -> str | None:
768
+ candidate = Path(self.executable)
769
+ if candidate.parent != Path() or candidate.is_absolute():
770
+ return str(candidate) if candidate.exists() else None
771
+ sibling = Path(sys.executable).parent / self.executable
772
+ if sibling.exists():
773
+ return str(sibling)
774
+ return shutil.which(self.executable)
775
+
776
+ def _subprocess_env(self) -> dict[str, str]:
777
+ if self.env is None:
778
+ return os.environ.copy()
779
+ merged = os.environ.copy()
780
+ merged.update(self.env)
781
+ return merged
782
+
783
+ def _start_stderr_drain(self, process: subprocess.Popen[bytes]) -> None:
784
+ stderr = process.stderr
785
+ if stderr is None:
786
+ return
787
+ thread = threading.Thread(target=self._drain_stderr, args=(stderr,), daemon=True)
788
+ thread.start()
789
+ self._stderr_thread = thread
790
+
791
+ def _drain_stderr(self, stderr: IO[bytes]) -> None:
792
+ while True:
793
+ chunk = stderr.read(8192)
794
+ if not chunk:
795
+ return
796
+ self._stderr_tail.extend(chunk)
797
+ if len(self._stderr_tail) > STDERR_TAIL_LIMIT:
798
+ del self._stderr_tail[:-STDERR_TAIL_LIMIT]
799
+
800
+ def _request(self, method: str, params: dict[str, object], timeout_secs: float) -> object:
801
+ process = self._live_process()
802
+ request_id = self._next_id
803
+ self._next_id += 1
804
+ self._write_message(
805
+ {
806
+ "jsonrpc": "2.0",
807
+ "id": request_id,
808
+ "method": method,
809
+ "params": params,
810
+ },
811
+ )
812
+ while True:
813
+ response = self._read_message(timeout_secs)
814
+ if "method" in response:
815
+ self._handle_server_message(response)
816
+ continue
817
+ if response.get("id") != request_id:
818
+ continue
819
+ if "error" in response:
820
+ raise LspTransportClosedError(str(response["error"]))
821
+ process.poll()
822
+ return response.get("result")
823
+
824
+ def _handle_server_message(self, message: dict[str, Any]) -> None:
825
+ if "id" not in message:
826
+ return
827
+ request_id = message["id"]
828
+ method = message.get("method")
829
+ if method == "workspace/configuration":
830
+ result = self._workspace_configuration_result(message)
831
+ else:
832
+ result = None
833
+ self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
834
+
835
+ def _workspace_configuration_result(self, message: dict[str, Any]) -> list[object]:
836
+ params = message.get("params")
837
+ items = params.get("items") if isinstance(params, dict) else None
838
+ if not isinstance(items, list):
839
+ return []
840
+ return [self._configuration_for_section(item) for item in items]
841
+
842
+ def _configuration_for_section(self, item: object) -> object:
843
+ section = item.get("section") if isinstance(item, dict) else None
844
+ analysis = {
845
+ "diagnosticMode": "openFilesOnly",
846
+ "exclude": PYRIGHT_EXCLUDE_PATTERNS,
847
+ "indexing": False,
848
+ "useLibraryCodeForTypes": False,
849
+ }
850
+ if section == "python":
851
+ return {"analysis": analysis}
852
+ if section == "python.analysis":
853
+ return analysis
854
+ if section == "pyright":
855
+ return {}
856
+ return None
857
+
858
+ def _notify(self, method: str, params: dict[str, object]) -> None:
859
+ self._live_process()
860
+ self._write_message({"jsonrpc": "2.0", "method": method, "params": params})
861
+
862
+ def _live_process(self) -> subprocess.Popen[bytes]:
863
+ if self._process is None or self._process.poll() is not None:
864
+ message = "pyright subprocess is not running"
865
+ raise LspTransportClosedError(message)
866
+ return self._process
867
+
868
+ def _write_message(self, message: dict[str, object]) -> None:
869
+ process = self._live_process()
870
+ if process.stdin is None:
871
+ error_message = "pyright stdin is closed"
872
+ raise LspTransportClosedError(error_message)
873
+ body = json.dumps(message, separators=(",", ":")).encode("utf-8")
874
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
875
+ process.stdin.write(header)
876
+ process.stdin.write(body)
877
+ process.stdin.flush()
878
+
879
+ def _read_message(self, timeout_secs: float) -> dict[str, Any]:
880
+ process = self._live_process()
881
+ if process.stdout is None:
882
+ message = "pyright stdout is closed"
883
+ raise LspTransportClosedError(message)
884
+ fd = process.stdout.fileno()
885
+ deadline = time.monotonic() + timeout_secs
886
+ headers: dict[str, str] = {}
887
+ while True:
888
+ line = _read_line(fd, deadline)
889
+ if line in (b"\r\n", b"\n"):
890
+ break
891
+ decoded_line = line.decode("ascii", errors="ignore").strip()
892
+ name, sep, value = decoded_line.partition(":")
893
+ if not sep:
894
+ continue
895
+ headers[name.strip().lower()] = value.strip()
896
+ if "content-length" not in headers:
897
+ message = f"missing LSP Content-Length header: {headers!r}"
898
+ raise LspTransportClosedError(message)
899
+ length = int(headers["content-length"])
900
+ body = _read_exact(fd, length, deadline)
901
+ parsed: dict[str, Any] = json.loads(body)
902
+ return parsed
903
+
904
+ def _target_id_from_call(self, call: dict[object, object]) -> str | None:
905
+ raw_to = call.get("to")
906
+ if not isinstance(raw_to, dict):
907
+ return None
908
+ raw_uri = raw_to.get("uri")
909
+ raw_selection = raw_to.get("selectionRange")
910
+ if not isinstance(raw_uri, str) or not isinstance(raw_selection, dict):
911
+ return None
912
+ target_path = _path_from_uri(raw_uri)
913
+ if target_path is None:
914
+ return None
915
+ if not self._is_internal_project_path(target_path):
916
+ return None
917
+ index = self._function_index_for_path(target_path)
918
+ if index.parse_status == "syntax_error":
919
+ return None
920
+ key = _range_start_key(raw_selection)
921
+ if key is not None and key in index.by_name_position:
922
+ return index.by_name_position[key].entity_id
923
+ return _containing_function_id(index, raw_selection)
924
+
925
+ def _is_internal_project_path(self, path: Path) -> bool:
926
+ if not path.is_relative_to(self.project_root):
927
+ return False
928
+ relative = path.relative_to(self.project_root)
929
+ return not any(part in PROJECT_LOCAL_EXTERNAL_DIRS for part in relative.parts)
930
+
931
+ def _function_index_for_path(self, path: Path) -> _FunctionIndex:
932
+ resolved = path.resolve()
933
+ cached = self._function_indexes.get(resolved)
934
+ if cached is not None:
935
+ return cached
936
+ source = resolved.read_text(encoding="utf-8")
937
+ index = _build_function_index(self.project_root, resolved, source)
938
+ self._function_indexes[resolved] = index
939
+ self._index_parse_latency_ms.append(index.parse_latency_ms)
940
+ return index
941
+
942
+ def _record_finding(self, subcode: str, message: str, **metadata: object) -> None:
943
+ self._findings.append(
944
+ {
945
+ "subcode": subcode,
946
+ "severity": "warning",
947
+ "message": message,
948
+ "metadata": metadata,
949
+ },
950
+ )
951
+
952
+ def _pop_findings(self) -> list[Finding]:
953
+ findings = self._findings
954
+ self._findings = []
955
+ return findings
956
+
957
+ def _pop_index_parse_latencies(self) -> list[int]:
958
+ latencies = self._index_parse_latency_ms
959
+ self._index_parse_latency_ms = []
960
+ return latencies
961
+
962
+
963
+ def _build_function_index(project_root: Path, path: Path, source: str) -> _FunctionIndex:
964
+ relative = path.relative_to(project_root) if path.is_relative_to(project_root) else path
965
+ dotted_module = module_dotted_name(relative.as_posix())
966
+ parse_started = time.perf_counter()
967
+ parse_status: Literal["ok", "syntax_error"] = "ok"
968
+ try:
969
+ tree = ast.parse(source)
970
+ except SyntaxError:
971
+ tree = ast.Module(body=[], type_ignores=[])
972
+ parse_status = "syntax_error"
973
+ parse_latency_ms = max(1, math.ceil((time.perf_counter() - parse_started) * 1000))
974
+ functions: list[_FunctionInfo] = []
975
+ entities: list[_EntityInfo] = []
976
+ source_lines = source.splitlines()
977
+ _collect_entities(tree, [tree], dotted_module, source_lines, functions, entities, set())
978
+ line_starts = _line_starts(source)
979
+ module_id = entity_id("python", "module", dotted_module)
980
+ by_id = {function.entity_id: function for function in functions}
981
+ by_name_position = {(function.line, function.character): function for function in functions}
982
+ entity_by_name_position = {
983
+ (entity.line, entity.character): entity.entity_id for entity in entities
984
+ }
985
+ by_short_name = {function.name: function.entity_id for function in functions}
986
+ dunder_call_by_class = _dunder_call_targets(functions)
987
+ return _FunctionIndex(
988
+ source=source,
989
+ line_starts=line_starts,
990
+ parse_latency_ms=parse_latency_ms,
991
+ module_id=module_id,
992
+ by_id=by_id,
993
+ by_name_position=by_name_position,
994
+ entity_by_name_position=entity_by_name_position,
995
+ by_short_name=by_short_name,
996
+ dunder_call_by_class=dunder_call_by_class,
997
+ functions=tuple(functions),
998
+ entities=tuple(entities),
999
+ tree=tree,
1000
+ parse_status=parse_status,
1001
+ )
1002
+
1003
+
1004
+ def _declaration_name_character(
1005
+ line_text: str,
1006
+ expected_name: str,
1007
+ declaration_kind: Literal["function", "class"],
1008
+ ) -> int:
1009
+ keyword = "def" if declaration_kind == "function" else "class"
1010
+ try:
1011
+ tokens = tokenize.generate_tokens(StringIO(line_text).readline)
1012
+ seen_keyword = False
1013
+ for token in tokens:
1014
+ if token.type != tokenize.NAME:
1015
+ continue
1016
+ if not seen_keyword:
1017
+ if token.string == keyword:
1018
+ seen_keyword = True
1019
+ continue
1020
+ if token.string == expected_name:
1021
+ return token.start[1]
1022
+ except tokenize.TokenError:
1023
+ return -1
1024
+ return -1
1025
+
1026
+
1027
+ def _collect_entities( # noqa: PLR0913 - keeps function/class indexes in one traversal.
1028
+ node: ast.AST,
1029
+ parents: list[ast.AST],
1030
+ dotted_module: str,
1031
+ source_lines: list[str],
1032
+ out: list[_FunctionInfo],
1033
+ out_entities: list[_EntityInfo],
1034
+ seen_ids: set[str],
1035
+ ) -> None:
1036
+ for child in ast.iter_child_nodes(node):
1037
+ match child:
1038
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
1039
+ if _has_overload_decorator(child):
1040
+ continue
1041
+ python_qualname = reconstruct_qualname(child, parents)
1042
+ qualified_name = f"{dotted_module}.{python_qualname}"
1043
+ child_id = entity_id("python", "function", qualified_name)
1044
+ if child_id in seen_ids:
1045
+ continue
1046
+ seen_ids.add(child_id)
1047
+ line_text = (
1048
+ source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
1049
+ )
1050
+ name_character = _declaration_name_character(line_text, child.name, "function")
1051
+ character = (
1052
+ _codepoint_col_to_utf16(line_text, name_character)
1053
+ if name_character >= 0
1054
+ else _byte_col_to_utf16(line_text, child.col_offset)
1055
+ )
1056
+ entity = _EntityInfo(
1057
+ entity_id=child_id,
1058
+ line=child.lineno - 1,
1059
+ character=character,
1060
+ )
1061
+ out_entities.append(entity)
1062
+ out.append(
1063
+ _FunctionInfo(
1064
+ entity_id=entity.entity_id,
1065
+ qualified_name=qualified_name,
1066
+ name=child.name,
1067
+ line=child.lineno - 1,
1068
+ character=character,
1069
+ end_line=(child.end_lineno or child.lineno) - 1,
1070
+ end_character=_ast_position_to_lsp(
1071
+ source_lines,
1072
+ (child.end_lineno or child.lineno) - 1,
1073
+ child.end_col_offset or child.col_offset,
1074
+ ),
1075
+ call_sites=tuple(_function_call_sites(child, source_lines)),
1076
+ node=child,
1077
+ ),
1078
+ )
1079
+ _collect_entities(
1080
+ child,
1081
+ [*parents, child],
1082
+ dotted_module,
1083
+ source_lines,
1084
+ out,
1085
+ out_entities,
1086
+ seen_ids,
1087
+ )
1088
+ case ast.ClassDef():
1089
+ python_qualname = reconstruct_qualname(child, parents)
1090
+ qualified_name = f"{dotted_module}.{python_qualname}"
1091
+ child_id = entity_id("python", "class", qualified_name)
1092
+ if child_id in seen_ids:
1093
+ continue
1094
+ seen_ids.add(child_id)
1095
+ line_text = (
1096
+ source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
1097
+ )
1098
+ name_character = _declaration_name_character(line_text, child.name, "class")
1099
+ character = (
1100
+ _codepoint_col_to_utf16(line_text, name_character)
1101
+ if name_character >= 0
1102
+ else _byte_col_to_utf16(line_text, child.col_offset)
1103
+ )
1104
+ out_entities.append(
1105
+ _EntityInfo(
1106
+ entity_id=child_id,
1107
+ line=child.lineno - 1,
1108
+ character=character,
1109
+ ),
1110
+ )
1111
+ _collect_entities(
1112
+ child,
1113
+ [*parents, child],
1114
+ dotted_module,
1115
+ source_lines,
1116
+ out,
1117
+ out_entities,
1118
+ seen_ids,
1119
+ )
1120
+ case _:
1121
+ _collect_entities(
1122
+ child,
1123
+ [*parents, child],
1124
+ dotted_module,
1125
+ source_lines,
1126
+ out,
1127
+ out_entities,
1128
+ seen_ids,
1129
+ )
1130
+
1131
+
1132
+ def _has_overload_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
1133
+ for decorator in node.decorator_list:
1134
+ match decorator:
1135
+ case ast.Name(id="overload"):
1136
+ return True
1137
+ case ast.Attribute(
1138
+ value=ast.Name(id="typing" | "typing_extensions"),
1139
+ attr="overload",
1140
+ ):
1141
+ return True
1142
+ return False
1143
+
1144
+
1145
+ def _merge_reference_site(
1146
+ accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator],
1147
+ site: ReferenceSite,
1148
+ candidate_ids: Sequence[str],
1149
+ ) -> None:
1150
+ sorted_candidates = sorted(set(candidate_ids))
1151
+ to_id = sorted_candidates[0]
1152
+ key = (site.from_id, to_id)
1153
+ existing = accumulators.get(key)
1154
+ if existing is None:
1155
+ accumulators[key] = _ReferenceEdgeAccumulator(
1156
+ from_id=site.from_id,
1157
+ to_id=to_id,
1158
+ source_byte_start=site.source_byte_start,
1159
+ source_byte_end=site.source_byte_end,
1160
+ candidates=set(sorted_candidates),
1161
+ )
1162
+ return
1163
+ existing.candidates.update(sorted_candidates)
1164
+ if (site.source_byte_start, site.source_byte_end) < (
1165
+ existing.source_byte_start,
1166
+ existing.source_byte_end,
1167
+ ):
1168
+ existing.source_byte_start = site.source_byte_start
1169
+ existing.source_byte_end = site.source_byte_end
1170
+
1171
+
1172
+ def _reference_lookup_cache_key(
1173
+ site: ReferenceSite,
1174
+ source_bytes: bytes,
1175
+ ) -> tuple[str, str, str, int, int, int, int]:
1176
+ lexeme = source_bytes[site.source_byte_start : site.source_byte_end].decode("utf-8")
1177
+ return (
1178
+ site.from_id,
1179
+ site.kind,
1180
+ lexeme,
1181
+ site.line,
1182
+ site.character,
1183
+ site.source_byte_start,
1184
+ site.source_byte_end,
1185
+ )
1186
+
1187
+
1188
+ def _sorted_reference_accumulators(
1189
+ accumulators: dict[tuple[str, str], _ReferenceEdgeAccumulator],
1190
+ ) -> list[_ReferenceEdgeAccumulator]:
1191
+ return sorted(
1192
+ accumulators.values(),
1193
+ key=lambda acc: (
1194
+ acc.source_byte_start,
1195
+ acc.source_byte_end,
1196
+ acc.from_id,
1197
+ acc.to_id,
1198
+ ),
1199
+ )
1200
+
1201
+
1202
+ def _reference_accumulator_to_edge(
1203
+ accumulator: _ReferenceEdgeAccumulator,
1204
+ ) -> ReferencesRawEdge:
1205
+ candidates = sorted(accumulator.candidates)
1206
+ edge: ReferencesRawEdge = {
1207
+ "kind": "references",
1208
+ "from_id": accumulator.from_id,
1209
+ "to_id": accumulator.to_id,
1210
+ "source_byte_start": accumulator.source_byte_start,
1211
+ "source_byte_end": accumulator.source_byte_end,
1212
+ "confidence": "resolved" if len(candidates) == 1 else "ambiguous",
1213
+ }
1214
+ if len(candidates) > 1:
1215
+ edge["properties"] = {"candidates": candidates}
1216
+ return edge
1217
+
1218
+
1219
+ def _function_call_sites(
1220
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
1221
+ source_lines: Sequence[str],
1222
+ ) -> list[_CallSite]:
1223
+ visitor = _CallSiteVisitor(source_lines)
1224
+ for statement in node.body:
1225
+ visitor.visit(statement)
1226
+ return visitor.call_sites
1227
+
1228
+
1229
+ def _unresolved_call_site_total_for_function(
1230
+ function: _FunctionInfo,
1231
+ resolved_ranges: set[tuple[int, int, int, int]],
1232
+ ) -> int:
1233
+ return sum(
1234
+ 1
1235
+ for call_site in function.call_sites
1236
+ if (
1237
+ call_site.line,
1238
+ call_site.character,
1239
+ call_site.end_line,
1240
+ call_site.end_character,
1241
+ )
1242
+ not in resolved_ranges
1243
+ )
1244
+
1245
+
1246
+ def _unresolved_call_sites_for_function(
1247
+ index: _FunctionIndex,
1248
+ function: _FunctionInfo,
1249
+ resolved_ranges: set[tuple[int, int, int, int]],
1250
+ ) -> list[UnresolvedCallSite]:
1251
+ unresolved: list[UnresolvedCallSite] = []
1252
+ for site_ordinal, call_site in enumerate(function.call_sites):
1253
+ range_key = (
1254
+ call_site.line,
1255
+ call_site.character,
1256
+ call_site.end_line,
1257
+ call_site.end_character,
1258
+ )
1259
+ if range_key in resolved_ranges:
1260
+ continue
1261
+ if len(call_site.callee_expr.encode("utf-8")) > MAX_UNRESOLVED_CALLEE_EXPR_BYTES:
1262
+ continue
1263
+ start_byte = _position_to_byte(index, call_site.line, call_site.character)
1264
+ end_byte = _position_to_byte(index, call_site.end_line, call_site.end_character)
1265
+ unresolved.append(
1266
+ {
1267
+ "caller_entity_id": function.entity_id,
1268
+ "site_ordinal": site_ordinal,
1269
+ "source_byte_start": start_byte,
1270
+ "source_byte_end": end_byte,
1271
+ "callee_expr": call_site.callee_expr,
1272
+ },
1273
+ )
1274
+ return unresolved
1275
+
1276
+
1277
+ class _CallSiteVisitor(ast.NodeVisitor):
1278
+ def __init__(self, source_lines: Sequence[str]) -> None:
1279
+ self.source_lines = source_lines
1280
+ self.call_sites: list[_CallSite] = []
1281
+
1282
+ def visit_Call(self, node: ast.Call) -> None:
1283
+ func = node.func
1284
+ callee_expr = ast.unparse(func)
1285
+ self.call_sites.append(
1286
+ _CallSite(
1287
+ func.lineno - 1,
1288
+ _ast_position_to_lsp(
1289
+ self.source_lines,
1290
+ func.lineno - 1,
1291
+ func.col_offset,
1292
+ ),
1293
+ (func.end_lineno or func.lineno) - 1,
1294
+ _ast_position_to_lsp(
1295
+ self.source_lines,
1296
+ (func.end_lineno or func.lineno) - 1,
1297
+ func.end_col_offset or func.col_offset,
1298
+ ),
1299
+ callee_expr,
1300
+ ),
1301
+ )
1302
+ self.generic_visit(node)
1303
+
1304
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
1305
+ _ = node
1306
+
1307
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
1308
+ _ = node
1309
+
1310
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
1311
+ _ = node
1312
+
1313
+
1314
+ def _ambiguous_dict_dispatches(
1315
+ index: _FunctionIndex,
1316
+ function: _FunctionInfo,
1317
+ ) -> dict[tuple[int, int, int, int], set[str]]:
1318
+ candidate_maps = _callable_dict_maps(index, function.node)
1319
+ if not candidate_maps:
1320
+ return {}
1321
+ visitor = _DictDispatchVisitor(candidate_maps, index.source.splitlines())
1322
+ for statement in function.node.body:
1323
+ visitor.visit(statement)
1324
+ return visitor.dispatches
1325
+
1326
+
1327
+ def _dunder_call_dispatches(
1328
+ index: _FunctionIndex,
1329
+ function: _FunctionInfo,
1330
+ ) -> dict[tuple[int, int, int, int], set[str]]:
1331
+ if not index.dunder_call_by_class:
1332
+ return {}
1333
+ visitor = _DunderCallDispatchVisitor(
1334
+ index.dunder_call_by_class,
1335
+ index.source.splitlines(),
1336
+ )
1337
+ for statement in function.node.body:
1338
+ visitor.visit(statement)
1339
+ return visitor.dispatches
1340
+
1341
+
1342
+ def _dunder_call_targets(functions: list[_FunctionInfo]) -> dict[str, str]:
1343
+ targets: dict[str, str] = {}
1344
+ for function in functions:
1345
+ if not function.qualified_name.endswith(".__call__"):
1346
+ continue
1347
+ class_name = function.qualified_name.rsplit(".", 2)[-2]
1348
+ targets[class_name] = function.entity_id
1349
+ return targets
1350
+
1351
+
1352
+ def _callable_dict_maps(
1353
+ index: _FunctionIndex,
1354
+ function: ast.FunctionDef | ast.AsyncFunctionDef,
1355
+ ) -> dict[str, set[str]]:
1356
+ maps: dict[str, set[str]] = {}
1357
+ for body in [index.tree.body, function.body]:
1358
+ for statement in body:
1359
+ name, value = _callable_dict_assignment(statement, index.by_short_name)
1360
+ if name is not None and value:
1361
+ maps[name] = value
1362
+ return maps
1363
+
1364
+
1365
+ def _callable_dict_assignment(
1366
+ statement: ast.stmt,
1367
+ by_short_name: dict[str, str],
1368
+ ) -> tuple[str | None, set[str]]:
1369
+ target: ast.expr | None = None
1370
+ value: ast.expr | None = None
1371
+ match statement:
1372
+ case ast.Assign(targets=[ast.Name() as name], value=ast.Dict() as dict_value):
1373
+ target = name
1374
+ value = dict_value
1375
+ case ast.AnnAssign(target=ast.Name() as name, value=ast.Dict() as dict_value):
1376
+ target = name
1377
+ value = dict_value
1378
+ case _:
1379
+ return None, set()
1380
+ candidates: set[str] = set()
1381
+ if isinstance(value, ast.Dict):
1382
+ for item in value.values:
1383
+ if isinstance(item, ast.Name) and item.id in by_short_name:
1384
+ candidates.add(by_short_name[item.id])
1385
+ if isinstance(target, ast.Name):
1386
+ return target.id, candidates
1387
+ return None, candidates
1388
+
1389
+
1390
+ class _DictDispatchVisitor(ast.NodeVisitor):
1391
+ def __init__(
1392
+ self,
1393
+ candidate_maps: dict[str, set[str]],
1394
+ source_lines: Sequence[str],
1395
+ ) -> None:
1396
+ self.candidate_maps = candidate_maps
1397
+ self.source_lines = source_lines
1398
+ self.dispatches: dict[tuple[int, int, int, int], set[str]] = {}
1399
+
1400
+ def visit_Call(self, node: ast.Call) -> None:
1401
+ func = node.func
1402
+ if (
1403
+ isinstance(func, ast.Subscript)
1404
+ and isinstance(func.value, ast.Name)
1405
+ and func.value.id in self.candidate_maps
1406
+ ):
1407
+ key = (
1408
+ func.lineno - 1,
1409
+ _ast_position_to_lsp(
1410
+ self.source_lines,
1411
+ func.lineno - 1,
1412
+ func.col_offset,
1413
+ ),
1414
+ (func.end_lineno or func.lineno) - 1,
1415
+ _ast_position_to_lsp(
1416
+ self.source_lines,
1417
+ (func.end_lineno or func.lineno) - 1,
1418
+ func.end_col_offset or func.col_offset,
1419
+ ),
1420
+ )
1421
+ self.dispatches[key] = set(self.candidate_maps[func.value.id])
1422
+ self.generic_visit(node)
1423
+
1424
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
1425
+ _ = node
1426
+
1427
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
1428
+ _ = node
1429
+
1430
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
1431
+ _ = node
1432
+
1433
+
1434
+ class _DunderCallDispatchVisitor(ast.NodeVisitor):
1435
+ def __init__(
1436
+ self,
1437
+ dunder_call_by_class: dict[str, str],
1438
+ source_lines: Sequence[str],
1439
+ ) -> None:
1440
+ self.dunder_call_by_class = dunder_call_by_class
1441
+ self.source_lines = source_lines
1442
+ self.instance_targets: dict[str, str] = {}
1443
+ self.dispatches: dict[tuple[int, int, int, int], set[str]] = {}
1444
+
1445
+ def visit_Assign(self, node: ast.Assign) -> None:
1446
+ if (
1447
+ len(node.targets) == 1
1448
+ and isinstance(node.targets[0], ast.Name)
1449
+ and isinstance(node.value, ast.Call)
1450
+ and isinstance(node.value.func, ast.Name)
1451
+ and node.value.func.id in self.dunder_call_by_class
1452
+ ):
1453
+ self.instance_targets[node.targets[0].id] = self.dunder_call_by_class[
1454
+ node.value.func.id
1455
+ ]
1456
+ self.generic_visit(node)
1457
+
1458
+ def visit_Call(self, node: ast.Call) -> None:
1459
+ func = node.func
1460
+ if isinstance(func, ast.Name) and func.id in self.instance_targets:
1461
+ key = (
1462
+ func.lineno - 1,
1463
+ _ast_position_to_lsp(
1464
+ self.source_lines,
1465
+ func.lineno - 1,
1466
+ func.col_offset,
1467
+ ),
1468
+ (func.end_lineno or func.lineno) - 1,
1469
+ _ast_position_to_lsp(
1470
+ self.source_lines,
1471
+ (func.end_lineno or func.lineno) - 1,
1472
+ func.end_col_offset or func.col_offset,
1473
+ ),
1474
+ )
1475
+ self.dispatches[key] = {self.instance_targets[func.id]}
1476
+ self.generic_visit(node)
1477
+
1478
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
1479
+ _ = node
1480
+
1481
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
1482
+ _ = node
1483
+
1484
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
1485
+ _ = node
1486
+
1487
+
1488
+ def _line_starts(source: str) -> tuple[int, ...]:
1489
+ starts = [0]
1490
+ total = 0
1491
+ for line in source.splitlines(keepends=True):
1492
+ total += len(line.encode("utf-8"))
1493
+ starts.append(total)
1494
+ return tuple(starts)
1495
+
1496
+
1497
+ def _utf16_units(text: str) -> int:
1498
+ return len(text.encode("utf-16-le")) // 2
1499
+
1500
+
1501
+ def _byte_col_to_utf16(line_text: str, byte_col: int) -> int:
1502
+ line_bytes = line_text.encode("utf-8")
1503
+ prefix = line_bytes[: max(0, min(byte_col, len(line_bytes)))]
1504
+ return _utf16_units(prefix.decode("utf-8", errors="ignore"))
1505
+
1506
+
1507
+ def _codepoint_col_to_utf16(line_text: str, codepoint_col: int) -> int:
1508
+ return _utf16_units(line_text[: max(0, codepoint_col)])
1509
+
1510
+
1511
+ def _ast_position_to_lsp(
1512
+ source_lines: Sequence[str],
1513
+ line: int,
1514
+ byte_col: int,
1515
+ ) -> int:
1516
+ if line < 0 or line >= len(source_lines):
1517
+ return 0
1518
+ return _byte_col_to_utf16(source_lines[line], byte_col)
1519
+
1520
+
1521
+ def _utf16_col_to_byte(line_text: str, utf16_col: int) -> int:
1522
+ target = max(0, utf16_col)
1523
+ units = 0
1524
+ byte_count = 0
1525
+ for char in line_text:
1526
+ char_units = _utf16_units(char)
1527
+ if units + char_units > target:
1528
+ break
1529
+ units += char_units
1530
+ byte_count += len(char.encode("utf-8"))
1531
+ if units == target:
1532
+ break
1533
+ return byte_count
1534
+
1535
+
1536
+ def _position_to_byte(index: _FunctionIndex, line: int, character: int) -> int:
1537
+ if line >= len(index.line_starts):
1538
+ return len(index.source.encode("utf-8"))
1539
+ line_start = index.line_starts[line]
1540
+ line_text = index.source.splitlines(keepends=True)[line] if index.source else ""
1541
+ return line_start + _utf16_col_to_byte(line_text, character)
1542
+
1543
+
1544
+ def _range_key(raw_range: object) -> tuple[int, int, int, int] | None:
1545
+ if not isinstance(raw_range, dict):
1546
+ return None
1547
+ start = raw_range.get("start")
1548
+ end = raw_range.get("end")
1549
+ if not isinstance(start, dict) or not isinstance(end, dict):
1550
+ return None
1551
+ start_line = start.get("line")
1552
+ start_character = start.get("character")
1553
+ end_line = end.get("line")
1554
+ end_character = end.get("character")
1555
+ if not isinstance(start_line, int):
1556
+ return None
1557
+ if not isinstance(start_character, int):
1558
+ return None
1559
+ if not isinstance(end_line, int):
1560
+ return None
1561
+ if not isinstance(end_character, int):
1562
+ return None
1563
+ return (start_line, start_character, end_line, end_character)
1564
+
1565
+
1566
+ def _range_within_function(
1567
+ range_key: tuple[int, int, int, int],
1568
+ function: _FunctionInfo,
1569
+ ) -> bool:
1570
+ start_line, start_character, end_line, end_character = range_key
1571
+ if start_line < function.line or end_line > function.end_line:
1572
+ return False
1573
+ if start_line == function.line and start_character < function.character:
1574
+ return False
1575
+ return not (end_line == function.end_line and end_character > function.end_character)
1576
+
1577
+
1578
+ def _range_start_key(raw_range: dict[object, object]) -> tuple[int, int] | None:
1579
+ start = raw_range.get("start")
1580
+ if not isinstance(start, dict):
1581
+ return None
1582
+ line = start.get("line")
1583
+ character = start.get("character")
1584
+ if isinstance(line, int) and isinstance(character, int):
1585
+ return (line, character)
1586
+ return None
1587
+
1588
+
1589
+ def _containing_function_id(index: _FunctionIndex, raw_range: dict[object, object]) -> str | None:
1590
+ key = _range_start_key(raw_range)
1591
+ if key is None:
1592
+ return None
1593
+ line, character = key
1594
+ candidates: list[_FunctionInfo] = []
1595
+ for function in index.functions:
1596
+ starts_inside = function.line < line or (
1597
+ function.line == line and character >= function.character
1598
+ )
1599
+ ends_inside = line < function.end_line or (
1600
+ line == function.end_line and character <= function.end_character
1601
+ )
1602
+ if starts_inside and ends_inside:
1603
+ candidates.append(function)
1604
+ if not candidates:
1605
+ return None
1606
+ return min(
1607
+ candidates,
1608
+ key=lambda function: (
1609
+ function.end_line - function.line,
1610
+ function.end_character - function.character,
1611
+ ),
1612
+ ).entity_id
1613
+
1614
+
1615
+ def _path_from_uri(uri: str) -> Path | None:
1616
+ parsed = urlparse(uri)
1617
+ if parsed.scheme != "file":
1618
+ return None
1619
+ return Path(unquote(parsed.path)).resolve()
1620
+
1621
+
1622
+ def _read_line(fd: int, deadline: float) -> bytes:
1623
+ chunks = bytearray()
1624
+ while True:
1625
+ _wait_readable(fd, deadline)
1626
+ chunk = os.read(fd, 1)
1627
+ if not chunk:
1628
+ message = "EOF while reading LSP header"
1629
+ raise LspTransportClosedError(message)
1630
+ chunks.extend(chunk)
1631
+ if chunk == b"\n":
1632
+ return bytes(chunks)
1633
+
1634
+
1635
+ def _read_exact(fd: int, length: int, deadline: float) -> bytes:
1636
+ chunks = bytearray()
1637
+ while len(chunks) < length:
1638
+ _wait_readable(fd, deadline)
1639
+ chunk = os.read(fd, length - len(chunks))
1640
+ if not chunk:
1641
+ message = "EOF while reading LSP body"
1642
+ raise LspTransportClosedError(message)
1643
+ chunks.extend(chunk)
1644
+ return bytes(chunks)
1645
+
1646
+
1647
+ def _wait_readable(fd: int, deadline: float) -> None:
1648
+ remaining = deadline - time.monotonic()
1649
+ if remaining <= 0:
1650
+ message = "LSP read"
1651
+ raise LspTimeoutError(message)
1652
+ ready, _, _ = select.select([fd], [], [], remaining)
1653
+ if not ready:
1654
+ message = "LSP read"
1655
+ raise LspTimeoutError(message)