beatrina 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
"""analyze.py — the Python ANALYSIS child. It reads Python; it never runs Python.
|
|
2
|
+
|
|
3
|
+
Behind Beatrix's analysis plane (host/planes/analyze.mjs), beside spike/analyze.R
|
|
4
|
+
and for the same reason one language over: the evaluating session (the Python
|
|
5
|
+
engine's ipykernel) answers nothing while a cell is inside a long computation,
|
|
6
|
+
and that must not take syntax checking, navigation and rename down with it.
|
|
7
|
+
|
|
8
|
+
browser ⇅ host/main.mjs ⇅ ipykernel — evaluates everything
|
|
9
|
+
⇅ analyze.py — evaluates NOTHING
|
|
10
|
+
|
|
11
|
+
Ported from carmapy's analyze.py (../carmar-python/py/carmapy/analyze.py), with
|
|
12
|
+
one deliberate change recorded in docs/wp/wp8a-python-analyzer.md: the
|
|
13
|
+
reference index is built from `ast`, not from `jedi`, and jedi is not imported.
|
|
14
|
+
|
|
15
|
+
THE ONE RULE: nothing in this file may evaluate user source.
|
|
16
|
+
|
|
17
|
+
· `ast.parse()` builds a syntax tree and runs no line of it.
|
|
18
|
+
· `codeop.compile_command()` COMPILES (bytecode) and never executes; it is
|
|
19
|
+
here only to answer "would more input finish this?".
|
|
20
|
+
· There is no `eval`, no `exec`, no `compile(..., "exec")` handed to a
|
|
21
|
+
runner, no `import` of anything the user wrote, and none may be added.
|
|
22
|
+
test/python-analyzer.test.mjs greps this file for them.
|
|
23
|
+
· The process runs `python -I`: no PYTHONPATH, no user site-packages, no
|
|
24
|
+
script directory on sys.path. It imports the standard library and nothing
|
|
25
|
+
else, so there is no third-party module whose import could run code.
|
|
26
|
+
|
|
27
|
+
── WHY AST AND NOT JEDI ─────────────────────────────────────────────────────
|
|
28
|
+
The page's rename (lib/rename.js) rests on analyze.R's contract: two
|
|
29
|
+
occurrences are the SAME symbol when name and `scope` match, where scope 0 is a
|
|
30
|
+
document's top level and any other scope is the innermost FUNCTION THAT BINDS
|
|
31
|
+
the name. carmapy took `scope` from jedi's `parent()`, which is the enclosing
|
|
32
|
+
definition, not the binding — its own docstring says so and says the rule is
|
|
33
|
+
therefore "not claimed for Python". Fed to lib/rename.js that is a silent
|
|
34
|
+
partial rename: a read of a top-level name inside a function would carry the
|
|
35
|
+
function's id and be skipped as "a local of the same name". A binder over the
|
|
36
|
+
syntax tree answers the real question with Python's own rules (parameters,
|
|
37
|
+
assignment, `global`, `nonlocal`, class bodies that do not enclose methods,
|
|
38
|
+
comprehension scopes), costs nothing per name, and needs no third-party code.
|
|
39
|
+
|
|
40
|
+
The ops this child answers are exactly the four the host forwards
|
|
41
|
+
(`analyze`, `ping`, `workspace`, `workspace_references`) — the same as
|
|
42
|
+
analyze.R. Hover, definition and rename are the PAGE's, computed from the
|
|
43
|
+
reference index and the symbols, as they are for R.
|
|
44
|
+
|
|
45
|
+
── POSITIONS ────────────────────────────────────────────────────────────────
|
|
46
|
+
Every position emitted is 1-based line, 1-based CODE POINT column,
|
|
47
|
+
`unit: "codepoint"` — analyze.R's contract, so lib/r-intelligence.js converts a
|
|
48
|
+
Python reply with no change. `ast` reports columns as 0-based UTF-8 BYTE
|
|
49
|
+
offsets, which are converted against the line's own bytes (`_cp`) and never
|
|
50
|
+
emitted raw. `SyntaxError.offset` is already 1-based code points (measured in
|
|
51
|
+
carmapy: `x = "👩" z` reports 9). Only "\n" starts a line, as in the page; the
|
|
52
|
+
notebook normalises CRLF before a source is analysed.
|
|
53
|
+
|
|
54
|
+
Python, like R, reports only the FIRST syntax error, so a reply carries at most
|
|
55
|
+
one parse diagnostic.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import ast
|
|
61
|
+
import codeop
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import re
|
|
65
|
+
import sys
|
|
66
|
+
import time
|
|
67
|
+
import warnings
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _budget() -> float:
|
|
71
|
+
try:
|
|
72
|
+
v = float(os.environ.get("CARMAR_ANALYZE_BUDGET", "3"))
|
|
73
|
+
except ValueError:
|
|
74
|
+
v = 3.0
|
|
75
|
+
if v != v or v in (float("inf"), float("-inf")):
|
|
76
|
+
v = 3.0
|
|
77
|
+
return max(0.0, min(v, 60.0))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
ANALYZE_BUDGET = _budget()
|
|
81
|
+
|
|
82
|
+
MAX_FILES = 300
|
|
83
|
+
MAX_DEPTH = 4
|
|
84
|
+
MAX_BYTES = 512_000
|
|
85
|
+
MAX_MATCHES = 50
|
|
86
|
+
MAX_REPLY_BYTES = 4_194_304
|
|
87
|
+
|
|
88
|
+
SKIP_DIRS = frozenset({
|
|
89
|
+
".git", ".hg", ".svn", "node_modules", ".venv", "venv", "env",
|
|
90
|
+
"__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox",
|
|
91
|
+
".ipynb_checkpoints", ".quarto", "_site", "dist", "build", ".eggs",
|
|
92
|
+
"site-packages", ".Rproj.user", "renv", "packrat",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── framing ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
class Emitter:
|
|
99
|
+
"""One control frame: the session's random sentinel, then compact JSON."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, sentinel: str, out=None):
|
|
102
|
+
self.sentinel = sentinel
|
|
103
|
+
self.out = out if out is not None else sys.stdout
|
|
104
|
+
|
|
105
|
+
def __call__(self, obj: dict) -> None:
|
|
106
|
+
self.out.write(self.sentinel + json.dumps(obj, separators=(",", ":"),
|
|
107
|
+
ensure_ascii=False) + "\n")
|
|
108
|
+
self.out.flush()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── positions ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
class Lines:
|
|
114
|
+
"""A source's lines, with byte-offset -> code-point conversion per line."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, source: str):
|
|
117
|
+
self.text = source.split("\n")
|
|
118
|
+
self._bytes: dict = {}
|
|
119
|
+
|
|
120
|
+
def line(self, lineno: int) -> str:
|
|
121
|
+
i = (lineno or 1) - 1
|
|
122
|
+
return self.text[i] if 0 <= i < len(self.text) else ""
|
|
123
|
+
|
|
124
|
+
def cp(self, lineno: int, byte_col: int) -> int:
|
|
125
|
+
"""0-based UTF-8 byte offset on a line -> 0-based code-point offset."""
|
|
126
|
+
raw = self._bytes.get(lineno)
|
|
127
|
+
if raw is None:
|
|
128
|
+
raw = self.line(lineno).encode("utf-8")
|
|
129
|
+
self._bytes[lineno] = raw
|
|
130
|
+
return len(raw[:max(0, byte_col)].decode("utf-8", errors="ignore"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── diagnostics ────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def _syntax_diagnostic(err: SyntaxError, incomplete: bool) -> dict:
|
|
136
|
+
line = err.lineno
|
|
137
|
+
col = err.offset # 1-based CODE POINTS
|
|
138
|
+
end_line = getattr(err, "end_lineno", None)
|
|
139
|
+
end_col = getattr(err, "end_offset", None)
|
|
140
|
+
# `end_offset` is -1 on an IndentationError and 0 on "'[' was never closed".
|
|
141
|
+
# Those are not positions: an end that does not lie strictly after the start
|
|
142
|
+
# is dropped, and the page widens the point to the token under it.
|
|
143
|
+
if col is None or end_col is None or end_line is None:
|
|
144
|
+
end_line, end_col = line, None
|
|
145
|
+
elif end_line < (line or 0) or (end_line == line and end_col <= col):
|
|
146
|
+
end_line, end_col = line, None
|
|
147
|
+
return {
|
|
148
|
+
"severity": "error", "rule": "parse",
|
|
149
|
+
"message": err.msg or "invalid syntax",
|
|
150
|
+
# A NUL byte is a SyntaxError with no position (3.12+); the raw text then
|
|
151
|
+
# says the message alone rather than "(None:None)".
|
|
152
|
+
"raw": f"{err.msg} ({err.lineno}:{err.offset})" if line is not None else str(err.msg),
|
|
153
|
+
"unit": "codepoint", "line": line, "col": col,
|
|
154
|
+
"endLine": end_line, "endCol": end_col, "incomplete": incomplete,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_incomplete(source: str) -> bool:
|
|
159
|
+
"""Would more input make this parse? `codeop` compiles; it never executes."""
|
|
160
|
+
try:
|
|
161
|
+
with warnings.catch_warnings():
|
|
162
|
+
warnings.simplefilter("ignore")
|
|
163
|
+
return codeop.compile_command(source, "<analyze>", "exec") is None
|
|
164
|
+
except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse(source: str):
|
|
169
|
+
# SyntaxWarnings (an invalid escape, `is` with a literal) are not errors and
|
|
170
|
+
# must not reach stderr as noise; they are silenced, never turned into
|
|
171
|
+
# diagnostics the page would underline as broken code.
|
|
172
|
+
with warnings.catch_warnings():
|
|
173
|
+
warnings.simplefilter("ignore")
|
|
174
|
+
return ast.parse(source)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── symbols (lines only, as analyze.R) ─────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def _signature_of(node) -> tuple:
|
|
180
|
+
a = node.args
|
|
181
|
+
names: list = []
|
|
182
|
+
parts: list = []
|
|
183
|
+
|
|
184
|
+
def render(arg, default=None, prefix=""):
|
|
185
|
+
text = prefix + arg.arg
|
|
186
|
+
if arg.annotation is not None:
|
|
187
|
+
text += ": " + ast.unparse(arg.annotation)
|
|
188
|
+
if default is not None:
|
|
189
|
+
text += ("=" if arg.annotation is None else " = ") + ast.unparse(default)
|
|
190
|
+
names.append(arg.arg)
|
|
191
|
+
parts.append(text)
|
|
192
|
+
|
|
193
|
+
posonly = list(a.posonlyargs)
|
|
194
|
+
normal = list(a.args)
|
|
195
|
+
defaults = list(a.defaults)
|
|
196
|
+
positional = posonly + normal
|
|
197
|
+
pad = [None] * (len(positional) - len(defaults)) + defaults
|
|
198
|
+
for arg, dflt in zip(posonly, pad[:len(posonly)]):
|
|
199
|
+
render(arg, dflt)
|
|
200
|
+
if posonly:
|
|
201
|
+
parts.append("/")
|
|
202
|
+
for arg, dflt in zip(normal, pad[len(posonly):]):
|
|
203
|
+
render(arg, dflt)
|
|
204
|
+
if a.vararg is not None:
|
|
205
|
+
render(a.vararg, None, "*")
|
|
206
|
+
elif a.kwonlyargs:
|
|
207
|
+
parts.append("*")
|
|
208
|
+
for arg, dflt in zip(a.kwonlyargs, a.kw_defaults):
|
|
209
|
+
render(arg, dflt)
|
|
210
|
+
if a.kwarg is not None:
|
|
211
|
+
render(a.kwarg, None, "**")
|
|
212
|
+
return node.name + "(" + ", ".join(parts) + ")", names
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def top_symbols(tree) -> list:
|
|
216
|
+
"""Top-level names a document defines, with the line each sits on."""
|
|
217
|
+
out = []
|
|
218
|
+
for node in getattr(tree, "body", []):
|
|
219
|
+
end = getattr(node, "end_lineno", node.lineno)
|
|
220
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
221
|
+
signature, params = _signature_of(node)
|
|
222
|
+
out.append({"name": node.name, "line": node.lineno, "endLine": end,
|
|
223
|
+
"kind": "function", "params": params, "signature": signature})
|
|
224
|
+
elif isinstance(node, ast.ClassDef):
|
|
225
|
+
out.append({"name": node.name, "line": node.lineno, "endLine": end,
|
|
226
|
+
"kind": "class", "params": [], "signature": node.name})
|
|
227
|
+
elif isinstance(node, ast.Assign):
|
|
228
|
+
for target in node.targets:
|
|
229
|
+
if isinstance(target, ast.Name):
|
|
230
|
+
out.append({"name": target.id, "line": node.lineno, "endLine": end,
|
|
231
|
+
"kind": "variable", "params": [], "signature": None})
|
|
232
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
233
|
+
out.append({"name": node.target.id, "line": node.lineno, "endLine": end,
|
|
234
|
+
"kind": "variable", "params": [], "signature": None})
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── the reference index: a binder over the syntax tree ─────────────────────
|
|
239
|
+
|
|
240
|
+
_FUNC_KINDS = ("function", "lambda", "comprehension")
|
|
241
|
+
_COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Scope:
|
|
245
|
+
__slots__ = ("kind", "id", "parent", "bound", "globals", "nonlocals")
|
|
246
|
+
|
|
247
|
+
def __init__(self, kind, sid, parent):
|
|
248
|
+
self.kind = kind
|
|
249
|
+
self.id = sid
|
|
250
|
+
self.parent = parent
|
|
251
|
+
self.bound: set = set()
|
|
252
|
+
self.globals: set = set()
|
|
253
|
+
self.nonlocals: set = set()
|
|
254
|
+
|
|
255
|
+
def binder(self):
|
|
256
|
+
"""Where a walrus binds: the nearest scope that is not a comprehension."""
|
|
257
|
+
s = self
|
|
258
|
+
while s is not None and s.kind == "comprehension":
|
|
259
|
+
s = s.parent
|
|
260
|
+
return s
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _targets(node):
|
|
264
|
+
"""Every plain name an assignment target binds (tuples, lists, stars)."""
|
|
265
|
+
if isinstance(node, ast.Name):
|
|
266
|
+
yield node.id
|
|
267
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
268
|
+
for elt in node.elts:
|
|
269
|
+
yield from _targets(elt)
|
|
270
|
+
elif isinstance(node, ast.Starred):
|
|
271
|
+
yield from _targets(node.value)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class References:
|
|
275
|
+
"""Every parser-confirmed name occurrence, with its role and its binding scope.
|
|
276
|
+
|
|
277
|
+
Roles (analyze.R's list, analyze.R's reasons):
|
|
278
|
+
def a binding — the thing to jump TO (assignment, def, class, import,
|
|
279
|
+
`except ... as`, a `for`/`with` target, a match capture).
|
|
280
|
+
formal a parameter: a binding, so it shadows an outer name.
|
|
281
|
+
field `obj.attr` and `f(name=...)` — a name in ANOTHER namespace, kept in
|
|
282
|
+
the index so a rename can say what it declined to touch.
|
|
283
|
+
call `f(...)` — a use, and a call.
|
|
284
|
+
use any other mention, including `global x` / `nonlocal x`.
|
|
285
|
+
|
|
286
|
+
SCOPE is analyze.R's: 0 for a document's top level, otherwise the id of the
|
|
287
|
+
innermost function (or lambda, or comprehension) that BINDS the name. A
|
|
288
|
+
class body binds only for occurrences directly inside it — methods do not see
|
|
289
|
+
class names, which is Python's rule. Ids are `line * 10000 + column` of the
|
|
290
|
+
scope's node, stable within one parse, which is all analyze.R's are.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def __init__(self, source: str):
|
|
294
|
+
self.lines = Lines(source)
|
|
295
|
+
self.scopes: dict = {}
|
|
296
|
+
self.out: list = []
|
|
297
|
+
|
|
298
|
+
# -- pass 1: what each scope binds -------------------------------------
|
|
299
|
+
|
|
300
|
+
def _sid(self, node) -> int:
|
|
301
|
+
return (node.lineno or 0) * 10000 + self.lines.cp(node.lineno, node.col_offset)
|
|
302
|
+
|
|
303
|
+
def collect(self, tree) -> None:
|
|
304
|
+
module = Scope("module", 0, None)
|
|
305
|
+
self.scopes[id(tree)] = module
|
|
306
|
+
self._collect_body(tree.body, module)
|
|
307
|
+
|
|
308
|
+
def _collect_body(self, nodes, scope) -> None:
|
|
309
|
+
for node in nodes:
|
|
310
|
+
self._collect(node, scope)
|
|
311
|
+
|
|
312
|
+
def _collect_args(self, args, scope) -> None:
|
|
313
|
+
for a in (list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
|
|
314
|
+
+ [x for x in (args.vararg, args.kwarg) if x is not None]):
|
|
315
|
+
scope.bound.add(a.arg)
|
|
316
|
+
|
|
317
|
+
def _collect(self, node, scope) -> None:
|
|
318
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
319
|
+
scope.bound.add(node.name)
|
|
320
|
+
for d in node.decorator_list:
|
|
321
|
+
self._collect(d, scope)
|
|
322
|
+
for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
|
|
323
|
+
self._collect(d, scope)
|
|
324
|
+
inner = Scope("function", self._sid(node), scope)
|
|
325
|
+
self.scopes[id(node)] = inner
|
|
326
|
+
self._collect_args(node.args, inner)
|
|
327
|
+
self._collect_body(node.body, inner)
|
|
328
|
+
return
|
|
329
|
+
if isinstance(node, ast.ClassDef):
|
|
330
|
+
scope.bound.add(node.name)
|
|
331
|
+
for d in list(node.decorator_list) + list(node.bases) + [k.value for k in node.keywords]:
|
|
332
|
+
self._collect(d, scope)
|
|
333
|
+
inner = Scope("class", self._sid(node), scope)
|
|
334
|
+
self.scopes[id(node)] = inner
|
|
335
|
+
self._collect_body(node.body, inner)
|
|
336
|
+
return
|
|
337
|
+
if isinstance(node, ast.Lambda):
|
|
338
|
+
for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
|
|
339
|
+
self._collect(d, scope)
|
|
340
|
+
inner = Scope("lambda", self._sid(node), scope)
|
|
341
|
+
self.scopes[id(node)] = inner
|
|
342
|
+
self._collect_args(node.args, inner)
|
|
343
|
+
self._collect(node.body, inner)
|
|
344
|
+
return
|
|
345
|
+
if isinstance(node, _COMPREHENSIONS):
|
|
346
|
+
inner = Scope("comprehension", self._sid(node), scope)
|
|
347
|
+
self.scopes[id(node)] = inner
|
|
348
|
+
for gen in node.generators:
|
|
349
|
+
for name in _targets(gen.target):
|
|
350
|
+
inner.bound.add(name)
|
|
351
|
+
self._collect(gen.iter, inner)
|
|
352
|
+
for cond in gen.ifs:
|
|
353
|
+
self._collect(cond, inner)
|
|
354
|
+
for part in ("elt", "key", "value"):
|
|
355
|
+
if hasattr(node, part):
|
|
356
|
+
self._collect(getattr(node, part), inner)
|
|
357
|
+
return
|
|
358
|
+
if isinstance(node, ast.Global):
|
|
359
|
+
scope.globals.update(node.names)
|
|
360
|
+
return
|
|
361
|
+
if isinstance(node, ast.Nonlocal):
|
|
362
|
+
scope.nonlocals.update(node.names)
|
|
363
|
+
return
|
|
364
|
+
if isinstance(node, ast.NamedExpr):
|
|
365
|
+
binder = scope.binder()
|
|
366
|
+
for name in _targets(node.target):
|
|
367
|
+
binder.bound.add(name)
|
|
368
|
+
self._collect(node.value, scope)
|
|
369
|
+
return
|
|
370
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
371
|
+
scope.bound.add(node.id)
|
|
372
|
+
return
|
|
373
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
374
|
+
for alias in node.names:
|
|
375
|
+
if alias.name == "*":
|
|
376
|
+
continue
|
|
377
|
+
scope.bound.add(alias.asname or alias.name.split(".")[0])
|
|
378
|
+
return
|
|
379
|
+
if isinstance(node, ast.ExceptHandler) and node.name:
|
|
380
|
+
scope.bound.add(node.name)
|
|
381
|
+
if isinstance(node, ast.MatchAs) and node.name:
|
|
382
|
+
scope.bound.add(node.name)
|
|
383
|
+
if isinstance(node, ast.MatchStar) and node.name:
|
|
384
|
+
scope.bound.add(node.name)
|
|
385
|
+
if isinstance(node, ast.MatchMapping) and node.rest:
|
|
386
|
+
scope.bound.add(node.rest)
|
|
387
|
+
for child in ast.iter_child_nodes(node):
|
|
388
|
+
self._collect(child, scope)
|
|
389
|
+
|
|
390
|
+
# -- resolution ---------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def resolve(self, name: str, scope):
|
|
393
|
+
"""The id and kind of the scope that binds `name` as seen from `scope`."""
|
|
394
|
+
# Python's rule, in the order Python applies it. `global x` sends x to the
|
|
395
|
+
# module; `nonlocal x` says "not bound HERE", so the walk moves outward;
|
|
396
|
+
# a class body binds only what is written directly in it (a method does
|
|
397
|
+
# not see its class's names).
|
|
398
|
+
s = scope
|
|
399
|
+
while s is not None:
|
|
400
|
+
if s.kind == "module":
|
|
401
|
+
return 0, "module"
|
|
402
|
+
if s.kind == "class":
|
|
403
|
+
if s is scope and name in s.bound:
|
|
404
|
+
return s.id, "class"
|
|
405
|
+
else:
|
|
406
|
+
if name in s.globals:
|
|
407
|
+
return 0, "module"
|
|
408
|
+
if name not in s.nonlocals and name in s.bound:
|
|
409
|
+
return s.id, s.kind
|
|
410
|
+
s = s.parent
|
|
411
|
+
return 0, "module"
|
|
412
|
+
|
|
413
|
+
# -- pass 2: occurrences ------------------------------------------------
|
|
414
|
+
|
|
415
|
+
def emit(self, name, lineno, cp_col, role, scope) -> None:
|
|
416
|
+
if lineno is None or cp_col is None:
|
|
417
|
+
return
|
|
418
|
+
sid, kind = self.resolve(name, scope)
|
|
419
|
+
self.out.append({
|
|
420
|
+
"name": name, "line": lineno, "col": cp_col + 1,
|
|
421
|
+
"endCol": cp_col + 1 + len(name), "role": role, "unit": "codepoint",
|
|
422
|
+
"scope": sid, "scopeKind": kind,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
def emit_field(self, name, lineno, cp_col, scope) -> None:
|
|
426
|
+
if lineno is None or cp_col is None:
|
|
427
|
+
return
|
|
428
|
+
self.out.append({
|
|
429
|
+
"name": name, "line": lineno, "col": cp_col + 1,
|
|
430
|
+
"endCol": cp_col + 1 + len(name), "role": "field", "unit": "codepoint",
|
|
431
|
+
"scope": scope.id if scope.kind != "module" else 0, "scopeKind": scope.kind,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
def _find(self, lineno, byte_start, pattern, name):
|
|
435
|
+
"""The code-point column of `name` matched by `pattern` on a line."""
|
|
436
|
+
line = self.lines.line(lineno)
|
|
437
|
+
start = self.lines.cp(lineno, byte_start)
|
|
438
|
+
m = re.compile(pattern.format(re.escape(name))).search(line, start)
|
|
439
|
+
return m.start(1) if m else None
|
|
440
|
+
|
|
441
|
+
def walk(self, tree) -> list:
|
|
442
|
+
self._walk_body(tree.body, self.scopes[id(tree)])
|
|
443
|
+
self.out.sort(key=lambda r: (r["line"], r["col"]))
|
|
444
|
+
return self.out
|
|
445
|
+
|
|
446
|
+
def _walk_body(self, nodes, scope) -> None:
|
|
447
|
+
for node in nodes:
|
|
448
|
+
self._walk(node, scope, None)
|
|
449
|
+
|
|
450
|
+
def _walk_args(self, args, scope) -> None:
|
|
451
|
+
for a in (list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs)
|
|
452
|
+
+ [x for x in (args.vararg, args.kwarg) if x is not None]):
|
|
453
|
+
if a.annotation is not None:
|
|
454
|
+
self._walk(a.annotation, scope.parent, None)
|
|
455
|
+
self.emit(a.arg, a.lineno, self.lines.cp(a.lineno, a.col_offset), "formal", scope)
|
|
456
|
+
|
|
457
|
+
def _walk(self, node, scope, parent) -> None:
|
|
458
|
+
if node is None:
|
|
459
|
+
return
|
|
460
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
461
|
+
for d in node.decorator_list:
|
|
462
|
+
self._walk(d, scope, node)
|
|
463
|
+
col = self._find(node.lineno, node.col_offset, r"(?:async\s+)?def\s+({})\b", node.name)
|
|
464
|
+
self.emit(node.name, node.lineno, col, "def", scope)
|
|
465
|
+
inner = self.scopes[id(node)]
|
|
466
|
+
for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
|
|
467
|
+
self._walk(d, scope, node)
|
|
468
|
+
self._walk_args(node.args, inner)
|
|
469
|
+
if node.returns is not None:
|
|
470
|
+
self._walk(node.returns, scope, node)
|
|
471
|
+
self._walk_body(node.body, inner)
|
|
472
|
+
return
|
|
473
|
+
if isinstance(node, ast.ClassDef):
|
|
474
|
+
for d in node.decorator_list:
|
|
475
|
+
self._walk(d, scope, node)
|
|
476
|
+
col = self._find(node.lineno, node.col_offset, r"class\s+({})\b", node.name)
|
|
477
|
+
self.emit(node.name, node.lineno, col, "def", scope)
|
|
478
|
+
for b in node.bases:
|
|
479
|
+
self._walk(b, scope, node)
|
|
480
|
+
for k in node.keywords:
|
|
481
|
+
self._walk(k, scope, node)
|
|
482
|
+
self._walk_body(node.body, self.scopes[id(node)])
|
|
483
|
+
return
|
|
484
|
+
if isinstance(node, ast.Lambda):
|
|
485
|
+
inner = self.scopes[id(node)]
|
|
486
|
+
for d in list(node.args.defaults) + [x for x in node.args.kw_defaults if x is not None]:
|
|
487
|
+
self._walk(d, scope, node)
|
|
488
|
+
self._walk_args(node.args, inner)
|
|
489
|
+
self._walk(node.body, inner, node)
|
|
490
|
+
return
|
|
491
|
+
if isinstance(node, _COMPREHENSIONS):
|
|
492
|
+
inner = self.scopes[id(node)]
|
|
493
|
+
for gen in node.generators:
|
|
494
|
+
self._walk(gen.target, inner, gen)
|
|
495
|
+
self._walk(gen.iter, inner, gen)
|
|
496
|
+
for cond in gen.ifs:
|
|
497
|
+
self._walk(cond, inner, gen)
|
|
498
|
+
for part in ("elt", "key", "value"):
|
|
499
|
+
if hasattr(node, part):
|
|
500
|
+
self._walk(getattr(node, part), inner, node)
|
|
501
|
+
return
|
|
502
|
+
if isinstance(node, ast.NamedExpr):
|
|
503
|
+
binder = scope.binder()
|
|
504
|
+
if isinstance(node.target, ast.Name):
|
|
505
|
+
t = node.target
|
|
506
|
+
self.emit(t.id, t.lineno, self.lines.cp(t.lineno, t.col_offset), "def", binder)
|
|
507
|
+
self._walk(node.value, scope, node)
|
|
508
|
+
return
|
|
509
|
+
if isinstance(node, ast.Name):
|
|
510
|
+
col = self.lines.cp(node.lineno, node.col_offset)
|
|
511
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
512
|
+
role = "def"
|
|
513
|
+
elif isinstance(parent, ast.Call) and parent.func is node:
|
|
514
|
+
role = "call"
|
|
515
|
+
else:
|
|
516
|
+
role = "use"
|
|
517
|
+
self.emit(node.id, node.lineno, col, role, scope)
|
|
518
|
+
return
|
|
519
|
+
if isinstance(node, ast.Attribute):
|
|
520
|
+
self._walk(node.value, scope, node)
|
|
521
|
+
end_line = getattr(node, "end_lineno", None)
|
|
522
|
+
end_col = getattr(node, "end_col_offset", None)
|
|
523
|
+
if end_line is not None and end_col is not None:
|
|
524
|
+
cp_end = self.lines.cp(end_line, end_col)
|
|
525
|
+
start = cp_end - len(node.attr)
|
|
526
|
+
if self.lines.line(end_line)[start:cp_end] == node.attr:
|
|
527
|
+
self.emit_field(node.attr, end_line, start, scope)
|
|
528
|
+
return
|
|
529
|
+
if isinstance(node, ast.keyword):
|
|
530
|
+
if node.arg is not None and getattr(node, "lineno", None) is not None:
|
|
531
|
+
col = self.lines.cp(node.lineno, node.col_offset)
|
|
532
|
+
if self.lines.line(node.lineno)[col:col + len(node.arg)] == node.arg:
|
|
533
|
+
self.emit_field(node.arg, node.lineno, col, scope)
|
|
534
|
+
self._walk(node.value, scope, node)
|
|
535
|
+
return
|
|
536
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
537
|
+
for alias in node.names:
|
|
538
|
+
if alias.name == "*":
|
|
539
|
+
continue
|
|
540
|
+
bound = alias.asname or alias.name.split(".")[0]
|
|
541
|
+
lineno = getattr(alias, "lineno", node.lineno)
|
|
542
|
+
byte = getattr(alias, "col_offset", node.col_offset)
|
|
543
|
+
if alias.asname:
|
|
544
|
+
col = self._find(lineno, byte, r"\bas\s+({})\b", bound)
|
|
545
|
+
else:
|
|
546
|
+
col = self.lines.cp(lineno, byte)
|
|
547
|
+
if self.lines.line(lineno)[col:col + len(bound)] != bound:
|
|
548
|
+
col = self._find(lineno, byte, r"\b({})\b", bound)
|
|
549
|
+
self.emit(bound, lineno, col, "def", scope)
|
|
550
|
+
return
|
|
551
|
+
if isinstance(node, (ast.Global, ast.Nonlocal)):
|
|
552
|
+
keyword = "global" if isinstance(node, ast.Global) else "nonlocal"
|
|
553
|
+
line = self.lines.line(node.lineno)
|
|
554
|
+
pos = self.lines.cp(node.lineno, node.col_offset) + len(keyword)
|
|
555
|
+
for name in node.names:
|
|
556
|
+
m = re.compile(r"\b({})\b".format(re.escape(name))).search(line, pos)
|
|
557
|
+
if m:
|
|
558
|
+
self.emit(name, node.lineno, m.start(1), "use", scope)
|
|
559
|
+
pos = m.end(1)
|
|
560
|
+
return
|
|
561
|
+
if isinstance(node, ast.ExceptHandler):
|
|
562
|
+
if node.type is not None:
|
|
563
|
+
self._walk(node.type, scope, node)
|
|
564
|
+
if node.name:
|
|
565
|
+
col = self._find(node.lineno, node.col_offset, r"\bas\s+({})\b", node.name)
|
|
566
|
+
self.emit(node.name, node.lineno, col, "def", scope)
|
|
567
|
+
self._walk_body(node.body, scope)
|
|
568
|
+
return
|
|
569
|
+
if isinstance(node, ast.MatchAs):
|
|
570
|
+
if node.pattern is not None:
|
|
571
|
+
self._walk(node.pattern, scope, node)
|
|
572
|
+
if node.name:
|
|
573
|
+
if node.pattern is None:
|
|
574
|
+
col = self.lines.cp(node.lineno, node.col_offset)
|
|
575
|
+
else:
|
|
576
|
+
col = self._find(node.lineno, node.col_offset, r"\bas\s+({})\b", node.name)
|
|
577
|
+
self.emit(node.name, node.lineno, col, "def", scope)
|
|
578
|
+
return
|
|
579
|
+
if isinstance(node, ast.MatchStar):
|
|
580
|
+
if node.name:
|
|
581
|
+
col = self._find(node.lineno, node.col_offset, r"\*\s*({})\b", node.name)
|
|
582
|
+
self.emit(node.name, node.lineno, col, "def", scope)
|
|
583
|
+
return
|
|
584
|
+
if isinstance(node, ast.MatchMapping):
|
|
585
|
+
for key in node.keys:
|
|
586
|
+
self._walk(key, scope, node)
|
|
587
|
+
for pat in node.patterns:
|
|
588
|
+
self._walk(pat, scope, node)
|
|
589
|
+
if node.rest:
|
|
590
|
+
end_line = getattr(node, "end_lineno", node.lineno)
|
|
591
|
+
col = self._find(node.lineno, node.col_offset, r"\*\*\s*({})\b", node.rest)
|
|
592
|
+
self.emit(node.rest, node.lineno if col is not None else end_line, col, "def", scope)
|
|
593
|
+
return
|
|
594
|
+
for child in ast.iter_child_nodes(node):
|
|
595
|
+
self._walk(child, scope, node)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def document_references(tree, source: str) -> list:
|
|
599
|
+
refs = References(source)
|
|
600
|
+
refs.collect(tree)
|
|
601
|
+
return refs.walk(tree)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ── the ops ────────────────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
def analyze_source(source) -> dict:
|
|
607
|
+
"""Diagnostics always; symbols and the reference index when it parses."""
|
|
608
|
+
src = source if isinstance(source, str) else ""
|
|
609
|
+
diagnostics: list = []
|
|
610
|
+
symbols: list = []
|
|
611
|
+
references: list = []
|
|
612
|
+
complete = True
|
|
613
|
+
failed = None
|
|
614
|
+
try:
|
|
615
|
+
tree = _parse(src)
|
|
616
|
+
except SyntaxError as err:
|
|
617
|
+
complete = not _is_incomplete(src)
|
|
618
|
+
diagnostics.append(_syntax_diagnostic(err, incomplete=not complete))
|
|
619
|
+
tree = None
|
|
620
|
+
except (ValueError, OverflowError, MemoryError, RecursionError) as err:
|
|
621
|
+
diagnostics.append({
|
|
622
|
+
"severity": "error", "rule": "parse", "message": str(err),
|
|
623
|
+
"raw": f"{type(err).__name__}: {err}", "unit": "codepoint",
|
|
624
|
+
"line": None, "col": None, "endLine": None, "endCol": None,
|
|
625
|
+
"incomplete": False,
|
|
626
|
+
})
|
|
627
|
+
tree = None
|
|
628
|
+
if tree is not None:
|
|
629
|
+
symbols = top_symbols(tree)
|
|
630
|
+
try:
|
|
631
|
+
references = document_references(tree, src)
|
|
632
|
+
except RecursionError:
|
|
633
|
+
failed = "references unavailable: the document nests too deeply to index"
|
|
634
|
+
return {"diagnostics": diagnostics, "symbols": symbols,
|
|
635
|
+
"references": references, "complete": complete, "failed": failed}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _walk_files(root: str, deadline: float, max_files: int, max_depth: int):
|
|
639
|
+
found: list = []
|
|
640
|
+
truncated = False
|
|
641
|
+
stack = [(root, 1)]
|
|
642
|
+
while stack:
|
|
643
|
+
directory, depth = stack.pop()
|
|
644
|
+
if depth > max_depth:
|
|
645
|
+
continue
|
|
646
|
+
if len(found) >= max_files or time.monotonic() >= deadline:
|
|
647
|
+
truncated = True
|
|
648
|
+
break
|
|
649
|
+
try:
|
|
650
|
+
entries = sorted(os.scandir(directory), key=lambda e: e.name)
|
|
651
|
+
except OSError:
|
|
652
|
+
continue
|
|
653
|
+
for entry in entries:
|
|
654
|
+
if len(found) >= max_files or time.monotonic() >= deadline:
|
|
655
|
+
truncated = True
|
|
656
|
+
break
|
|
657
|
+
try:
|
|
658
|
+
if entry.is_dir(follow_symlinks=False):
|
|
659
|
+
if entry.name in SKIP_DIRS or entry.name.startswith("."):
|
|
660
|
+
continue
|
|
661
|
+
stack.append((entry.path, depth + 1))
|
|
662
|
+
elif entry.is_file(follow_symlinks=False) and entry.name.endswith(".py"):
|
|
663
|
+
found.append(entry.path)
|
|
664
|
+
except OSError:
|
|
665
|
+
continue
|
|
666
|
+
return found, truncated
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _read_text(path: str, max_bytes: int):
|
|
670
|
+
try:
|
|
671
|
+
size = os.path.getsize(path)
|
|
672
|
+
except OSError:
|
|
673
|
+
return None
|
|
674
|
+
if size > max_bytes:
|
|
675
|
+
return None
|
|
676
|
+
try:
|
|
677
|
+
with open(path, "rb") as fh:
|
|
678
|
+
raw = fh.read(size)
|
|
679
|
+
except OSError:
|
|
680
|
+
return None
|
|
681
|
+
if b"\0" in raw:
|
|
682
|
+
return None
|
|
683
|
+
try:
|
|
684
|
+
return raw.decode("utf-8").replace("\r\n", "\n")
|
|
685
|
+
except UnicodeDecodeError:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def workspace_symbols(root, max_files=MAX_FILES, max_depth=MAX_DEPTH,
|
|
690
|
+
max_bytes=MAX_BYTES, max_seconds=None) -> dict:
|
|
691
|
+
"""Every top-level definition in the `.py` files below a root. Read, never run."""
|
|
692
|
+
budget = ANALYZE_BUDGET if max_seconds is None else max_seconds
|
|
693
|
+
if not root or not os.path.isdir(root):
|
|
694
|
+
return {"files": 0, "symbols": [], "truncated": False, "error": "no such directory"}
|
|
695
|
+
deadline = time.monotonic() + budget
|
|
696
|
+
base = os.path.realpath(root)
|
|
697
|
+
files, truncated = _walk_files(base, deadline, max_files, max_depth)
|
|
698
|
+
out = []
|
|
699
|
+
for path in files:
|
|
700
|
+
if time.monotonic() >= deadline:
|
|
701
|
+
truncated = True
|
|
702
|
+
break
|
|
703
|
+
text = _read_text(path, max_bytes)
|
|
704
|
+
if text is None:
|
|
705
|
+
continue
|
|
706
|
+
try:
|
|
707
|
+
tree = _parse(text)
|
|
708
|
+
except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
|
|
709
|
+
continue
|
|
710
|
+
rel = os.path.relpath(path, base)
|
|
711
|
+
for sym in top_symbols(tree):
|
|
712
|
+
out.append({**sym, "path": path, "file": rel})
|
|
713
|
+
return {"files": len(files), "symbols": out, "truncated": truncated, "error": None}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def workspace_references(root, name, max_files=MAX_FILES, max_depth=MAX_DEPTH,
|
|
717
|
+
max_bytes=MAX_BYTES, max_matches=MAX_MATCHES,
|
|
718
|
+
max_reply_bytes=MAX_REPLY_BYTES, max_seconds=None) -> dict:
|
|
719
|
+
"""Every parser-confirmed occurrence of one name in bounded `.py` files."""
|
|
720
|
+
budget = ANALYZE_BUDGET if max_seconds is None else max_seconds
|
|
721
|
+
if not root or not os.path.isdir(root):
|
|
722
|
+
return {"files": 0, "documents": [], "truncated": False, "error": "no such directory"}
|
|
723
|
+
want = str(name or "")
|
|
724
|
+
if not want:
|
|
725
|
+
return {"files": 0, "documents": [], "truncated": False, "error": "name is required"}
|
|
726
|
+
deadline = time.monotonic() + budget
|
|
727
|
+
base = os.path.realpath(root)
|
|
728
|
+
files, truncated = _walk_files(base, deadline, max_files, max_depth)
|
|
729
|
+
documents = []
|
|
730
|
+
total = 0
|
|
731
|
+
for path in files:
|
|
732
|
+
if time.monotonic() >= deadline:
|
|
733
|
+
truncated = True
|
|
734
|
+
break
|
|
735
|
+
text = _read_text(path, max_bytes)
|
|
736
|
+
if text is None or want not in text:
|
|
737
|
+
continue
|
|
738
|
+
try:
|
|
739
|
+
tree = _parse(text)
|
|
740
|
+
refs = document_references(tree, text)
|
|
741
|
+
except (SyntaxError, ValueError, OverflowError, MemoryError, RecursionError):
|
|
742
|
+
continue
|
|
743
|
+
hits = [r for r in refs if r["name"] == want]
|
|
744
|
+
if not hits:
|
|
745
|
+
continue
|
|
746
|
+
size = len(text.encode("utf-8"))
|
|
747
|
+
if len(documents) >= max_matches or total + size > max_reply_bytes:
|
|
748
|
+
truncated = True
|
|
749
|
+
break
|
|
750
|
+
documents.append({"path": path, "file": os.path.relpath(path, base),
|
|
751
|
+
"source": text, "references": hits, "symbols": top_symbols(tree)})
|
|
752
|
+
total += size
|
|
753
|
+
return {"files": len(files), "documents": documents, "truncated": truncated, "error": None}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ── the command loop ───────────────────────────────────────────────────────
|
|
757
|
+
# NDJSON in, sentinel-framed JSON out, exactly as analyze.R: `analyze` echoes
|
|
758
|
+
# `uri` and `version` so the page can discard a stale answer by strict equality.
|
|
759
|
+
|
|
760
|
+
def handle(cmd: dict) -> dict:
|
|
761
|
+
op = cmd.get("type")
|
|
762
|
+
rid = cmd.get("id")
|
|
763
|
+
if op == "ping":
|
|
764
|
+
return {"type": "pong", "id": rid, "python": sys.version.split()[0]}
|
|
765
|
+
if op == "analyze":
|
|
766
|
+
return {"type": "analyze", "id": rid, "uri": cmd.get("uri"),
|
|
767
|
+
"version": cmd.get("version"), **analyze_source(cmd.get("source"))}
|
|
768
|
+
if op == "workspace":
|
|
769
|
+
root = cmd.get("root")
|
|
770
|
+
res = workspace_symbols(str(root) if root else "")
|
|
771
|
+
return {"type": "workspace", "id": rid, "root": root, **res}
|
|
772
|
+
if op == "workspace_references":
|
|
773
|
+
root = cmd.get("root")
|
|
774
|
+
res = workspace_references(str(root) if root else "", cmd.get("name"))
|
|
775
|
+
return {"type": "workspace_references", "id": rid, "root": root,
|
|
776
|
+
"name": cmd.get("name"), **res}
|
|
777
|
+
return {"type": "analyze-error", "id": rid, "message": f"unknown command: {op}"}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def main(argv=None, stdin=None, stdout=None) -> int:
|
|
781
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
782
|
+
if not argv or not argv[0]:
|
|
783
|
+
print("analyze.py needs a frame sentinel as its first argument", file=sys.stderr)
|
|
784
|
+
return 2
|
|
785
|
+
emit = Emitter(argv[0], stdout)
|
|
786
|
+
stdin = stdin if stdin is not None else sys.stdin
|
|
787
|
+
emit({"type": "analyze-ready", "pid": os.getpid(),
|
|
788
|
+
"python": sys.version.split()[0], "unit": "codepoint", "budget": ANALYZE_BUDGET})
|
|
789
|
+
for line in stdin:
|
|
790
|
+
line = line.strip()
|
|
791
|
+
if not line:
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
cmd = json.loads(line)
|
|
795
|
+
except ValueError:
|
|
796
|
+
continue
|
|
797
|
+
if not isinstance(cmd, dict) or not isinstance(cmd.get("type"), str):
|
|
798
|
+
continue
|
|
799
|
+
try:
|
|
800
|
+
emit(handle(cmd))
|
|
801
|
+
except RecursionError:
|
|
802
|
+
emit({"type": cmd["type"], "id": cmd.get("id"), "uri": cmd.get("uri"),
|
|
803
|
+
"version": cmd.get("version"),
|
|
804
|
+
"failed": "the analyzer ran out of stack on this document"})
|
|
805
|
+
except Exception as err: # noqa: BLE001
|
|
806
|
+
# Reported, never swallowed: `failed` is how the editor says what it
|
|
807
|
+
# could not do, and the process lives on for the next request.
|
|
808
|
+
emit({"type": cmd["type"], "id": cmd.get("id"), "uri": cmd.get("uri"),
|
|
809
|
+
"version": cmd.get("version"), "failed": f"{type(err).__name__}: {err}"})
|
|
810
|
+
return 0
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
if __name__ == "__main__":
|
|
814
|
+
raise SystemExit(main())
|