cg-code-graph 0.10.1__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 (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,930 @@
1
+ """C / C++ language plugin.
2
+
3
+ Exact mode: scip-clang over a compile database (compile_commands.json). Every reference is resolved by clang with
4
+ the project's real flags; definitions are matched to syntactic items by (file, line, name), and scip-clang's
5
+ relationships give virtual-dispatch edges (base method -> override) and class inheritance.
6
+ Heuristic mode (no compile database, or scip-clang not installed): name-based resolution over the tree-sitter
7
+ layer; every resolved reference is labelled `heuristic`.
8
+
9
+ The compile database is looked up at $CODEGRAPH_COMPDB, <root>/compile_commands.json, <root>/build*/,
10
+ <root>/out/, <root>/cmake-build-*/. Generate one with `cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON` or
11
+ `bear -- make`. See docs/native.md.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shlex
19
+ import time
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+
23
+ from ... import presets
24
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
25
+ from ...core.fsutil import content_key, keep_file
26
+ from ...core.paths import rules as path_rules
27
+ from ...core.plugin import FrameworkPlugin, GraphBuilder, LanguagePlugin, Project
28
+ from ..native import gates as G
29
+ from ..native import runner, scipread
30
+ from ..native.ts import TreeSitterMissing
31
+ from .syntax import CFile, CItem, annotation_macros, annotation_regex, extract, generated_names
32
+
33
+ C_EXT = {".c"}
34
+ CPP_EXT = {".cc", ".cpp", ".cxx", ".c++", ".cp", ".C"}
35
+ HDR_EXT = {".h", ".hh", ".hpp", ".hxx", ".h++", ".ipp", ".inl", ".tcc", ".ixx", ".cuh"}
36
+ # vendored third-party trees, test frameworks, build output (codegraph/presets/c_cpp.yaml; .cg.yaml skip_dirs adjusts them)
37
+ SKIP_DIRS = presets.skip_dirs("c_cpp")
38
+ SKIP_PREFIXES = tuple(presets.values("c_cpp", "skip_dir_prefixes", default=[]))
39
+ CODE = {"function", "method"}
40
+ TYPES = {"class", "struct", "union", "enum", "typedef"}
41
+ VALUES = {"global", "enumerator", "macro"}
42
+ SCOPES = CODE | TYPES | {"global", "macro"}
43
+ TEST_DIR = re.compile(r"(^|/)(tests?|testing|unittests?|gtest|spec|check)(/|$)", re.I)
44
+ TEST_FILE = re.compile(r"(^|/)(test_[^/]*|[^/]*_(unit)?tests?\.[^/]+|[^/]*Tests?\.[^/]+|tests?\.[^/]+)$")
45
+ EXAMPLE_DIR = re.compile(r"(^|/)(examples?|samples?|demos?|tutorials?)(/|$)", re.I)
46
+ BENCH_DIR = re.compile(r"(^|/)(bench|benches|benchmarks?|perf)(/|$)", re.I)
47
+ INCLUDE_DIR = re.compile(r"(^|/)include/")
48
+ MAIN_NAMES = {"main", "wmain", "WinMain", "wWinMain", "_tmain"}
49
+ # C std / POSIX names: never resolved heuristically to a project function even if the project defines one
50
+ LIBC = set("""malloc calloc realloc free memcpy memmove memset memcmp strlen strcmp strncmp strcpy strncpy strcat strdup
51
+ printf fprintf sprintf snprintf vsnprintf vfprintf puts fputs fputc fopen fclose fread fwrite fflush exit abort assert
52
+ open close read write lseek getenv atoi atol strtol strtoul qsort bsearch time clock sleep usleep pthread_create""".split())
53
+
54
+
55
+ def _skip_dirs() -> set:
56
+ extra = {d.strip() for d in os.environ.get("CODEGRAPH_EXCLUDE_DIRS", "").split(",") if d.strip()}
57
+ keep = {d.strip() for d in os.environ.get("CODEGRAPH_INCLUDE_DIRS", "").split(",") if d.strip()}
58
+ return (SKIP_DIRS | extra) - keep
59
+
60
+
61
+ def source_files(root: Path, limit: int | None = None, project=None):
62
+ rules = path_rules(project, "c_cpp", base=_skip_dirs())
63
+ n = 0
64
+ for dp, dn, fn in os.walk(root):
65
+ rel_dir = Path(dp).relative_to(root).as_posix()
66
+ rd = "" if rel_dir == "." else rel_dir
67
+ dn[:] = [d for d in rules.prune(rd, dn, dot=True) if rules.on_include_path(f"{rd}/{d}" if rd else d)
68
+ or not (d.startswith(SKIP_PREFIXES) or (Path(dp) / d / "CMakeCache.txt").exists())]
69
+ for f in sorted(fn):
70
+ ext = os.path.splitext(f)[1]
71
+ rel = f if rel_dir == "." else f"{rel_dir}/{f}"
72
+ if (ext in C_EXT or ext in CPP_EXT or ext in HDR_EXT) and keep_file(os.path.join(dp, f)) and not rules.excluded(rel):
73
+ yield rel
74
+ n += 1
75
+ if limit and n >= limit:
76
+ return
77
+
78
+
79
+ def find_compdb(root: Path) -> Path | None:
80
+ v = os.environ.get("CODEGRAPH_COMPDB")
81
+ if v:
82
+ p = Path(v)
83
+ if p.is_dir():
84
+ p = p / "compile_commands.json"
85
+ return p.resolve() if p.exists() else None
86
+ cands = [root / "compile_commands.json"]
87
+ for d in sorted(root.iterdir()) if root.is_dir() else []:
88
+ if d.is_dir() and (d.name.startswith(("build", "cmake-build", "out")) or d.name in ("builddir", "_build")):
89
+ cands.append(d / "compile_commands.json")
90
+ cands += sorted(d.glob("*/compile_commands.json"))
91
+ return next((c.resolve() for c in cands if c.exists()), None)
92
+
93
+
94
+ def _kind_ok(suffix: str, kind: str) -> bool:
95
+ """SCIP descriptor suffix vs syntactic kind: a namespace (`/`) never matches an item, methods/functions are `(`,
96
+ types `#`, fields / globals / enumerators `.`, macros `!`."""
97
+ if suffix == "(":
98
+ return kind in CODE or kind == "macro"
99
+ if suffix == "#":
100
+ return kind in TYPES or kind in CODE # constructors of class templates sometimes surface as types
101
+ if suffix == ".":
102
+ return kind in ("field", "global", "enumerator", "typedef") or kind in CODE # `f(<hash>).` may parse as a term
103
+ if suffix == "!":
104
+ return kind == "macro"
105
+ return False
106
+
107
+
108
+ def scip_cache_key(root: Path, files: list[str], indexer_version: str, compdb: Path) -> str:
109
+ """SCIP cache key: the sources' content plus the compile_commands.json content (flags and defines change the
110
+ index even when no source does)."""
111
+ return runner.fingerprint(root, files, f"{indexer_version}|{compdb}|{content_key(compdb)}")
112
+
113
+
114
+ class CFamilyPlugin(LanguagePlugin):
115
+ name = "c_cpp"
116
+
117
+ def detect(self, project: Project) -> bool:
118
+ if os.environ.get("CODEGRAPH_CFAMILY") == "0":
119
+ return False
120
+ root = project.root
121
+ if find_compdb(root):
122
+ return True
123
+ # another language owns the repo (incl. Python/Django and Dart/Flutter, whose desktop runners ship C++ sources)
124
+ other_lang = any(project.exists(m) for m in ("Cargo.toml", "package.json", "composer.json", "go.mod", "pyproject.toml",
125
+ "setup.py", "setup.cfg", "requirements.txt", "manage.py", "Pipfile",
126
+ "pubspec.yaml"))
127
+ if other_lang and os.environ.get("CODEGRAPH_CFAMILY") != "1":
128
+ return False
129
+ return next(source_files(root, limit=1, project=project), None) is not None
130
+
131
+ # ------------------------------------------------------------------ main
132
+ def index(self, project: Project, builder: GraphBuilder, frameworks: list[FrameworkPlugin]) -> dict:
133
+ t0 = time.time()
134
+ self.root, self.b = project.root, builder
135
+ stats: dict = defaultdict(int)
136
+ try:
137
+ from ..native.ts import parser
138
+ parser("c")
139
+ parser("cpp")
140
+ except TreeSitterMissing as e:
141
+ return {"status": "error", "reason": str(e)}
142
+ files = list(source_files(self.root, project=project))
143
+ self.file_report = {"seen": files, "skipped_oversize": []}
144
+ is_cpp = any(os.path.splitext(f)[1] in CPP_EXT for f in files)
145
+ self.compdb_path = find_compdb(self.root)
146
+ self.compdb = self._read_compdb(self.compdb_path) if self.compdb_path else {}
147
+ stats["compile_database"] = str(self.compdb_path.relative_to(self.root) if self.compdb_path and self.compdb_path.is_relative_to(self.root)
148
+ else self.compdb_path) if self.compdb_path else None
149
+ stats["translation_units_in_compdb"] = len(self.compdb)
150
+ scen = (project.options.get("gates") or [None])[0]
151
+ self.scen = G.Scenario(scen)
152
+ self.files: dict[str, CFile] = {}
153
+ srcs = {}
154
+ limit = int(os.environ.get("CODEGRAPH_MAX_FILE_BYTES", str(30 * 1024 * 1024)))
155
+ for rel in files:
156
+ try:
157
+ src = (self.root / rel).read_bytes()
158
+ except OSError:
159
+ continue
160
+ if len(src) > limit:
161
+ stats["skipped_large_files"] += 1
162
+ self.file_report["skipped_oversize"].append(rel)
163
+ continue
164
+ srcs[rel] = src
165
+ annot = annotation_macros(srcs.values()) if os.environ.get("CODEGRAPH_C_MASK_ANNOTATIONS", "1") != "0" else set()
166
+ stats["annotation_macros_masked"] = len(annot)
167
+ blank = annotation_regex(annot)
168
+ for rel, src in srcs.items():
169
+ ext = os.path.splitext(rel)[1]
170
+ lang = "c" if ext in C_EXT or (ext == ".h" and not is_cpp) else "cpp"
171
+ mod = os.path.dirname(rel) or "."
172
+ self.files[rel] = extract(rel, src, lang, mod, blank)
173
+ self._macro_generated(srcs, blank, is_cpp, stats)
174
+ stats["files"] = len(self.files)
175
+ self.file_report["syntax_errors"] = {r: f.error_spans for r, f in self.files.items() if f.error_spans}
176
+ stats["recovered_definitions"] = sum(f.recovered for f in self.files.values())
177
+ stats["files_c"] = sum(1 for f in self.files.values() if f.lang == "c")
178
+ stats["files_cpp"] = sum(1 for f in self.files.values() if f.lang == "cpp")
179
+ self._finalize_items(stats)
180
+ self._emit_structure(stats)
181
+ mode = "heuristic"
182
+ scip_info = None
183
+ if os.environ.get("CODEGRAPH_C_SCIP", "1") != "0":
184
+ if self.compdb_path:
185
+ scip_path, scip_info = self._run_scip()
186
+ if scip_path:
187
+ self._import_scip(scip_path, stats)
188
+ mode = "scip"
189
+ if self._scip_warning:
190
+ scip_info["warning"] = self._scip_warning
191
+ else:
192
+ scip_info = {"status": "no compile_commands.json found; heuristic mode. Generate one with "
193
+ "`cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON` or `bear -- make` (docs/native.md)"}
194
+ self._inc = self._resolve_includes(stats)
195
+ if mode == "heuristic":
196
+ self._heuristic_refs(stats)
197
+ self._heuristic_dispatch(stats)
198
+ self._includes(stats)
199
+ self._facts(stats)
200
+ self._entries(stats)
201
+ self._gating(stats)
202
+ out = dict(stats)
203
+ out.update({"mode": mode, "seconds": round(time.time() - t0, 2)})
204
+ if scip_info:
205
+ out["scip"] = scip_info
206
+ return out
207
+
208
+ def _read_compdb(self, p: Path) -> dict:
209
+ try:
210
+ entries = json.loads(p.read_text())
211
+ except Exception:
212
+ return {}
213
+ out = {}
214
+ for e in entries:
215
+ d = Path(e.get("directory") or ".")
216
+ f = Path(e["file"])
217
+ f = (d / f) if not f.is_absolute() else f
218
+ try:
219
+ rel = f.resolve().relative_to(self.root).as_posix()
220
+ except ValueError:
221
+ continue
222
+ args = e.get("arguments") or shlex.split(e.get("command", ""))
223
+ incs, defs = [], []
224
+ i = 0
225
+ while i < len(args):
226
+ a = args[i]
227
+ if a in ("-I", "-isystem", "-iquote") and i + 1 < len(args):
228
+ incs.append(args[i + 1]); i += 2; continue
229
+ if a.startswith("-I") or a.startswith("-iquote"):
230
+ incs.append(a[2:] if a.startswith("-I") else a[7:])
231
+ elif a.startswith("-isystem"):
232
+ incs.append(a[8:])
233
+ elif a.startswith("-D"):
234
+ defs.append(a[2:])
235
+ i += 1
236
+ inc_rel = []
237
+ for x in incs:
238
+ xp = (d / x).resolve() if not Path(x).is_absolute() else Path(x)
239
+ try:
240
+ inc_rel.append(xp.relative_to(self.root).as_posix())
241
+ except ValueError:
242
+ pass
243
+ out[rel] = {"includes": inc_rel, "defines": defs}
244
+ return out
245
+
246
+ def _macro_generated(self, srcs, blank, is_cpp, stats):
247
+ """Functions defined by expanding a project macro at file level (`#define DEFINE_GETTER(n) int get_##n(void)
248
+ {...}` + `DEFINE_GETTER(width)` -> get_width): a node at the expansion line (attrs.macro_generated). The
249
+ expansion lines are blanked and the file parsed again, so tree-sitter does not read the definitions after
250
+ them as part of one bogus function."""
251
+ gens = {}
252
+ for f in self.files.values():
253
+ for k, v in f.generators.items():
254
+ gens.setdefault(k, v)
255
+ if not gens:
256
+ return
257
+ for rel, f in list(self.files.items()):
258
+ hits = [tc for tc in f.top_calls if tc[0] in gens]
259
+ if not hits:
260
+ continue
261
+ src = bytearray(srcs[rel])
262
+ starts = [0] + [i + 1 for i, ch in enumerate(src) if ch == 10]
263
+ for _name, _args, line, _col, end in hits:
264
+ for ln in range(line, end + 1):
265
+ a = starts[ln - 1]
266
+ z = starts[ln] - 1 if ln < len(starts) else len(src)
267
+ src[a:z] = b" " * (z - a)
268
+ lines = f.lines
269
+ f2 = extract(rel, bytes(src), f.lang, os.path.dirname(rel) or ".", blank)
270
+ f2.lines = lines
271
+ f2.generators = f.generators
272
+ mod = os.path.dirname(rel) or "."
273
+ for name, args, line, col, _end in hits:
274
+ params, value = gens[name]
275
+ for fname, static, sig in generated_names(params, value, _split_args(args)):
276
+ f2.items.append(CItem("function", fname, f"{rel}#{fname}" if static else fname, rel, line, col, line, _end,
277
+ mod, fname, static=static, attrs={"macro_generated": name}, sig=sig))
278
+ stats["macro_generated_functions"] += 1
279
+ self.files[rel] = f2
280
+
281
+ # ------------------------------------------------------------------ items
282
+ def _finalize_items(self, stats):
283
+ all_items = [it for f in self.files.values() for it in f.items]
284
+ class_quals = {it.qual for it in all_items if it.kind in ("class", "struct", "union")}
285
+ class_names = defaultdict(set)
286
+ for q in class_quals:
287
+ class_names[q.split("::")[-1]].add(q)
288
+ for it in all_items:
289
+ if it.kind == "method" and it.scope and not it.parent:
290
+ # out-of-line definition A::f: method if A names a known class (qualified or by short name)
291
+ owner = it.qual.rsplit("::", 1)[0]
292
+ if owner in class_quals:
293
+ it.parent = owner
294
+ elif class_names.get(it.scope[-1]):
295
+ it.parent = sorted(class_names[it.scope[-1]])[0]
296
+ else:
297
+ it.kind = "function"
298
+ # declarations: map to definitions by qualified name
299
+ self.decls_by_qual = defaultdict(list)
300
+ for f in self.files.values():
301
+ for d in f.decls:
302
+ self.decls_by_qual[d.qual].append(d)
303
+ # key collisions: overloads / same-named non-static C functions in different programs
304
+ by_key = defaultdict(list)
305
+ for it in all_items:
306
+ by_key[(it.kind in CODE and "code" or it.kind, it.key)].append(it)
307
+ for (k, key), lst in by_key.items():
308
+ if len(lst) < 2:
309
+ continue
310
+ if k == "code":
311
+ sigs = {x.sig for x in lst}
312
+ files = {x.file for x in lst}
313
+ for x in lst:
314
+ if len(sigs) == len(lst):
315
+ x.key = f"{x.key}({x.sig})"
316
+ elif len(files) == len(lst):
317
+ x.key = f"{x.file}#{x.key}"
318
+ else:
319
+ x.key = f"{x.key}@{x.file}:{x.line}"
320
+ stats["code_key_collisions"] += len(lst)
321
+ else:
322
+ for x in lst[1:]:
323
+ x.key = f"{x.key}@{x.file}:{x.line}"
324
+ self.items = all_items
325
+ self.by_key = {}
326
+ for it in all_items:
327
+ self.by_key.setdefault(it.key, it)
328
+ self.by_type_key = {}
329
+ for it in all_items:
330
+ if it.kind in ("class", "struct", "union", "enum"):
331
+ self.by_type_key.setdefault(it.key, it)
332
+ self.by_pos = {}
333
+ for it in all_items:
334
+ self.by_pos.setdefault((it.file, it.line, it.name), it)
335
+ if it.kind == "macro":
336
+ self.by_pos.setdefault((it.file, it.line, "!macro"), it)
337
+ if it.attrs.get("test_macro"):
338
+ self.by_pos.setdefault((it.file, it.line, "!test"), it)
339
+ self.by_line = defaultdict(list)
340
+ for it in all_items:
341
+ self.by_line[(it.file, it.line)].append(it)
342
+ self.by_name = defaultdict(list)
343
+ for it in all_items:
344
+ self.by_name[it.name].append(it)
345
+
346
+ def nid(self, it: CItem) -> str:
347
+ return f"{it.kind}:{it.key}"
348
+
349
+ def _lang(self, rel: str) -> str:
350
+ return self.files[rel].lang
351
+
352
+ def _emit_structure(self, stats):
353
+ b = self.b
354
+ for rel, f in self.files.items():
355
+ ext = os.path.splitext(rel)[1]
356
+ b.add_node("file", rel, name=os.path.basename(rel), file=rel, line=1, module=os.path.dirname(rel) or ".", lang=f.lang,
357
+ attrs={"header": ext in HDR_EXT, "translation_unit": rel in self.compdb})
358
+ for it in self.items:
359
+ attrs = dict(it.attrs)
360
+ if it.static:
361
+ attrs["static"] = True
362
+ if it.sig and it.kind in CODE:
363
+ attrs["signature"] = it.sig
364
+ decls = self.decls_by_qual.get(it.qual) if it.kind in CODE else None
365
+ if decls:
366
+ attrs["declared_in"] = sorted({f"{d.file}:{d.line}" for d in decls})[:4]
367
+ if any(d.export for d in decls):
368
+ attrs["export"] = True
369
+ for d in decls:
370
+ if d.virtual:
371
+ attrs["virtual"] = True
372
+ if d.override:
373
+ attrs["override"] = True
374
+ if d.pure:
375
+ attrs["pure"] = True
376
+ if d.access and "access" not in attrs:
377
+ attrs["access"] = d.access
378
+ if d.extern_c:
379
+ attrs["extern_c"] = True
380
+ n = b.add_node(it.kind, it.key, name=it.name, fqn=it.qual or it.key, file=it.file, line=it.line, end_line=it.end,
381
+ module=it.module, doc=it.doc, lang=self._lang(it.file), attrs=attrs)
382
+ par = (self.by_type_key.get(it.parent) or self.by_key.get(it.parent)) if it.parent else None
383
+ if par is not None:
384
+ b.add_edge(self.nid(par), n, "CONTAINS", it.file, it.line, EXACT)
385
+ else:
386
+ b.add_edge(f"file:{it.file}", n, "CONTAINS", it.file, it.line, EXACT)
387
+ stats[f"items_{it.kind}"] += 1
388
+ self.decl_items = []
389
+ # pure virtual declarations have no definition: give them a node so overrides can attach
390
+ for qual, decls in self.decls_by_qual.items():
391
+ for d in decls:
392
+ if d.kind == "method" and d.pure and qual not in self.by_key:
393
+ owner = qual.rsplit("::", 1)[0]
394
+ oit = self.by_type_key.get(owner) or self.by_key.get(owner)
395
+ n = b.add_node("method", qual, name=d.name, fqn=qual, file=d.file, line=d.line, module=os.path.dirname(d.file) or ".",
396
+ lang=self._lang(d.file), attrs={"pure": True, "virtual": True, "access": d.access, "declaration_only": True})
397
+ if oit is not None:
398
+ b.add_edge(self.nid(oit), n, "CONTAINS", d.file, d.line, EXACT)
399
+ self.decl_items.append(CItem("method", d.name, qual, d.file, d.line, d.col, d.line, d.line,
400
+ os.path.dirname(d.file) or ".", qual=qual, attrs={"pure": True}))
401
+ stats["pure_virtual_decls"] += 1
402
+
403
+ # ------------------------------------------------------------------ scip
404
+ def _run_scip(self):
405
+ tool = runner.find_tool("CODEGRAPH_SCIP_CLANG", ["scip-clang"], [Path.home() / ".local" / "bin"])
406
+ pre = os.environ.get("CODEGRAPH_C_SCIP_FILE")
407
+ if pre:
408
+ return (Path(pre) if Path(pre).exists() else None), {"source": "CODEGRAPH_C_SCIP_FILE", "path": pre}
409
+ if not tool:
410
+ return None, {"status": "scip-clang not installed; heuristic mode (docs/native.md)"}
411
+ ver = runner.tool_version(tool)
412
+ key = scip_cache_key(self.root, list(self.files), ver, self.compdb_path)
413
+ jobs = os.environ.get("CODEGRAPH_JOBS")
414
+ cmd = [tool, f"--compdb-path={self.compdb_path}"] + ([f"--jobs={jobs}"] if jobs else [])
415
+ timeout = int(os.environ.get("CODEGRAPH_INDEXER_TIMEOUT", "3600"))
416
+ path, info = runner.run_cached("cfamily", key, cmd, self.root, "--index-output-path", timeout, out_flag_style="eq")
417
+ info["indexer"] = ver
418
+ if info.get("stderr_tail"):
419
+ m = [l for l in info["stderr_tail"] if "num errored TUs" in l or "Finished indexing" in l]
420
+ if m:
421
+ info["summary"] = m[-1].strip()
422
+ return path, info
423
+
424
+ def _painter(self, rel: str):
425
+ f = self.files.get(rel)
426
+ if f is None:
427
+ return None
428
+ n = len(f.lines) + 2
429
+ paint = [None] * n
430
+ for it in sorted((x for x in f.items if x.kind in SCOPES), key=lambda x: -(x.end - x.start)):
431
+ for ln in range(max(0, it.start), min(n, it.end + 1)):
432
+ paint[ln] = it
433
+ return paint
434
+
435
+ def _import_scip(self, path: Path, stats):
436
+ idx = scipread.load(path)
437
+ stats["scip_documents"] = len(idx.docs)
438
+ sym_nodes: dict[str, list[tuple[str, str]]] = defaultdict(list)
439
+ for sym, defs in idx.defs.items():
440
+ d = scipread.descriptors(sym)
441
+ if not d or not d[1]:
442
+ continue
443
+ name, suf = d[1][-1]
444
+ for rel, o in defs:
445
+ it = self.by_pos.get((rel, o.line + 1, name))
446
+ if it is None and "<" in name:
447
+ it = self.by_pos.get((rel, o.line + 1, name.split("<")[0]))
448
+ if it is None and suf in ("(", "#", "."):
449
+ # operators / conversion functions / constructors of templates print differently: accept the single
450
+ # syntactic item of a compatible kind declared on that line
451
+ want = CODE if suf == "(" else (TYPES if suf == "#" else {"field", "global", "enumerator"})
452
+ cands = [x for x in self.by_line.get((rel, o.line + 1), []) if x.kind in want]
453
+ if len(cands) == 1:
454
+ it = cands[0]
455
+ if it is None and suf == "!":
456
+ it = self.by_pos.get((rel, o.line + 1, "!macro"))
457
+ if it is None:
458
+ it = self.by_pos.get((rel, o.line + 1, "!test"))
459
+ if it is not None and not _kind_ok(suf, it.kind):
460
+ stats[f"scip_kind_mismatch_{suf}_{it.kind}"] += 1
461
+ it = None
462
+ if it is None:
463
+ if suf == "!" or rel not in self.files:
464
+ continue
465
+ nid = self._synthetic(rel, o, sym, d)
466
+ if nid is None:
467
+ continue
468
+ stats["scip_defs_synthetic"] += 1
469
+ else:
470
+ nid = self.nid(it)
471
+ stats["scip_defs_matched"] += 1
472
+ # namespaces hidden behind macros (BEGIN_NAMESPACE-style) are only known to clang: adopt its
473
+ # qualified name for display / spec matching (the node id stays syntactic, stable across modes)
474
+ sq = "::".join(n for n, s in d[1] if s in ("/", "#", "(", ".") and not n.startswith(("<file>", "$")))
475
+ node = self.b.nodes.get(nid)
476
+ if node is not None and sq and len(sq) > len(node.fqn or "") and sq.endswith("::" + (node.fqn or "").split("<")[0]):
477
+ node.fqn = sq
478
+ stats["fqn_from_scip"] += 1
479
+ if (rel, nid) not in sym_nodes[sym]:
480
+ sym_nodes[sym].append((rel, nid))
481
+ self._scip_warning = scipread.native_warning(path, idx, stats)
482
+ # pure-virtual / declaration-only methods: the symbol is only ever declared
483
+ for rel, doc in idx.docs.items():
484
+ f = self.files.get(rel)
485
+ if f is None:
486
+ continue
487
+ for o in doc.occs:
488
+ if o.symbol in sym_nodes or (o.line + 1, o.col) not in f.decl_pos:
489
+ continue
490
+ d = scipread.descriptors(o.symbol)
491
+ if not d or not d[1]:
492
+ continue
493
+ qual = "::".join(n for n, s in d[1] if s in ("/", "#", "(") and not n.startswith("<file>") and not n.startswith("$"))
494
+ if f"method:{qual}" in self.b.nodes:
495
+ sym_nodes[o.symbol].append((rel, f"method:{qual}"))
496
+ self.sym_nodes = sym_nodes
497
+ # relationships: overrides and inheritance
498
+ virtual = set()
499
+ for rel, doc in idx.docs.items():
500
+ for sym, si in doc.symbols.items():
501
+ if sym not in sym_nodes:
502
+ continue
503
+ for r in si.relationships:
504
+ if not r.is_implementation or r.symbol not in sym_nodes:
505
+ continue
506
+ for _, child in sym_nodes[sym]:
507
+ for _, base in sym_nodes[r.symbol][:1]:
508
+ if child.startswith("method:") and base.startswith("method:"):
509
+ bn = self.b.nodes.get(base)
510
+ ek = "IMPLEMENTED_BY" if bn is not None and bn.attrs.get("pure") else "OVERRIDDEN_BY"
511
+ cn = self.b.nodes.get(child)
512
+ self.b.add_edge(base, child, ek, cn.file if cn else rel, cn.line if cn else None, RESOLVED)
513
+ virtual.add(base)
514
+ stats["virtual_dispatch_edges"] += 1
515
+ elif not child.startswith("method:") and not base.startswith("method:"):
516
+ cn = self.b.nodes.get(child)
517
+ self.b.add_edge(child, base, "EXTENDS", cn.file if cn else rel, cn.line if cn else None, EXACT)
518
+ stats["inheritance_edges"] += 1
519
+ self.virtual = virtual | {nid for nid, n in self.b.nodes.items() if n.kind == "method" and n.attrs.get("virtual")}
520
+ for rel, doc in idx.docs.items():
521
+ f = self.files.get(rel)
522
+ if f is None:
523
+ stats["scip_docs_outside_indexed_files"] += 1
524
+ continue
525
+ paint = self._painter(rel)
526
+ lines = f.lines
527
+ # references reported at a macro expansion site (the token under the range is the macro, not the target):
528
+ # real dependencies (TEST/REGISTER-style macros), but type-check / dispatch macros can expand to dozens of
529
+ # symbols per use. Keep small expansions (labelled via_macro, resolved), drop large ones.
530
+ exp_groups = defaultdict(set)
531
+ name_cache = {}
532
+ for o in doc.occs:
533
+ if o.roles & scipread.DEFINITION or o.symbol not in sym_nodes:
534
+ continue
535
+ tok = lines[o.line][o.col:o.end_col] if o.line < len(lines) and o.end_col > o.col else ""
536
+ nm = name_cache.get(o.symbol)
537
+ if nm is None:
538
+ d = scipread.descriptors(o.symbol)
539
+ nm = name_cache[o.symbol] = (d[1][-1][0].split("<")[0] if d and d[1] else "")
540
+ if tok and nm and tok != nm and not nm.startswith(("operator", "~")) and not tok.startswith(("operator", "~")) \
541
+ and re.fullmatch(r"[A-Za-z_]\w*", tok):
542
+ exp_groups[(o.line, o.col)].add(o.symbol)
543
+ max_exp = int(os.environ.get("CODEGRAPH_C_MAX_MACRO_REFS", "8"))
544
+ for o in doc.occs:
545
+ if o.roles & scipread.DEFINITION:
546
+ continue
547
+ if (o.line + 1, o.col) in f.decl_pos:
548
+ continue
549
+ cands = sym_nodes.get(o.symbol)
550
+ if not cands:
551
+ continue
552
+ ln = o.line + 1
553
+ owner = paint[ln] if ln < len(paint) else None
554
+ if owner is None:
555
+ stats["scip_refs_outside_items"] += 1
556
+ continue
557
+ if len(cands) > 1:
558
+ same = [c for c in cands if c[0] == rel]
559
+ cands = same[:1] if same else cands[:3]
560
+ line_txt = lines[o.line] if o.line < len(lines) else ""
561
+ before = line_txt[:o.col].rstrip()
562
+ after = line_txt[o.end_col:].lstrip()
563
+ grp = exp_groups.get((o.line, o.col))
564
+ via = {}
565
+ if grp and o.symbol in grp:
566
+ if len(grp) > max_exp:
567
+ stats["scip_macro_expansion_refs_dropped"] += 1
568
+ continue
569
+ via = {"via_macro": line_txt[o.col:o.end_col]}
570
+ stats["scip_macro_expansion_refs"] += 1
571
+ for _, dst in cands:
572
+ conf = HEURISTIC if len(cands) > 1 else (RESOLVED if via or before.endswith((".", "->")) else EXACT)
573
+ self._ref_edge(self.nid(owner), dst, rel, ln, conf, "(" if via and dst.split(":", 1)[0] in CODE else after, stats, **via)
574
+
575
+ def _synthetic(self, rel, o, sym, d) -> str | None:
576
+ names = [(n, s) for n, s in d[1] if not n.startswith("<file>") and not n.startswith("$")]
577
+ if not names:
578
+ return None
579
+ last, suf = names[-1]
580
+ if suf not in ("(", "#", "."):
581
+ return None
582
+ qual = "::".join(n for n, s in names if s in ("/", "#", "(", "."))
583
+ f = self.files[rel]
584
+ if suf == "(":
585
+ kind = "method" if any(s == "#" for _, s in names[:-1]) else "function"
586
+ elif suf == "#":
587
+ kind = "class"
588
+ else:
589
+ kind = "field" if any(s == "#" for _, s in names[:-1]) else "global"
590
+ key = qual if any(s == "/" for _, s in d[1][:1]) or kind in ("method", "class", "field") else qual
591
+ if any(n.startswith("<file>") or n.startswith("$anonymous") for n, _ in d[1]):
592
+ key = f"{rel}#{qual}"
593
+ if f"{kind}:{key}" in self.b.nodes:
594
+ return f"{kind}:{key}"
595
+ nid = self.b.add_node(kind, key, name=last, fqn=qual, file=rel, line=o.line + 1, module=os.path.dirname(rel) or ".",
596
+ lang=f.lang, attrs={"from": "scip"})
597
+ return nid
598
+
599
+ def _ref_edge(self, src: str, dst: str, rel: str, ln: int, conf: str, after: str, stats, **attrs):
600
+ if src == dst:
601
+ return
602
+ kind = dst.split(":", 1)[0]
603
+ node = self.b.nodes.get(dst)
604
+ if kind in CODE:
605
+ ek = "CALLS" if after.startswith(("(", "<")) else "REFERENCES_FN"
606
+ if dst in getattr(self, "virtual", ()) and ek == "CALLS":
607
+ attrs["dispatch"] = "virtual"
608
+ stats["calls_virtual_dispatch"] += 1
609
+ elif kind == "macro":
610
+ ek = "CALLS" if (node is not None and node.attrs.get("function_like")) else "USES_VALUE"
611
+ elif kind in TYPES:
612
+ ek = "USES_TYPE"
613
+ elif kind == "field":
614
+ ek = "ACCESSES_FIELD"
615
+ elif kind in ("global", "enumerator"):
616
+ ek = "USES_VALUE"
617
+ else:
618
+ return
619
+ self.b.add_edge(src, dst, ek, rel, ln, conf, **attrs)
620
+ stats[f"edges_{ek}"] += 1
621
+
622
+ # ------------------------------------------------------------------ heuristic
623
+ def _heuristic_refs(self, stats):
624
+ from ...platforms import c_relevant, path_convention
625
+
626
+ def _platform_scoped(it) -> bool:
627
+ if path_convention(it.file):
628
+ return True
629
+ f = self.files.get(it.file)
630
+ return bool(f) and any(a <= it.start and it.end <= z and c_relevant(c) for a, z, c, _l, _b in f.regions)
631
+
632
+ def _alternatives(cands):
633
+ # same symbol: keys equal up to the collision suffix (`@file:line`) or file prefix (`src/win/x.c#name`)
634
+ if (1 < len(cands) <= 8 and len({c.key.split("@", 1)[0].rsplit("#", 1)[-1] for c in cands}) == 1
635
+ and len({c.kind for c in cands}) == 1
636
+ and all(_platform_scoped(c) for c in cands)):
637
+ return list(cands)
638
+ return []
639
+
640
+ funcs = defaultdict(list)
641
+ methods = defaultdict(list)
642
+ for it in self.items + self.decl_items:
643
+ if it.kind == "function":
644
+ funcs[it.name].append(it)
645
+ elif it.kind == "method":
646
+ methods[it.name].append(it)
647
+ quals = {it.qual: it for it in self.items if it.kind in CODE}
648
+ macros = {it.name: it for it in self.items if it.kind == "macro"}
649
+ globals_ = defaultdict(list)
650
+ for it in self.items:
651
+ if it.kind in ("global", "enumerator"):
652
+ globals_[it.name].append(it)
653
+ types = defaultdict(list)
654
+ for it in self.items:
655
+ if it.kind in TYPES:
656
+ types[it.name].append(it)
657
+
658
+ def pick(cands, rel):
659
+ if not cands:
660
+ return []
661
+ same = [c for c in cands if c.file == rel]
662
+ if same:
663
+ return _alternatives(same) or same[:1]
664
+ # a static (inline) function defined in a header is compiled into every file that includes it (#92)
665
+ inc = self._included(rel) if any(c.static for c in cands) else ()
666
+ hdr = [c for c in cands if c.static and c.file in inc]
667
+ if hdr:
668
+ return _alternatives(hdr) or (hdr if len(hdr) == 1 else [])
669
+ nonstatic = [c for c in cands if not c.static]
670
+ if len(nonstatic) == 1:
671
+ return nonstatic
672
+ # one symbol defined once per build configuration (#ifdef _WIN32 / #else, src/win/x.c + src/unix/x.c):
673
+ # every definition is a target (each is tagged with its platforms)
674
+ return _alternatives(nonstatic)
675
+
676
+ for rel, f in self.files.items():
677
+ for owner_key, form, txt, name, line, col in f.calls:
678
+ owner = owner_key
679
+ if owner is None:
680
+ continue
681
+ targets = []
682
+ if form == "name":
683
+ if name in LIBC:
684
+ continue
685
+ if name in macros and macros[name].attrs.get("function_like"):
686
+ targets = [macros[name]]
687
+ else:
688
+ targets = pick(funcs.get(name), rel) or (pick(methods.get(name), rel) if owner.kind == "method" else [])
689
+ elif form == "qualified":
690
+ q = quals.get(txt) or next((v for k, v in quals.items() if k.endswith("::" + txt)), None)
691
+ targets = [q] if q else []
692
+ else:
693
+ c = methods.get(name, [])
694
+ if len(c) > 1:
695
+ # several classes define the method: if exactly one is the virtual root, dispatch through it
696
+ roots = [m for m in c if self._is_virtual(m) and not self._is_override(m)]
697
+ c = roots if len(roots) == 1 else c
698
+ targets = c if len(c) == 1 else []
699
+ if not targets:
700
+ stats["heuristic_unresolved_calls"] += 1
701
+ continue
702
+ for t in targets:
703
+ self._ref_edge(self.nid(owner), self.nid(t), rel, line, HEURISTIC, "(", stats,
704
+ **({"dispatch": "virtual"} if form == "member" and self._is_virtual(t) else {}))
705
+ for owner_key, name, line, col in f.idents:
706
+ owner = owner_key
707
+ if owner is None:
708
+ continue
709
+ if name in funcs:
710
+ t = pick(funcs[name], rel)
711
+ for x in t:
712
+ self._ref_edge(self.nid(owner), self.nid(x), rel, line, HEURISTIC, "", stats)
713
+ elif name in globals_:
714
+ for x in pick(globals_[name], rel):
715
+ self._ref_edge(self.nid(owner), self.nid(x), rel, line, HEURISTIC, "", stats)
716
+ elif name in macros and not macros[name].attrs.get("function_like"):
717
+ self._ref_edge(self.nid(owner), self.nid(macros[name]), rel, line, HEURISTIC, "", stats)
718
+ for owner_key, name, line, col in f.type_refs:
719
+ owner = owner_key
720
+ if owner is None:
721
+ continue
722
+ for x in pick(types.get(name), rel):
723
+ self._ref_edge(self.nid(owner), self.nid(x), rel, line, HEURISTIC, "", stats)
724
+
725
+ def _is_virtual(self, it) -> bool:
726
+ n = self.b.nodes.get(self.nid(it))
727
+ return bool(n is not None and n.attrs.get("virtual"))
728
+
729
+ def _is_override(self, it) -> bool:
730
+ n = self.b.nodes.get(self.nid(it))
731
+ return bool(n is not None and n.attrs.get("override"))
732
+
733
+ def _heuristic_dispatch(self, stats):
734
+ cls_by_name = defaultdict(list)
735
+ for it in self.items:
736
+ if it.kind in ("class", "struct"):
737
+ cls_by_name[it.name].append(it)
738
+ parents = defaultdict(list)
739
+ for f in self.files.values():
740
+ for ckey, base, line, access in f.bases:
741
+ cands = cls_by_name.get(base.split("::")[-1], [])
742
+ if len(cands) == 1 and ckey in self.by_key:
743
+ parents[ckey].append(cands[0].key)
744
+ self.b.add_edge(self.nid(self.by_key[ckey]), self.nid(cands[0]), "EXTENDS", f.path, line, HEURISTIC)
745
+ stats["inheritance_edges"] += 1
746
+ methods_of = defaultdict(dict)
747
+ for nid, n in self.b.nodes.items():
748
+ if n.kind == "method" and n.fqn and "::" in n.fqn:
749
+ methods_of[n.fqn.rsplit("::", 1)[0]][n.name] = nid
750
+ for ckey, ps in parents.items():
751
+ cq = self.by_key[ckey].qual
752
+ for pkey in ps:
753
+ pq = self.by_key[pkey].qual
754
+ for mname, child in methods_of.get(cq, {}).items():
755
+ base = methods_of.get(pq, {}).get(mname)
756
+ if base:
757
+ bn = self.b.nodes[base]
758
+ if bn.attrs.get("virtual") or self.b.nodes[child].attrs.get("override"):
759
+ ek = "IMPLEMENTED_BY" if bn.attrs.get("pure") else "OVERRIDDEN_BY"
760
+ cn = self.b.nodes[child]
761
+ self.b.add_edge(base, child, ek, cn.file, cn.line, HEURISTIC)
762
+ stats["virtual_dispatch_edges"] += 1
763
+
764
+ # ------------------------------------------------------------------ includes, env, gates
765
+ def _includes(self, stats):
766
+ for rel, hits in self._inc.items():
767
+ for hit, line, conf in hits:
768
+ self.b.add_edge(f"file:{rel}", f"file:{hit}", "INCLUDES", rel, line, conf)
769
+
770
+ def _included(self, rel: str) -> set:
771
+ """Files `rel` includes, directly or through other project headers."""
772
+ memo = self.__dict__.setdefault("_inc_closure", {})
773
+ if rel not in memo:
774
+ seen, todo = set(), [rel]
775
+ while todo and len(seen) < 2000:
776
+ for hit, _l, _c in self._inc.get(todo.pop(), ()):
777
+ if hit not in seen and hit != rel:
778
+ seen.add(hit)
779
+ todo.append(hit)
780
+ memo[rel] = seen
781
+ return memo[rel]
782
+
783
+ def _resolve_includes(self, stats) -> dict:
784
+ out = defaultdict(list)
785
+ by_base = defaultdict(list)
786
+ for rel in self.files:
787
+ by_base[os.path.basename(rel)].append(rel)
788
+ global_incs = set()
789
+ for e in self.compdb.values():
790
+ global_incs.update(e["includes"])
791
+ default_incs = [d for d in ("include", "src", "inc", "") if d == "" or (self.root / d).is_dir()]
792
+ for rel, f in self.files.items():
793
+ dirs = (self.compdb.get(rel) or {}).get("includes") or []
794
+ for path, line, system in f.includes:
795
+ hit, conf = None, EXACT
796
+ cand = [os.path.normpath(os.path.join(os.path.dirname(rel), path))] if not system else []
797
+ cand += [os.path.normpath(os.path.join(d, path)) for d in dirs]
798
+ cand += [os.path.normpath(os.path.join(d, path)) for d in sorted(global_incs) + default_incs]
799
+ for c in cand:
800
+ c = c.replace("\\", "/").lstrip("./") if not c.startswith("../") else c
801
+ if c in self.files:
802
+ hit = c
803
+ conf = EXACT if c == cand[0] or dirs else RESOLVED
804
+ break
805
+ if hit is None:
806
+ bb = [x for x in by_base.get(os.path.basename(path), []) if x.endswith("/" + path) or x == path]
807
+ if len(bb) == 1:
808
+ hit, conf = bb[0], HEURISTIC
809
+ if hit is None:
810
+ if not system:
811
+ stats["includes_unresolved"] += 1
812
+ continue
813
+ out[rel].append((hit, line, conf))
814
+ stats["includes_resolved"] += 1
815
+ return out
816
+
817
+ def _facts(self, stats):
818
+ b = self.b
819
+ for rel, f in self.files.items():
820
+ for key, line, owner_key in f.env:
821
+ owner = owner_key
822
+ if owner is None:
823
+ continue
824
+ b.add_node("env", key, name=key, lang=f.lang)
825
+ b.add_edge(self.nid(owner), f"env:{key}", "READS_ENV", rel, line, EXACT, how="getenv")
826
+ stats["env_reads"] += 1
827
+ # preprocessor gates: items overlapping each conditional region
828
+ if not f.regions:
829
+ continue
830
+ self._platform_marks(rel, f)
831
+ scoped = [it for it in f.items if it.kind in CODE | TYPES | {"global", "field", "macro"}]
832
+ for start, end, cond, dline, branch in f.regions:
833
+ macros = [m for m in G.pp_macros(re.sub(r"__has_\w+\s*\([^)]*\)", "", cond)) if m != "__cplusplus" and m not in f.guards]
834
+ if not macros:
835
+ continue
836
+ for it in scoped:
837
+ if it.start > end or it.end < start:
838
+ continue
839
+ inside = start <= it.start and it.end <= end
840
+ if not inside and it.kind not in CODE:
841
+ continue
842
+ for m in macros:
843
+ b.add_node("define", m, name=m, lang=f.lang)
844
+ b.add_edge(self.nid(it), f"define:{m}", "GATED_BY", rel, dline, EXACT, cond=cond,
845
+ scope="item" if inside else "block")
846
+ stats["gated_by_edges"] += 1
847
+
848
+ def _platform_marks(self, rel: str, f) -> None:
849
+ """Platform conditions (codegraph/platforms.py): #if regions over platform macros (_WIN32, __APPLE__, ...)."""
850
+ from ...platforms import Cond, c_relevant, mark
851
+ for start, end, cond, dline, branch in f.regions:
852
+ if c_relevant(cond):
853
+ txt = f"#{'if' if branch == 'if' else branch} {cond}" if branch in ("if", "elif") else f"#{branch} ({cond})"
854
+ mark(self.b, rel, start, end, Cond("c", cond, txt[:200]), line=dline)
855
+
856
+ def _entries(self, stats):
857
+ b = self.b
858
+ for it in self.items:
859
+ if it.kind not in CODE:
860
+ continue
861
+ n = b.nodes.get(self.nid(it))
862
+ if n is None:
863
+ continue
864
+ path = it.file
865
+ kinds = []
866
+ is_test_file = bool(TEST_DIR.search(path) or TEST_FILE.search(path))
867
+ if it.attrs.get("test_macro"):
868
+ kinds.append("bench" if it.attrs["test_macro"] == "BENCHMARK" else "test")
869
+ if it.kind == "function" and it.name in MAIN_NAMES and not it.qual.count("::"):
870
+ if is_test_file:
871
+ kinds.append("test")
872
+ elif EXAMPLE_DIR.search(path):
873
+ kinds.append("example")
874
+ elif BENCH_DIR.search(path):
875
+ kinds.append("bench")
876
+ else:
877
+ kinds.append("main")
878
+ elif is_test_file and re.match(r"(?i)test", it.name) and it.kind == "function":
879
+ kinds.append("test")
880
+ if not it.static and not is_test_file and not EXAMPLE_DIR.search(path) and "#" not in it.key.split("(")[0]:
881
+ decls = self.decls_by_qual.get(it.qual, [])
882
+ public = n.attrs.get("export") or any(d.export for d in decls)
883
+ if not public:
884
+ hdr = [d for d in decls if INCLUDE_DIR.search(d.file)]
885
+ public = bool(hdr) and all(d.access in (None, "public") for d in hdr)
886
+ if not public and it.kind == "method" and INCLUDE_DIR.search(it.file) and it.attrs.get("access", "public") == "public":
887
+ public = True
888
+ if public and n.attrs.get("access") in ("private",):
889
+ public = False
890
+ if public:
891
+ kinds.append("public_api")
892
+ if not kinds:
893
+ continue
894
+ order = ["main", "test", "bench", "example", "public_api"]
895
+ n.entry_kind = min(kinds, key=order.index)
896
+ stats[f"entry_{n.entry_kind}"] += 1
897
+
898
+ def _gating(self, stats):
899
+ sc = self.scen
900
+ if not sc.active or not (sc.defines or sc.undefined):
901
+ return
902
+ dead = defaultdict(list)
903
+ for rel, f in self.files.items():
904
+ for start, end, cond, dline, branch in f.regions:
905
+ if sc.eval_pp(cond) is False:
906
+ dead[rel].append((start, end, cond, dline))
907
+ n = 0
908
+ for e in self.b.edges.values():
909
+ if e.file in dead and e.line is not None and e.gate is None and e.kind != "GATED_BY":
910
+ for a, z, cond, dline in dead[e.file]:
911
+ if a <= e.line <= z:
912
+ e.gate = sc.name
913
+ e.attrs = {**e.attrs, "guard": f"{e.file}:{dline}", "guard_expr": f"#if {cond}"}
914
+ n += 1
915
+ break
916
+ stats["gated_edges"] = n
917
+
918
+
919
+ def _split_args(args: str) -> list[str]:
920
+ out, depth, cur = [], 0, ""
921
+ for ch in args:
922
+ if ch == "," and depth == 0:
923
+ out.append(cur)
924
+ cur = ""
925
+ continue
926
+ depth += ch in "([{"
927
+ depth -= ch in ")]}"
928
+ cur += ch
929
+ out.append(cur)
930
+ return [x.strip() for x in out]