ph-code-graph 0.1.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.
- ph_code_graph/__init__.py +825 -0
- ph_code_graph/_extract.py +574 -0
- ph_code_graph/_store.py +638 -0
- ph_code_graph/bundle.yaml +21 -0
- ph_code_graph/py.typed +0 -0
- ph_code_graph/skills/code-graph/SKILL.md +66 -0
- ph_code_graph-0.1.0.dist-info/METADATA +267 -0
- ph_code_graph-0.1.0.dist-info/RECORD +11 -0
- ph_code_graph-0.1.0.dist-info/WHEEL +4 -0
- ph_code_graph-0.1.0.dist-info/entry_points.txt +5 -0
- ph_code_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""One file in, definitions and references out — with the lines they are on.
|
|
2
|
+
|
|
3
|
+
Two passes over `tree-sitter-language-pack`, because each answers something the
|
|
4
|
+
other cannot and both are cheap:
|
|
5
|
+
|
|
6
|
+
* **`process()`** — the pack's own intelligence layer. Definitions with kinds,
|
|
7
|
+
the file's imports, and its docstrings and comments. What it does not give is
|
|
8
|
+
*references*: `SymbolInfo` says a function exists, never who calls it. It also
|
|
9
|
+
does not give a symbol its own prose — `SymbolInfo.doc` is always `None`; see
|
|
10
|
+
`_documented`.
|
|
11
|
+
* **the tags query** — tree-sitter's own `tags.scm` for the language, the same
|
|
12
|
+
data GitHub's code navigation is built on. `@definition.*` and, crucially,
|
|
13
|
+
`@reference.call`. That is the edge this package exists to record.
|
|
14
|
+
|
|
15
|
+
Measured over `packages/ph-core/src/ph`: 136 files and 1.58 MiB through
|
|
16
|
+
`process()` in 219 ms and through the tags query in 108 ms, so both passes
|
|
17
|
+
together index this repository's core in about a third of a second. Parsing
|
|
18
|
+
twice is worth more than the parse it saves.
|
|
19
|
+
|
|
20
|
+
## The two coordinate systems, reconciled here
|
|
21
|
+
|
|
22
|
+
`ProcessConfig` spans are **0-based**; tree-sitter points are 0-based too, and
|
|
23
|
+
every line number pH puts in front of a model is **1-based**, because that is
|
|
24
|
+
what `read` takes and what an editor shows. Both are normalised on the way out
|
|
25
|
+
of this module, once, so nothing downstream has to remember which source a
|
|
26
|
+
number came from. Getting this wrong is a one-line-off pointer, which is the
|
|
27
|
+
kind of error a model stops trusting the tool over rather than reporting.
|
|
28
|
+
|
|
29
|
+
## Query inheritance
|
|
30
|
+
|
|
31
|
+
`tree-sitter-typescript`'s grammar extends javascript's, and upstream splits the
|
|
32
|
+
tags queries the same way: the typescript query covers only what typescript adds
|
|
33
|
+
(interfaces, signatures, abstract classes), so `class`, `function` and `call`
|
|
34
|
+
live in the *javascript* query. A `.ts` file matched against the typescript
|
|
35
|
+
query alone yields two tags and no calls — measured. `INHERITS` is that fact.
|
|
36
|
+
|
|
37
|
+
@module ph_code_graph._extract
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
from collections.abc import Callable, Sequence
|
|
44
|
+
from dataclasses import dataclass, replace
|
|
45
|
+
from functools import cache
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"INHERITS",
|
|
51
|
+
"Definition",
|
|
52
|
+
"Extraction",
|
|
53
|
+
"Reference",
|
|
54
|
+
"cache_release",
|
|
55
|
+
"detect_language",
|
|
56
|
+
"ensure",
|
|
57
|
+
"extract",
|
|
58
|
+
"indexable",
|
|
59
|
+
"local",
|
|
60
|
+
"owners",
|
|
61
|
+
"parseable",
|
|
62
|
+
"readiness",
|
|
63
|
+
"use_cache",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
log = logging.getLogger("ph_code_graph.extract")
|
|
67
|
+
|
|
68
|
+
_configured: Path | None = None
|
|
69
|
+
"""The cache base this process last handed the pack, or `None` for its default.
|
|
70
|
+
|
|
71
|
+
Mirrored here because the pack does not report it: `cache_dir()` answers with the
|
|
72
|
+
resolved leaf it derives, which cannot be fed back in. Module state because the
|
|
73
|
+
thing it mirrors is a module-level global in somebody else's library — recording
|
|
74
|
+
it anywhere narrower would be a second answer to a process-wide question.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
INHERITS: dict[str, tuple[str, ...]] = {
|
|
78
|
+
"typescript": ("javascript",),
|
|
79
|
+
"tsx": ("javascript",),
|
|
80
|
+
}
|
|
81
|
+
"""Languages whose tags query is only half the story. See the module docstring."""
|
|
82
|
+
|
|
83
|
+
REFERENCE_KINDS = ("call",)
|
|
84
|
+
"""Which `@reference.*` captures become edges.
|
|
85
|
+
|
|
86
|
+
`call` only, deliberately. The tags queries also emit `reference.type`,
|
|
87
|
+
`reference.class`, `reference.implementation` and more, and each is a real
|
|
88
|
+
relationship — but they answer a different question than "what runs what", and
|
|
89
|
+
mixing them into one edge table would make `callers` return the places that
|
|
90
|
+
merely *mention* a type. They are dropped rather than stored-and-filtered so the
|
|
91
|
+
index does not carry rows nothing reads.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Definition:
|
|
97
|
+
"""A symbol this file defines."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
kind: str
|
|
101
|
+
"""`function`, `class`, `method`, `interface`, `module`, `constant` — whatever
|
|
102
|
+
the language's own tags query calls it, with the `definition.` prefix off."""
|
|
103
|
+
start_line: int
|
|
104
|
+
"""1-based, inclusive."""
|
|
105
|
+
end_line: int
|
|
106
|
+
"""1-based, inclusive."""
|
|
107
|
+
doc: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class Reference:
|
|
112
|
+
"""A name this file uses, and where."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
kind: str
|
|
116
|
+
line: int
|
|
117
|
+
"""1-based."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class Extraction:
|
|
122
|
+
"""Everything one file yielded."""
|
|
123
|
+
|
|
124
|
+
language: str
|
|
125
|
+
definitions: tuple[Definition, ...]
|
|
126
|
+
references: tuple[Reference, ...]
|
|
127
|
+
imports: tuple[str, ...]
|
|
128
|
+
lines: int
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pack() -> Any: # noqa: ANN401
|
|
132
|
+
import tree_sitter_language_pack
|
|
133
|
+
|
|
134
|
+
return tree_sitter_language_pack
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cache_release(directory: Path) -> Callable[[], None]:
|
|
138
|
+
"""Point the pack's grammar cache at `directory`; return the restore.
|
|
139
|
+
|
|
140
|
+
**An `enter` returning its own release**, which is the shape `ctx.effect`
|
|
141
|
+
takes — so the mutation unwinds with the row that made it (§4.9, I2) instead
|
|
142
|
+
of outliving it. `pack.configure` is process-global and last-writer-wins, so
|
|
143
|
+
without this the library kept pointing at an unmounted row's `$PH_CACHE`:
|
|
144
|
+
visible in this suite, where that path is a per-test `tmp_path` the pack went
|
|
145
|
+
on holding after teardown.
|
|
146
|
+
|
|
147
|
+
Harmless in production today — every mount in a process computes the same
|
|
148
|
+
path — and wrong depth all the same, which is the whole argument for the
|
|
149
|
+
rule: a global taken without a release is one nobody notices until two
|
|
150
|
+
deployments in one process disagree.
|
|
151
|
+
"""
|
|
152
|
+
# **The base we set, not `cache_dir()`.** That reports the *resolved* leaf —
|
|
153
|
+
# `<base>/tree-sitter-language-pack/<version>/libs` — so feeding it back to
|
|
154
|
+
# `PackConfig(cache_dir=...)` makes the pack append its own suffix a second
|
|
155
|
+
# time and the restore nests instead of restoring. Caught by the test for
|
|
156
|
+
# this function, which is the argument for having written one.
|
|
157
|
+
previous = _configured
|
|
158
|
+
use_cache(directory)
|
|
159
|
+
|
|
160
|
+
def restore() -> None:
|
|
161
|
+
# Best-effort and logged rather than raised: a disposer that throws is
|
|
162
|
+
# logged and the unwind continues anyway, so failing loudly here would
|
|
163
|
+
# only obscure whatever else the scope was releasing.
|
|
164
|
+
try:
|
|
165
|
+
if previous is None:
|
|
166
|
+
_reset()
|
|
167
|
+
else:
|
|
168
|
+
use_cache(previous)
|
|
169
|
+
except Exception: # pragma: no cover - a library that cannot be restored
|
|
170
|
+
log.warning("ph_code_graph: could not restore the grammar cache", exc_info=True)
|
|
171
|
+
|
|
172
|
+
return restore
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def use_cache(directory: Path) -> Path:
|
|
176
|
+
"""Point the pack's grammar cache at `directory`. Returns where it landed.
|
|
177
|
+
|
|
178
|
+
**Not optional, and not only about the download tail.** The 26 bundled
|
|
179
|
+
languages are shipped inside the wheel as an archive and *materialised into
|
|
180
|
+
this directory on first use* — so a deployment whose cache directory is not
|
|
181
|
+
writable does not fall back to the wheel, it fails:
|
|
182
|
+
|
|
183
|
+
Download error: Failed to create cache directory … Permission denied
|
|
184
|
+
|
|
185
|
+
The default is `$XDG_CACHE_HOME/tree-sitter-language-pack/<version>/libs`,
|
|
186
|
+
which is fine on a developer's laptop and wrong in a container with a
|
|
187
|
+
read-only `HOME` — measured both ways. So the row hands over `$PH_CACHE`
|
|
188
|
+
instead: the root pH already designates for rebuildable artifacts, on a path
|
|
189
|
+
it has created and verified.
|
|
190
|
+
|
|
191
|
+
Through the pack's own `configure()` rather than by setting
|
|
192
|
+
`TREE_SITTER_LANGUAGE_PACK_CACHE_DIR`: a row that mutated the process
|
|
193
|
+
environment would change it for every child `ctx.subprocess` spawns too, and
|
|
194
|
+
this is a decision about *this* library.
|
|
195
|
+
|
|
196
|
+
An operator who set the environment variable deliberately keeps it — their
|
|
197
|
+
spelling wins, which is what makes the variable still mean something.
|
|
198
|
+
"""
|
|
199
|
+
import os
|
|
200
|
+
|
|
201
|
+
global _configured
|
|
202
|
+
|
|
203
|
+
override = os.environ.get("TREE_SITTER_LANGUAGE_PACK_CACHE_DIR")
|
|
204
|
+
if override:
|
|
205
|
+
return Path(override)
|
|
206
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
pack = _pack()
|
|
208
|
+
pack.configure(pack.PackConfig(cache_dir=str(directory)))
|
|
209
|
+
_configured = directory
|
|
210
|
+
return directory
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _reset() -> None:
|
|
214
|
+
"""Hand the pack back its own default. The `previous is None` restore."""
|
|
215
|
+
global _configured
|
|
216
|
+
|
|
217
|
+
pack = _pack()
|
|
218
|
+
pack.configure()
|
|
219
|
+
_configured = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def detect_language(path: str) -> str | None:
|
|
223
|
+
"""The pack's own name for this path's language, or `None` for "not code".
|
|
224
|
+
|
|
225
|
+
`None` is the ordinary answer for most of a repository — a `.md`, a lockfile,
|
|
226
|
+
an image — so it is a value and not an error. Markdown and the other prose
|
|
227
|
+
languages the pack recognises are excluded by the caller, which knows it
|
|
228
|
+
wants code; this only reports what the extension says.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
found: str | None = _pack().detect_language_from_path(path)
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
return found
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@cache
|
|
238
|
+
def indexable(language: str) -> bool:
|
|
239
|
+
"""Whether this language has a tags query, and so can yield an edge at all.
|
|
240
|
+
|
|
241
|
+
**Derived, not listed.** This replaced a hand-written `PROSE` set of nine
|
|
242
|
+
names, which was a copy of a property the pack reports directly — and an
|
|
243
|
+
incomplete one, because the detector claims 371 languages: `.txt`, `.ini`,
|
|
244
|
+
`.proto`, `.rst`, `.conf` and a dozen others also have no tags query, and
|
|
245
|
+
with the row's default `glob: "**/*"` every one of them reached the
|
|
246
|
+
extractor and came back as an internal `TypeError` dressed up as a per-file
|
|
247
|
+
skip reason.
|
|
248
|
+
|
|
249
|
+
Asking the pack cannot drift and covers all 371. It also costs nothing: the
|
|
250
|
+
answer is cached, and `get_tags_query` reads a string the pack already holds
|
|
251
|
+
rather than materialising a grammar.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
return bool(_pack().get_tags_query(language))
|
|
255
|
+
except Exception:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def local(language: str) -> bool:
|
|
260
|
+
"""Whether this language's grammar is **already on disk**.
|
|
261
|
+
|
|
262
|
+
A cache listing, so it reads the disk and fetches nothing — which is what
|
|
263
|
+
makes it the right question for `/code-graph status`: a person asking "is
|
|
264
|
+
this ready" must not be answered by an action that makes it ready.
|
|
265
|
+
|
|
266
|
+
**Deliberately not the indexer's question.** The pack unpacks its 26 bundled
|
|
267
|
+
grammars from the wheel on first use, with no network, so gating indexing on
|
|
268
|
+
this would skip `.py` files on a fresh cache and tell the caller to run a
|
|
269
|
+
command that had nothing to fetch. The indexer asks `parseable`, which
|
|
270
|
+
admits that unpack; the residue — a language outside the bundle, which does
|
|
271
|
+
reach GitHub — is what `/code-graph install` is for, and what the skip
|
|
272
|
+
reason names.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
return language in set(_pack().downloaded_languages())
|
|
276
|
+
except Exception:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parseable(language: str) -> bool:
|
|
281
|
+
"""Whether a parser for `language` can be obtained at all.
|
|
282
|
+
|
|
283
|
+
Materialising, and that is the point: `get_parser` unpacks a bundled grammar
|
|
284
|
+
from the wheel — local, and the ordinary case — and only reaches the network
|
|
285
|
+
for the ~345 outside it. The docstring this replaced claimed to be "the
|
|
286
|
+
bundled question" and was not, which mattered because it was also the skip
|
|
287
|
+
*reason*: a network-denied deployment reported every file as an unsupported
|
|
288
|
+
language rather than an unprovisioned cache.
|
|
289
|
+
|
|
290
|
+
So the wording is now about the parser rather than about bundling, and
|
|
291
|
+
`local` answers the readiness question separately.
|
|
292
|
+
"""
|
|
293
|
+
if not indexable(language):
|
|
294
|
+
return False
|
|
295
|
+
try:
|
|
296
|
+
_pack().get_parser(language)
|
|
297
|
+
except Exception:
|
|
298
|
+
return False
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def readiness(languages: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
303
|
+
"""Which of `languages` are on disk, and which are not. One pass.
|
|
304
|
+
|
|
305
|
+
Here rather than beside its caller because there were two of these: the
|
|
306
|
+
provisioning command partitioned the list one way and `ensure` re-derived
|
|
307
|
+
the same partition with a `one not in ready` scan three functions along —
|
|
308
|
+
the very O(n²) rederivation the other one's docstring claimed to have
|
|
309
|
+
replaced. One function, so the two cannot disagree about which list the
|
|
310
|
+
unavailable side is drawn from.
|
|
311
|
+
"""
|
|
312
|
+
ready = [one for one in languages if local(one)]
|
|
313
|
+
have = set(ready)
|
|
314
|
+
return ready, [one for one in languages if one not in have]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def ensure(languages: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
318
|
+
"""Fetch what is missing. `(ready, unavailable)` — the provisioning door.
|
|
319
|
+
|
|
320
|
+
The pack's own `download()` rather than `get_parser`-for-its-side-effect, so
|
|
321
|
+
the fetch lives in exactly one place: the command a person runs.
|
|
322
|
+
"""
|
|
323
|
+
wanted = [one for one in languages if indexable(one)]
|
|
324
|
+
try:
|
|
325
|
+
_pack().download(list(wanted))
|
|
326
|
+
except Exception:
|
|
327
|
+
log.warning("ph_code_graph: grammar download failed", exc_info=True)
|
|
328
|
+
ready, _ = readiness(wanted)
|
|
329
|
+
return ready, [one for one in languages if one not in set(ready)]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@cache
|
|
333
|
+
def _tags_query(language: str) -> Any: # noqa: ANN401
|
|
334
|
+
"""The compiled tags query for one language. **Cached, and measurably so.**
|
|
335
|
+
|
|
336
|
+
Compiling is per-language work that was being paid per *file*: 2.8 ms for
|
|
337
|
+
python, 22 ms for ruby, and 35.7 ms for typescript — the worst case, because
|
|
338
|
+
`INHERITS` concatenates two queries. Over this repo's `ph-core` tree (136
|
|
339
|
+
files) that was 44 % of all extraction time; a thousand-file TypeScript
|
|
340
|
+
package paid 35 s of it.
|
|
341
|
+
|
|
342
|
+
`functools.cache` is the `ph.seams.fs._compiled` precedent, and it is safe
|
|
343
|
+
for the same reason: a `Query` is read-only once built, and `extract` makes
|
|
344
|
+
a fresh `QueryCursor` per call — which is the mutable half.
|
|
345
|
+
"""
|
|
346
|
+
from tree_sitter import Query
|
|
347
|
+
|
|
348
|
+
pack = _pack()
|
|
349
|
+
sources = [pack.get_tags_query(one) for one in INHERITS.get(language, ())]
|
|
350
|
+
sources.append(pack.get_tags_query(language))
|
|
351
|
+
return Query(pack.get_language(language), "\n".join(one for one in sources if one))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def extract(path: str, text: str, language: str) -> Extraction:
|
|
355
|
+
"""Parse `text` and return what it defines, what it references, what it imports.
|
|
356
|
+
|
|
357
|
+
:raises Exception: whatever the pack raises for an unparseable file. Not
|
|
358
|
+
caught here: the caller indexes a *batch*, and whether one bad file
|
|
359
|
+
should stop the batch or be reported and skipped is its decision, not
|
|
360
|
+
this function's.
|
|
361
|
+
"""
|
|
362
|
+
from tree_sitter import QueryCursor
|
|
363
|
+
|
|
364
|
+
pack = _pack()
|
|
365
|
+
config = pack.ProcessConfig(
|
|
366
|
+
language=language, symbols=True, imports=True, docstrings=True, comments=True
|
|
367
|
+
)
|
|
368
|
+
result = pack.process(text, config)
|
|
369
|
+
|
|
370
|
+
# +1: `Span.start_line` is 0-based and everything downstream is 1-based.
|
|
371
|
+
# See the module docstring.
|
|
372
|
+
definitions = [
|
|
373
|
+
Definition(
|
|
374
|
+
name=symbol.name,
|
|
375
|
+
kind=_kind_of(symbol.kind),
|
|
376
|
+
start_line=symbol.span.start_line + 1,
|
|
377
|
+
end_line=symbol.span.end_line + 1,
|
|
378
|
+
# **Not `symbol.doc`**, which this pack never populates — measured
|
|
379
|
+
# `None` for every symbol in a file full of docstrings. Prose
|
|
380
|
+
# arrives on two other channels and `_documented` merges them.
|
|
381
|
+
doc=None,
|
|
382
|
+
)
|
|
383
|
+
for symbol in (result.symbols or [])
|
|
384
|
+
if symbol.name
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
source = text.encode("utf-8")
|
|
388
|
+
tree = pack.get_parser(language).parse(source)
|
|
389
|
+
references: list[Reference] = []
|
|
390
|
+
seen_definitions = {(one.name, one.start_line) for one in definitions}
|
|
391
|
+
for _pattern, capture in QueryCursor(_tags_query(language)).matches(tree.root_node):
|
|
392
|
+
named = capture.get("name") or []
|
|
393
|
+
if not named:
|
|
394
|
+
continue
|
|
395
|
+
# `Node.text` is `bytes | None` — `None` when the tree outlived the
|
|
396
|
+
# source it was parsed from, which cannot happen here but is the
|
|
397
|
+
# signature's promise, so it is handled rather than asserted away.
|
|
398
|
+
raw = named[0].text
|
|
399
|
+
if raw is None:
|
|
400
|
+
continue
|
|
401
|
+
name = raw.decode("utf-8", errors="replace")
|
|
402
|
+
for label, nodes in capture.items():
|
|
403
|
+
if not label.startswith("definition.") and not label.startswith("reference."):
|
|
404
|
+
continue
|
|
405
|
+
head, _, tail = label.partition(".")
|
|
406
|
+
line = nodes[0].start_point[0] + 1
|
|
407
|
+
if head == "definition":
|
|
408
|
+
# The tags query finds definitions too, and mostly the same ones
|
|
409
|
+
# `process()` did. Kept only where it found one `process()`
|
|
410
|
+
# missed — a language whose intel layer is thinner than its
|
|
411
|
+
# tags query — and matched on (name, line) so the union has no
|
|
412
|
+
# duplicates to deduplicate later.
|
|
413
|
+
if (name, line) not in seen_definitions:
|
|
414
|
+
seen_definitions.add((name, line))
|
|
415
|
+
definitions.append(
|
|
416
|
+
Definition(
|
|
417
|
+
name=name,
|
|
418
|
+
kind=_kind_of(tail),
|
|
419
|
+
start_line=line,
|
|
420
|
+
end_line=nodes[0].end_point[0] + 1,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
elif tail in REFERENCE_KINDS:
|
|
424
|
+
references.append(Reference(name=name, kind=tail, line=line))
|
|
425
|
+
|
|
426
|
+
return Extraction(
|
|
427
|
+
language=language,
|
|
428
|
+
definitions=_documented(definitions, result),
|
|
429
|
+
references=tuple(references),
|
|
430
|
+
imports=tuple(one.source for one in (result.imports or []) if getattr(one, "source", None)),
|
|
431
|
+
lines=text.count("\n") + 1,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _documented(
|
|
436
|
+
definitions: list[Definition],
|
|
437
|
+
result: object,
|
|
438
|
+
) -> tuple[Definition, ...]:
|
|
439
|
+
"""Attach each definition's prose, from the two channels that carry it.
|
|
440
|
+
|
|
441
|
+
**`SymbolInfo.doc` is always `None`** in this pack — measured against a file
|
|
442
|
+
full of docstrings — so a row that trusted it would have shipped an index
|
|
443
|
+
with an empty `doc` column and a `search` mode that matched names only. The
|
|
444
|
+
prose is there, on two other channels:
|
|
445
|
+
|
|
446
|
+
* **`result.docstrings`** carries `associated_item`, the pack's own answer to
|
|
447
|
+
"which symbol is this the docstring of". Used verbatim where present,
|
|
448
|
+
because the pack knows each language's convention better than a heuristic
|
|
449
|
+
here would.
|
|
450
|
+
* **`result.comments`** carries doc comments — Rust's `///` and
|
|
451
|
+
TypeScript's block form (kind `Doc`), and Go's `//` (kind `Line`, because
|
|
452
|
+
that *is* Go's documented convention). `associated_item` is not set for
|
|
453
|
+
these, so the rule is positional and deliberately narrow: the comment's
|
|
454
|
+
last line must be **immediately above** a definition's first. A gap of one
|
|
455
|
+
blank line means it was about something else, and attaching it anyway
|
|
456
|
+
would put the wrong prose in front of the model.
|
|
457
|
+
|
|
458
|
+
Anything owned by nobody — a module docstring, a comment inside a function
|
|
459
|
+
body — is dropped rather than attached to whatever happened to be nearest.
|
|
460
|
+
"""
|
|
461
|
+
prose: dict[str, str] = {}
|
|
462
|
+
starts: dict[int, str] = {}
|
|
463
|
+
for one in definitions:
|
|
464
|
+
starts.setdefault(one.start_line, one.name)
|
|
465
|
+
|
|
466
|
+
for docstring in getattr(result, "docstrings", None) or []:
|
|
467
|
+
item = getattr(docstring, "associated_item", None)
|
|
468
|
+
text = (docstring.text or "").strip()
|
|
469
|
+
if item and text:
|
|
470
|
+
prose.setdefault(str(item), clean_prose(text))
|
|
471
|
+
|
|
472
|
+
for _, last, text in _comment_blocks(result):
|
|
473
|
+
# **The comment's own text, not its `span.end_line`.** Rust's `///`
|
|
474
|
+
# comment node includes the trailing newline, so its `end_line` is one
|
|
475
|
+
# past the line it occupies — which made the immediately-above rule miss
|
|
476
|
+
# the definition it should have matched *and* match one a blank line
|
|
477
|
+
# away. Counting the lines the stripped text actually spans is exact for
|
|
478
|
+
# every language here, and was wrong for none.
|
|
479
|
+
owner = starts.get(last + 1)
|
|
480
|
+
if owner and text:
|
|
481
|
+
prose.setdefault(owner, clean_prose(text))
|
|
482
|
+
|
|
483
|
+
# `replace` rather than a hand-written six-field rebuild: the copy could
|
|
484
|
+
# silently drop a field the day `Definition` grows one.
|
|
485
|
+
return tuple(
|
|
486
|
+
replace(one, doc=prose[one.name]) if one.name in prose else one for one in definitions
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _comment_blocks(result: object) -> list[tuple[int, int, str]]:
|
|
491
|
+
"""Doc comments as `(first line, last line, text)`, contiguous runs merged.
|
|
492
|
+
|
|
493
|
+
Both 1-based. Merged because Rust and Go write a paragraph as a *run* of
|
|
494
|
+
single-line comments — each its own node — so attaching only the last one
|
|
495
|
+
would index the final sentence of every doc comment in those languages and
|
|
496
|
+
drop the rest.
|
|
497
|
+
|
|
498
|
+
A run breaks on a blank line, which is also how a reader would break it.
|
|
499
|
+
"""
|
|
500
|
+
blocks: list[tuple[int, int, str]] = []
|
|
501
|
+
for comment in getattr(result, "comments", None) or []:
|
|
502
|
+
if str(getattr(comment, "kind", "")).rsplit(".", 1)[-1] not in ("Doc", "Line"):
|
|
503
|
+
continue
|
|
504
|
+
text = (comment.text or "").rstrip()
|
|
505
|
+
if not text.strip():
|
|
506
|
+
continue
|
|
507
|
+
first = comment.span.start_line + 1
|
|
508
|
+
last = first + text.count("\n")
|
|
509
|
+
if blocks and blocks[-1][1] + 1 == first:
|
|
510
|
+
was_first, _, before = blocks[-1]
|
|
511
|
+
blocks[-1] = (was_first, last, f"{before}\n{text}")
|
|
512
|
+
else:
|
|
513
|
+
blocks.append((first, last, text))
|
|
514
|
+
return blocks
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
MARKERS = ('"""', "'''", "///", "/**", "*/", "//", "#")
|
|
518
|
+
"""Comment and docstring delimiters, longest-first within each family."""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def clean_prose(text: str) -> str:
|
|
522
|
+
"""The prose without its delimiters — what an FTS index should hold.
|
|
523
|
+
|
|
524
|
+
A docstring stored with its quotes still attached puts punctuation into
|
|
525
|
+
every neighbouring search term and shows the model syntax it did not ask
|
|
526
|
+
for. Stripped line by line, so a block comment loses its leading `*` too.
|
|
527
|
+
"""
|
|
528
|
+
lines: list[str] = []
|
|
529
|
+
for raw in text.splitlines():
|
|
530
|
+
line = raw.strip()
|
|
531
|
+
for marker in MARKERS:
|
|
532
|
+
while line.startswith(marker):
|
|
533
|
+
line = line[len(marker) :].strip()
|
|
534
|
+
while line.endswith(marker):
|
|
535
|
+
line = line[: -len(marker)].rstrip()
|
|
536
|
+
if line.startswith("*"):
|
|
537
|
+
line = line[1:].strip()
|
|
538
|
+
lines.append(line)
|
|
539
|
+
return "\n".join(lines).strip()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _kind_of(raw: object) -> str:
|
|
543
|
+
"""`SymbolKind.Function` / `"function"` / `"Function"` → `function`.
|
|
544
|
+
|
|
545
|
+
The pack's `kind` is an enum whose `str()` is the variant name, while the
|
|
546
|
+
tags query hands over a plain lowercase string. One spelling reaches the
|
|
547
|
+
index, so `kind: "class"` in a tool argument means the same thing whichever
|
|
548
|
+
pass produced the row.
|
|
549
|
+
"""
|
|
550
|
+
text = getattr(raw, "name", None) or str(raw)
|
|
551
|
+
return text.rsplit(".", 1)[-1].lower()
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def owners(definitions: Sequence[Definition]) -> dict[int, Definition]:
|
|
555
|
+
"""`{line: the tightest definition containing it}`, in one pass per file.
|
|
556
|
+
|
|
557
|
+
**Tightest, not first**: a nested `def` inside a 40-line function is the
|
|
558
|
+
caller a reference belongs to, and taking the outer one would attribute
|
|
559
|
+
every closure's calls to whatever happened to contain it. `_registry.py`'s
|
|
560
|
+
`claim_key` spans 40-75 and its inner `release` spans 69-73, so a call on
|
|
561
|
+
line 70 has two candidates and exactly one right answer.
|
|
562
|
+
|
|
563
|
+
Built once for the file rather than searched per reference, which is what
|
|
564
|
+
this replaced: that was O(definitions x references), fine for a 30-symbol
|
|
565
|
+
module and 58 ms — as much as the whole parse — for a generated file with
|
|
566
|
+
1 500 definitions and 3 000 references, which `max_bytes` happily admits.
|
|
567
|
+
Widest first, so a narrower span overwrites it and the last writer per line
|
|
568
|
+
is the tightest.
|
|
569
|
+
"""
|
|
570
|
+
table: dict[int, Definition] = {}
|
|
571
|
+
for one in sorted(definitions, key=lambda d: d.start_line - d.end_line):
|
|
572
|
+
for line in range(one.start_line, one.end_line + 1):
|
|
573
|
+
table[line] = one
|
|
574
|
+
return table
|