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.
@@ -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