1
0

c-fnptr-synthesizer.ts 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450
  1. /**
  2. * C/C++ function-pointer dispatch synthesis (#932).
  3. *
  4. * C/C++ polymorphism is the function pointer: a struct carries a fn-pointer
  5. * field (`int (*fn)(int)`, or a fn-pointer-typedef field `hook_func func`),
  6. * concrete functions are *registered* into it through a table
  7. * (`static struct cmd cmds[] = {{"add", cmd_add}, …}`, a designated
  8. * `.fn = cmd_add`, or `x->fn = cmd_add`), and the dispatcher calls through it
  9. * indirectly (`p->fn(argv)`). Static extraction captures neither the
  10. * registration→field binding nor the indirect call, so the dispatcher→handler
  11. * edge is missing and `git`'s `run_builtin` looks like it calls nothing, the
  12. * hooks in `hook_demo.c` are unreachable, etc.
  13. *
  14. * This bridges it, keyed by **(struct type, fn-pointer field)**:
  15. * • registrations — a function bound to `S.field` via a positional
  16. * initializer (matched by field index), a designated `.field = fn`, or a
  17. * direct `x.field = fn` / `x->field = fn` assignment;
  18. * • dispatch — `recv->field(…)` / `recv.field(…)` where `recv` resolves to a
  19. * value of struct type `S` (from the enclosing function's params / locals,
  20. * or by walking a chained/array receiver `c->cmd->proc` across field types),
  21. * falling back to the field name when it is unique to one struct;
  22. * • field←field propagation — `a->f = b->g` merges `B.g`'s handlers into
  23. * `A.f`, so a generic single-slot hook that is reassigned from a registry
  24. * (the `hook_demo.c` shape: `h->func = found->fn`) still resolves.
  25. *
  26. * Also handles **macro-built tables** (#991) — the dominant real-world shape,
  27. * e.g. redis' command table, sqlite's builtin functions, and vim's `:ex` /
  28. * normal-mode commands. The fn-pointer arg lives inside a macro call
  29. * (`MAKE_CMD(…,proc,…)` / `FUNCTION(…,xFunc)` / `EXCMD(…,fn,…)`) in a generated
  30. * or `#include`-d file; the table's struct type may itself be an object-macro
  31. * alias; the field may use a function-TYPE typedef; the struct may be defined
  32. * INLINE with the array; and the whole thing may sit behind `#ifdef` switched on
  33. * by the includer. The registration pass reads each `#include`-d file as a unit
  34. * with the includer's effective macro env (own + headers) in scope, evaluates
  35. * its `#ifdef`s against the includer's defined set, expands object/function
  36. * macros, peels a brace-wrapped element, and parses an inline struct in place —
  37. * then reads the positional/designated bindings. Dispatch additionally resolves
  38. * an array subscript through a file-scope table (`(cmdnames[i].cmd_func)(…)`).
  39. *
  40. * Also bridges **bare arrays of function pointers** (no struct, no field) —
  41. * `opcode_t *opcodes[256] = {nop,…}` dispatched `opcodes[op](…)` (SameBoy's CPU),
  42. * `zend_rc_dtor_func_t t[] = {[IS_STRING]=(cast)fn,…}` dispatched `t[GC_TYPE(p)](…)`
  43. * (php's Zend) — keyed by the array VARIABLE name. The element type must be a
  44. * function typedef (the precision gate), entries are literal function names, and
  45. * the same-file table wins on a name collision (two file-local `opcodes[256]`).
  46. *
  47. * Whole-graph pass after base resolution; all edges are `provenance:'heuristic'`
  48. * (`synthesizedBy:'fn-pointer-dispatch'`). High precision via the (type, field)
  49. * key + a real-function gate; a project with no fn-pointer dispatch is a no-op.
  50. *
  51. * ## Fuse-then-link architecture (§7a.8, task #5 step 1)
  52. *
  53. * The pass used to sweep every file's text FOUR times (typedefs, registrations,
  54. * propagation, dispatch), and on the Linux kernel the all-or-nothing source
  55. * cache declines, so each sweep re-read + re-stripped the whole corpus — 4.4
  56. * strips/file, ~78s of the ~230s kernel-scale wall (§7a.8 calibration). It now
  57. * runs as ONE extraction sweep plus filtered linking stages:
  58. *
  59. * 1. **Extraction sweep** — reads + strips each file ONCE and collects, per
  60. * file: typedef names, each struct node's field declarations (parsed
  61. * structurally, fn-pointer classification deferred — the typedef sets
  62. * aren't complete mid-sweep), the resolved local includes, and cheap
  63. * SURVIVAL FILTERS for the later stages (distinct initializer type
  64. * tokens, array element types, inline-struct summaries, field-assignment
  65. * field pairs, dispatch field / array names — all interned, a few MB even
  66. * on the kernel).
  67. * 2. **Struct-layout linking** — classifies the deferred fields against the
  68. * now-complete typedef sets and registers layouts by replaying the struct
  69. * kind-scan, so registration order (which decides same-name layout
  70. * precedence) is byte-identical to the old dedicated pass.
  71. * 3. **Registration / propagation / dispatch** — the original pass bodies,
  72. * UNCHANGED, but each file is first checked against its survival filter
  73. * and only surviving files are re-stripped (LRU-served). The filters only
  74. * ever over-approximate: a filtered-out file is one where every match
  75. * would have failed the pass's own gates before any side effect, so
  76. * skipping it cannot change the edge set. On the kernel only ~16% of
  77. * files have any dispatch-shaped match at all, so the lazy re-strips are
  78. * a fraction of a sweep and total strip work drops ~4.4× → ~1.5×.
  79. *
  80. * The extraction sweep is also the step-2 boundary: a native per-file extractor
  81. * can replace the sweep's scans without touching the linking stages.
  82. */
  83. import * as path from 'node:path';
  84. import type { Edge, Node } from '../types';
  85. import type { QueryBuilder } from '../db/queries';
  86. import type { ResolutionContext } from './types';
  87. import type { MaybeYield } from './cooperative-yield';
  88. import { memoryBudgetBytes } from './memory-budget';
  89. import { LRUCache } from './lru-cache';
  90. import { stripCommentsForRegex } from './strip-comments';
  91. import { getKernel } from '../extraction/kernel/loader';
  92. import type { CfnptrFactsOut, CfnptrFileIn } from '../extraction/kernel/loader';
  93. const C_CPP_EXT = /\.(c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc)$/i;
  94. const FN_KINDS = new Set(['function', 'method']);
  95. const FANOUT_CAP = 300; // a real command table (git ~150) is legitimate fan-out; this only stops pathological cases.
  96. /** A struct field, in declaration order, flagged when it is a function pointer. */
  97. interface FieldInfo {
  98. name: string;
  99. index: number;
  100. isFnPtr: boolean;
  101. /** The field's declared type token (e.g. `redisCommand` for `struct redisCommand *cmd`),
  102. * used to walk a chained receiver `c->cmd->proc`. Empty for fn-pointer fields. */
  103. type: string;
  104. }
  105. /** A struct field as parsed during the extraction sweep: structure only. The
  106. * `(*name)(…)` pointer syntax is a local fact (`ptr`), but a typedef-typed
  107. * field's fn-pointer-ness depends on the GLOBAL typedef sets, which aren't
  108. * complete until the sweep ends — so classification into `FieldInfo.isFnPtr`
  109. * is deferred to the linking stage. */
  110. interface RawFieldDecl {
  111. name: string | null;
  112. index: number;
  113. ptr: boolean;
  114. type: string;
  115. }
  116. /** Slice a node's body from a pre-split line array — the per-file sweeps
  117. * call this once per NODE, and splitting the whole file per node was an
  118. * O(nodes × file-size) term (~1.6M full-file splits on the Linux tree,
  119. * §7a.3 cFnPtr round). Split once per file, slice many times. */
  120. function sliceLinesPre(lines: string[], startLine?: number, endLine?: number): string {
  121. if (!startLine) return '';
  122. return lines.slice(startLine - 1, endLine ?? startLine).join('\n');
  123. }
  124. /** Index of the `}` matching the `{` at `open` (which must point at a `{`). -1 if unbalanced. */
  125. function matchBrace(src: string, open: number): number {
  126. let depth = 0;
  127. for (let i = open; i < src.length; i++) {
  128. const c = src[i];
  129. if (c === '{') depth++;
  130. else if (c === '}') {
  131. depth--;
  132. if (depth === 0) return i;
  133. }
  134. }
  135. return -1;
  136. }
  137. /** Split `body` on `sep` at brace/paren/bracket depth 0 (commas inside `{…}` / `(…)` stay together). */
  138. function splitTopLevel(body: string, sep: string): string[] {
  139. const out: string[] = [];
  140. let depth = 0;
  141. let start = 0;
  142. for (let i = 0; i < body.length; i++) {
  143. const c = body[i]!;
  144. if (c === '{' || c === '(' || c === '[') depth++;
  145. else if (c === '}' || c === ')' || c === ']') depth--;
  146. else if (c === sep && depth === 0) {
  147. out.push(body.slice(start, i));
  148. start = i + 1;
  149. }
  150. }
  151. out.push(body.slice(start));
  152. return out;
  153. }
  154. /** Index of the `)` matching the `(` at `open` (which must point at a `(`). -1 if unbalanced. */
  155. function matchParen(src: string, open: number): number {
  156. let depth = 0;
  157. for (let i = open; i < src.length; i++) {
  158. const c = src[i];
  159. if (c === '(') depth++;
  160. else if (c === ')') {
  161. depth--;
  162. if (depth === 0) return i;
  163. }
  164. }
  165. return -1;
  166. }
  167. /** A function-like macro: `#define NAME(p0,p1,…) expansion`. */
  168. interface MacroDef {
  169. params: string[];
  170. expansion: string;
  171. }
  172. /**
  173. * Collect function-like macros from (comment-stripped) source, joining
  174. * `\`-continuations first. Only object/positional table macros matter here, so
  175. * variadic macros are skipped. Used to expand registration tables built through
  176. * a macro (redis' `MAKE_CMD(…)`) before reading the struct-field bindings.
  177. */
  178. function parseFunctionMacros(stripped: string): Map<string, MacroDef> {
  179. const out = new Map<string, MacroDef>();
  180. if (!stripped.includes('#define') && !stripped.includes('# define')) return out;
  181. const joined = stripped.replace(/\\\r?\n/g, ' ');
  182. const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)\s+(.+)$/gm;
  183. let m: RegExpExecArray | null;
  184. while ((m = RE.exec(joined))) {
  185. const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean);
  186. if (params.some((p) => p === '...' || p.endsWith('...'))) continue; // variadic — skip
  187. out.set(m[1]!, { params, expansion: m[3]!.trim() });
  188. }
  189. return out;
  190. }
  191. /**
  192. * Collect object-like macros `#define NAME value` (NAME not immediately followed
  193. * by `(`). redis aliases the table's struct type this way:
  194. * `#define COMMAND_STRUCT redisCommand`, used as `struct COMMAND_STRUCT table[]`.
  195. */
  196. function parseObjectMacros(stripped: string): Map<string, string> {
  197. const out = new Map<string, string>();
  198. if (!stripped.includes('#define') && !stripped.includes('# define')) return out;
  199. const joined = stripped.replace(/\\\r?\n/g, ' ');
  200. const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(\S[^\n]*)$/gm;
  201. let m: RegExpExecArray | null;
  202. while ((m = RE.exec(joined))) out.set(m[1]!, m[2]!.trim());
  203. return out;
  204. }
  205. /** All macro names a file `#define`s (value-ful or not) — the "defined" set for #ifdef. */
  206. function parseDefinedNames(stripped: string): Set<string> {
  207. const out = new Set<string>();
  208. if (!stripped.includes('#define') && !stripped.includes('# define')) return out;
  209. const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)/gm;
  210. let m: RegExpExecArray | null;
  211. while ((m = RE.exec(stripped))) out.add(m[1]!);
  212. return out;
  213. }
  214. /**
  215. * Drop the inactive arms of `#ifdef`/`#ifndef`/`#if defined(X)`/`#else`/`#elif`/
  216. * `#endif` given a set of defined macro names, keeping line offsets (inactive
  217. * lines are blanked, not removed). A conditional whose expression we can't
  218. * evaluate (`#if SOME_EXPR`) keeps its body — better to over-keep than to drop
  219. * live code. This is what makes a header included with a switch macro defined
  220. * (vim's `ex_cmds.h` under `DO_DECLARE_EXCMD`) expose only its active table.
  221. */
  222. function evalConditionals(text: string, defined: Set<string>): string {
  223. if (!/#\s*if/.test(text)) return text;
  224. const lines = text.split('\n');
  225. // stack frame: parentActive = enclosing kept?; active = this arm kept?; taken = any arm taken yet
  226. const stack: { parentActive: boolean; active: boolean; taken: boolean }[] = [];
  227. const activeNow = (): boolean => (stack.length === 0 ? true : stack[stack.length - 1]!.active);
  228. const condDefined = (expr: string): boolean | null => {
  229. let mm = expr.match(/^defined\s*\(?\s*(\w+)\s*\)?$/);
  230. if (mm) return defined.has(mm[1]!);
  231. mm = expr.match(/^!\s*defined\s*\(?\s*(\w+)\s*\)?$/);
  232. if (mm) return !defined.has(mm[1]!);
  233. return null; // unevaluable
  234. };
  235. for (let i = 0; i < lines.length; i++) {
  236. const t = lines[i]!.trim();
  237. let mm: RegExpMatchArray | null;
  238. if ((mm = t.match(/^#\s*ifdef\s+(\w+)/))) {
  239. const pa = activeNow();
  240. const cond = defined.has(mm[1]!);
  241. stack.push({ parentActive: pa, active: pa && cond, taken: cond });
  242. lines[i] = '';
  243. continue;
  244. }
  245. if ((mm = t.match(/^#\s*ifndef\s+(\w+)/))) {
  246. const pa = activeNow();
  247. const cond = !defined.has(mm[1]!);
  248. stack.push({ parentActive: pa, active: pa && cond, taken: cond });
  249. lines[i] = '';
  250. continue;
  251. }
  252. if ((mm = t.match(/^#\s*if\s+(.+)$/))) {
  253. const pa = activeNow();
  254. const c = condDefined(mm[1]!.trim());
  255. const cond = c === null ? true : c; // unevaluable → keep
  256. stack.push({ parentActive: pa, active: pa && cond, taken: cond });
  257. lines[i] = '';
  258. continue;
  259. }
  260. if (/^#\s*elif\b/.test(t)) {
  261. const top = stack[stack.length - 1];
  262. if (top) { top.active = top.parentActive && !top.taken; top.taken = true; }
  263. lines[i] = '';
  264. continue;
  265. }
  266. if (/^#\s*else\b/.test(t)) {
  267. const top = stack[stack.length - 1];
  268. if (top) { top.active = top.parentActive && !top.taken; top.taken = true; }
  269. lines[i] = '';
  270. continue;
  271. }
  272. if (/^#\s*endif\b/.test(t)) {
  273. stack.pop();
  274. lines[i] = '';
  275. continue;
  276. }
  277. if (!activeNow()) lines[i] = ''; // blank an inactive line (keep the newline)
  278. }
  279. return lines.join('\n');
  280. }
  281. /** Resolve a type token through object-like macro aliases (transitive, capped). */
  282. function resolveTypeName(name: string, objEnv: Map<string, string> | undefined): string {
  283. let n = name;
  284. for (let i = 0; objEnv && i < 5; i++) {
  285. const v = objEnv.get(n);
  286. const t = v?.trim().match(/^(?:(?:struct|union)\s+)?(\w+)$/);
  287. if (!t) break;
  288. n = t[1]!;
  289. }
  290. return n;
  291. }
  292. /** Substitute call args for the macro's params (whole-token) in its expansion. */
  293. function substituteMacro(def: MacroDef, args: string[]): string {
  294. const map = new Map<string, string>();
  295. def.params.forEach((p, i) => map.set(p, args[i] ?? ''));
  296. return def.expansion.replace(/\b\w+\b/g, (tok) => (map.has(tok) ? map.get(tok)! : tok));
  297. }
  298. /**
  299. * Expand known function-like macro calls in `text` to a fixpoint (depth-capped).
  300. * `MAKE_CMD("get",…,getCommand,…)` → the positional value list whose slots line
  301. * up with the struct's fields, so the existing positional registration can read
  302. * `getCommand` straight out of the `proc` slot.
  303. */
  304. function expandMacroCalls(text: string, env: Map<string, MacroDef>): string {
  305. if (env.size === 0) return text;
  306. let out = text;
  307. for (let pass = 0; pass < 6; pass++) {
  308. let changed = false;
  309. const RE = /\b(\w+)\s*\(/g;
  310. let m: RegExpExecArray | null;
  311. while ((m = RE.exec(out))) {
  312. const def = env.get(m[1]!);
  313. if (!def) continue;
  314. const open = m.index + m[0].length - 1; // index of the `(`
  315. const close = matchParen(out, open);
  316. if (close < 0) continue;
  317. const args = splitTopLevel(out.slice(open + 1, close), ',').map((a) => a.trim());
  318. out = out.slice(0, m.index) + substituteMacro(def, args) + out.slice(close + 1);
  319. changed = true;
  320. break; // restart scan — offsets shifted
  321. }
  322. if (!changed) break;
  323. }
  324. return out;
  325. }
  326. /** A fn-pointer field looks like `… (*name)(…)` — capture `name`. A
  327. * calling-convention / attribute macro may precede the `*`
  328. * (`(ZEND_FASTCALL *name)`), so allow leading word tokens. */
  329. const FNPTR_DECL_RE = /\(\s*(?:\w+\s+)*\*\s*(\w+)\s*\)\s*\(/;
  330. /** `typedef RET (*NAME)(…)` — a function-pointer typedef (CC/attr macro before
  331. * the `*` allowed, as in php's `typedef void (ZEND_FASTCALL *fn_t)(…)`). */
  332. const FNPTR_TYPEDEF_RE = /\btypedef\b[^;{}]*?\(\s*(?:\w+\s+)*\*\s*(\w+)\s*\)\s*\(/g;
  333. /** A whole brace-free `typedef … ;` statement — capture the guts to spot the
  334. * function-TYPE form `typedef RET NAME(params)` (no `(*name)` pointer form). */
  335. const FNTYPE_TYPEDEF_STMT_RE = /\btypedef\b([^;{}]*);/g;
  336. /** Return-type keywords that must never be mistaken for the typedef's name. */
  337. const C_TYPE_KEYWORDS = new Set([
  338. 'void', 'int', 'char', 'short', 'long', 'unsigned', 'signed', 'float', 'double',
  339. 'const', 'struct', 'union', 'enum', 'static', 'volatile', 'register', 'inline',
  340. ]);
  341. /** `#include "local/header"` — captured from RAW source (string contents survive). */
  342. const INCLUDE_RE = /#[ \t]*include[ \t]+"([^"\n]+)"/g;
  343. /** Included files worth scanning for registration tables (e.g. a generated `.def`). */
  344. const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i;
  345. /** `#define NAME single_identifier` (possibly `struct`-prefixed) — an
  346. * object-macro that COULD alias a struct type name (`resolveTypeName`'s exact
  347. * value shape). The extraction sweep collects every such NAME into a global
  348. * set: an initializer type token that direct-misses the struct layouts still
  349. * survives the registration filter when it is alias-SHAPED anywhere, so the
  350. * per-file macro-env alias resolution (redis' `COMMAND_STRUCT`) keeps working
  351. * without retaining per-file object-macro tables (6.1M `#define`s on the
  352. * Linux tree — the amdgpu register headers — rule that out). Numeric values
  353. * are excluded: `resolveTypeName` would rewrite to a dead-end token that can
  354. * never name a struct, so skipping them is exact, and it drops the register
  355. * flood. */
  356. const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm;
  357. /** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that
  358. * has ≥1 fn-pointer field. Handles both single (`= {…}`) and array
  359. * (`[] = { {…}, {…} }`) forms. Macro calls inside an element are expanded first. */
  360. const INIT_RE =
  361. /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:(?:struct|union)\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g;
  362. /** `struct TAG { … } var[opt] [= {…}]` — the struct is defined INLINE with the
  363. * table (vim's `cmdname`/`nv_cmd`); its layout never became a node, so parse it
  364. * here and register it before reading the entries. No leading anchor: a
  365. * `struct TAG {` with a brace body is always a definition (it may be preceded
  366. * by a `#define …` line ending in a digit, as in vim), and the trailing
  367. * `var … = {` check below is what distinguishes a TABLE from a plain type. */
  368. const INLINE_STRUCT_RE = /\b(?:struct|union)\s+(\w+)\s*\{/g;
  369. /** `(?:static …)* ELEMTYPE [*] name[…] = { … }` — a bare array of function
  370. * pointers (no struct wrapper). The optional `*` covers a function-TYPE
  371. * typedef element (`opcode_t *opcodes[]`); a function-pointer typedef element
  372. * (`zend_rc_dtor_func_t t[]`) needs none. The typedef-set membership gate
  373. * is what separates this from a plain data/struct array. */
  374. const ARRAY_TABLE_RE =
  375. /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(\w+)\s+(\*\s*)?(\w+)\s*\[[^\]]*\]\s*=\s*\{/g;
  376. /** Dispatch sites: `base->…->field(` or `base.…field(` where `field` is a known
  377. * fn-pointer field. The base may be a chain (`c->cmd->proc`) or carry array
  378. * subscripts (`cmdnames[i].cmd_func`). An optional `)` before the call covers
  379. * the parenthesized form `(cmdnames[i].cmd_func)(&ea)` vim uses. */
  380. const DISPATCH_RE = /((?:\w+(?:\s*\[[^\][]*\])?\s*(?:->|\.)\s*)+)(\w+)\s*\)?\s*\(/g;
  381. /** Bare-array dispatch: `tbl[i](…)` or the explicit-deref `(*tbl[i])(…)`. The
  382. * subscript may itself contain a call (`tbl[GC_TYPE(p)](…)`), so the index
  383. * class excludes only brackets. Precision comes from the `arrayReg` gate —
  384. * this fires only when `tbl` is a known fn-pointer array. */
  385. const ARRAY_DISPATCH_RE = /(?:\(\s*\*\s*)?\b(\w+)\s*\[[^\][]*\]\s*\)?\s*\(/g;
  386. /** Field←field propagation sites: `a->f = b->g`. */
  387. const FIELD_ASSIGN_RE = /(\w+)\s*(?:->|\.)\s*(\w+)\s*=\s*(\w+)\s*(?:->|\.)\s*(\w+)/g;
  388. /** Per-file facts the extraction sweep leaves behind for the linking stages.
  389. * Everything here is a SURVIVAL FILTER (over-approximate by construction —
  390. * collected with full-file, no-skip scans that match a superset of what the
  391. * original pass bodies can act on) except `includes`, which is exact. */
  392. interface FileFacts {
  393. /** Distinct `INIT_RE` type tokens (registration filter). */
  394. initTokens: string[] | null;
  395. /** Distinct `ARRAY_TABLE_RE` element types, `*`-prefixed when the decl has
  396. * the pointer star (registration filter). */
  397. arrayElems: string[] | null;
  398. /** Any inline-struct candidate with a `(*name)(…)` field (registration filter). */
  399. inlinePtr: boolean;
  400. /** Field type tokens across inline-struct candidates (registration filter —
  401. * fn-pointer-ness via typedef is only decidable once the sweep completes). */
  402. inlineTypes: string[] | null;
  403. /** Distinct `FIELD_ASSIGN_RE` `lfield\0rfield` pairs (propagation filter). */
  404. dPairs: string[] | null;
  405. /** Distinct `DISPATCH_RE` field names (dispatch filter). */
  406. dispatchFields: string[] | null;
  407. /** Distinct `ARRAY_DISPATCH_RE` array names (dispatch filter). */
  408. arrayDispatchNames: string[] | null;
  409. /** Resolved local `#include` targets, in source order (exact, from raw text). */
  410. includes: string[];
  411. }
  412. const NO_INCLUDES: string[] = [];
  413. export async function cFnPointerDispatchEdges(
  414. _queries: QueryBuilder,
  415. ctx: ResolutionContext,
  416. onYield: MaybeYield,
  417. onFraction?: (fraction: number) => void
  418. ): Promise<Edge[]> {
  419. let scannedFiles = 0;
  420. const files = ctx.getAllFiles().filter((f) => C_CPP_EXT.test(f));
  421. if (files.length === 0) return [];
  422. // CODEGRAPH_SYNTH_TIMINGS sub-attribution: this pass is 86% of kernel-scale
  423. // synthesis (306s, §7a.2/§7a.3) — per-stage walls + read/strip accounting
  424. // name which stage and which cost class owns it. Post-refactor mapping:
  425. // A = extraction sweep, B = struct-layout linking, C = registration,
  426. // D = propagation, E = dispatch.
  427. const prof = process.env.CODEGRAPH_SYNTH_TIMINGS
  428. ? { A: 0, B: 0, C: 0, D: 0, E: 0, readMs: 0, readN: 0, stripMs: 0, stripN: 0, nodesMs: 0, nodesN: 0 }
  429. : null;
  430. // Within-pass progress: this is the pass that parks the "Linking dynamic
  431. // dispatch" bar on C-heavy repos, so it reports a real fraction of its
  432. // dominant work. `files` is swept once per stage loop below (extraction,
  433. // registration, propagation, dispatch), reported at the same per-16-files
  434. // cadence as the cooperative yield.
  435. const FILE_SWEEPS = 4;
  436. const tick = async (): Promise<void> => {
  437. if ((++scannedFiles & 15) === 0) {
  438. onFraction?.(scannedFiles / (files.length * FILE_SWEEPS));
  439. await onYield();
  440. }
  441. };
  442. // Cache raw + stripped source per file, LRU-BOUNDED. The old unbounded Maps
  443. // retained every C/C++ file's raw AND stripped text for the whole pass —
  444. // multiple GB on the Linux kernel, one of the two OOM culprits in #1212.
  445. // The extraction sweep reads sequentially; the linking stages re-request
  446. // only surviving files (plus include units), so access is near-sequential
  447. // and a small LRU hits; a miss just re-reads + re-strips.
  448. // Cache sizing is memory-budget-aware AND all-or-nothing (§7a.3 cFnPtr
  449. // round): a partial LRU is WORSE than useless for cyclic sweeps (a first
  450. // attempt sized ~61k against 63.8k files thrashed to a ~0% cross-sweep hit
  451. // rate). Hold every stripped file (~24KB each measured on the Linux tree)
  452. // only when 40% of the live memory budget covers it; otherwise keep the
  453. // within-stage-locality 128. When the big cache declines (the kernel), the
  454. // survival filters keep the linking stages' re-strips to a fraction of a
  455. // sweep. Slack over files.length: non-indexed includes (.def/.inc, generated
  456. // headers) join the working set mid-pass. Pass-scoped transient, freed on
  457. // return.
  458. const fullCacheCap = Math.ceil(files.length * 1.05) + 512;
  459. const cacheCap = memoryBudgetBytes() * 0.5 >= fullCacheCap * 24_576 ? fullCacheCap : 128;
  460. const rawCache = new LRUCache<string, string | null>(Math.min(cacheCap, 4096));
  461. const raw = (file: string): string | null => {
  462. if (rawCache.has(file)) return rawCache.get(file)!;
  463. const t0 = prof ? Date.now() : 0;
  464. const r = ctx.readFile(file);
  465. if (prof) { prof.readMs += Date.now() - t0; prof.readN++; }
  466. rawCache.set(file, r);
  467. return r;
  468. };
  469. const srcCache = new LRUCache<string, string>(cacheCap);
  470. const src = (file: string): string | null => {
  471. // A cached '' (empty or unreadable file) returns '' where the miss path
  472. // returns null for unreadable — every caller falsy-checks, so the two are
  473. // interchangeable.
  474. const hit = srcCache.get(file);
  475. if (hit !== undefined) return hit;
  476. const r = raw(file);
  477. const t0 = prof ? Date.now() : 0;
  478. const s = r == null ? '' : stripCommentsForRegex(r, 'c');
  479. if (prof) { prof.stripMs += Date.now() - t0; prof.stripN++; }
  480. srcCache.set(file, s);
  481. return r == null ? null : s;
  482. };
  483. // Resolve a quoted include relative to the includer's directory, then the
  484. // project root. Returns a project-root-relative path that exists on disk
  485. // (even if it was never indexed — e.g. redis' generated `commands.def`).
  486. const resolveInclude = (includer: string, inc: string): string | null => {
  487. const dir = path.posix.dirname(includer.replace(/\\/g, '/'));
  488. const cand = path.posix.normalize(path.posix.join(dir, inc));
  489. if (ctx.fileExists(cand)) return cand;
  490. if (ctx.fileExists(inc)) return inc;
  491. return null;
  492. };
  493. // Retained strings are interned through here. Regex captures off a big file
  494. // string are V8 sliced strings — retaining one pins the whole parent file
  495. // text, and the facts tables retain captures from EVERY file for the whole
  496. // pass. The Buffer round-trip forces a flat copy on first sight; repeats
  497. // (field names recur heavily) then share the one flat instance.
  498. const interned = new Map<string, string>();
  499. const intern = (x: string): string => {
  500. let f = interned.get(x);
  501. if (f === undefined) {
  502. f = Buffer.from(x, 'utf8').toString('utf8');
  503. interned.set(f, f);
  504. }
  505. return f;
  506. };
  507. // ---- Global tables the extraction sweep fills ----
  508. // fn-pointer: typedef RET (*NAME)(…) → a field `NAME f` is a fn ptr
  509. // fn-type: typedef RET NAME(params) → a field `NAME *f` is a fn ptr
  510. // The fn-type form is redis' command idiom: `typedef void redisCommandProc(client*)`
  511. // declared as `redisCommandProc *proc;`. Without this, `proc` reads as data.
  512. const fnPtrTypedefs = new Set<string>();
  513. const fnTypeTypedefs = new Set<string>();
  514. /** Struct node id → its structurally-parsed fields (classified + registered
  515. * in the linking stage, in kind-scan order). */
  516. const rawFieldsByNode = new Map<string, RawFieldDecl[]>();
  517. const factsByFile = new Map<string, FileFacts>();
  518. /** Every inline-struct candidate tag anywhere — an over-approximation of the
  519. * tags the registration stage can add to `structLayout` mid-stage, folded
  520. * into the registration filter's layout check. */
  521. const inlineTags = new Set<string>();
  522. /** Object-macro names with an alias-shaped value anywhere (see OBJ_ALIAS_RE). */
  523. const aliasNames = new Set<string>();
  524. // Parse a struct body (the text between its `{` and `}`) into ordered fields,
  525. // structure only — see RawFieldDecl for why classification is deferred.
  526. const parseStructFieldsRaw = (inner: string): RawFieldDecl[] => {
  527. const fields: RawFieldDecl[] = [];
  528. let idx = 0;
  529. for (const rawDecl of splitTopLevel(inner, ';')) {
  530. const decl = rawDecl.trim();
  531. if (!decl) continue;
  532. // A field decl can declare several names sharing a leading type:
  533. // `struct redisCommand *cmd, *lastcmd;`. Each declarator is its own
  534. // positional slot and carries that type (so `client.cmd → redisCommand`).
  535. const parts = splitTopLevel(decl, ',');
  536. const firstTyped = parts[0]!.match(/(\w+)\s+\**\s*(\w+)\s*$/);
  537. const sharedType = firstTyped ? firstTyped[1]! : '';
  538. for (let pi = 0; pi < parts.length; pi++) {
  539. const p = parts[pi]!.trim();
  540. let name: string | null = null;
  541. let type = '';
  542. let ptr = false;
  543. const pm = p.match(FNPTR_DECL_RE);
  544. if (pm) {
  545. name = pm[1]!; // `… (*name)(…)` — a function pointer
  546. ptr = true;
  547. } else if (pi === 0) {
  548. if (firstTyped) { name = firstTyped[2]!; type = sharedType; }
  549. } else {
  550. // a subsequent declarator: `*name` / `**name` / `name`
  551. const dm = p.match(/^\**\s*(\w+)/);
  552. if (dm) { name = dm[1]!; type = sharedType; }
  553. }
  554. // Always advance the positional index. An unparsed field (anonymous
  555. // union, exotic declarator) still occupies one slot, and macro-expanded
  556. // positional tables (redis' MAKE_CMD) only align if every field counts.
  557. fields.push({ name, index: idx, ptr, type });
  558. idx++;
  559. }
  560. }
  561. return fields;
  562. };
  563. // Classify deferred fields against the (now-complete) typedef sets.
  564. const classifyFields = (rawFields: RawFieldDecl[]): FieldInfo[] =>
  565. rawFields.map((f) => ({
  566. name: f.name ?? '',
  567. index: f.index,
  568. isFnPtr:
  569. !!f.name &&
  570. (f.ptr || (!!f.type && (fnPtrTypedefs.has(f.type) || fnTypeTypedefs.has(f.type)))),
  571. type: f.type,
  572. }));
  573. const parseStructFields = (inner: string): FieldInfo[] => classifyFields(parseStructFieldsRaw(inner));
  574. // Exact per-file include resolution (from RAW source — string contents survive).
  575. const scanIncludes = (file: string): string[] => {
  576. const rawText = raw(file);
  577. if (!rawText || !rawText.includes('include')) return NO_INCLUDES;
  578. const out: string[] = [];
  579. INCLUDE_RE.lastIndex = 0;
  580. let im: RegExpExecArray | null;
  581. while ((im = INCLUDE_RE.exec(rawText))) {
  582. if (!INCLUDABLE_EXT.test(im[1]!)) continue;
  583. const t = resolveInclude(file, im[1]!);
  584. if (t) out.push(intern(t));
  585. }
  586. return out.length ? out : NO_INCLUDES;
  587. };
  588. // Indexed files answer from their facts; non-indexed includes (reached by
  589. // buildEnv's depth-2 recursion) fall back to a bounded lazy scan.
  590. const includeCache = new LRUCache<string, string[]>(1024);
  591. const localIncludesOf = (file: string): string[] => {
  592. const f = factsByFile.get(file);
  593. if (f) return f.includes;
  594. let out = includeCache.get(file);
  595. if (out) return out;
  596. out = scanIncludes(file);
  597. includeCache.set(file, out);
  598. return out;
  599. };
  600. // ---- Stage A: the extraction sweep — ONE read + strip per file ----
  601. //
  602. // Two implementations, record-identical by the differential suite:
  603. // • native (task #5 step 2): the kernel's `cfnptrScanFiles` strips and
  604. // scans a BATCH of files per NAPI call (codegraph-kernel/src/cfnptr.rs —
  605. // hand-rolled byte machines replicating the JS regex semantics), and the
  606. // TS side only reads files, ships batches, and interns the returned
  607. // facts. Include-path resolution stays here (it needs the filesystem).
  608. // • JS: the original sweep, kept verbatim — the fallback for platforms
  609. // without a kernel binary, older binaries (feature detection), the
  610. // CODEGRAPH_KERNEL=0 kill switch, and CODEGRAPH_KERNEL_CFNPTR=0 (this
  611. // scanner's own switch).
  612. const kernel =
  613. process.env.CODEGRAPH_KERNEL === '0' || process.env.CODEGRAPH_KERNEL_CFNPTR === '0'
  614. ? null
  615. : getKernel();
  616. const nativeSweep =
  617. kernel && typeof kernel.cfnptrScanFiles === 'function' ? kernel.cfnptrScanFiles.bind(kernel) : null;
  618. const mergeNativeFacts = (file: string, out: CfnptrFactsOut): void => {
  619. for (const t of out.fnPtrTypedefs) fnPtrTypedefs.add(intern(t));
  620. for (const t of out.fnTypeTypedefs) fnTypeTypedefs.add(intern(t));
  621. for (const so of out.structs) {
  622. if (!so.parsed) continue; // body never parsed — the JS sweep records nothing either
  623. rawFieldsByNode.set(
  624. so.id,
  625. so.fields.map((f) => ({ name: f.name || null, index: f.index, ptr: f.ptr, type: f.type }))
  626. );
  627. }
  628. for (const t of out.inlineTags) inlineTags.add(intern(t));
  629. for (const t of out.aliasNames) aliasNames.add(intern(t));
  630. const includes: string[] = [];
  631. for (const cap of out.includes) {
  632. if (!INCLUDABLE_EXT.test(cap)) continue;
  633. const t = resolveInclude(file, cap);
  634. if (t) includes.push(intern(t));
  635. }
  636. if (
  637. out.initTokens.length || out.arrayElems.length || out.inlinePtr || out.inlineTypes.length ||
  638. out.dPairs.length || out.dispatchFields.length || out.arrayDispatchNames.length || includes.length
  639. ) {
  640. factsByFile.set(file, {
  641. initTokens: out.initTokens.length ? out.initTokens.map(intern) : null,
  642. arrayElems: out.arrayElems.length ? out.arrayElems.map(intern) : null,
  643. inlinePtr: out.inlinePtr,
  644. inlineTypes: out.inlineTypes.length ? out.inlineTypes.map(intern) : null,
  645. dPairs: out.dPairs.length ? out.dPairs.map(intern) : null,
  646. dispatchFields: out.dispatchFields.length ? out.dispatchFields.map(intern) : null,
  647. arrayDispatchNames: out.arrayDispatchNames.length ? out.arrayDispatchNames.map(intern) : null,
  648. includes: includes.length ? includes : NO_INCLUDES,
  649. });
  650. }
  651. };
  652. let tPass = Date.now();
  653. if (nativeSweep) {
  654. // Batch of 16 = the tick/onFraction cadence, so yielding and progress
  655. // reporting keep their shape while the boundary crossing amortizes.
  656. const BATCH = 16;
  657. let batch: { file: string; input: CfnptrFileIn }[] = [];
  658. const flush = (): void => {
  659. if (batch.length === 0) return;
  660. const outs = nativeSweep(batch.map((b) => b.input));
  661. for (let bi = 0; bi < batch.length; bi++) mergeNativeFacts(batch[bi]!.file, outs[bi]!);
  662. batch = [];
  663. };
  664. for (const file of files) {
  665. await tick();
  666. const rawText = raw(file);
  667. if (!rawText) continue; // unreadable or empty — the JS sweep skips these too
  668. const tN = prof ? Date.now() : 0;
  669. const fileNodes = ctx.getNodesInFile(file);
  670. if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; }
  671. const structs: CfnptrFileIn['structs'] = [];
  672. for (const st of fileNodes) {
  673. if (st.kind !== 'struct' && st.kind !== 'union') continue;
  674. // sliceLinesPre semantics ride along: falsy startLine never parses,
  675. // and `endLine ?? startLine` is applied here so the kernel sees the
  676. // exact slice bounds the JS sweep would use.
  677. structs.push({ id: st.id, startLine: st.startLine ?? 0, endLine: st.endLine ?? st.startLine ?? 0 });
  678. }
  679. batch.push({ file, input: { text: rawText, structs } });
  680. if (batch.length >= BATCH) flush();
  681. }
  682. flush();
  683. }
  684. // JS sweep (fallback path — see the stage comment above).
  685. if (!nativeSweep) for (const file of files) {
  686. await tick();
  687. const s = src(file);
  688. if (!s) continue;
  689. // Typedefs (cross-file).
  690. if (s.includes('typedef')) {
  691. FNPTR_TYPEDEF_RE.lastIndex = 0;
  692. let m: RegExpExecArray | null;
  693. while ((m = FNPTR_TYPEDEF_RE.exec(s))) fnPtrTypedefs.add(intern(m[1]!));
  694. FNTYPE_TYPEDEF_STMT_RE.lastIndex = 0;
  695. while ((m = FNTYPE_TYPEDEF_STMT_RE.exec(s))) {
  696. const guts = m[1]!;
  697. if (guts.includes('(*') || guts.includes('( *')) continue; // pointer form — handled above
  698. const fm = guts.match(/\b(\w+)\s*\(/); // last identifier before the param list
  699. if (fm && !C_TYPE_KEYWORDS.has(fm[1]!)) fnTypeTypedefs.add(intern(fm[1]!));
  700. }
  701. }
  702. // Struct-node field declarations (registered later in kind-scan order).
  703. const tN = prof ? Date.now() : 0;
  704. const fileNodes = ctx.getNodesInFile(file);
  705. if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; }
  706. let lines: string[] | null = null;
  707. for (const st of fileNodes) {
  708. if (st.kind !== 'struct' && st.kind !== 'union') continue;
  709. lines ??= s.split('\n');
  710. const body = sliceLinesPre(lines, st.startLine, st.endLine);
  711. const open = body.indexOf('{');
  712. const close = open >= 0 ? matchBrace(body, open) : -1;
  713. if (open < 0 || close < 0) continue;
  714. rawFieldsByNode.set(st.id, parseStructFieldsRaw(body.slice(open + 1, close)));
  715. }
  716. // Registration filters. These are full-file, NO-SKIP scans: the original
  717. // registration pass jumps its scan cursor past a processed initializer
  718. // body, so a no-skip scan finds a SUPERSET of its matches — exactly the
  719. // over-approximation the filter needs.
  720. const initTokens = new Set<string>();
  721. const arrayElems = new Set<string>();
  722. const inlineTypes = new Set<string>();
  723. let inlinePtr = false;
  724. if (s.includes('{')) {
  725. INLINE_STRUCT_RE.lastIndex = 0;
  726. let im: RegExpExecArray | null;
  727. while ((im = INLINE_STRUCT_RE.exec(s))) {
  728. const sOpen = im.index + im[0].length - 1;
  729. const sClose = matchBrace(s, sOpen);
  730. if (sClose < 0) continue;
  731. // After `}`, expect `var [opt] [= {…}]` to be a table candidate.
  732. const vm = s.slice(sClose + 1).match(/^\s*(\w+)\s*(\[[^\]]*\])?\s*(=\s*\{)?/);
  733. if (!vm || !vm[1]) continue;
  734. inlineTags.add(intern(im[1]!));
  735. for (const f of parseStructFieldsRaw(s.slice(sOpen + 1, sClose))) {
  736. if (!f.name) continue;
  737. if (f.ptr) inlinePtr = true;
  738. else if (f.type) inlineTypes.add(intern(f.type));
  739. }
  740. }
  741. if (s.includes('=')) {
  742. INIT_RE.lastIndex = 0;
  743. let m: RegExpExecArray | null;
  744. while ((m = INIT_RE.exec(s))) initTokens.add(intern(m[1]!));
  745. ARRAY_TABLE_RE.lastIndex = 0;
  746. while ((m = ARRAY_TABLE_RE.exec(s))) arrayElems.add(intern((m[2] ? '*' : '') + m[1]!));
  747. }
  748. }
  749. // Alias-shaped object macros (registration filter support).
  750. if (s.includes('#define') || s.includes('# define')) {
  751. const joined = s.replace(/\\\r?\n/g, ' ');
  752. OBJ_ALIAS_RE.lastIndex = 0;
  753. let m: RegExpExecArray | null;
  754. while ((m = OBJ_ALIAS_RE.exec(joined))) aliasNames.add(intern(m[1]!));
  755. }
  756. // Propagation + dispatch filters (full-file scans ⊇ the per-function-body
  757. // scans the pass bodies run — a body slice is a substring of the file).
  758. const dPairs = new Set<string>();
  759. if (s.includes('=')) {
  760. FIELD_ASSIGN_RE.lastIndex = 0;
  761. let m: RegExpExecArray | null;
  762. while ((m = FIELD_ASSIGN_RE.exec(s))) dPairs.add(intern(m[2]! + '\0' + m[4]!));
  763. }
  764. const dispatchFields = new Set<string>();
  765. const arrayNames = new Set<string>();
  766. DISPATCH_RE.lastIndex = 0;
  767. let dm: RegExpExecArray | null;
  768. while ((dm = DISPATCH_RE.exec(s))) dispatchFields.add(intern(dm[2]!));
  769. ARRAY_DISPATCH_RE.lastIndex = 0;
  770. while ((dm = ARRAY_DISPATCH_RE.exec(s))) arrayNames.add(intern(dm[1]!));
  771. const includes = scanIncludes(file);
  772. if (
  773. initTokens.size || arrayElems.size || inlinePtr || inlineTypes.size ||
  774. dPairs.size || dispatchFields.size || arrayNames.size || includes.length
  775. ) {
  776. factsByFile.set(file, {
  777. initTokens: initTokens.size ? [...initTokens] : null,
  778. arrayElems: arrayElems.size ? [...arrayElems] : null,
  779. inlinePtr,
  780. inlineTypes: inlineTypes.size ? [...inlineTypes] : null,
  781. dPairs: dPairs.size ? [...dPairs] : null,
  782. dispatchFields: dispatchFields.size ? [...dispatchFields] : null,
  783. arrayDispatchNames: arrayNames.size ? [...arrayNames] : null,
  784. includes,
  785. });
  786. }
  787. }
  788. if (prof) { prof.A = Date.now() - tPass; tPass = Date.now(); }
  789. // ---- Stage B: struct field layouts (linking — text-free) ----
  790. // structLayout: struct name → ordered fields, for structs with ≥1 fn-pointer
  791. // field (drives positional registration + dispatch).
  792. // allStructFields: EVERY struct name → ALL its field layouts (a name can be
  793. // reused across files — e.g. redis has two unrelated `client` structs), used
  794. // to walk a chained receiver's field types (`c->cmd->proc`: client.cmd →
  795. // redisCommand). The walk searches every same-named layout for the field.
  796. // fieldToStructs: fn-pointer field name → set of struct names that declare it.
  797. // Registration REPLAYS the struct kind-scan (rowid order, ≠ the extraction
  798. // sweep's path order): same-name layout precedence — `structLayout.set`
  799. // last-wins, `allStructFields` first-match in the chain walk — depends on it.
  800. const structLayout = new Map<string, FieldInfo[]>();
  801. const allStructFields = new Map<string, FieldInfo[][]>();
  802. const fieldToStructs = new Map<string, Set<string>>();
  803. // Register a parsed struct under `name` into the three indexes.
  804. const registerStructLayout = (name: string, fields: FieldInfo[]): void => {
  805. if (!allStructFields.has(name)) allStructFields.set(name, []);
  806. allStructFields.get(name)!.push(fields);
  807. for (const f of fields) {
  808. if (f.name && f.isFnPtr) {
  809. if (!fieldToStructs.has(f.name)) fieldToStructs.set(f.name, new Set());
  810. fieldToStructs.get(f.name)!.add(name);
  811. }
  812. }
  813. if (fields.some((f) => f.isFnPtr)) structLayout.set(name, fields);
  814. };
  815. for (const kind of ['struct', 'union'] as const) {
  816. for (const st of (ctx.iterateNodesByKind?.(kind) ?? ctx.getNodesByKind(kind))) {
  817. if ((++scannedFiles & 255) === 0) await onYield();
  818. if (!C_CPP_EXT.test(st.filePath)) continue;
  819. const rawFields = rawFieldsByNode.get(st.id);
  820. if (!rawFields) continue; // file unreadable or body unparsable at sweep time — the old pass skipped it too
  821. registerStructLayout(st.name, classifyFields(rawFields));
  822. }
  823. }
  824. rawFieldsByNode.clear();
  825. if (prof) { prof.B = Date.now() - tPass; tPass = Date.now(); }
  826. // NB: no early return on an empty structLayout here — an inline `struct TAG
  827. // { … } var[]` table whose struct never became a node (vim's `cmdname`, broken
  828. // up by `#ifdef`) is discovered later during the unit scan. The `reg.size === 0`
  829. // guard after registration still short-circuits when nothing bridges.
  830. const fnPtrFieldOf = (struct: string, field: string): boolean =>
  831. !!structLayout.get(struct)?.some((f) => f.name === field && f.isFnPtr);
  832. // C/C++ function + method nodes are STREAMED per stage (see D/E) —
  833. // the old materialized `cFns` array held every function node on the repo
  834. // (O(nodes) memory, part of the #1212 kernel OOM).
  835. // ---- function-name → node resolution (prefer a function in the same file) ----
  836. const resolveFn = (name: string, preferFile?: string): Node | null => {
  837. const cands = ctx.getNodesByName(name).filter((n) => FN_KINDS.has(n.kind));
  838. if (cands.length === 0) return null;
  839. if (cands.length === 1) return cands[0]!;
  840. if (preferFile) {
  841. const same = cands.find((n) => n.filePath === preferFile);
  842. if (same) return same;
  843. }
  844. return cands[0]!;
  845. };
  846. // ---- Stage C: registrations — Map<"struct.field", Set<funcNodeId>> ----
  847. // Ids only — retaining the full Node per registration (the old `idToNode`)
  848. // was write-only dead weight at O(registrations) memory.
  849. const reg = new Map<string, Set<string>>();
  850. const addReg = (struct: string, field: string, fn: Node): void => {
  851. const key = `${struct}.${field}`;
  852. if (!reg.has(key)) reg.set(key, new Set());
  853. reg.get(key)!.add(fn.id);
  854. };
  855. // Bare arrays-of-fn-pointers (no struct): array VARIABLE name → per-file sets
  856. // of registered function ids. Multi-entry because a file-scope `static` table
  857. // name can recur across files (SameBoy declares `static opcode_t *opcodes[256]`
  858. // in BOTH sm83_cpu.c and sm83_disassembler.c), so dispatch resolves same-file.
  859. const arrayReg = new Map<string, { file: string; ids: Set<string> }[]>();
  860. const addArrayReg = (name: string, file: string, fn: Node): void => {
  861. let entries = arrayReg.get(name);
  862. if (!entries) { entries = []; arrayReg.set(name, entries); }
  863. let e = entries.find((x) => x.file === file);
  864. if (!e) { e = { file, ids: new Set() }; entries.push(e); }
  865. e.ids.add(fn.id);
  866. };
  867. // A struct value `{ … }` (one element) — register its function entries to the
  868. // struct's fields, by `.field = fn` designators or by positional slot.
  869. const registerStructValue = (
  870. struct: string,
  871. valueBody: string,
  872. file: string,
  873. env?: Map<string, MacroDef>,
  874. ): void => {
  875. const layout = structLayout.get(struct);
  876. if (!layout) return;
  877. if (env && env.size) valueBody = expandMacroCalls(valueBody, env);
  878. // A macro can expand to a whole brace-wrapped element (sqlite's
  879. // `FUNCTION(…)` → `{nArg, …, xFunc, …}`); peel one outer layer so the
  880. // positional slots are visible.
  881. valueBody = valueBody.trim();
  882. if (valueBody.startsWith('{')) {
  883. const e = matchBrace(valueBody, 0);
  884. if (e > 0 && valueBody.slice(e + 1).trim() === '') valueBody = valueBody.slice(1, e);
  885. }
  886. const items = splitTopLevel(valueBody, ',');
  887. let pos = 0;
  888. for (const rawItem of items) {
  889. const item = rawItem.trim();
  890. if (!item) continue;
  891. const des = item.match(/^\.\s*(\w+)\s*=\s*(?:&\s*)?(\w+)\s*$/);
  892. if (des) {
  893. const field = des[1]!;
  894. if (fnPtrFieldOf(struct, field)) {
  895. const fn = resolveFn(des[2]!, file);
  896. if (fn) addReg(struct, field, fn);
  897. }
  898. // a designated item does not advance positional counting
  899. continue;
  900. }
  901. const field = layout.find((f) => f.index === pos);
  902. if (field?.isFnPtr) {
  903. const id = item.match(/^&?\s*(\w+)\s*$/);
  904. if (id) {
  905. const fn = resolveFn(id[1]!, file);
  906. if (fn) addReg(struct, field.name, fn);
  907. }
  908. }
  909. pos++;
  910. }
  911. };
  912. // Collect the literal function entries of an array-of-fn-pointers initializer
  913. // and register them under the array's variable name. Entries may be positional
  914. // (`fn`, `&fn`), designated by index (`[OP] = fn`), or cast-wrapped
  915. // (`(handler_t)fn`, as in php's Zend dtor table). Non-identifier entries
  916. // (`NULL`, `0`, a nested expression) are skipped — a miss, never a wrong edge.
  917. // No index tracking: a runtime subscript fans the dispatch out to the whole
  918. // set, exactly like a command table reaches every command.
  919. const registerArrayValue = (
  920. name: string,
  921. body: string,
  922. file: string,
  923. env?: Map<string, MacroDef>,
  924. ): void => {
  925. if (env && env.size) body = expandMacroCalls(body, env);
  926. for (const rawItem of splitTopLevel(body, ',')) {
  927. let item = rawItem.trim();
  928. if (!item) continue;
  929. const des = item.match(/^\[[^\]]*\]\s*=\s*([\s\S]*)$/); // `[IDX] = …` designator
  930. if (des) item = des[1]!.trim();
  931. item = item.replace(/^\((?:[\w\s*]+)\)\s*/, '').replace(/^&\s*/, '').trim(); // (cast) / &
  932. const id = item.match(/^(\w+)$/);
  933. if (!id) continue;
  934. const fn = resolveFn(id[1]!, file);
  935. if (fn) addArrayReg(name, file, fn);
  936. }
  937. };
  938. // Per-file macro + include parsing (any file, indexed or not), cached.
  939. // Derived per-file caches, LRU-bounded like the content caches (#1212).
  940. // These stay LAZY (recompute-on-miss through `src`): retaining every file's
  941. // parsed tables is ruled out by the kernel's 6.1M `#define`s, and the
  942. // registration stage below only builds an env for files that survive its
  943. // filter or carry local includes, so most files never need one.
  944. const fnMacroCache = new LRUCache<string, Map<string, MacroDef>>(256);
  945. const fileFnMacros = (file: string): Map<string, MacroDef> => {
  946. let m = fnMacroCache.get(file);
  947. if (!m) { m = parseFunctionMacros(src(file) ?? ''); fnMacroCache.set(file, m); }
  948. return m;
  949. };
  950. const objMacroCache = new LRUCache<string, Map<string, string>>(256);
  951. const fileObjMacros = (file: string): Map<string, string> => {
  952. let m = objMacroCache.get(file);
  953. if (!m) { m = parseObjectMacros(src(file) ?? ''); objMacroCache.set(file, m); }
  954. return m;
  955. };
  956. const definedCache = new LRUCache<string, Set<string>>(256);
  957. const fileDefinedNames = (file: string): Set<string> => {
  958. let d = definedCache.get(file);
  959. if (!d) { d = parseDefinedNames(src(file) ?? ''); definedCache.set(file, d); }
  960. return d;
  961. };
  962. // A file's effective macro environment = its own #defines PLUS those of the
  963. // headers it #includes (redis' `MAKE_CMD` sits beside the table; sqlite's
  964. // `FUNCTION` lives in `sqliteInt.h`, included by the file with the table).
  965. // First writer wins, so the file's own defs override included ones; depth-2
  966. // covers a macro defined in a header-of-a-header.
  967. const buildEnv = (
  968. file: string,
  969. depth: number,
  970. seen: Set<string>,
  971. fn: Map<string, MacroDef>,
  972. obj: Map<string, string>,
  973. def: Set<string>,
  974. ): void => {
  975. if (depth < 0 || seen.has(file)) return;
  976. seen.add(file);
  977. for (const [k, v] of fileFnMacros(file)) if (!fn.has(k)) fn.set(k, v);
  978. for (const [k, v] of fileObjMacros(file)) if (!obj.has(k)) obj.set(k, v);
  979. for (const n of fileDefinedNames(file)) def.add(n);
  980. for (const inc of localIncludesOf(file)) buildEnv(inc, depth - 1, seen, fn, obj, def);
  981. };
  982. // Registration units: every indexed C file, plus the local headers/tables it
  983. // `#include`s. A non-indexed include (redis' generated `commands.def`) is
  984. // always scanned; an INDEXED header is re-scanned in an includer's context
  985. // ONLY when that includer switches on conditional code the header guards — it
  986. // `#define`s a name the header itself doesn't and the header has `#if` (vim's
  987. // `ex_cmds.h`, whose command table is behind `#ifdef DO_DECLARE_EXCMD` set by
  988. // `ex_docmd.c`). The include is scanned with the includer's effective macro
  989. // env (its `MAKE_CMD(…)` resolves there) and its conditionals evaluated
  990. // against the includer's defined set. `reg` is a Set, so unioning across
  991. // multiple includers is safe.
  992. interface Unit {
  993. text: string;
  994. file: string;
  995. env: Map<string, MacroDef>;
  996. objEnv: Map<string, string>;
  997. }
  998. const indexedSet = new Set(files);
  999. const seenInclude = new Set<string>();
  1000. // Global variable → struct type, for resolving a dispatch through a file-scope
  1001. // table by subscript (`cmdnames[i].cmd_func(…)`).
  1002. const globalVarType = new Map<string, string>();
  1003. // Process a `{ … }` initializer body (array of elements or a single struct).
  1004. const processInit = (
  1005. struct: string,
  1006. body: string,
  1007. isArray: boolean,
  1008. file: string,
  1009. env: Map<string, MacroDef>,
  1010. ): void => {
  1011. if (isArray) {
  1012. for (const el of splitTopLevel(body, ',')) {
  1013. const t = el.trim();
  1014. if (t.startsWith('{')) {
  1015. const e = matchBrace(t, 0);
  1016. if (e > 0) registerStructValue(struct, t.slice(1, e), file, env);
  1017. } else if (t) {
  1018. // an element built by a macro (`MAKE_CMD(…)`/`FUNCTION(…)`) or a bare value
  1019. registerStructValue(struct, t, file, env);
  1020. }
  1021. }
  1022. } else {
  1023. registerStructValue(struct, body, file, env);
  1024. }
  1025. };
  1026. // Process ONE unit's text and discard it. The old shape built every unit up
  1027. // front (`const units: Unit[]`) — the full text of every C file plus its
  1028. // expanded includes held simultaneously, gigabytes on the kernel (#1212).
  1029. const processUnit = (unit: Unit): void => {
  1030. const s = unit.text;
  1031. if (!s || !s.includes('{')) return;
  1032. INLINE_STRUCT_RE.lastIndex = 0;
  1033. let im: RegExpExecArray | null;
  1034. while ((im = INLINE_STRUCT_RE.exec(s))) {
  1035. const tag = im[1]!;
  1036. const sOpen = im.index + im[0].length - 1; // the struct body's `{`
  1037. const sClose = matchBrace(s, sOpen);
  1038. if (sClose < 0) continue;
  1039. // After `}`, expect `var [opt] [= {…}]` to be a table; else it's a plain type.
  1040. const after = s.slice(sClose + 1);
  1041. const vm = after.match(/^\s*(\w+)\s*(\[[^\]]*\])?\s*(=\s*\{)?/);
  1042. if (!vm || !vm[1]) continue;
  1043. const fields = parseStructFields(s.slice(sOpen + 1, sClose));
  1044. if (!fields.some((f) => f.isFnPtr)) continue; // only tables of fn pointers matter
  1045. if (!structLayout.has(tag)) registerStructLayout(tag, fields);
  1046. globalVarType.set(vm[1]!, tag);
  1047. if (vm[3]) {
  1048. const aOpen = sClose + 1 + after.indexOf('{', vm[0].length - 1);
  1049. const aClose = matchBrace(s, aOpen);
  1050. if (aClose > 0) {
  1051. processInit(tag, s.slice(aOpen + 1, aClose), !!vm[2], unit.file, unit.env);
  1052. INLINE_STRUCT_RE.lastIndex = aClose;
  1053. }
  1054. }
  1055. }
  1056. if (!s.includes('=')) return;
  1057. INIT_RE.lastIndex = 0;
  1058. let m: RegExpExecArray | null;
  1059. while ((m = INIT_RE.exec(s))) {
  1060. let struct = m[1]!;
  1061. if (!structLayout.has(struct)) struct = resolveTypeName(struct, unit.objEnv);
  1062. if (!structLayout.has(struct)) continue;
  1063. const isArray = !!m[3];
  1064. const open = m.index + m[0].length - 1; // points at the `{`
  1065. const close = matchBrace(s, open);
  1066. if (close < 0) continue;
  1067. globalVarType.set(m[2]!, struct);
  1068. processInit(struct, s.slice(open + 1, close), isArray, unit.file, unit.env);
  1069. INIT_RE.lastIndex = close;
  1070. }
  1071. // Bare arrays-of-function-pointers (no struct, no field). Gated on the
  1072. // element type being a function typedef — a fn-TYPE typedef needs the `*`
  1073. // (array of pointers to it), a fn-pointer typedef does not. A data or
  1074. // struct array's element type is never in these sets, so it never fires.
  1075. ARRAY_TABLE_RE.lastIndex = 0;
  1076. let am: RegExpExecArray | null;
  1077. while ((am = ARRAY_TABLE_RE.exec(s))) {
  1078. const elemType = am[1]!;
  1079. const hasStar = !!am[2];
  1080. if (!((fnTypeTypedefs.has(elemType) && hasStar) || fnPtrTypedefs.has(elemType))) continue;
  1081. const open = am.index + am[0].length - 1; // the `{`
  1082. const close = matchBrace(s, open);
  1083. if (close < 0) continue;
  1084. registerArrayValue(am[3]!, s.slice(open + 1, close), unit.file, unit.env);
  1085. ARRAY_TABLE_RE.lastIndex = close;
  1086. }
  1087. };
  1088. // Can this file's OWN unit have any side effect? Every check mirrors a gate
  1089. // in processUnit, over-approximated to the filter's coarser knowledge:
  1090. // • inline structs — the fn-ptr-field gate, with per-candidate field types
  1091. // unioned per file;
  1092. // • initializers — `structLayout.has` against the layouts' SUPERSET
  1093. // (kind-scan layouts ∪ every inline tag — structLayout only grows during
  1094. // this stage), with alias-shaped tokens surviving in place of the
  1095. // per-file `resolveTypeName` walk;
  1096. // • bare arrays — the exact typedef-set gate.
  1097. // A filtered-out file is one where every match fails its gate before any
  1098. // side effect, so skipping the unit cannot change the outcome.
  1099. const typedefHit = (t: string): boolean => fnPtrTypedefs.has(t) || fnTypeTypedefs.has(t);
  1100. const regSurvives = (f: FileFacts): boolean =>
  1101. f.inlinePtr ||
  1102. (f.inlineTypes?.some(typedefHit) ?? false) ||
  1103. (f.initTokens?.some((t) => structLayout.has(t) || inlineTags.has(t) || aliasNames.has(t)) ?? false) ||
  1104. (f.arrayElems?.some((e) =>
  1105. e.charCodeAt(0) === 42 /* '*' */ ? typedefHit(e.slice(1)) : fnPtrTypedefs.has(e)
  1106. ) ?? false);
  1107. // ---- Stage C: registrations — stream each surviving file (and every file's
  1108. // qualifying local includes) through processUnit, one at a time.
  1109. for (const file of files) {
  1110. await tick();
  1111. const facts = factsByFile.get(file);
  1112. if (!facts) continue; // no facts ⇒ nothing matched at sweep time ⇒ the old pass would no-op here
  1113. const survives = regSurvives(facts);
  1114. if (!survives && facts.includes.length === 0) continue;
  1115. const env = new Map<string, MacroDef>();
  1116. const objEnv = new Map<string, string>();
  1117. const defined = new Set<string>();
  1118. buildEnv(file, 2, new Set(), env, objEnv, defined);
  1119. if (survives) {
  1120. const s = src(file);
  1121. if (s) processUnit({ text: s, file, env, objEnv });
  1122. }
  1123. for (const target of facts.includes) {
  1124. if (seenInclude.has(`${file}>${target}`)) continue;
  1125. const incSrc = src(target);
  1126. if (!incSrc) continue;
  1127. if (indexedSet.has(target)) {
  1128. // Re-scan an indexed header only when this includer unlocks guarded code.
  1129. const ownDef = fileDefinedNames(target);
  1130. const adds = [...defined].some((n) => !ownDef.has(n));
  1131. if (!adds || !/#\s*if/.test(incSrc)) continue;
  1132. }
  1133. seenInclude.add(`${file}>${target}`);
  1134. // The include is pasted into the includer — evaluate its conditionals in
  1135. // the includer's defined set (a no-op when it has none). Re-parse the
  1136. // included file's OWN macros from that resolved text so a macro it defines
  1137. // conditionally (vim's `EXCMD`, whose plain last-wins parse picks the enum
  1138. // arm) overrides with the ARM THAT IS ACTUALLY ACTIVE here.
  1139. const text = evalConditionals(incSrc, defined);
  1140. const incEnv = new Map(env);
  1141. for (const [k, v] of parseFunctionMacros(text)) incEnv.set(k, v);
  1142. const incObjEnv = new Map(objEnv);
  1143. for (const [k, v] of parseObjectMacros(text)) incObjEnv.set(k, v);
  1144. processUnit({ text, file: target, env: incEnv, objEnv: incObjEnv });
  1145. }
  1146. }
  1147. if (prof) { prof.C = Date.now() - tPass; tPass = Date.now(); }
  1148. // ---- receiver-type resolution within a function's source ----
  1149. // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known
  1150. // fn-pointer-bearing struct).
  1151. const recvReCache = new LRUCache<string, RegExp>(4096);
  1152. const recvTypeIn = (fnSrc: string, recv: string): string | null => {
  1153. let re = recvReCache.get(recv);
  1154. if (!re) {
  1155. re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g');
  1156. recvReCache.set(recv, re);
  1157. }
  1158. re.lastIndex = 0;
  1159. let m: RegExpExecArray | null;
  1160. while ((m = re.exec(fnSrc))) {
  1161. if (structLayout.has(m[1]!)) return m[1]!;
  1162. }
  1163. return null;
  1164. };
  1165. // Declared type of a local/param `v` — ANY type token, not just fn-pointer
  1166. // structs (the base of a chained receiver needn't carry a fn pointer itself).
  1167. // Falls back to a file-scope table variable (`cmdnames` in `cmdnames[i].fn()`).
  1168. const escapeRe = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1169. const varReCache = new LRUCache<string, RegExp>(4096);
  1170. const varTypeIn = (fnSrc: string, v: string): string | null => {
  1171. let re = varReCache.get(v);
  1172. if (!re) {
  1173. re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g');
  1174. varReCache.set(v, re);
  1175. }
  1176. re.lastIndex = 0;
  1177. let m: RegExpExecArray | null;
  1178. while ((m = re.exec(fnSrc))) {
  1179. if (!C_TYPE_KEYWORDS.has(m[1]!)) return m[1]!;
  1180. }
  1181. return globalVarType.get(v) ?? null;
  1182. };
  1183. // Resolve a member-access chain (`c->cmd`, or just `p`) to a struct type,
  1184. // walking each segment's declared field type. `c->cmd->proc` dispatch:
  1185. // base chain `c->cmd` → client.cmd's type `redisCommand`, the proc owner.
  1186. // Array subscripts (`cmdnames[i]`) are stripped — an index yields one element.
  1187. const resolveChainType = (fnSrc: string, chain: string): string | null => {
  1188. const segs = chain.replace(/\s*\[[^\]]*\]/g, '').split(/\s*(?:->|\.)\s*/).filter(Boolean);
  1189. if (segs.length === 0) return null;
  1190. let t = varTypeIn(fnSrc, segs[0]!);
  1191. for (let i = 1; t && i < segs.length; i++) {
  1192. let next: string | null = null;
  1193. for (const fields of allStructFields.get(t) ?? []) {
  1194. const f = fields.find((fl) => fl.name === segs[i] && fl.type);
  1195. if (f) { next = f.type; break; }
  1196. }
  1197. t = next;
  1198. }
  1199. return t;
  1200. };
  1201. // ---- Stage D: field←field propagation (`a->f = b->g`) ----
  1202. // Collected as (targetStruct.field ← sourceStruct.field) pairs, then merged to
  1203. // a fixpoint so a hook slot inherits a registry field's handlers.
  1204. // Filter: a file matters only if SOME collected pair has BOTH fields known as
  1205. // fn-pointer fields — the loop body's own pre-gate. A skipped file's matches
  1206. // would all `continue` there, so skipping is side-effect-free.
  1207. const propagations: { to: string; from: string }[] = [];
  1208. for (const file of files) {
  1209. await tick();
  1210. const facts = factsByFile.get(file);
  1211. if (
  1212. !facts?.dPairs?.some((p) => {
  1213. const i = p.indexOf('\0');
  1214. return fieldToStructs.has(p.slice(0, i)) && fieldToStructs.has(p.slice(i + 1));
  1215. })
  1216. ) continue;
  1217. const s = src(file);
  1218. if (!s || !s.includes('=')) continue;
  1219. const tN = prof ? Date.now() : 0;
  1220. const fnsD = ctx.getNodesInFile(file);
  1221. if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; }
  1222. const dLines = s.split('\n');
  1223. for (const fn of fnsD) {
  1224. if (!FN_KINDS.has(fn.kind)) continue;
  1225. const body = sliceLinesPre(dLines, fn.startLine, fn.endLine);
  1226. if (!body.includes('=')) continue;
  1227. FIELD_ASSIGN_RE.lastIndex = 0;
  1228. let m: RegExpExecArray | null;
  1229. while ((m = FIELD_ASSIGN_RE.exec(body))) {
  1230. const [, lrecv, lfield, rrecv, rfield] = m;
  1231. // Pre-gate on field NAMES: `a->f = b->g` matches every struct-field
  1232. // assignment in the tree (millions on the kernel), but only fields
  1233. // that are fn-pointer fields of SOME struct can pass fnPtrFieldOf —
  1234. // skip the two regex type resolutions for the ~99% that can't.
  1235. if (!fieldToStructs.has(lfield!) || !fieldToStructs.has(rfield!)) continue;
  1236. const lt = recvTypeIn(body, lrecv!);
  1237. const rt = recvTypeIn(body, rrecv!);
  1238. if (lt && rt && fnPtrFieldOf(lt, lfield!) && fnPtrFieldOf(rt, rfield!)) {
  1239. propagations.push({ to: `${lt}.${lfield}`, from: `${rt}.${rfield}` });
  1240. }
  1241. }
  1242. }
  1243. }
  1244. for (let pass = 0; pass < 3 && propagations.length; pass++) {
  1245. let changed = false;
  1246. for (const { to, from } of propagations) {
  1247. const fromSet = reg.get(from);
  1248. if (!fromSet) continue;
  1249. if (!reg.has(to)) reg.set(to, new Set());
  1250. const toSet = reg.get(to)!;
  1251. for (const id of fromSet) {
  1252. if (!toSet.has(id)) {
  1253. toSet.add(id);
  1254. changed = true;
  1255. }
  1256. }
  1257. }
  1258. if (!changed) break;
  1259. }
  1260. if (prof) { prof.D = Date.now() - tPass; tPass = Date.now(); }
  1261. if (reg.size === 0 && arrayReg.size === 0) return [];
  1262. // ---- Stage E: dispatch sites → edges ----
  1263. // Filter: a file matters only if some dispatch field is a known fn-pointer
  1264. // field, or some subscripted name is a registered fn-pointer array — the loop
  1265. // body's own first gates (`owners` / `entries`), which a skipped file's
  1266. // matches would all fail before touching `seen`/`added`/`edges`.
  1267. const edges: Edge[] = [];
  1268. const seen = new Set<string>();
  1269. for (const file of files) {
  1270. await tick();
  1271. const facts = factsByFile.get(file);
  1272. if (!facts) continue;
  1273. const eSurvives =
  1274. (facts.dispatchFields?.some((f) => fieldToStructs.has(f)) ?? false) ||
  1275. (arrayReg.size > 0 && (facts.arrayDispatchNames?.some((n) => arrayReg.has(n)) ?? false));
  1276. if (!eSurvives) continue;
  1277. const s = src(file);
  1278. if (!s) continue;
  1279. const tN = prof ? Date.now() : 0;
  1280. const fnsE = ctx.getNodesInFile(file);
  1281. if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; }
  1282. const eLines = s.split('\n');
  1283. for (const fn of fnsE) {
  1284. if (!FN_KINDS.has(fn.kind)) continue;
  1285. const body = sliceLinesPre(eLines, fn.startLine, fn.endLine);
  1286. DISPATCH_RE.lastIndex = 0;
  1287. let m: RegExpExecArray | null;
  1288. let added = 0;
  1289. // Incremental line counting: matches arrive in ascending index order, so
  1290. // count newlines since the previous match instead of re-splitting the
  1291. // whole body prefix per match (O(body) each — real time on god-files).
  1292. let lcIdx = 0;
  1293. let lcLine = fn.startLine;
  1294. const lineAt = (idx: number): number => {
  1295. for (let i = lcIdx; i < idx; i++) if (body.charCodeAt(i) === 10) lcLine++;
  1296. lcIdx = idx;
  1297. return lcLine;
  1298. };
  1299. while ((m = DISPATCH_RE.exec(body)) && added < FANOUT_CAP) {
  1300. const baseChain = m[1]!.replace(/\s*(?:->|\.)\s*$/, '').trim(); // receiver, minus the trailing arrow
  1301. const field = m[2]!;
  1302. const owners = fieldToStructs.get(field);
  1303. if (!owners || owners.size === 0) continue;
  1304. // 1) resolve the receiver chain's struct type precisely (handles c->cmd->proc);
  1305. // 2) else the last segment as a simple local/param of a fn-pointer-bearing struct;
  1306. // 3) else fall back to a field name that belongs to exactly one struct.
  1307. let struct = resolveChainType(body, baseChain);
  1308. if (!struct || !owners.has(struct)) {
  1309. const lastSeg = baseChain.replace(/\s*\[[^\]]*\]/g, '').split(/\s*(?:->|\.)\s*/).pop()!;
  1310. const t = recvTypeIn(body, lastSeg);
  1311. struct = t && owners.has(t) ? t : null;
  1312. }
  1313. if (!struct || !owners.has(struct)) struct = owners.size === 1 ? [...owners][0]! : null;
  1314. if (!struct) continue;
  1315. const targets = reg.get(`${struct}.${field}`);
  1316. if (!targets) continue;
  1317. const line = lineAt(m.index);
  1318. for (const tid of targets) {
  1319. if (tid === fn.id) continue;
  1320. const key = `${fn.id}>${tid}`;
  1321. if (seen.has(key)) continue;
  1322. seen.add(key);
  1323. edges.push({
  1324. source: fn.id,
  1325. target: tid,
  1326. kind: 'calls',
  1327. line,
  1328. provenance: 'heuristic',
  1329. metadata: {
  1330. synthesizedBy: 'fn-pointer-dispatch',
  1331. via: `${struct}.${field}`,
  1332. registeredAt: `${fn.filePath}:${line}`,
  1333. },
  1334. });
  1335. if (++added >= FANOUT_CAP) break;
  1336. }
  1337. }
  1338. // ---- bare array-of-fn-pointers dispatch (`tbl[i](…)`) ----
  1339. if (arrayReg.size && added < FANOUT_CAP) {
  1340. // Fresh scan from the body's start — rewind the line-count cursor too.
  1341. lcIdx = 0;
  1342. lcLine = fn.startLine;
  1343. ARRAY_DISPATCH_RE.lastIndex = 0;
  1344. while ((m = ARRAY_DISPATCH_RE.exec(body)) && added < FANOUT_CAP) {
  1345. const entries = arrayReg.get(m[1]!);
  1346. if (!entries) continue;
  1347. // Same-file table wins on a name collision (two file-local `opcodes`);
  1348. // a unique name resolves cross-file; otherwise ambiguous — bail.
  1349. const ids = entries.length === 1
  1350. ? entries[0]!.ids
  1351. : (entries.find((e) => e.file === fn.filePath)?.ids ?? null);
  1352. if (!ids) continue;
  1353. const line = lineAt(m.index);
  1354. for (const tid of ids) {
  1355. if (tid === fn.id) continue;
  1356. const key = `${fn.id}>${tid}`;
  1357. if (seen.has(key)) continue;
  1358. seen.add(key);
  1359. edges.push({
  1360. source: fn.id,
  1361. target: tid,
  1362. kind: 'calls',
  1363. line,
  1364. provenance: 'heuristic',
  1365. metadata: {
  1366. synthesizedBy: 'fn-pointer-dispatch',
  1367. via: `${m[1]}[]`,
  1368. registeredAt: `${fn.filePath}:${line}`,
  1369. },
  1370. });
  1371. if (++added >= FANOUT_CAP) break;
  1372. }
  1373. }
  1374. }
  1375. }
  1376. }
  1377. if (prof) {
  1378. prof.E = Date.now() - tPass;
  1379. console.error(
  1380. `[synth-timing] cFnPtr sub: A=${prof.A}ms B=${prof.B}ms C=${prof.C}ms D=${prof.D}ms E=${prof.E}ms | read n=${prof.readN} ${prof.readMs}ms strip n=${prof.stripN} ${prof.stripMs}ms nodesInFile n=${prof.nodesN} ${prof.nodesMs}ms`
  1381. );
  1382. }
  1383. return edges;
  1384. }