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.
- loomweave_plugin_python/__init__.py +3 -0
- loomweave_plugin_python/__main__.py +15 -0
- loomweave_plugin_python/call_resolver.py +65 -0
- loomweave_plugin_python/entity_id.py +75 -0
- loomweave_plugin_python/extractor.py +1312 -0
- loomweave_plugin_python/py.typed +0 -0
- loomweave_plugin_python/pyright_session.py +1655 -0
- loomweave_plugin_python/qualname.py +48 -0
- loomweave_plugin_python/reference_resolver.py +70 -0
- loomweave_plugin_python/server.py +310 -0
- loomweave_plugin_python/stdout_guard.py +62 -0
- loomweave_plugin_python/wardline_descriptor.py +197 -0
- loomweave_plugin_python-1.0.0.data/data/share/loomweave/plugins/python/plugin.toml +71 -0
- loomweave_plugin_python-1.0.0.dist-info/METADATA +73 -0
- loomweave_plugin_python-1.0.0.dist-info/RECORD +17 -0
- loomweave_plugin_python-1.0.0.dist-info/WHEEL +4 -0
- loomweave_plugin_python-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -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)
|