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.
- thrubase/__init__.py +6 -0
- thrubase/__main__.py +3 -0
- thrubase/adapters/base.py +101 -0
- thrubase/adapters/python/__init__.py +470 -0
- thrubase/adapters/python/entry.py +143 -0
- thrubase/adapters/python/queries.scm +50 -0
- thrubase/adapters/python/resolve.py +402 -0
- thrubase/api.py +468 -0
- thrubase/cli.py +281 -0
- thrubase/engine/altitude.py +78 -0
- thrubase/engine/checkpoint.py +414 -0
- thrubase/engine/collect.py +127 -0
- thrubase/engine/diff.py +332 -0
- thrubase/engine/graph.py +114 -0
- thrubase/engine/index.py +58 -0
- thrubase/engine/layout.py +140 -0
- thrubase/engine/parse.py +174 -0
- thrubase/engine/pipeline.py +302 -0
- thrubase/engine/trail.py +258 -0
- thrubase/guide/__init__.py +5 -0
- thrubase/guide/cache.py +26 -0
- thrubase/guide/prompt.py +67 -0
- thrubase/guide/providers.py +118 -0
- thrubase/guide/service.py +42 -0
- thrubase/i18n/__init__.py +52 -0
- thrubase/i18n/en.json +88 -0
- thrubase/i18n/ko.json +88 -0
- thrubase/model.py +384 -0
- thrubase/rpc.py +152 -0
- thrubase/server.py +127 -0
- thrubase/store.py +304 -0
- thrubase/tui.py +154 -0
- thrubase/web/.gitkeep +0 -0
- thrubase/web/assets/index.css +1 -0
- thrubase/web/assets/index.js +42 -0
- thrubase/web/fonts/JetBrainsMono-latin-wght.woff2 +0 -0
- thrubase/web/fonts/PretendardVariable.woff2 +0 -0
- thrubase/web/fonts/codicon.ttf +0 -0
- thrubase/web/index.html +15 -0
- thrubase-0.1.0a1.dist-info/METADATA +60 -0
- thrubase-0.1.0a1.dist-info/RECORD +44 -0
- thrubase-0.1.0a1.dist-info/WHEEL +4 -0
- thrubase-0.1.0a1.dist-info/entry_points.txt +2 -0
- thrubase-0.1.0a1.dist-info/licenses/LICENSE +661 -0
thrubase/__init__.py
ADDED
thrubase/__main__.py
ADDED
|
@@ -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"]
|