codegraph-network 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.
- codegraph/__init__.py +1 -0
- codegraph/bench.py +93 -0
- codegraph/cli.py +128 -0
- codegraph/community.py +77 -0
- codegraph/docs.py +141 -0
- codegraph/embed.py +221 -0
- codegraph/indexer.py +190 -0
- codegraph/install.py +219 -0
- codegraph/keys.py +94 -0
- codegraph/links.py +220 -0
- codegraph/parser.py +459 -0
- codegraph/py.typed +0 -0
- codegraph/queries.py +657 -0
- codegraph/rank.py +121 -0
- codegraph/report.py +199 -0
- codegraph/review.py +145 -0
- codegraph/server.py +81 -0
- codegraph/store.py +638 -0
- codegraph/tools.py +99 -0
- codegraph_network-0.1.0.dist-info/METADATA +220 -0
- codegraph_network-0.1.0.dist-info/RECORD +25 -0
- codegraph_network-0.1.0.dist-info/WHEEL +5 -0
- codegraph_network-0.1.0.dist-info/entry_points.txt +2 -0
- codegraph_network-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_network-0.1.0.dist-info/top_level.txt +1 -0
codegraph/links.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Hidden links that are not explicit in code: tests <-> subject, shared string keys, git co-change.
|
|
2
|
+
|
|
3
|
+
Everything here is derived from the index plus (optionally) `git log`; none of it needs a model.
|
|
4
|
+
Symbol-level results go to the `links` table and file-level ones to `file_links`, never to `edges`,
|
|
5
|
+
so callers / callees / impact stay purely structural.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from itertools import combinations
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .store import TARGET_KINDS_CALL, Store, split_words
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------------- tests <-> subject
|
|
20
|
+
TEST_DIR = re.compile(r"(^|/)(tests?|__tests__|spec|specs)/")
|
|
21
|
+
# (pattern, group holding the subject stem) per naming convention
|
|
22
|
+
TEST_NAMES = (
|
|
23
|
+
re.compile(r"^test_(.+)\.py$"), re.compile(r"^(.+)_test\.py$"),
|
|
24
|
+
re.compile(r"^(.+)\.(?:test|spec)\.[cm]?[jt]sx?$"),
|
|
25
|
+
re.compile(r"^(.+)_test\.(?:go|rs|cpp|cc|cxx)$"), re.compile(r"^test_(.+)\.(?:cpp|cc|cxx|c)$"),
|
|
26
|
+
)
|
|
27
|
+
TEST_SYM = re.compile(r"^(?:test_?|Test_?)(.+?)(?:_?[Tt]ests?)?$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_test_path(path: str) -> bool:
|
|
31
|
+
return bool(TEST_DIR.search(path)) or subject_stem(path) is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def subject_stem(path: str) -> str | None:
|
|
35
|
+
"""`tests/test_store.py` -> `store`, `a/foo.test.ts` -> `foo`, `foo_test.go` -> `foo`."""
|
|
36
|
+
base = posixpath.basename(path)
|
|
37
|
+
for pat in TEST_NAMES:
|
|
38
|
+
m = pat.match(base)
|
|
39
|
+
if m:
|
|
40
|
+
return m.group(1)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _subject_file(test: str, by_stem: dict[str, list[str]]) -> str | None:
|
|
45
|
+
"""The non-test file a test file is named after; the nearest one wins when several share a stem."""
|
|
46
|
+
stem = subject_stem(test)
|
|
47
|
+
cands = [f for f in by_stem.get(stem or "", ()) if not is_test_path(f)]
|
|
48
|
+
if not cands:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def shared(f: str) -> int:
|
|
52
|
+
n = 0
|
|
53
|
+
for a, b in zip(test.split("/"), f.split("/"), strict=False):
|
|
54
|
+
if a != b:
|
|
55
|
+
break
|
|
56
|
+
n += 1
|
|
57
|
+
return n
|
|
58
|
+
|
|
59
|
+
best = max(shared(f) for f in cands)
|
|
60
|
+
top = [f for f in cands if shared(f) == best]
|
|
61
|
+
return top[0] if len(top) == 1 else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _name_words(name: str) -> list[str]:
|
|
65
|
+
return [w.lower() for w in split_words(name).split() if w]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _subject_symbol(test_name: str, candidates: dict[str, int]) -> int | None:
|
|
69
|
+
"""`test_resolve_import_roots` -> the longest prefix that names a symbol: resolve_import_roots,
|
|
70
|
+
resolve_import, resolve. `candidates` maps a normalised name to a symbol id."""
|
|
71
|
+
m = TEST_SYM.match(test_name)
|
|
72
|
+
words = _name_words(m.group(1) if m else test_name)
|
|
73
|
+
for n in range(len(words), 0, -1):
|
|
74
|
+
hit = candidates.get("".join(words[:n]))
|
|
75
|
+
if hit is not None and (n > 1 or len(words[0]) >= 3): # a lone tiny word is too weak a signal
|
|
76
|
+
return hit
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def link_tests(db) -> int:
|
|
81
|
+
"""Test file <-> subject file by naming convention, and `test_x` -> `x` inside that pair."""
|
|
82
|
+
db.execute("DELETE FROM links WHERE kind='tests'")
|
|
83
|
+
files = [r[0] for r in db.execute("SELECT path FROM files")]
|
|
84
|
+
by_stem: dict[str, list[str]] = defaultdict(list)
|
|
85
|
+
for f in files:
|
|
86
|
+
by_stem[posixpath.splitext(posixpath.basename(f))[0]].append(f)
|
|
87
|
+
syms: dict[str, list] = defaultdict(list)
|
|
88
|
+
for r in db.execute("SELECT id, file, name, kind FROM symbols WHERE kind != 'impl'"):
|
|
89
|
+
syms[r["file"]].append(r)
|
|
90
|
+
rows = []
|
|
91
|
+
for t in files:
|
|
92
|
+
subject = _subject_file(t, by_stem)
|
|
93
|
+
if subject is None:
|
|
94
|
+
continue
|
|
95
|
+
tmod = next((s["id"] for s in syms[t] if s["kind"] == "module"), None)
|
|
96
|
+
smod = next((s["id"] for s in syms[subject] if s["kind"] == "module"), None)
|
|
97
|
+
if tmod is not None and smod is not None:
|
|
98
|
+
rows.append((tmod, smod, "tests", 1.0, "file"))
|
|
99
|
+
cands = {"".join(_name_words(s["name"])): s["id"] for s in syms[subject]
|
|
100
|
+
if s["kind"] in TARGET_KINDS_CALL}
|
|
101
|
+
for s in syms[t]:
|
|
102
|
+
if s["kind"] in ("function", "method", "class") and (s["name"].lower().startswith("test") or
|
|
103
|
+
s["name"].lower().endswith("test")):
|
|
104
|
+
hit = _subject_symbol(s["name"], cands)
|
|
105
|
+
if hit is not None:
|
|
106
|
+
rows.append((s["id"], hit, "tests", 1.0, "name"))
|
|
107
|
+
db.executemany("INSERT INTO links VALUES(?,?,?,?,?)", rows)
|
|
108
|
+
return len(rows)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------------- string keys
|
|
112
|
+
# namespace -> (role on one side, role on the other). Same role twice: everything sharing the key is related.
|
|
113
|
+
KEY_ROLES = {"route": ("provides", "uses"), "event": ("emits", "listens"), "config": ("reads", "reads")}
|
|
114
|
+
MAX_KEY_GROUP = 8 # a key shared by more symbols than this is a convention, not a link
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def link_keys(db) -> int:
|
|
118
|
+
"""Link symbols in different files that use the same string key in matching roles."""
|
|
119
|
+
db.execute("DELETE FROM links WHERE kind='key'")
|
|
120
|
+
groups: dict[tuple[str, str], dict[str, set[int]]] = defaultdict(lambda: defaultdict(set))
|
|
121
|
+
for r in db.execute("SELECT src, ns, key, role FROM raw_keys"):
|
|
122
|
+
groups[(r["ns"], r["key"])][r["role"]].add(r["src"])
|
|
123
|
+
file_of = {r[0]: r[1] for r in db.execute("SELECT id, file FROM symbols")}
|
|
124
|
+
rows = []
|
|
125
|
+
for (ns, key), roles in groups.items():
|
|
126
|
+
a_role, b_role = KEY_ROLES.get(ns, (None, None))
|
|
127
|
+
if a_role is None:
|
|
128
|
+
continue
|
|
129
|
+
a, b = roles.get(a_role, set()), roles.get(b_role, set())
|
|
130
|
+
if a_role == b_role:
|
|
131
|
+
pairs = combinations(sorted(a), 2) if 2 <= len(a) <= MAX_KEY_GROUP else ()
|
|
132
|
+
elif a and b and len(a) <= MAX_KEY_GROUP and len(b) <= MAX_KEY_GROUP:
|
|
133
|
+
pairs = ((x, y) for x in sorted(a) for y in sorted(b))
|
|
134
|
+
else:
|
|
135
|
+
pairs = ()
|
|
136
|
+
note = f"{ns}:{key}"[:60]
|
|
137
|
+
rows += [(x, y, "key", 1.0, note) for x, y in pairs if file_of.get(x) != file_of.get(y)]
|
|
138
|
+
db.executemany("INSERT INTO links VALUES(?,?,?,?,?)", rows)
|
|
139
|
+
return len(rows)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------------- git co-change
|
|
143
|
+
GIT_COMMITS = 1000 # history window
|
|
144
|
+
MAX_COMMIT_FILES = 30 # bigger commits (renames, reformatting, vendoring) say nothing about coupling
|
|
145
|
+
MIN_COMMITS = 3 # a pair must co-change at least this often: twice is a coincidence
|
|
146
|
+
MIN_WEIGHT = 0.3
|
|
147
|
+
MAX_PARTNERS = 8
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
151
|
+
"""stdout of a git command in `root`, or None when git is missing / this is not a repository."""
|
|
152
|
+
if shutil.which("git") is None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
r = subprocess.run(["git", "-C", str(root), "-c", "core.quotepath=off", *args],
|
|
156
|
+
capture_output=True, text=True, timeout=60)
|
|
157
|
+
except (OSError, subprocess.SubprocessError):
|
|
158
|
+
return None
|
|
159
|
+
return r.stdout if r.returncode == 0 else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cochange_pairs(log: str, known: set[str]) -> dict[tuple[str, str], float]:
|
|
163
|
+
"""Parse `git log --name-only` output (commits separated by a \\x1e line) into pair weights.
|
|
164
|
+
|
|
165
|
+
A pair's weight is its co-change count over sqrt(commits of a * commits of b) (Ochiai), so a file
|
|
166
|
+
touched in every commit does not become everyone's partner, shrunk by n/(n+2) so that pairs seen
|
|
167
|
+
only a few times rank below pairs with a long shared history.
|
|
168
|
+
"""
|
|
169
|
+
touched: dict[str, int] = defaultdict(int)
|
|
170
|
+
pair_n: dict[tuple[str, str], int] = defaultdict(int)
|
|
171
|
+
for commit in log.split("\x1e"):
|
|
172
|
+
names = [f for f in commit.split("\n") if f]
|
|
173
|
+
files = sorted({f for f in names if f in known})
|
|
174
|
+
if len(files) < 2 or len(names) > MAX_COMMIT_FILES: # size counts every file, indexed or not
|
|
175
|
+
continue
|
|
176
|
+
for f in files:
|
|
177
|
+
touched[f] += 1
|
|
178
|
+
for a, b in combinations(files, 2):
|
|
179
|
+
pair_n[(a, b)] += 1
|
|
180
|
+
out = {}
|
|
181
|
+
for (a, b), n in pair_n.items():
|
|
182
|
+
if n >= MIN_COMMITS:
|
|
183
|
+
weight = n / (touched[a] * touched[b]) ** 0.5 * n / (n + 2)
|
|
184
|
+
if weight >= MIN_WEIGHT:
|
|
185
|
+
out[(a, b)] = weight
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def link_cochange(store: Store, root: Path, force: bool = False) -> int:
|
|
190
|
+
"""Refresh file-level co-change edges when HEAD moved. No git, no history or no repo: no edges."""
|
|
191
|
+
head = _git(root, "rev-parse", "HEAD")
|
|
192
|
+
head = head.strip() if head else ""
|
|
193
|
+
if not head:
|
|
194
|
+
if store.get_meta("git_head"): # was a repo, no longer is: drop the stale edges
|
|
195
|
+
store.db.execute("DELETE FROM file_links WHERE kind='cochange'")
|
|
196
|
+
store.set_meta("git_head", "")
|
|
197
|
+
return 0
|
|
198
|
+
if not force and store.get_meta("git_head") == head:
|
|
199
|
+
return -1 # unchanged
|
|
200
|
+
log = _git(root, "log", f"-n{GIT_COMMITS}", "--no-merges", "--relative", "--name-only",
|
|
201
|
+
"--pretty=format:%x1e")
|
|
202
|
+
known = {r[0] for r in store.db.execute("SELECT path FROM files")}
|
|
203
|
+
pairs = cochange_pairs(log or "", known)
|
|
204
|
+
# keep each file's strongest partners only, so a hub file cannot fan out to the whole repo
|
|
205
|
+
per_file: dict[str, list[tuple[float, tuple[str, str]]]] = defaultdict(list)
|
|
206
|
+
for pair, w in pairs.items():
|
|
207
|
+
per_file[pair[0]].append((w, pair))
|
|
208
|
+
per_file[pair[1]].append((w, pair))
|
|
209
|
+
keep = {p for lst in per_file.values() for _, p in sorted(lst, reverse=True)[:MAX_PARTNERS]}
|
|
210
|
+
store.db.execute("DELETE FROM file_links WHERE kind='cochange'")
|
|
211
|
+
store.db.executemany("INSERT INTO file_links VALUES(?,?,?,?)",
|
|
212
|
+
[(a, b, "cochange", pairs[(a, b)]) for a, b in sorted(keep)])
|
|
213
|
+
store.set_meta("git_head", head)
|
|
214
|
+
return len(keep)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def link_static(db) -> dict:
|
|
218
|
+
"""The cheap, model-free symbol links; rebuilt wholesale whenever the index changed."""
|
|
219
|
+
return {"tests": link_tests(db), "key": link_keys(db)}
|
|
220
|
+
|
codegraph/parser.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Tree-sitter based extraction of symbols and relations (no LLM involved)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from tree_sitter_language_pack import get_parser
|
|
8
|
+
|
|
9
|
+
from . import keys
|
|
10
|
+
|
|
11
|
+
IDENT = re.compile(r"[^\W\d][\w]*", re.UNICODE)
|
|
12
|
+
CONST_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
13
|
+
|
|
14
|
+
EXT = {
|
|
15
|
+
".py": "python",
|
|
16
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
17
|
+
".ts": "typescript", ".tsx": "tsx",
|
|
18
|
+
".go": "go",
|
|
19
|
+
".rs": "rust",
|
|
20
|
+
".c": "cpp", ".h": "cpp", ".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_JS = {
|
|
24
|
+
"function_declaration": "function",
|
|
25
|
+
"generator_function_declaration": "function",
|
|
26
|
+
"class_declaration": "class",
|
|
27
|
+
"method_definition": "method",
|
|
28
|
+
"pair": "method", # `get x() {}` / `set x(v) {}` in an object literal
|
|
29
|
+
"public_field_definition": "field",
|
|
30
|
+
}
|
|
31
|
+
_TS = {
|
|
32
|
+
**_JS,
|
|
33
|
+
"abstract_class_declaration": "class",
|
|
34
|
+
"interface_declaration": "interface",
|
|
35
|
+
"type_alias_declaration": "type",
|
|
36
|
+
"enum_declaration": "enum",
|
|
37
|
+
"method_signature": "method", # `m(x: T): R;` inside an interface
|
|
38
|
+
"property_signature": "field",
|
|
39
|
+
}
|
|
40
|
+
DEFS = {
|
|
41
|
+
"python": {"function_definition": "function", "class_definition": "class"},
|
|
42
|
+
"javascript": _JS,
|
|
43
|
+
"typescript": _TS,
|
|
44
|
+
"tsx": _TS,
|
|
45
|
+
"go": {"function_declaration": "function", "method_declaration": "method",
|
|
46
|
+
"type_spec": "type", "method_elem": "method"},
|
|
47
|
+
"rust": {
|
|
48
|
+
"function_item": "function", "struct_item": "struct", "enum_item": "enum",
|
|
49
|
+
"trait_item": "trait", "impl_item": "impl", "mod_item": "module_decl",
|
|
50
|
+
"trait_item_signature": "method", "function_signature_item": "function",
|
|
51
|
+
},
|
|
52
|
+
"cpp": {
|
|
53
|
+
"function_definition": "function", "class_specifier": "class", "struct_specifier": "struct",
|
|
54
|
+
"namespace_definition": "namespace", "enum_specifier": "enum", "type_definition": "type",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
# node type -> field holding the callee
|
|
58
|
+
CALLS = {
|
|
59
|
+
"python": {"call": "function"},
|
|
60
|
+
"javascript": {"call_expression": "function", "new_expression": "constructor"},
|
|
61
|
+
"typescript": {"call_expression": "function", "new_expression": "constructor"},
|
|
62
|
+
"tsx": {"call_expression": "function", "new_expression": "constructor"},
|
|
63
|
+
"go": {"call_expression": "function"},
|
|
64
|
+
"rust": {"call_expression": "function", "macro_invocation": "macro"},
|
|
65
|
+
"cpp": {"call_expression": "function"},
|
|
66
|
+
}
|
|
67
|
+
IMPORTS = {
|
|
68
|
+
"python": {"import_statement", "import_from_statement"},
|
|
69
|
+
"javascript": {"import_statement"},
|
|
70
|
+
"typescript": {"import_statement"},
|
|
71
|
+
"tsx": {"import_statement"},
|
|
72
|
+
"go": {"import_spec"},
|
|
73
|
+
"rust": {"use_declaration"},
|
|
74
|
+
"cpp": {"preproc_include"},
|
|
75
|
+
}
|
|
76
|
+
# callee shapes like `obj.name()`: the receiver type is unknown, so the target is only a guess
|
|
77
|
+
ATTR_CALLEES = {"attribute", "member_expression", "field_expression", "selector_expression",
|
|
78
|
+
"scoped_identifier", "qualified_name"}
|
|
79
|
+
JSLIKE = {"javascript", "typescript", "tsx"}
|
|
80
|
+
FUNC_VALUES = {"arrow_function", "function_expression", "function", "generator_function"}
|
|
81
|
+
CONTAINER_KINDS = {"class", "impl", "struct", "trait", "interface", "namespace"}
|
|
82
|
+
HERITAGE = {"class_heritage", "extends_type_clause", "extends_clause", "implements_clause",
|
|
83
|
+
"base_class_clause", "superclasses", "type_parameters"}
|
|
84
|
+
# subtrees inside a heritage clause that must never contribute a name (generic arguments,
|
|
85
|
+
# type parameters) -- this is what used to turn `extends Base<T>` into a phantom `T` edge
|
|
86
|
+
SKIP_SUBTREES = {"type_arguments", "type_parameters", "type_parameter", "type_parameter_list",
|
|
87
|
+
"modifiers", "attribute_item", "variance", "constraints"}
|
|
88
|
+
NAME_NODES = {"identifier", "type_identifier", "field_identifier", "constant", "property_identifier"}
|
|
89
|
+
KW = {"extends", "implements", "public", "private", "protected", "virtual", "class", "struct",
|
|
90
|
+
"template", "typename", "using", "interface", "enum", "namespace", "operator"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Sym:
|
|
95
|
+
id: int
|
|
96
|
+
name: str
|
|
97
|
+
qualname: str
|
|
98
|
+
kind: str
|
|
99
|
+
start: int
|
|
100
|
+
end: int
|
|
101
|
+
sig: str
|
|
102
|
+
doc: str
|
|
103
|
+
parent: int | None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Parsed:
|
|
108
|
+
syms: list[Sym] = field(default_factory=list)
|
|
109
|
+
refs: list[tuple[int, str, str]] = field(default_factory=list) # (src local id, name, kind)
|
|
110
|
+
imports: list[str] = field(default_factory=list)
|
|
111
|
+
keys: list[tuple[int, str, str, str]] = field(default_factory=list) # (src local id, ns, key, role)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_parsers: dict = {}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parser(lang: str):
|
|
118
|
+
if lang not in _parsers:
|
|
119
|
+
_parsers[lang] = get_parser(lang)
|
|
120
|
+
return _parsers[lang]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _clean_comment(raw: str) -> str:
|
|
124
|
+
"""Turn any supported comment syntax into its first line of prose."""
|
|
125
|
+
for line in raw.splitlines():
|
|
126
|
+
line = line.strip()
|
|
127
|
+
for prefix in ("///", "//!", "//", "/**", "/*", "*/", "///<", "//!<", "*", "#!", "#", "<!--", "-->"):
|
|
128
|
+
if line.startswith(prefix):
|
|
129
|
+
line = line[len(prefix):].strip()
|
|
130
|
+
break
|
|
131
|
+
line = line.strip(" \t*/#!\"'").strip()
|
|
132
|
+
if line and set(line) != {"*"}:
|
|
133
|
+
return line[:160]
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _first_line(s: str) -> str:
|
|
138
|
+
return _clean_comment(s)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _FileParser:
|
|
142
|
+
"""Walks one file's syntax tree once and collects symbols, references and imports."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, src: bytes, lang: str, modname: str):
|
|
145
|
+
self.src = src
|
|
146
|
+
self.lang = lang
|
|
147
|
+
self.modname = modname
|
|
148
|
+
self.out = Parsed()
|
|
149
|
+
self.defs = DEFS[lang]
|
|
150
|
+
self.calls = CALLS[lang]
|
|
151
|
+
self.imports_set = IMPORTS[lang]
|
|
152
|
+
|
|
153
|
+
# ---- small helpers ----
|
|
154
|
+
def text(self, n) -> str:
|
|
155
|
+
return self.src[n.start_byte:n.end_byte].decode("utf8", "replace")
|
|
156
|
+
|
|
157
|
+
def field(self, n, *names):
|
|
158
|
+
for nm in names:
|
|
159
|
+
c = n.child_by_field_name(nm)
|
|
160
|
+
if c is not None:
|
|
161
|
+
return c
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def last_ident(s: str) -> str | None:
|
|
166
|
+
ids = IDENT.findall(s)
|
|
167
|
+
return ids[-1] if ids else None
|
|
168
|
+
|
|
169
|
+
# ---- naming ----
|
|
170
|
+
def get_name(self, n) -> tuple[str | None, str | None]:
|
|
171
|
+
"""Return (name, owner_override) for a definition node."""
|
|
172
|
+
t = n.type
|
|
173
|
+
if t == "impl_item":
|
|
174
|
+
ty = self.field(n, "type")
|
|
175
|
+
ids = IDENT.findall(self.text(ty)) if ty is not None else []
|
|
176
|
+
return (ids[0] if ids else None), None
|
|
177
|
+
if self.lang == "cpp" and t == "function_definition":
|
|
178
|
+
d = self.field(n, "declarator")
|
|
179
|
+
while d is not None and self.field(d, "declarator") is not None:
|
|
180
|
+
d = self.field(d, "declarator")
|
|
181
|
+
if d is None:
|
|
182
|
+
return None, None
|
|
183
|
+
ids = IDENT.findall(self.text(d))
|
|
184
|
+
if not ids:
|
|
185
|
+
return None, None
|
|
186
|
+
owner = ids[-2] if "::" in self.text(d) and len(ids) > 1 else None
|
|
187
|
+
return ids[-1], owner
|
|
188
|
+
if self.lang == "go" and t == "method_declaration":
|
|
189
|
+
nm = self.field(n, "name")
|
|
190
|
+
rc = self.field(n, "receiver")
|
|
191
|
+
owner = self.last_ident(self.text(rc)) if rc is not None else None
|
|
192
|
+
return (self.text(nm) if nm is not None else None), owner
|
|
193
|
+
if t in ("method_signature", "property_signature", "pair", "public_field_definition"):
|
|
194
|
+
nm = self.field(n, "name", "key", "property")
|
|
195
|
+
if nm is None or nm.type in ("string", "string_literal", "computed_property_name"):
|
|
196
|
+
return None, None # `{"a-b": fn}` has no identifier to link
|
|
197
|
+
return self.last_ident(self.text(nm)), None
|
|
198
|
+
if t == "assignment" and self.lang == "python":
|
|
199
|
+
left = self.field(n, "left")
|
|
200
|
+
return (self.text(left) if left is not None and left.type == "identifier" else None), None
|
|
201
|
+
nm = self.field(n, "name")
|
|
202
|
+
return (self.text(nm) if nm is not None else None), None
|
|
203
|
+
|
|
204
|
+
# ---- doc comments ----
|
|
205
|
+
def get_doc(self, n) -> str:
|
|
206
|
+
if self.lang == "python":
|
|
207
|
+
body = self.field(n, "body")
|
|
208
|
+
if body is not None and body.named_children:
|
|
209
|
+
f = body.named_children[0]
|
|
210
|
+
if f.type == "expression_statement" and f.named_children and f.named_children[0].type == "string":
|
|
211
|
+
return _clean_comment(self.text(f.named_children[0]))
|
|
212
|
+
return ""
|
|
213
|
+
# collect the run of comment lines directly above the definition
|
|
214
|
+
parts: list[str] = []
|
|
215
|
+
p = n.prev_named_sibling
|
|
216
|
+
while p is not None and "comment" in p.type and p.end_point[0] >= n.start_point[0] - 1:
|
|
217
|
+
line = _clean_comment(self.text(p))
|
|
218
|
+
if line:
|
|
219
|
+
parts.insert(0, line)
|
|
220
|
+
p = p.prev_named_sibling
|
|
221
|
+
return " ".join(parts)[:160]
|
|
222
|
+
|
|
223
|
+
# ---- signatures ----
|
|
224
|
+
def sig(self, n) -> str:
|
|
225
|
+
"""Header of the definition: the first line, extended while parentheses stay unbalanced."""
|
|
226
|
+
lines = self.text(n).split("\n")
|
|
227
|
+
head = lines[0].strip()
|
|
228
|
+
for extra in lines[1:4]: # a signature may wrap over a few lines
|
|
229
|
+
if head.count("(") <= head.count(")"):
|
|
230
|
+
break
|
|
231
|
+
head = (head + " " + extra.strip()).strip()
|
|
232
|
+
return head[:160]
|
|
233
|
+
|
|
234
|
+
# ---- inheritance ----
|
|
235
|
+
def _collect_ids(self, n, bucket: list[str], depth: int = 0):
|
|
236
|
+
if depth > 8 or n.type in SKIP_SUBTREES:
|
|
237
|
+
return
|
|
238
|
+
if n.type in NAME_NODES:
|
|
239
|
+
txt = self.text(n)
|
|
240
|
+
if txt and txt not in KW:
|
|
241
|
+
bucket.append(txt)
|
|
242
|
+
return
|
|
243
|
+
for c in n.children:
|
|
244
|
+
self._collect_ids(c, bucket, depth + 1)
|
|
245
|
+
|
|
246
|
+
def _clause_names(self, clause, sid: int):
|
|
247
|
+
"""Each comma-separated entry of a heritage clause contributes exactly one name:
|
|
248
|
+
the last identifier in its subtree, so `ns::Outer` and `Base<T>` both resolve right."""
|
|
249
|
+
bucket: list[str] = []
|
|
250
|
+
|
|
251
|
+
def flush():
|
|
252
|
+
if bucket:
|
|
253
|
+
name = bucket[-1]
|
|
254
|
+
if name not in KW:
|
|
255
|
+
self.out.refs.append((sid, name, "inherits"))
|
|
256
|
+
bucket.clear()
|
|
257
|
+
|
|
258
|
+
for c in clause.children:
|
|
259
|
+
# keywords (`extends`, `implements`, access specifiers) end the current entry; note that
|
|
260
|
+
# `extends_clause` / `implements_clause` are containers and must be descended into
|
|
261
|
+
if c.type == "," or c.type in KW:
|
|
262
|
+
flush()
|
|
263
|
+
continue
|
|
264
|
+
self._collect_ids(c, bucket)
|
|
265
|
+
flush()
|
|
266
|
+
|
|
267
|
+
def collect_inherits(self, n, sid: int):
|
|
268
|
+
lang = self.lang
|
|
269
|
+
if lang == "python" and n.type == "class_definition":
|
|
270
|
+
sc = self.field(n, "superclasses")
|
|
271
|
+
if sc is not None:
|
|
272
|
+
for c in sc.named_children: # python keeps one entry per base, no clause commas
|
|
273
|
+
bucket: list[str] = []
|
|
274
|
+
self._collect_ids(c, bucket)
|
|
275
|
+
if bucket and bucket[-1] not in KW:
|
|
276
|
+
self.out.refs.append((sid, bucket[-1], "inherits"))
|
|
277
|
+
elif lang in JSLIKE and n.type in ("class_declaration", "abstract_class_declaration",
|
|
278
|
+
"interface_declaration"):
|
|
279
|
+
for c in n.children:
|
|
280
|
+
if c.type in HERITAGE:
|
|
281
|
+
self._clause_names(c, sid)
|
|
282
|
+
elif lang == "cpp" and n.type in ("class_specifier", "struct_specifier"):
|
|
283
|
+
for c in n.children:
|
|
284
|
+
if c.type == "base_class_clause":
|
|
285
|
+
self._clause_names(c, sid)
|
|
286
|
+
elif lang == "rust" and n.type == "impl_item":
|
|
287
|
+
tr = self.field(n, "trait")
|
|
288
|
+
if tr is not None:
|
|
289
|
+
bucket: list[str] = []
|
|
290
|
+
self._collect_ids(tr, bucket)
|
|
291
|
+
if bucket and bucket[-1] not in KW:
|
|
292
|
+
self.out.refs.append((sid, bucket[-1], "inherits"))
|
|
293
|
+
# Go interfaces have no base clause; their methods are indexed as method_elem
|
|
294
|
+
|
|
295
|
+
# ---- imports ----
|
|
296
|
+
def import_raws(self, n) -> list[str]:
|
|
297
|
+
t = n.type
|
|
298
|
+
lang = self.lang
|
|
299
|
+
if lang == "python":
|
|
300
|
+
if t == "import_from_statement":
|
|
301
|
+
m = self.field(n, "module_name")
|
|
302
|
+
if m is None:
|
|
303
|
+
return []
|
|
304
|
+
mod = self.text(m)
|
|
305
|
+
# `from app import util` may mean the submodule app/util.py rather than the
|
|
306
|
+
# package itself, so offer both readings and let the store pick what exists
|
|
307
|
+
cands = [mod]
|
|
308
|
+
seen_import = False
|
|
309
|
+
for c in n.children: # only what follows the `import` keyword
|
|
310
|
+
if c.type == "import":
|
|
311
|
+
seen_import = True
|
|
312
|
+
continue
|
|
313
|
+
if not seen_import:
|
|
314
|
+
continue
|
|
315
|
+
if c.type == "dotted_name":
|
|
316
|
+
cands.append(f"{mod}.{self.text(c)}")
|
|
317
|
+
elif c.type == "aliased_import":
|
|
318
|
+
target = self.field(c, "name")
|
|
319
|
+
if target is not None and target.type != "dotted_name":
|
|
320
|
+
cands.append(f"{mod}.{self.text(target)}")
|
|
321
|
+
return cands
|
|
322
|
+
res = []
|
|
323
|
+
for c in n.named_children:
|
|
324
|
+
if c.type == "aliased_import":
|
|
325
|
+
c = self.field(c, "name") or c
|
|
326
|
+
res.append(self.text(c))
|
|
327
|
+
return res
|
|
328
|
+
if lang in JSLIKE:
|
|
329
|
+
s = self.field(n, "source")
|
|
330
|
+
return [self.text(s).strip("'\"`")] if s is not None else []
|
|
331
|
+
if lang == "go":
|
|
332
|
+
p = self.field(n, "path")
|
|
333
|
+
return [self.text(p).strip('"`')] if p is not None else []
|
|
334
|
+
if lang == "rust":
|
|
335
|
+
a = self.field(n, "argument")
|
|
336
|
+
return [self.text(a)] if a is not None else []
|
|
337
|
+
if lang == "cpp":
|
|
338
|
+
p = self.field(n, "path")
|
|
339
|
+
return [self.text(p).strip('<>"\'')] if p is not None else []
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
# ---- calls ----
|
|
343
|
+
def is_self_call(self, f) -> bool:
|
|
344
|
+
"""`self.x()` / `cls.x()` (python), `this.x()` (js/ts/c++), `self.x()` (rust)."""
|
|
345
|
+
if f.type == "attribute":
|
|
346
|
+
o = self.field(f, "object")
|
|
347
|
+
return o is not None and o.type == "identifier" and self.text(o) in ("self", "cls")
|
|
348
|
+
if f.type == "member_expression":
|
|
349
|
+
o = self.field(f, "object")
|
|
350
|
+
return o is not None and o.type == "this"
|
|
351
|
+
if f.type == "field_expression":
|
|
352
|
+
o = self.field(f, "value", "argument")
|
|
353
|
+
return o is not None and o.end_byte - o.start_byte <= 4 and self.text(o) in ("self", "this")
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
# ---- string-keyed links (routes, events, config keys; see keys.py) ----
|
|
357
|
+
def first_string_arg(self, call) -> str | None:
|
|
358
|
+
args = self.field(call, "arguments")
|
|
359
|
+
if args is None or not args.named_children:
|
|
360
|
+
return None
|
|
361
|
+
a = args.named_children[0]
|
|
362
|
+
if a.type not in ("string", "template_string", "string_literal"):
|
|
363
|
+
return None
|
|
364
|
+
return keys.string_value(self.text(a))
|
|
365
|
+
|
|
366
|
+
def collect_key(self, call, callee, src: int, decorator: bool = False):
|
|
367
|
+
if self.lang not in keys.ANY:
|
|
368
|
+
return
|
|
369
|
+
key = self.first_string_arg(call)
|
|
370
|
+
if key is not None:
|
|
371
|
+
for ns, k, role in keys.match(self.lang, self.text(callee), key, decorator):
|
|
372
|
+
self.out.keys.append((src, ns, k, role))
|
|
373
|
+
|
|
374
|
+
def collect_decorator_keys(self, defn, sid: int):
|
|
375
|
+
"""`@app.get("/x")` registers the *decorated* function, not the scope the decorator sits in."""
|
|
376
|
+
parent = defn.parent
|
|
377
|
+
if self.lang != "python" or parent is None or parent.type != "decorated_definition":
|
|
378
|
+
return
|
|
379
|
+
for dec in parent.children:
|
|
380
|
+
call = next((c for c in dec.named_children if c.type == "call"), None) if dec.type == "decorator" else None
|
|
381
|
+
callee = self.field(call, "function") if call is not None else None
|
|
382
|
+
if callee is not None:
|
|
383
|
+
self.collect_key(call, callee, sid, decorator=True)
|
|
384
|
+
|
|
385
|
+
# ---- main walk (iterative: no recursion limit games) ----
|
|
386
|
+
def run(self) -> Parsed:
|
|
387
|
+
tree = _parser(self.lang).parse(self.src)
|
|
388
|
+
nlines = self.src.count(b"\n") + 1
|
|
389
|
+
self.out.syms.append(Sym(0, self.modname, "", "module", 1, nlines, "", "", None))
|
|
390
|
+
stack = [(tree.root_node, 0)]
|
|
391
|
+
while stack:
|
|
392
|
+
n, cur = stack.pop()
|
|
393
|
+
t = n.type
|
|
394
|
+
kind = self.defs.get(t)
|
|
395
|
+
if kind is None and self.lang in JSLIKE and t == "variable_declarator":
|
|
396
|
+
kind = self._js_declarator_kind(n, cur)
|
|
397
|
+
elif kind is None and self.lang == "python" and t == "assignment":
|
|
398
|
+
kind = self._py_assignment_kind(n, cur)
|
|
399
|
+
new = cur
|
|
400
|
+
if kind:
|
|
401
|
+
name, owner = self.get_name(n)
|
|
402
|
+
if name:
|
|
403
|
+
parent = self.out.syms[cur]
|
|
404
|
+
prefix = owner or parent.qualname
|
|
405
|
+
qn = f"{prefix}.{name}" if prefix else name
|
|
406
|
+
if kind == "function" and parent.kind in CONTAINER_KINDS:
|
|
407
|
+
kind = "method"
|
|
408
|
+
# `int Shape::area() {}` at file scope: the owner comes from the declarator
|
|
409
|
+
if kind == "function" and owner:
|
|
410
|
+
kind = "method"
|
|
411
|
+
if kind == "module_decl":
|
|
412
|
+
kind = "module"
|
|
413
|
+
s = Sym(len(self.out.syms), name, qn, kind,
|
|
414
|
+
n.start_point[0] + 1, n.end_point[0] + 1,
|
|
415
|
+
self.sig(n), self.get_doc(n), cur)
|
|
416
|
+
self.out.syms.append(s)
|
|
417
|
+
new = s.id
|
|
418
|
+
self.collect_inherits(n, s.id)
|
|
419
|
+
self.collect_decorator_keys(n, s.id)
|
|
420
|
+
cm = self.calls.get(t)
|
|
421
|
+
if cm:
|
|
422
|
+
f = self.field(n, cm)
|
|
423
|
+
if f is not None and f.end_byte - f.start_byte <= 120:
|
|
424
|
+
nm = self.last_ident(self.text(f))
|
|
425
|
+
if nm:
|
|
426
|
+
rk = ("calls_self" if self.is_self_call(f)
|
|
427
|
+
else "calls_attr" if f.type in ATTR_CALLEES else "calls")
|
|
428
|
+
self.out.refs.append((cur, nm, rk))
|
|
429
|
+
if not (n.parent is not None and n.parent.type == "decorator"): # handled at the def
|
|
430
|
+
self.collect_key(n, f, cur)
|
|
431
|
+
if t in self.imports_set:
|
|
432
|
+
self.out.imports.extend(self.import_raws(n))
|
|
433
|
+
for c in reversed(n.children):
|
|
434
|
+
stack.append((c, new))
|
|
435
|
+
return self.out
|
|
436
|
+
|
|
437
|
+
def _js_declarator_kind(self, n, cur) -> str | None:
|
|
438
|
+
parent = self.out.syms[cur]
|
|
439
|
+
v = self.field(n, "value")
|
|
440
|
+
if v is not None and v.type in FUNC_VALUES:
|
|
441
|
+
return "function"
|
|
442
|
+
if parent.kind in ("module",) or parent.kind in CONTAINER_KINDS:
|
|
443
|
+
return "variable"
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
def _py_assignment_kind(self, n, cur) -> str | None:
|
|
447
|
+
"""Only ALL_CAPS module-level bindings, so constants show up without flooding."""
|
|
448
|
+
parent = self.out.syms[cur]
|
|
449
|
+
if parent.kind not in ("module", "class"):
|
|
450
|
+
return None
|
|
451
|
+
targets = self.field(n, "left") or (n.named_children[0] if n.named_children else None)
|
|
452
|
+
if targets is None or targets.type != "identifier":
|
|
453
|
+
return None
|
|
454
|
+
txt = self.text(targets)
|
|
455
|
+
return "constant" if CONST_NAME.match(txt) else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def parse(src: bytes, lang: str, modname: str) -> Parsed:
|
|
459
|
+
return _FileParser(src, lang, modname).run()
|
codegraph/py.typed
ADDED
|
File without changes
|