thrubase 0.1.0a1__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.
Files changed (44) hide show
  1. thrubase/__init__.py +6 -0
  2. thrubase/__main__.py +3 -0
  3. thrubase/adapters/base.py +101 -0
  4. thrubase/adapters/python/__init__.py +470 -0
  5. thrubase/adapters/python/entry.py +143 -0
  6. thrubase/adapters/python/queries.scm +50 -0
  7. thrubase/adapters/python/resolve.py +402 -0
  8. thrubase/api.py +468 -0
  9. thrubase/cli.py +281 -0
  10. thrubase/engine/altitude.py +78 -0
  11. thrubase/engine/checkpoint.py +414 -0
  12. thrubase/engine/collect.py +127 -0
  13. thrubase/engine/diff.py +332 -0
  14. thrubase/engine/graph.py +114 -0
  15. thrubase/engine/index.py +58 -0
  16. thrubase/engine/layout.py +140 -0
  17. thrubase/engine/parse.py +174 -0
  18. thrubase/engine/pipeline.py +302 -0
  19. thrubase/engine/trail.py +258 -0
  20. thrubase/guide/__init__.py +5 -0
  21. thrubase/guide/cache.py +26 -0
  22. thrubase/guide/prompt.py +67 -0
  23. thrubase/guide/providers.py +118 -0
  24. thrubase/guide/service.py +42 -0
  25. thrubase/i18n/__init__.py +52 -0
  26. thrubase/i18n/en.json +88 -0
  27. thrubase/i18n/ko.json +88 -0
  28. thrubase/model.py +384 -0
  29. thrubase/rpc.py +152 -0
  30. thrubase/server.py +127 -0
  31. thrubase/store.py +304 -0
  32. thrubase/tui.py +154 -0
  33. thrubase/web/.gitkeep +0 -0
  34. thrubase/web/assets/index.css +1 -0
  35. thrubase/web/assets/index.js +42 -0
  36. thrubase/web/fonts/JetBrainsMono-latin-wght.woff2 +0 -0
  37. thrubase/web/fonts/PretendardVariable.woff2 +0 -0
  38. thrubase/web/fonts/codicon.ttf +0 -0
  39. thrubase/web/index.html +15 -0
  40. thrubase-0.1.0a1.dist-info/METADATA +60 -0
  41. thrubase-0.1.0a1.dist-info/RECORD +44 -0
  42. thrubase-0.1.0a1.dist-info/WHEEL +4 -0
  43. thrubase-0.1.0a1.dist-info/entry_points.txt +2 -0
  44. thrubase-0.1.0a1.dist-info/licenses/LICENSE +661 -0
thrubase/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Thrubase: climb a codebase."""
2
+
3
+ __version__ = "0.1.0a1"
4
+ SCHEMA_VERSION = 1
5
+
6
+ __all__ = ["SCHEMA_VERSION", "__version__"]
thrubase/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,101 @@
1
+ """The ``LanguageAdapter`` protocol and the registry that finds adapters by file extension.
2
+
3
+ Everything language-specific lives behind this protocol, so adding a language means adding
4
+ an ``adapters/<language>/`` package and registering it; ``engine/`` never changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
13
+
14
+ if TYPE_CHECKING:
15
+ import tree_sitter as ts
16
+
17
+ from ..engine.index import SymbolIndex
18
+ from ..engine.parse import ParsedFile
19
+ from ..model import Edge
20
+
21
+
22
+ @dataclass
23
+ class Entry:
24
+ node: str
25
+ kind: str
26
+ detail: str = ""
27
+
28
+
29
+ @runtime_checkable
30
+ class LanguageAdapter(Protocol):
31
+ name: str
32
+ #: bumped whenever extraction or resolution output changes, invalidating caches
33
+ version: str
34
+ extensions: tuple[str, ...]
35
+ #: gitignore-style patterns that are never analyzed (virtualenvs, build output, tests …)
36
+ excludes: tuple[str, ...]
37
+
38
+ def language(self) -> ts.Language: ...
39
+
40
+ def query_source(self) -> str: ...
41
+
42
+ def module_name(self, path: str, all_paths: frozenset[str]) -> str: ...
43
+
44
+ def extract(self, pf: ParsedFile, tree: ts.Tree, matches: list[tuple[int, dict[str, list[ts.Node]]]],
45
+ source: bytes) -> None: ...
46
+
47
+ def resolve(self, files: list[ParsedFile], index: SymbolIndex) -> list[Edge]: ...
48
+
49
+ def entries(self, files: list[ParsedFile], index: SymbolIndex, root: Path,
50
+ edges: list[Edge]) -> list[Entry]: ...
51
+
52
+ def library_roots(self, files: list[ParsedFile], index: SymbolIndex) -> list[str]:
53
+ """Node ids a library exposes as its API (reached even if no entry point calls them)."""
54
+ ...
55
+
56
+
57
+ class Registry:
58
+ def __init__(self) -> None:
59
+ self._adapters: list[LanguageAdapter] = []
60
+
61
+ def register(self, adapter: LanguageAdapter) -> None:
62
+ if any(a.name == adapter.name for a in self._adapters):
63
+ self._adapters = [a for a in self._adapters if a.name != adapter.name]
64
+ self._adapters.append(adapter)
65
+
66
+ def unregister(self, name: str) -> None:
67
+ self._adapters = [a for a in self._adapters if a.name != name]
68
+
69
+ def for_path(self, path: str) -> LanguageAdapter | None:
70
+ lower = path.lower()
71
+ for a in self._adapters:
72
+ if lower.endswith(a.extensions):
73
+ return a
74
+ return None
75
+
76
+ def get(self, name: str) -> LanguageAdapter:
77
+ for a in self._adapters:
78
+ if a.name == name:
79
+ return a
80
+ raise KeyError(name)
81
+
82
+ def __iter__(self) -> Iterator[LanguageAdapter]:
83
+ return iter(list(self._adapters))
84
+
85
+ @property
86
+ def names(self) -> list[str]:
87
+ return [a.name for a in self._adapters]
88
+
89
+ def describe(self) -> list[dict[str, Any]]:
90
+ return [{"name": a.name, "extensions": list(a.extensions)} for a in self._adapters]
91
+
92
+
93
+ registry = Registry()
94
+
95
+
96
+ def default_registry() -> Registry:
97
+ if not registry.names:
98
+ from .python import PythonAdapter
99
+
100
+ registry.register(PythonAdapter())
101
+ return registry
@@ -0,0 +1,470 @@
1
+ """PythonAdapter: everything Python-specific about Thrubase."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import hashlib
7
+ import re
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import tree_sitter as ts
13
+ import tree_sitter_python as tsp
14
+
15
+ from ...engine.parse import Call, Definition, Import, ParsedFile, Spot
16
+ from ..base import Entry
17
+
18
+ if TYPE_CHECKING:
19
+ from ...engine.index import SymbolIndex
20
+ from ...model import Edge
21
+
22
+ _DEF_TYPES = ("function_definition", "class_definition")
23
+
24
+
25
+ def _text(node: ts.Node | None) -> str:
26
+ if node is None or node.text is None:
27
+ return ""
28
+ return node.text.decode("utf-8", "replace")
29
+
30
+
31
+ def _compact(text: str) -> str:
32
+ return "".join(text.split())
33
+
34
+
35
+ def _line(node: ts.Node) -> int:
36
+ return node.start_point[0] + 1
37
+
38
+
39
+ def _end_line(node: ts.Node) -> int:
40
+ row, col = node.end_point
41
+ # A node ending at column 0 really ended on the previous line's newline.
42
+ return row if col == 0 and row > node.start_point[0] else row + 1
43
+
44
+
45
+ _OPTIONAL = ("Optional", "typing.Optional", "t.Optional", "Final", "typing.Final", "ClassVar",
46
+ "typing.ClassVar", "Annotated", "typing.Annotated", "type", "Type", "typing.Type")
47
+
48
+
49
+ def _type_name(node: ts.Node | None) -> str:
50
+ """The class named by an annotation: ``Foo``, ``m.Foo``, ``"Foo"``, ``Foo | None``,
51
+ ``Optional[Foo]``. Containers (``list[Foo]``) and unions of classes give ``""``."""
52
+ if node is None:
53
+ return ""
54
+ if node.type == "type" and node.named_children:
55
+ return _type_name(node.named_children[0])
56
+ if node.type in ("identifier", "attribute"):
57
+ return _compact(_text(node))
58
+ if node.type == "string":
59
+ inner = _text(node).strip("\"'")
60
+ return inner if inner.replace(".", "").replace("_", "").isalnum() else ""
61
+ if node.type == "binary_operator": # X | None
62
+ parts = [c for c in node.named_children if _text(c) != "None"]
63
+ return _type_name(parts[0]) if len(parts) == 1 else ""
64
+ if node.type in ("generic_type", "subscript"):
65
+ head = node.named_children[0] if node.named_children else None
66
+ if head is not None and _compact(_text(head)) in _OPTIONAL:
67
+ args = [c for c in node.named_children[1:]]
68
+ flat: list[ts.Node] = []
69
+ for a in args:
70
+ flat.extend(a.named_children if a.type in ("type_parameter", "subscript") else [a])
71
+ flat = [a for a in flat if _text(a) != "None"]
72
+ return _type_name(flat[0]) if flat else ""
73
+ return ""
74
+
75
+
76
+ def _enclosing_def(node: ts.Node) -> ts.Node | None:
77
+ p = node.parent
78
+ while p is not None:
79
+ if p.type in _DEF_TYPES:
80
+ return p
81
+ p = p.parent
82
+ return None
83
+
84
+
85
+ _LITERALS = frozenset({"string", "concatenated_string", "list", "dictionary", "set", "tuple", "integer",
86
+ "float", "list_comprehension", "dictionary_comprehension", "set_comprehension",
87
+ "generator_expression", "true", "false", "none", "parenthesized_expression"})
88
+
89
+
90
+ _WS = re.compile(rb"\s+")
91
+
92
+
93
+ def _outer(node: ts.Node) -> ts.Node:
94
+ """A definition including its decorators."""
95
+ p = node.parent
96
+ return p if p is not None and p.type == "decorated_definition" else node
97
+
98
+
99
+ def _hash_without(source: bytes, start: int, end: int, holes: list[tuple[int, int]]) -> str:
100
+ """sha1 of ``source[start:end]`` minus the ``holes`` (comments, nested definitions) and all
101
+ whitespace, so comment and formatting changes do not count as code changes."""
102
+ h = hashlib.sha1()
103
+ pos = start
104
+ for a, b in sorted(holes):
105
+ if b <= pos or a >= end:
106
+ continue
107
+ if a > pos:
108
+ h.update(_WS.sub(b"", source[pos:a]))
109
+ pos = max(pos, b)
110
+ if pos < end:
111
+ h.update(_WS.sub(b"", source[pos:end]))
112
+ return h.hexdigest()[:16]
113
+
114
+
115
+ class PythonAdapter:
116
+ name = "python"
117
+ version = "py-7"
118
+ extensions: tuple[str, ...] = (".py",)
119
+ excludes: tuple[str, ...] = (
120
+ ".git/",
121
+ ".hg/",
122
+ ".venv/",
123
+ "venv/",
124
+ "env/",
125
+ ".env/",
126
+ ".tox/",
127
+ ".nox/",
128
+ "__pycache__/",
129
+ "site-packages/",
130
+ "node_modules/",
131
+ "build/",
132
+ "dist/",
133
+ ".eggs/",
134
+ "*.egg-info/",
135
+ ".mypy_cache/",
136
+ ".pytest_cache/",
137
+ "tests/",
138
+ "test/",
139
+ "testing/",
140
+ "test_*.py",
141
+ "*_test.py",
142
+ "conftest.py",
143
+ "docs/",
144
+ "examples/",
145
+ "migrations/",
146
+ "setup.py",
147
+ "noxfile.py",
148
+ )
149
+
150
+ def __init__(self) -> None:
151
+ self._lang: ts.Language | None = None
152
+
153
+ # -------------------------------------------------------------- grammar
154
+ def language(self) -> ts.Language:
155
+ if self._lang is None:
156
+ self._lang = ts.Language(tsp.language())
157
+ return self._lang
158
+
159
+ def query_source(self) -> str:
160
+ return resources.files(__package__).joinpath("queries.scm").read_text(encoding="utf-8")
161
+
162
+ # -------------------------------------------------------------- modules
163
+ def module_name(self, path: str, all_paths: frozenset[str]) -> str:
164
+ """Dotted module name. Walks up while parent folders are packages (have ``__init__.py``);
165
+ a leading ``src/`` layout folder is never part of the name."""
166
+ parts = path.split("/")
167
+ stem = parts[-1][: -len(".py")]
168
+ dirs = parts[:-1]
169
+ mod = [] if stem == "__init__" else [stem]
170
+ i = len(dirs)
171
+ while i > 0:
172
+ pkg_init = "/".join([*dirs[:i], "__init__.py"])
173
+ if pkg_init not in all_paths:
174
+ break
175
+ i -= 1
176
+ names = dirs[i:] + mod
177
+ if not names:
178
+ names = dirs[-1:] if dirs else [stem]
179
+ return ".".join(names)
180
+
181
+ # -------------------------------------------------------------- extraction
182
+ def extract(self, pf: ParsedFile, tree: ts.Tree,
183
+ matches: list[tuple[int, dict[str, list[ts.Node]]]], source: bytes) -> None:
184
+ qual_of: dict[int, str] = {}
185
+
186
+ def qualname(def_node: ts.Node) -> str:
187
+ if def_node.id in qual_of:
188
+ return qual_of[def_node.id]
189
+ parts = [_text(def_node.child_by_field_name("name"))]
190
+ p = _enclosing_def(def_node)
191
+ q = f"{qualname(p)}.{parts[0]}" if p is not None else parts[0]
192
+ qual_of[def_node.id] = q
193
+ return q
194
+
195
+ def scope_of(node: ts.Node) -> str | None:
196
+ d = _enclosing_def(node)
197
+ return qualname(d) if d is not None else None
198
+
199
+ def function_scope_of(node: ts.Node) -> str | None:
200
+ d = _enclosing_def(node)
201
+ while d is not None and d.type != "function_definition":
202
+ d = _enclosing_def(d)
203
+ return qualname(d) if d is not None else None
204
+
205
+ seen_defs: set[str] = set()
206
+ defs_by_node: dict[int, int] = {}
207
+ def_nodes: list[tuple[int, ts.Node, ts.Node | None]] = [] # (definition index, node, parent def)
208
+ comments: list[tuple[int, int]] = []
209
+ imports: list[tuple[int, int]] = []
210
+
211
+ for _, caps in matches:
212
+ if "def.function" in caps or "def.class" in caps:
213
+ is_fn = "def.function" in caps
214
+ node = caps["def.function" if is_fn else "def.class"][0]
215
+ q = qualname(node)
216
+ if q in seen_defs: # redefinition (e.g. property setter): keep the first
217
+ q_alt = f"{q}@{_line(node)}"
218
+ qual_of[node.id] = q_alt
219
+ q = q_alt
220
+ seen_defs.add(q)
221
+ parent_node = _enclosing_def(node)
222
+ parent = qualname(parent_node) if parent_node is not None else None
223
+ if is_fn:
224
+ kind = "method" if parent_node is not None and parent_node.type == "class_definition" \
225
+ else "function"
226
+ else:
227
+ kind = "class"
228
+ decorators: list[str] = []
229
+ if node.parent is not None and node.parent.type == "decorated_definition":
230
+ for ch in node.parent.children:
231
+ if ch.type == "decorator":
232
+ decorators.append(_compact(_text(ch))[1:])
233
+ bases: list[str] = []
234
+ if "def.bases" in caps:
235
+ for arg in caps["def.bases"][0].named_children:
236
+ if arg.type in ("identifier", "attribute"):
237
+ bases.append(_compact(_text(arg)))
238
+ name = _text(node.child_by_field_name("name"))
239
+ params: dict[str, str] = {}
240
+ if "def.params" in caps:
241
+ for prm in caps["def.params"][0].named_children:
242
+ if prm.type in ("typed_parameter", "typed_default_parameter"):
243
+ pname = prm.child_by_field_name("name")
244
+ if pname is None:
245
+ pname = next((c for c in prm.named_children if c.type == "identifier"), None)
246
+ ptype = _type_name(prm.child_by_field_name("type"))
247
+ if pname is not None and ptype:
248
+ params[_text(pname)] = ptype
249
+ returns = _type_name(caps["def.returns"][0]) if "def.returns" in caps else ""
250
+ defs_by_node[node.id] = len(pf.definitions)
251
+ def_nodes.append((len(pf.definitions), node, parent_node))
252
+ pf.definitions.append(Definition(
253
+ qualname=q, name=name, kind=kind, # type: ignore[arg-type]
254
+ start_line=_line(node), end_line=_end_line(node), parent=parent,
255
+ bases=bases, decorators=decorators,
256
+ is_async=any(c.type == "async" for c in node.children),
257
+ params=params, returns=returns,
258
+ ))
259
+ elif "bind" in caps:
260
+ left = caps["bind.left"][0]
261
+ value = ""
262
+ if "bind.type" in caps:
263
+ tname = _type_name(caps["bind.type"][0])
264
+ value = f"ann:{tname}" if tname else ""
265
+ if not value and "bind.right" in caps:
266
+ right = caps["bind.right"][0]
267
+ if right.type == "call":
268
+ fn = right.child_by_field_name("function")
269
+ if fn is not None and fn.type in ("identifier", "attribute"):
270
+ value = "call:" + _compact(_text(fn))
271
+ elif right.type == "identifier":
272
+ # `self.bus = bus` takes the annotation of the parameter it came from
273
+ owner_fn = _enclosing_def(caps["bind"][0])
274
+ if owner_fn is not None and owner_fn.id in defs_by_node:
275
+ ptype_text = pf.definitions[defs_by_node[owner_fn.id]].params.get(_text(right))
276
+ value = f"ann:{ptype_text}" if ptype_text else ""
277
+ elif right.type == "await":
278
+ inner = right.named_children[0] if right.named_children else None
279
+ if inner is not None and inner.type == "call":
280
+ fn = inner.child_by_field_name("function")
281
+ if fn is not None and fn.type in ("identifier", "attribute"):
282
+ value = "call:" + _compact(_text(fn))
283
+ if not value:
284
+ continue
285
+ owner = _enclosing_def(caps["bind"][0])
286
+ if owner is None or owner.id not in defs_by_node:
287
+ continue
288
+ d_owner = pf.definitions[defs_by_node[owner.id]]
289
+ if left.type == "identifier":
290
+ target = d_owner.attrs if d_owner.kind == "class" else d_owner.locals
291
+ target.setdefault(_text(left), value)
292
+ elif left.type == "attribute" and d_owner.kind == "method":
293
+ obj = left.child_by_field_name("object")
294
+ if obj is not None and _text(obj) == "self":
295
+ cls_node = _enclosing_def(owner)
296
+ if cls_node is not None and cls_node.id in defs_by_node:
297
+ attr_name = _text(left.child_by_field_name("attribute"))
298
+ pf.definitions[defs_by_node[cls_node.id]].attrs.setdefault(attr_name, value)
299
+ elif "comment" in caps:
300
+ c = caps["comment"][0]
301
+ comments.append((c.start_byte, c.end_byte))
302
+ elif "import" in caps:
303
+ node = caps["import"][0]
304
+ if node.parent is not None and node.parent.type == "module":
305
+ imports.append((node.start_byte, node.end_byte))
306
+ scope = function_scope_of(node)
307
+ for ch in node.named_children:
308
+ if ch.type == "dotted_name":
309
+ pf.imports.append(Import(module=_text(ch), names=[], level=0, line=_line(node),
310
+ scope=scope))
311
+ elif ch.type == "aliased_import":
312
+ pf.imports.append(Import(
313
+ module=_text(ch.child_by_field_name("name")), names=[], level=0,
314
+ line=_line(node), scope=scope, alias=_text(ch.child_by_field_name("alias"))))
315
+ elif "import.from" in caps:
316
+ node = caps["import.from"][0]
317
+ if node.parent is not None and node.parent.type == "module":
318
+ imports.append((node.start_byte, node.end_byte))
319
+ scope = function_scope_of(node)
320
+ mod_node = node.child_by_field_name("module_name")
321
+ level = 0
322
+ module = ""
323
+ if mod_node is not None:
324
+ if mod_node.type == "relative_import":
325
+ for ch in mod_node.children:
326
+ if ch.type == "import_prefix":
327
+ level = _text(ch).count(".")
328
+ elif ch.type == "dotted_name":
329
+ module = _text(ch)
330
+ else:
331
+ module = _text(mod_node)
332
+ names: list[tuple[str, str | None]] = []
333
+ for ch in node.children_by_field_name("name"):
334
+ if ch.type == "dotted_name":
335
+ names.append((_text(ch), None))
336
+ elif ch.type == "aliased_import":
337
+ names.append((_text(ch.child_by_field_name("name")),
338
+ _text(ch.child_by_field_name("alias"))))
339
+ if any(ch.type == "wildcard_import" for ch in node.children):
340
+ names.append(("*", None))
341
+ pf.imports.append(Import(module=module, names=names, level=level, line=_line(node),
342
+ scope=scope, is_from=True))
343
+ elif "call" in caps:
344
+ node = caps["call"][0]
345
+ callee_node = caps["call.callee"][0]
346
+ callee = _compact(_text(callee_node))
347
+ if callee_node.type not in ("identifier", "attribute"):
348
+ # Keep `super().m` and `super(C, self).m`; everything else is a dynamic target.
349
+ callee = callee if callee.startswith("super(") else f"<expr>{callee[:40]}"
350
+ elif callee_node.type == "attribute":
351
+ obj = callee_node.child_by_field_name("object")
352
+ if obj is not None and obj.type == "call":
353
+ fn_node = obj.child_by_field_name("function")
354
+ inner_fn = _compact(_text(fn_node))
355
+ attr_text = _text(callee_node.child_by_field_name("attribute"))
356
+ if inner_fn == "super":
357
+ callee = f"super().{attr_text}"
358
+ elif fn_node is not None and fn_node.type in ("identifier", "attribute"):
359
+ # `Service(db).run()`: the method of whatever the call returns
360
+ callee = f"{inner_fn}().{attr_text}"
361
+ else:
362
+ callee = f"<expr>{callee[:40]}"
363
+ elif obj is not None and obj.type in _LITERALS:
364
+ callee = f"<lit>{callee[:40]}"
365
+ elif obj is not None and obj.type not in ("identifier", "attribute"):
366
+ callee = f"<expr>{callee[:40]}"
367
+ caller = scope_of(node)
368
+ pf.calls.append(Call(caller=caller, callee=callee, line=_line(node),
369
+ end_line=_end_line(node), col=node.start_point[1]))
370
+ for arg in caps["call.args"][0].named_children:
371
+ if arg.type == "keyword_argument":
372
+ arg = arg.child_by_field_name("value") or arg
373
+ if arg.type in ("identifier", "attribute"):
374
+ pf.refs.append(Call(caller=caller, callee=_compact(_text(arg)), line=_line(arg),
375
+ end_line=_line(arg), col=arg.start_point[1]))
376
+ elif "spot.raise" in caps or "spot.except" in caps or "spot.return" in caps:
377
+ kind = next(k for k in ("spot.raise", "spot.except", "spot.return") if k in caps)
378
+ node = caps[kind][0]
379
+ end = _end_line(node)
380
+ if kind == "spot.except":
381
+ # The handler header is the "spot"; its body belongs to it as well.
382
+ block = next((c for c in node.children if c.type == "block"), None)
383
+ end = _end_line(block) if block is not None else end
384
+ pf.spots.append(Spot(kind=kind.split(".")[1], owner=function_scope_of(node),
385
+ start_line=_line(node), end_line=end,
386
+ detail=_compact(_text(node))[:60]))
387
+ elif "main.block" in caps:
388
+ cond = _compact(_text(caps["main.cond"][0]))
389
+ if cond in ('__name__=="__main__"', "__name__=='__main__'",
390
+ '"__main__"==__name__', "'__main__'==__name__"):
391
+ block = caps["main.block"][0]
392
+ pf.extra["main_block"] = [_line(block), _end_line(block)]
393
+ elif "assign.name" in caps:
394
+ name = _text(caps["assign.name"][0])
395
+ rhs = caps["assign.value"][0]
396
+ if rhs.type == "call":
397
+ fn = rhs.child_by_field_name("function")
398
+ if fn is not None and fn.type in ("identifier", "attribute"):
399
+ pf.assignments[name] = "call:" + _compact(_text(fn))
400
+ elif rhs.type in ("identifier", "attribute"):
401
+ pf.assignments[name] = "ref:" + _compact(_text(rhs))
402
+ if name == "urlpatterns" and rhs.type in ("list", "binary_operator"):
403
+ pf.extra["urlpatterns"] = _url_refs(rhs)
404
+
405
+ # Body hashes: each definition without comments and without its nested definitions; the
406
+ # module without definitions, comments and imports (import-only edits are not changes).
407
+ comments.sort()
408
+ starts = [c[0] for c in comments]
409
+ children: dict[int | None, list[tuple[int, int]]] = {}
410
+ for _, def_node, owner_node in def_nodes:
411
+ o = _outer(def_node)
412
+ key = owner_node.id if owner_node is not None else None
413
+ children.setdefault(key, []).append((o.start_byte, o.end_byte))
414
+ for i, node, _ in def_nodes:
415
+ lo = bisect.bisect_left(starts, node.start_byte)
416
+ hi = bisect.bisect_left(starts, node.end_byte)
417
+ holes = comments[lo:hi] + children.get(node.id, [])
418
+ pf.definitions[i].body_hash = _hash_without(source, node.start_byte, node.end_byte, holes)
419
+ pf.extra["module_hash"] = _hash_without(source, 0, len(source),
420
+ comments + children.get(None, []) + imports)
421
+ pf.definitions.sort(key=lambda d: (d.start_line, d.qualname))
422
+ pf.calls.sort(key=lambda c: (c.line, c.col))
423
+ pf.refs.sort(key=lambda c: (c.line, c.col))
424
+ pf.spots.sort(key=lambda s: s.start_line)
425
+
426
+ # -------------------------------------------------------------- resolution / entries
427
+ def resolve(self, files: list[ParsedFile], index: SymbolIndex) -> list[Edge]:
428
+ from .resolve import Resolver
429
+
430
+ return Resolver(files, index).edges()
431
+
432
+ def entries(self, files: list[ParsedFile], index: SymbolIndex, root: Path,
433
+ edges: list[Edge]) -> list[Entry]:
434
+ from .entry import detect_entries
435
+
436
+ return detect_entries(files, index, root, edges)
437
+
438
+ def library_roots(self, files: list[ParsedFile], index: SymbolIndex) -> list[str]:
439
+ from .resolve import Resolver
440
+
441
+ return Resolver(files, index).library_roots()
442
+
443
+
444
+ def _url_refs(value: ts.Node) -> list[list[object]]:
445
+ """Second positional argument of each ``path()``/``re_path()``/``url()`` call in urlpatterns."""
446
+ refs: list[list[object]] = []
447
+ stack = [value]
448
+ while stack:
449
+ n = stack.pop()
450
+ if n.type == "call":
451
+ fn = _compact(_text(n.child_by_field_name("function")))
452
+ if fn.split(".")[-1] in ("path", "re_path", "url"):
453
+ args = [a for a in (n.child_by_field_name("arguments") or n).named_children
454
+ if a.type != "keyword_argument" and a.type != "comment"]
455
+ if len(args) >= 2:
456
+ view = args[1]
457
+ if view.type == "call": # Class.as_view()
458
+ f = view.child_by_field_name("function")
459
+ txt = _compact(_text(f))
460
+ if txt.endswith(".as_view"):
461
+ refs.append([txt[: -len(".as_view")], _line(view)])
462
+ continue
463
+ if view.type in ("identifier", "attribute"):
464
+ refs.append([_compact(_text(view)), _line(view)])
465
+ continue
466
+ stack.extend(reversed(n.named_children))
467
+ return refs
468
+
469
+
470
+ __all__ = ["Entry", "PythonAdapter"]