From 703629edc3522c82f3087de4ca987e9d141b4252 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 26 Jun 2026 17:53:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(c/c++):=20resolve=20function-pointer=20com?= =?UTF-8?q?mand=20tables=20=E2=80=94=20macro-built,=20conditional-compilat?= =?UTF-8?q?ion=20&=20bare=20arrays=20(#991)=20(#1003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(c/c++): resolve macro-built function-pointer command tables (#991) C/C++ commands dispatched through macro-built function-pointer tables were dead-ends in the graph: redis' `call` never showed up as a caller of any command (`c->cmd->proc(c)`), because the table is generated into a #included `.def`, the handler is buried inside `MAKE_CMD(...)`, the struct type is itself a macro alias, the `proc` field uses a function-TYPE typedef, and the receiver is a chained field access. #954 deferred exactly this shape. Six composable additions to c-fnptr-synthesizer.ts close it: - function-type typedefs (`typedef RET T(...)` + `T *f`) flag the field as a function pointer; - multi-declarator fields (`struct redisCommand *cmd, *last`) each count as a slot/type (needed for positional alignment and the chain walk); - chained/array receivers (`c->cmd->proc`) resolve through field types across all same-named struct layouts (redis has two unrelated `client` structs); - `#include "x"` directives are followed (from raw source) so a non-indexed `.def` is read as a registration unit with the includer's effective macro env; - function-like + object-like macros are expanded (params->args, type aliases) before positional/designated registration; - a macro that expands to a brace-wrapped element (sqlite `FUNCTION(...)`) has one outer brace layer peeled. Validated on two independent macro-table lineages at 100% target precision: redis (209 commands via redisCommand.proc, `call`->every command) and sqlite (69 FuncDef.xSFunc targets). No regression on the controls: git (cmd_struct.fn, 138 builtins), curl (Curl_cftype.*), lua (0). 0 non-function targets across all five; +3 synthetic fixtures; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(c/c++): resolve conditional-compilation command tables (vim) (#991) Vim's `:ex` and normal-mode command tables are the hardest fn-pointer-table shape: the struct is defined INLINE with the array, the whole thing is behind `#ifdef DO_DECLARE_EXCMD`/`DO_DECLARE_NVCMD` (switched on by the includer), built by a macro the file conditionally redefines (`EXCMD`/`NVCMD` = the table element under the switch, a bare enum id otherwise), and dispatched by a parenthesized array subscript through a file-scope table: `(cmdnames[i].cmd_func)(&ea)`. Four more composable additions on top of the macro-table work: - a focused `#ifdef`/`#ifndef`/`#if defined`/`#else`/`#elif`/`#endif` evaluator drops inactive arms (unevaluable `#if EXPR` keeps its body); an indexed header is re-scanned in an includer's context only when that includer #defines a switch the header guards, with the include's macros re-read from the resolved text (the plain last-wins parse picks the wrong, enum, arm); - inline `struct TAG {…} var[] = {…}` tables whose struct never became a node are parsed in place and registered; - array-subscript receivers (`tbl[i].f`) strip the subscript and resolve the base through a global-var → struct-type map; - an optional `)` before the call covers the parenthesized `(….f)(args)` form. Validated on vim: 273 `:ex` commands (`do_one_cmd`→every command) + 67 normal-mode commands, 0 non-function targets, 0 cross-table misroute (registering both tables is what stops `normal_cmd`'s `nv_cmds[i].cmd_func` from falling back to the `cmdname` owner of the shared field name). Controls unchanged at 0 non-function (redis/sqlite/git/curl gain coverage from array/global dispatch, lua still 0); +1 synthetic fixture; full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(c/c++): resolve bare arrays of function pointers (#991) The C/C++ fn-pointer synthesizer keyed everything on (struct type, fn-pointer field), so a dispatch through a bare array of function pointers — no struct, no field — was unbridged: an opcode/handler table like `static op_t *opcodes[256] = {nop,…}` invoked `opcodes[op](…)` left every handler with zero callers. Closes the last #991 deferred item. Keyed by the array VARIABLE name (a new `arrayReg`, parallel to the struct `reg`). Registration detects an array whose element type is a function typedef — a function-TYPE typedef element (`opcode_t *ops[]`, the `*` making it an array of pointers) or a function-pointer typedef element (`zend_rc_dtor_func_t t[]`) — and reads its literal entries, whether positional (`fn`/`&fn`), designated by index (`[IDX]=fn`), or cast-wrapped (`(cast)fn`). Dispatch is `tbl[i](…)` / `(*tbl[i])(…)`, gated on `tbl` being a known fn-pointer array (the precision anchor); the fan-out reaches the whole set (a runtime subscript hits any entry), like a command table. The same-file table wins on a name collision, so two file-local `static opcodes[256]` (SameBoy's CPU + disassembler) never cross. The fn-pointer typedef/field regexes now also tolerate a calling-convention macro before the `*` (`(ZEND_FASTCALL *name)`), which hardens the existing struct-field path too. Validated on two independent lineages: SameBoy (GB emulator) — 147 edges via `opcodes[]`, 0 cross-file leak; php-src (Zend) — 54 edges across 7 tables in the designated+cast+CC-typedef form. Control: lua 0 — its `lua_CFunction searchers[]` is pushed into the VM, never C-dispatched, so the call-gate fires nothing. No regression on the #991 corpus: redis (835) / sqlite (683) struct edges byte-identical, git +3 / curl +20 legitimate new bare-array edges, vim 433 with all guards holding; 0 non-function targets across all. + 4 fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/c-fnptr-synthesizer.test.ts | 224 +++++- docs/design/dispatch-synthesizer-backlog.md | 2 +- src/resolution/c-fnptr-synthesizer.ts | 750 ++++++++++++++++++-- 4 files changed, 904 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b7a27..7aa3995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- CodeGraph now follows C/C++ commands that are dispatched through macro-built function-pointer tables, so the handler functions they reach are no longer dead-ends in the graph. Many C projects register a handler into a struct's function-pointer field through a macro and a generated table — redis is the classic case: every command (`getCommand`, `decrbyCommand`, …) is wired into the command struct's `proc` field by a `MAKE_CMD(…)` table that lives in a generated, `#include`-d file, then invoked as `c->cmd->proc(c)`. CodeGraph now reads those macro-built tables — including ones whose struct type is itself a macro alias, whose table sits in an `#include`-d file that is never indexed on its own, or that are wrapped in conditional compilation (`#ifdef`) and defined inline with the struct. It recognizes function-pointer fields declared through a function typedef, and follows the receiver — a chained access (`c->cmd->proc`) or an array subscript through a file-scope table (`(cmdnames[i].cmd_func)(…)`) — across field types. It also follows dispatch through a bare array of function pointers with no struct wrapper at all — the opcode/handler-table pattern common in interpreters and emulators, where a table like `opcodes[op](…)` invokes one of many registered handler functions by index — linking the dispatcher to every handler in the array. The upshot: asking for the callers or blast radius of a command handler now finds the dispatcher that reaches it. For redis, `call` shows up as a caller of every command; for SQLite, the builtin SQL functions registered through `FUNCTION(...)` link to where they're invoked; for Vim, every `:ex` and normal-mode command links from the dispatcher. (#991, extending #932) - CodeGraph no longer times out when many agents query it at once. The shared background server that serves all your editor and agent sessions used to run every query on a single thread, so a burst of concurrent requests — for example a swarm of subagents exploring a large monorepo together — queued up behind one another and, while the heavy ones ran, froze the connection so finished answers couldn't even be sent back until the whole batch drained. Past a handful of simultaneous callers that routinely surfaced as MCP request timeouts. The shared server now answers queries across a pool of worker threads, so concurrent requests run in parallel and the connection stays responsive the whole time; when it's genuinely saturated a call returns a brief "busy, retry shortly" note (not an error) instead of hanging past your client's timeout. The pool sizes itself to your machine — roughly one worker per core, leaving one for coordination — and a single editor session is unaffected (no pool, no overhead). Set `CODEGRAPH_QUERY_POOL_SIZE` to choose a specific number of workers, or `0` to revert to single-threaded in-process queries. diff --git a/__tests__/c-fnptr-synthesizer.test.ts b/__tests__/c-fnptr-synthesizer.test.ts index 3d83bf1..24d8240 100644 --- a/__tests__/c-fnptr-synthesizer.test.ts +++ b/__tests__/c-fnptr-synthesizer.test.ts @@ -10,7 +10,11 @@ * designated init, the typedef'd-field + field←field double-hop (the issue's * own hook_demo.c shape), by-value dispatch, and the precision boundaries * (a data field is never bridged, distinct fn-pointer fields don't cross-bleed, - * and a non-C project is a no-op). + * and a non-C project is a no-op). Plus the BARE ARRAY of function pointers + * (no struct, no field) keyed by the array variable name — the opcode-table + * shape `opcodes[op](…)`, the designated + cast-wrapped form with a + * calling-convention typedef, same-named file-local arrays resolving without a + * cross-file leak, and a registered-but-never-dispatched array (the control). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; @@ -140,6 +144,224 @@ int total(struct box *x) { return x->count + 1; } write('app.js', ` const handlers = { add: (x) => x + 1, rm: (x) => x - 1 }; function run(name, x) { return handlers[name](x); } +`); + const edges = await load(); + expect(edges.length).toBe(0); + }); + + // The redis command-table shape, minimized: the handler is wrapped in a + // function-like macro, the table's struct type is an object-like macro alias, + // the fn-pointer field uses a function-TYPE typedef, and the dispatch receiver + // is a chained field access through a multi-declarator field. + it('bridges a macro-built table with a typedef field, type-alias macro, and chained dispatch', async () => { + write('reg.h', ` +typedef void cmdProc(int x); /* function-TYPE typedef, not (*name) */ +struct command { const char *name; cmdProc *proc; }; +struct context { int id; struct command *cmd, *last; }; /* multi-declarator field */ +`); + write('reg.c', ` +#include "reg.h" +#define ENTRY(nm, handler) nm, handler /* function-like macro wrapping the handler */ +#define CMD_T command /* object-like macro: the struct-type alias */ +static void getCmd(int x) {} +static void setCmd(int x) {} +static void unusedCmd(int x) {} /* defined, NOT in the table */ +static struct CMD_T table[] = { + { ENTRY("get", getCmd) }, + { ENTRY("set", setCmd) }, +}; +void run(struct context *ctx, int x) { ctx->cmd->proc(x); } /* context.cmd → command → proc */ +`); + const edges = await load(); + expect(has(edges, 'run', 'getCmd')).toBe(true); + expect(has(edges, 'run', 'setCmd')).toBe(true); + expect(edges.every((e) => e.via === 'command.proc')).toBe(true); + // PRECISION: a function not registered in the table is never a target. + expect(has(edges, 'run', 'unusedCmd')).toBe(false); + }); + + // redis generates its command table into a `.def` that is #included (and never + // indexed on its own). The synthesizer reads the included file with the + // includer's macros in scope so the table still resolves. + it('reads a macro-built table from a non-indexed #included file', async () => { + write('inc.h', ` +typedef int opRun(void); +struct op { const char *name; opRun *run; }; +`); + write('inc.c', ` +#include "inc.h" +#define MK(nm, fn) nm, fn +#define CMD_T op +static int a_impl(void){return 0;} +static int b_impl(void){return 0;} +#include "ops.def" +int go(struct op *o) { return o->run(); } +`); + // `.def` is not a C source extension, so this file is never indexed — it is + // only visible to the synthesizer through inc.c's #include. + write('ops.def', ` +static struct CMD_T optable[] = { + { MK("a", a_impl) }, + { MK("b", b_impl) }, +}; +`); + const edges = await load(); + expect(has(edges, 'go', 'a_impl')).toBe(true); + expect(has(edges, 'go', 'b_impl')).toBe(true); + expect(edges.every((e) => e.via === 'op.run')).toBe(true); + }); + + // The sqlite builtin-function-table shape: the table-building macro lives in a + // header (`sqliteInt.h`), separate from the file with the table (`func.c`), and + // expands to a whole brace-wrapped struct element `{ …, xFunc, … }`. + it('expands a header-defined macro that produces a brace-wrapped element', async () => { + write('fn.h', ` +typedef void sqlFn(int *ctx); +struct FuncDef { int nArg; sqlFn *xFunc; const char *zName; }; +#define MKFUNC(name, impl) { 1, impl, #name } +`); + write('fn.c', ` +#include "fn.h" +static void absImpl(int *ctx) {} +static void lenImpl(int *ctx) {} +static struct FuncDef builtins[] = { + MKFUNC(abs, absImpl), + MKFUNC(len, lenImpl), +}; +void invoke(struct FuncDef *p, int *x) { p->xFunc(x); } +`); + const edges = await load(); + expect(has(edges, 'invoke', 'absImpl')).toBe(true); + expect(has(edges, 'invoke', 'lenImpl')).toBe(true); + expect(edges.every((e) => e.via === 'FuncDef.xFunc')).toBe(true); + }); + + // The vim command-table shape: a table-building macro and the struct are both + // behind `#ifdef`, defined INLINE with the array (`struct cmd_entry {…} table[]`) + // in a header that a `.c` #includes after setting the switch macro, and the + // dispatch is a parenthesized array subscript through the file-scope table + // (`(cmd_table[i].handler)(x)`). Exercises #ifdef evaluation, the conditionally + // redefined macro, the inline struct (never a node), and array/global dispatch. + it('bridges an #ifdef-guarded inline-struct table dispatched by array subscript', async () => { + write('cmds.h', ` +#ifdef DECLARE_TABLE +# define CMD(id, name, fn) { name, fn } +typedef void (*cmd_fn)(int arg); +static struct cmd_entry { const char *cmd_name; cmd_fn handler; } cmd_table[] = +#else +# define CMD(id, name, fn) id +enum cmd_id +#endif +{ +CMD(C_a, "a", do_a), +CMD(C_b, "b", do_b), +}; +`); + write('main.c', ` +#define DECLARE_TABLE +#include "cmds.h" +static void do_a(int arg) {} +static void do_b(int arg) {} +static void unused(int arg) {} /* defined, NOT in the table */ +void run(int idx, int x) { (cmd_table[idx].handler)(x); } +`); + const edges = await load(); + expect(has(edges, 'run', 'do_a')).toBe(true); + expect(has(edges, 'run', 'do_b')).toBe(true); + expect(edges.every((e) => e.via === 'cmd_entry.handler')).toBe(true); + expect(has(edges, 'run', 'unused')).toBe(false); + }); + + // A bare ARRAY of function pointers — no struct, no field. The element type is + // a function-TYPE typedef (`op_t *opcodes[]`), entries are literal function + // names, and dispatch is a plain subscript-then-call `opcodes[op](…)` (the + // SameBoy CPU opcode-table shape). Keyed by the array variable name. + it('bridges a bare array of function pointers dispatched by subscript (the opcode-table shape)', async () => { + write('cpu.c', ` +typedef void op_t(int *vm, unsigned char opcode); +static void nop(int *vm, unsigned char opcode) {} +static void inc(int *vm, unsigned char opcode) {} +static void unreg(int *vm, unsigned char opcode) {} /* defined, NOT in the table */ +static op_t *opcodes[256] = { nop, inc }; +void cpu_run(int *vm) { + unsigned char opcode = 0; + opcodes[opcode](vm, opcode); +} +`); + const edges = await load(); + expect(has(edges, 'cpu_run', 'nop')).toBe(true); + expect(has(edges, 'cpu_run', 'inc')).toBe(true); + expect(edges.every((e) => e.via === 'opcodes[]')).toBe(true); + // PRECISION: a function not in the array is never a target. + expect(has(edges, 'cpu_run', 'unreg')).toBe(false); + }); + + // The php Zend shape: a function-POINTER typedef whose declarator carries a + // calling-convention macro before the `*` (`(FASTCALL *dtor_t)`), an array of + // it filled by DESIGNATED index with CAST-wrapped entries (`[1] = (dtor_t)fn`), + // dispatched through a subscript whose index is itself a call (`t[type(p)](p)`). + it('bridges a designated + cast-wrapped array with a calling-convention typedef (the Zend dtor shape)', async () => { + write('rc.c', ` +#define FASTCALL +typedef void (FASTCALL *dtor_t)(int *p); +static void empty_dtor(int *p) {} +static void str_dtor(int *p) {} +static void arr_dtor(int *p) {} +static int type_of(int *p) { return 0; } +static const dtor_t rc_dtor[] = { + [0] = (dtor_t)empty_dtor, + [1] = (dtor_t)str_dtor, + [2] = (dtor_t)arr_dtor, +}; +void rc_free(int *p) { rc_dtor[type_of(p)](p); } +`); + const edges = await load(); + expect(has(edges, 'rc_free', 'empty_dtor')).toBe(true); + expect(has(edges, 'rc_free', 'str_dtor')).toBe(true); + expect(has(edges, 'rc_free', 'arr_dtor')).toBe(true); + expect(edges.every((e) => e.via === 'rc_dtor[]')).toBe(true); + }); + + // Two file-local `static` arrays share the same name across files (SameBoy + // declares `opcodes[256]` in both the CPU and the disassembler). Dispatch must + // resolve to the SAME file's table — no cross-file leak. + it('resolves same-named file-local arrays to their own file (no cross-file leak)', async () => { + write('a.c', ` +typedef void af_t(int *m); +static void a_one(int *m) {} +static void a_two(int *m) {} +static af_t *table[8] = { a_one, a_two }; +void a_run(int *m, int i) { table[i](m); } +`); + write('b.c', ` +typedef void bf_t(int *m); +static void b_one(int *m) {} +static void b_two(int *m) {} +static bf_t *table[8] = { b_one, b_two }; +void b_run(int *m, int i) { table[i](m); } +`); + const edges = await load(); + expect(has(edges, 'a_run', 'a_one')).toBe(true); + expect(has(edges, 'a_run', 'a_two')).toBe(true); + expect(has(edges, 'b_run', 'b_one')).toBe(true); + // PRECISION: a_run's `table` is a.c's, never b.c's (and vice versa). + expect(has(edges, 'a_run', 'b_one')).toBe(false); + expect(has(edges, 'b_run', 'a_one')).toBe(false); + }); + + // PRECISION: an array of function pointers that is REGISTERED elsewhere (passed + // by element to a registrar) but never C-dispatched `arr[i](…)` yields nothing + // — the lua `package.searchers` shape, where elements are pushed into the VM. + it('does not bridge a fn-pointer array that is registered, not dispatched (the searchers control)', async () => { + write('pkg.c', ` +typedef int searcher_t(int *L); +static int s_preload(int *L) { return 0; } +static int s_lua(int *L) { return 0; } +static searcher_t *searchers[] = { s_preload, s_lua, 0 }; +extern void register_one(int *L, searcher_t *s); +void setup(int *L) { + for (int i = 0; searchers[i]; i++) register_one(L, searchers[i]); +} `); const edges = await load(); expect(edges.length).toBe(0); diff --git a/docs/design/dispatch-synthesizer-backlog.md b/docs/design/dispatch-synthesizer-backlog.md index 49b6ee6..a2a19ed 100644 --- a/docs/design/dispatch-synthesizer-backlog.md +++ b/docs/design/dispatch-synthesizer-backlog.md @@ -91,7 +91,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und | MediatR | `mediatr-dispatch` | ✅ **shipped (2026-06-20)** — `_mediator.Send(x)`/`.Publish(x)` → the `Handle` of `IRequestHandler`/`INotificationHandler` by request type; 100% precision jasontaylor (9) / eShop (9, variable-passed), 0 on Newtonsoft control. Type from class base-list (C# has no signature) + arg resolved inline/local/param; receiver + handler-map gates. | | Sidekiq | `sidekiq-dispatch` | ✅ **shipped (2026-06-20)** — `W.perform_async/_in/_at(…)` → `W#perform`, gated on `include Sidekiq::Job`/`Worker`; 100% precision loomio (47) / forem (142, both aliases), 0 on jekyll control. Name-keyed; namespaced collisions disambiguated by qualified name; ActiveJob `perform_later` excluded. | | Laravel events | `laravel-event` | ✅ **shipped (2026-06-21)** — `event(new XEvent)` → each listener's `handle`, via typed `handle(XEvent $e)` (auto-discovery, union-split) AND the `$listen` map (covers untyped handles); 100% precision koel (9, `$listen`) / firefly (141, auto-discovery), 0 on guzzle control. Jobs excluded (they use `::dispatch`). | -| C/C++ fn-pointer dispatch | `fn-pointer-dispatch` | ✅ **shipped (2026-06-22)** — FIRST C / systems-language member (#932). Keyed by **(struct type, fn-pointer field)**: a fn registered to `S.field` (positional init matched by field index, designated `.field=fn`, or `x->field=fn`) ← linked → an indirect dispatch `recv->field(…)` whose receiver resolves to `S` (param/local type, else unique-field fallback). Source-read synth (`c-fnptr-synthesizer.ts`, regex over `ctx.readFile`), NOT extraction — handles the typedef'd field (`hook_func func`) + the **field←field double-hop** (`h->func = found->fn`, the issue's `hook_demo.c` shape). Covers BOTH the command-table idiom (Shape 1) and the ops-struct/vtable idiom (Shape 2) with the same key. Validated: **git 502** (`run_builtin→cmd_*` + 7 real vtables), **redis 357** (`dictType.hashFunction`, conn vtable), **curl 478** (`Curl_cwtype.do_init→{deflate,gzip,brotli,zstd}_do_init`); **0 non-function targets** everywhere, node-stable (pure edge synth), **0 on lua** (its `{name,fn}` tables register into the VM — no C indirect call → correctly nothing to bridge). **Deferred:** direct fn-pointer *variables* (`fp=f; fp()` — not field-keyed), array-of-fn-pointers without a struct, C++ *class* fn-pointer fields (virtual dispatch already covered by `interface-impl`/`cpp-override`), and macro-built tables (redis `MAKE_CMD(…)` proc arg lives inside a macro call, not a struct initializer, so `redisCommand.proc` registrations are unbridged). | +| C/C++ fn-pointer dispatch | `fn-pointer-dispatch` | ✅ **shipped (2026-06-22)** — FIRST C / systems-language member (#932). Keyed by **(struct type, fn-pointer field)**: a fn registered to `S.field` (positional init matched by field index, designated `.field=fn`, or `x->field=fn`) ← linked → an indirect dispatch `recv->field(…)` whose receiver resolves to `S` (param/local type, else unique-field fallback). Source-read synth (`c-fnptr-synthesizer.ts`, regex over `ctx.readFile`), NOT extraction — handles the typedef'd field (`hook_func func`) + the **field←field double-hop** (`h->func = found->fn`, the issue's `hook_demo.c` shape). Covers BOTH the command-table idiom (Shape 1) and the ops-struct/vtable idiom (Shape 2) with the same key. Validated: **git 502** (`run_builtin→cmd_*` + 7 real vtables), **redis 357** (`dictType.hashFunction`, conn vtable), **curl 478** (`Curl_cwtype.do_init→{deflate,gzip,brotli,zstd}_do_init`); **0 non-function targets** everywhere, node-stable (pure edge synth), **0 on lua** (its `{name,fn}` tables register into the VM — no C indirect call → correctly nothing to bridge). **⬆️ EXTENDED (2026-06-26, #991) — macro-built command tables (the deferred item below, now shipped):** redis dispatches every command via `c->cmd->proc(c)` but `redisCommand.proc` had **0** registrations because the table is generated into a `#include`-d `.def` that is never indexed, the `proc` arg is buried inside a `MAKE_CMD(…)` macro call, the struct type is itself an object-macro alias (`#define COMMAND_STRUCT redisCommand`), the `proc` field uses a function-TYPE typedef (`typedef void redisCommandProc(client*)` + `redisCommandProc *proc`, not the `(*name)` form), and the receiver is a chained field access. Five composable additions close it: **(1)** function-type typedefs flag `T *f` fields as fn pointers; **(2)** multi-declarator fields (`struct redisCommand *cmd, *last`) each count as a slot/type — needed both for positional alignment and the chain walk; **(3)** `#include "x"` directives are followed (from RAW source) so a non-indexed `.def` is read as a registration unit **with the includer's macros in scope**; **(4)** function-like macro calls are expanded (params→args) and object-like macros resolve the struct-type token, before positional/designated registration; **(5)** a chained/array receiver (`c->cmd->proc`) resolves through field types across structs (a name can be reused — redis has two unrelated `client` structs — so all same-named layouts are searched); a macro that expands to a whole brace-wrapped struct element (sqlite `FUNCTION(…)`) gets one outer brace layer peeled; and a file's effective macro env folds in its #included headers (sqlite's `FUNCTION` is defined in `sqliteInt.h`, the table in `func.c`). Validated on **two independent macro-table lineages**: **redis** — 209 commands via `redisCommand.proc`, 100% `*Command` targets, 0 garbage (`call`→every command, the issue's ask) + `getkeys_proc` as a bonus; **sqlite** — `FUNCTION(name,…,impl)` builtin table, 69 `FuncDef.xSFunc` targets (`valueFromFunction`→`absFunc`/…), header-defined macro + brace-wrapped element. **No regression on the controls** — git (`run_builtin→cmd_*`, 138 builtins, 0 non-`cmd_*`), curl (`Curl_cftype.*` filters), **0 on lua**; **0 non-function targets across all five**. `+` 3 synthetic fixtures (`c-fnptr-synthesizer.test.ts`). **⬆️⬆️ FURTHER EXTENDED (2026-06-26, #991) — conditional-compilation tables (vim, the deferred item below now shipped):** vim's `:ex` and normal-mode command tables are defined INLINE with the struct, behind `#ifdef DO_DECLARE_EXCMD`/`DO_DECLARE_NVCMD` switched on by the includer (`ex_docmd.c`/`normal.c`), built by a macro the same file **conditionally redefines** (`EXCMD`/`NVCMD` = the table element under the switch, the bare enum id otherwise), and dispatched by a parenthesized array subscript through a **file-scope** table (`(cmdnames[i].cmd_func)(&ea)`). Four more composable additions: **(6)** a focused `#ifdef`/`#ifndef`/`#if defined`/`#else`/`#elif`/`#endif` evaluator drops the inactive arms (an unevaluable `#if EXPR` keeps its body) so the active table + the right macro arm survive — and an indexed header is **re-scanned in an includer's context** only when that includer `#define`s a switch the header guards (so the include's conditionals resolve against the includer's defined set, and the include's own macros are re-read from the resolved text — the plain last-wins parse picks the enum arm); **(7)** inline `struct TAG {…} var[] = {…}` tables whose struct never became a node are parsed in place and registered; **(8)** array-subscript receivers (`tbl[i].f`) strip the subscript and resolve the base via a **global-var → struct-type** map; **(9)** an optional `)` before the call covers the parenthesized `(…​.f)(args)` form. Validated **vim** — 273 `:ex` commands (`do_one_cmd`→every command) + 67 normal-mode commands (`nv_cmd.cmd_func`), 0 non-function, **0 cross-table misroute** (registering BOTH tables is what stops `normal_cmd`'s `nv_cmds[i].cmd_func` from falling back to the `cmdname` owner of the shared field name `cmd_func`); controls unchanged at 0 non-function (redis/sqlite/git/curl up in coverage from array/global dispatch, lua still 0); `+` a 4th fixture. **⬆️⬆️⬆️ FURTHER EXTENDED (2026-06-26, #991) — bare arrays of function pointers (the deferred item below now shipped):** a fn-pointer dispatch with NO struct and NO field — an array whose ELEMENT type is a function typedef (`opcode_t *opcodes[256]`, a fn-TYPE typedef element where the `*` makes it an array of pointers; or `zend_rc_dtor_func_t t[]`, a fn-pointer typedef element, **including a calling-convention macro before the `*`** — `typedef void (ZEND_FASTCALL *t)(…)`), initialized with literal fn entries (positional `fn`/`&fn`, designated by index `[IDX]=fn`, or cast-wrapped `(cast)fn`), and dispatched `tbl[i](…)` or the explicit-deref `(*tbl[i])(…)`. **Keyed by the array VARIABLE name** (a new `arrayReg`, separate from the `(struct,field)` `reg`); targets are the literal entries and fan-out is the whole set (a runtime subscript reaches every handler, like a command table). **Same-file table wins on a name collision** — SameBoy declares two file-local `static opcode_t *opcodes[256]` (CPU + disassembler); the same-file gate stops them crossing (0 cross-file leak — the n>1 collision discipline again). Three additions: **(10)** the fn-pointer typedef/field regexes allow a CC/attr macro before the `*` (`(ZEND_FASTCALL *name)`) — also hardens the existing struct-field path; **(11)** `ELEMTYPE [*]name[…]={…}` array-table detection, gated on the element type being in the fn-typedef sets (a data/struct array's element type never is, so it never fires); **(12)** a `tbl[i](…)`/`(*tbl[i])(…)` dispatch regex, fired only when `tbl ∈ arrayReg` (the precision anchor). Validated on **two independent lineages**: **SameBoy** (GB emulator) — 147 edges via `opcodes[]` (`GB_cpu_run`→73 + `GB_cpu_disassemble`→74 opcode handlers, each resolving same-file), 0 non-fn; **php-src** (Zend runtime) — 54 edges across 7 tables (`zend_binary_ops`, `filters`, `zend_rc_dtor_func`, `fn_chain`, …), the designated+cast+CC-macro form, 0 non-fn, precision spot-checked (each is a real `tbl[i](…)`). **Control: lua 0** — its `lua_CFunction searchers[]` is pushed into the VM (`lua_pushcclosure(L, searchers[i], 1)`), never C-dispatched as `searchers[i](…)`, so the call-gate correctly fires nothing (the precision proof: a registered-but-not-called fn-ptr array yields no edge). **No regression** — redis (835) / sqlite (683) struct.field counts byte-identical, git **+3** / curl **+20** legitimate new bare-array edges (curl's `mstate`→`state_enter[state](data,oldstate)` machine, option setters), vim 433 unchanged with all three #991 guards holding (`do_one_cmd→ex_quit` TRUE, `normal_cmd→ex_quit` FALSE, `normal_cmd→nv_addsub` TRUE); **0 non-function targets across all**. `+` 4 fixtures (opcode-table positional, Zend designated+cast+CC-typedef, same-name file-local collision, searchers control). **Deferred (recall):** direct fn-pointer *variables* (`fp=f; fp()` — not field-keyed; mutable by nature → the data-flow frontier, deliberately uncovered), C++ *class* fn-pointer fields (virtual dispatch already covered by `interface-impl`/`cpp-override`), and `##` token-paste handler names (php `zif_##name`, busybox `name##_main` — also blocked upstream by those handlers not being plain extracted nodes). | | (see playbook §6 / `callback-synthesizer.ts` for the other ~20 channels) | | | ### redux-thunk follow-ups (found by the n>1 validation — this is exactly what it's for) diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index ba4ad7b..0caf4df 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -16,16 +16,39 @@ * initializer (matched by field index), a designated `.field = fn`, or a * direct `x.field = fn` / `x->field = fn` assignment; * • dispatch — `recv->field(…)` / `recv.field(…)` where `recv` resolves to a - * value of struct type `S` (from the enclosing function's params / locals), + * value of struct type `S` (from the enclosing function's params / locals, + * or by walking a chained/array receiver `c->cmd->proc` across field types), * falling back to the field name when it is unique to one struct; * • field←field propagation — `a->f = b->g` merges `B.g`'s handlers into * `A.f`, so a generic single-slot hook that is reassigned from a registry * (the `hook_demo.c` shape: `h->func = found->fn`) still resolves. * + * Also handles **macro-built tables** (#991) — the dominant real-world shape, + * e.g. redis' command table, sqlite's builtin functions, and vim's `:ex` / + * normal-mode commands. The fn-pointer arg lives inside a macro call + * (`MAKE_CMD(…,proc,…)` / `FUNCTION(…,xFunc)` / `EXCMD(…,fn,…)`) in a generated + * or `#include`-d file; the table's struct type may itself be an object-macro + * alias; the field may use a function-TYPE typedef; the struct may be defined + * INLINE with the array; and the whole thing may sit behind `#ifdef` switched on + * by the includer. The registration pass reads each `#include`-d file as a unit + * with the includer's effective macro env (own + headers) in scope, evaluates + * its `#ifdef`s against the includer's defined set, expands object/function + * macros, peels a brace-wrapped element, and parses an inline struct in place — + * then reads the positional/designated bindings. Dispatch additionally resolves + * an array subscript through a file-scope table (`(cmdnames[i].cmd_func)(…)`). + * + * Also bridges **bare arrays of function pointers** (no struct, no field) — + * `opcode_t *opcodes[256] = {nop,…}` dispatched `opcodes[op](…)` (SameBoy's CPU), + * `zend_rc_dtor_func_t t[] = {[IS_STRING]=(cast)fn,…}` dispatched `t[GC_TYPE(p)](…)` + * (php's Zend) — keyed by the array VARIABLE name. The element type must be a + * function typedef (the precision gate), entries are literal function names, and + * the same-file table wins on a name collision (two file-local `opcodes[256]`). + * * Whole-graph pass after base resolution; all edges are `provenance:'heuristic'` * (`synthesizedBy:'fn-pointer-dispatch'`). High precision via the (type, field) * key + a real-function gate; a project with no fn-pointer dispatch is a no-op. */ +import * as path from 'node:path'; import type { Edge, Node } from '../types'; import type { QueryBuilder } from '../db/queries'; import type { ResolutionContext } from './types'; @@ -40,6 +63,9 @@ interface FieldInfo { name: string; index: number; isFnPtr: boolean; + /** The field's declared type token (e.g. `redisCommand` for `struct redisCommand *cmd`), + * used to walk a chained receiver `c->cmd->proc`. Empty for fn-pointer fields. */ + type: string; } function sliceLines(content: string, startLine?: number, endLine?: number): string { @@ -79,40 +105,327 @@ function splitTopLevel(body: string, sep: string): string[] { return out; } -/** A fn-pointer field looks like `… (*name)(…)` — capture `name`. */ -const FNPTR_DECL_RE = /\(\s*\*\s*(\w+)\s*\)\s*\(/; -/** `typedef RET (*NAME)(…)` — a function-pointer typedef. */ -const FNPTR_TYPEDEF_RE = /\btypedef\b[^;{}]*?\(\s*\*\s*(\w+)\s*\)\s*\(/g; +/** Index of the `)` matching the `(` at `open` (which must point at a `(`). -1 if unbalanced. */ +function matchParen(src: string, open: number): number { + let depth = 0; + for (let i = open; i < src.length; i++) { + const c = src[i]; + if (c === '(') depth++; + else if (c === ')') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** A function-like macro: `#define NAME(p0,p1,…) expansion`. */ +interface MacroDef { + params: string[]; + expansion: string; +} + +/** + * Collect function-like macros from (comment-stripped) source, joining + * `\`-continuations first. Only object/positional table macros matter here, so + * variadic macros are skipped. Used to expand registration tables built through + * a macro (redis' `MAKE_CMD(…)`) before reading the struct-field bindings. + */ +function parseFunctionMacros(stripped: string): Map { + const out = new Map(); + if (!stripped.includes('#define') && !stripped.includes('# define')) return out; + const joined = stripped.replace(/\\\r?\n/g, ' '); + const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)\s+(.+)$/gm; + let m: RegExpExecArray | null; + while ((m = RE.exec(joined))) { + const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean); + if (params.some((p) => p === '...' || p.endsWith('...'))) continue; // variadic — skip + out.set(m[1]!, { params, expansion: m[3]!.trim() }); + } + return out; +} + +/** + * Collect object-like macros `#define NAME value` (NAME not immediately followed + * by `(`). redis aliases the table's struct type this way: + * `#define COMMAND_STRUCT redisCommand`, used as `struct COMMAND_STRUCT table[]`. + */ +function parseObjectMacros(stripped: string): Map { + const out = new Map(); + if (!stripped.includes('#define') && !stripped.includes('# define')) return out; + const joined = stripped.replace(/\\\r?\n/g, ' '); + const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(\S[^\n]*)$/gm; + let m: RegExpExecArray | null; + while ((m = RE.exec(joined))) out.set(m[1]!, m[2]!.trim()); + return out; +} + +/** All macro names a file `#define`s (value-ful or not) — the "defined" set for #ifdef. */ +function parseDefinedNames(stripped: string): Set { + const out = new Set(); + if (!stripped.includes('#define') && !stripped.includes('# define')) return out; + const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)/gm; + let m: RegExpExecArray | null; + while ((m = RE.exec(stripped))) out.add(m[1]!); + return out; +} + +/** + * Drop the inactive arms of `#ifdef`/`#ifndef`/`#if defined(X)`/`#else`/`#elif`/ + * `#endif` given a set of defined macro names, keeping line offsets (inactive + * lines are blanked, not removed). A conditional whose expression we can't + * evaluate (`#if SOME_EXPR`) keeps its body — better to over-keep than to drop + * live code. This is what makes a header included with a switch macro defined + * (vim's `ex_cmds.h` under `DO_DECLARE_EXCMD`) expose only its active table. + */ +function evalConditionals(text: string, defined: Set): string { + if (!/#\s*if/.test(text)) return text; + const lines = text.split('\n'); + // stack frame: parentActive = enclosing kept?; active = this arm kept?; taken = any arm taken yet + const stack: { parentActive: boolean; active: boolean; taken: boolean }[] = []; + const activeNow = (): boolean => (stack.length === 0 ? true : stack[stack.length - 1]!.active); + const condDefined = (expr: string): boolean | null => { + let mm = expr.match(/^defined\s*\(?\s*(\w+)\s*\)?$/); + if (mm) return defined.has(mm[1]!); + mm = expr.match(/^!\s*defined\s*\(?\s*(\w+)\s*\)?$/); + if (mm) return !defined.has(mm[1]!); + return null; // unevaluable + }; + for (let i = 0; i < lines.length; i++) { + const t = lines[i]!.trim(); + let mm: RegExpMatchArray | null; + if ((mm = t.match(/^#\s*ifdef\s+(\w+)/))) { + const pa = activeNow(); + const cond = defined.has(mm[1]!); + stack.push({ parentActive: pa, active: pa && cond, taken: cond }); + lines[i] = ''; + continue; + } + if ((mm = t.match(/^#\s*ifndef\s+(\w+)/))) { + const pa = activeNow(); + const cond = !defined.has(mm[1]!); + stack.push({ parentActive: pa, active: pa && cond, taken: cond }); + lines[i] = ''; + continue; + } + if ((mm = t.match(/^#\s*if\s+(.+)$/))) { + const pa = activeNow(); + const c = condDefined(mm[1]!.trim()); + const cond = c === null ? true : c; // unevaluable → keep + stack.push({ parentActive: pa, active: pa && cond, taken: cond }); + lines[i] = ''; + continue; + } + if (/^#\s*elif\b/.test(t)) { + const top = stack[stack.length - 1]; + if (top) { top.active = top.parentActive && !top.taken; top.taken = true; } + lines[i] = ''; + continue; + } + if (/^#\s*else\b/.test(t)) { + const top = stack[stack.length - 1]; + if (top) { top.active = top.parentActive && !top.taken; top.taken = true; } + lines[i] = ''; + continue; + } + if (/^#\s*endif\b/.test(t)) { + stack.pop(); + lines[i] = ''; + continue; + } + if (!activeNow()) lines[i] = ''; // blank an inactive line (keep the newline) + } + return lines.join('\n'); +} + +/** Resolve a type token through object-like macro aliases (transitive, capped). */ +function resolveTypeName(name: string, objEnv: Map | undefined): string { + let n = name; + for (let i = 0; objEnv && i < 5; i++) { + const v = objEnv.get(n); + const t = v?.trim().match(/^(?:struct\s+)?(\w+)$/); + if (!t) break; + n = t[1]!; + } + return n; +} + +/** Substitute call args for the macro's params (whole-token) in its expansion. */ +function substituteMacro(def: MacroDef, args: string[]): string { + const map = new Map(); + def.params.forEach((p, i) => map.set(p, args[i] ?? '')); + return def.expansion.replace(/\b\w+\b/g, (tok) => (map.has(tok) ? map.get(tok)! : tok)); +} + +/** + * Expand known function-like macro calls in `text` to a fixpoint (depth-capped). + * `MAKE_CMD("get",…,getCommand,…)` → the positional value list whose slots line + * up with the struct's fields, so the existing positional registration can read + * `getCommand` straight out of the `proc` slot. + */ +function expandMacroCalls(text: string, env: Map): string { + if (env.size === 0) return text; + let out = text; + for (let pass = 0; pass < 6; pass++) { + let changed = false; + const RE = /\b(\w+)\s*\(/g; + let m: RegExpExecArray | null; + while ((m = RE.exec(out))) { + const def = env.get(m[1]!); + if (!def) continue; + const open = m.index + m[0].length - 1; // index of the `(` + const close = matchParen(out, open); + if (close < 0) continue; + const args = splitTopLevel(out.slice(open + 1, close), ',').map((a) => a.trim()); + out = out.slice(0, m.index) + substituteMacro(def, args) + out.slice(close + 1); + changed = true; + break; // restart scan — offsets shifted + } + if (!changed) break; + } + return out; +} + +/** A fn-pointer field looks like `… (*name)(…)` — capture `name`. A + * calling-convention / attribute macro may precede the `*` + * (`(ZEND_FASTCALL *name)`), so allow leading word tokens. */ +const FNPTR_DECL_RE = /\(\s*(?:\w+\s+)*\*\s*(\w+)\s*\)\s*\(/; +/** `typedef RET (*NAME)(…)` — a function-pointer typedef (CC/attr macro before + * the `*` allowed, as in php's `typedef void (ZEND_FASTCALL *fn_t)(…)`). */ +const FNPTR_TYPEDEF_RE = /\btypedef\b[^;{}]*?\(\s*(?:\w+\s+)*\*\s*(\w+)\s*\)\s*\(/g; +/** A whole brace-free `typedef … ;` statement — capture the guts to spot the + * function-TYPE form `typedef RET NAME(params)` (no `(*name)` pointer form). */ +const FNTYPE_TYPEDEF_STMT_RE = /\btypedef\b([^;{}]*);/g; +/** Return-type keywords that must never be mistaken for the typedef's name. */ +const C_TYPE_KEYWORDS = new Set([ + 'void', 'int', 'char', 'short', 'long', 'unsigned', 'signed', 'float', 'double', + 'const', 'struct', 'union', 'enum', 'static', 'volatile', 'register', 'inline', +]); +/** `#include "local/header"` — captured from RAW source (string contents survive). */ +const INCLUDE_RE = /#[ \t]*include[ \t]+"([^"\n]+)"/g; +/** Included files worth scanning for registration tables (e.g. a generated `.def`). */ +const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i; export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] { const files = ctx.getAllFiles().filter((f) => C_CPP_EXT.test(f)); if (files.length === 0) return []; - // Cache stripped source per file (read once, reused across passes). + // Cache raw + stripped source per file (read once, reused across passes). + // Raw is needed for `#include "…"` directives — strip blanks string contents. + const rawCache = new Map(); + const raw = (file: string): string | null => { + if (rawCache.has(file)) return rawCache.get(file)!; + const r = ctx.readFile(file); + rawCache.set(file, r); + return r; + }; const srcCache = new Map(); const src = (file: string): string | null => { if (srcCache.has(file)) return srcCache.get(file)!; - const raw = ctx.readFile(file); - const s = raw == null ? '' : stripCommentsForRegex(raw, 'c'); + const r = raw(file); + const s = r == null ? '' : stripCommentsForRegex(r, 'c'); srcCache.set(file, s); - return raw == null ? null : s; + return r == null ? null : s; }; - // ---- Pass A: function-pointer typedefs (cross-file) ---- + // Resolve a quoted include relative to the includer's directory, then the + // project root. Returns a project-root-relative path that exists on disk + // (even if it was never indexed — e.g. redis' generated `commands.def`). + const resolveInclude = (includer: string, inc: string): string | null => { + const dir = path.posix.dirname(includer.replace(/\\/g, '/')); + const cand = path.posix.normalize(path.posix.join(dir, inc)); + if (ctx.fileExists(cand)) return cand; + if (ctx.fileExists(inc)) return inc; + return null; + }; + + // ---- Pass A: function-pointer AND function-type typedefs (cross-file) ---- + // fn-pointer: typedef RET (*NAME)(…) → a field `NAME f` is a fn ptr + // fn-type: typedef RET NAME(params) → a field `NAME *f` is a fn ptr + // The fn-type form is redis' command idiom: `typedef void redisCommandProc(client*)` + // declared as `redisCommandProc *proc;`. Without this, `proc` reads as data. const fnPtrTypedefs = new Set(); + const fnTypeTypedefs = new Set(); for (const file of files) { const s = src(file); if (!s || !s.includes('typedef')) continue; FNPTR_TYPEDEF_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = FNPTR_TYPEDEF_RE.exec(s))) fnPtrTypedefs.add(m[1]!); + FNTYPE_TYPEDEF_STMT_RE.lastIndex = 0; + while ((m = FNTYPE_TYPEDEF_STMT_RE.exec(s))) { + const guts = m[1]!; + if (guts.includes('(*') || guts.includes('( *')) continue; // pointer form — handled above + const fm = guts.match(/\b(\w+)\s*\(/); // last identifier before the param list + if (fm && !C_TYPE_KEYWORDS.has(fm[1]!)) fnTypeTypedefs.add(fm[1]!); + } } // ---- Pass B: struct field layouts ---- - // structLayout: struct name → ordered fields (with fn-pointer flag). + // structLayout: struct name → ordered fields, for structs with ≥1 fn-pointer + // field (drives positional registration + dispatch). + // allStructFields: EVERY struct name → ALL its field layouts (a name can be + // reused across files — e.g. redis has two unrelated `client` structs), used + // to walk a chained receiver's field types (`c->cmd->proc`: client.cmd → + // redisCommand). The walk searches every same-named layout for the field. // fieldToStructs: fn-pointer field name → set of struct names that declare it. const structLayout = new Map(); + const allStructFields = new Map(); const fieldToStructs = new Map>(); + + // Parse a struct body (the text between its `{` and `}`) into ordered fields. + const parseStructFields = (inner: string): FieldInfo[] => { + const fields: FieldInfo[] = []; + let idx = 0; + for (const rawDecl of splitTopLevel(inner, ';')) { + const decl = rawDecl.trim(); + if (!decl) continue; + // A field decl can declare several names sharing a leading type: + // `struct redisCommand *cmd, *lastcmd;`. Each declarator is its own + // positional slot and carries that type (so `client.cmd → redisCommand`). + const parts = splitTopLevel(decl, ','); + const firstTyped = parts[0]!.match(/(\w+)\s+\**\s*(\w+)\s*$/); + const sharedType = firstTyped ? firstTyped[1]! : ''; + for (let pi = 0; pi < parts.length; pi++) { + const p = parts[pi]!.trim(); + let name: string | null = null; + let type = ''; + let isFnPtr = false; + const ptr = p.match(FNPTR_DECL_RE); + if (ptr) { + name = ptr[1]!; // `… (*name)(…)` — a function pointer + isFnPtr = true; + } else if (pi === 0) { + if (firstTyped) { name = firstTyped[2]!; type = sharedType; } + } else { + // a subsequent declarator: `*name` / `**name` / `name` + const dm = p.match(/^\**\s*(\w+)/); + if (dm) { name = dm[1]!; type = sharedType; } + } + if (!ptr && type) isFnPtr = fnPtrTypedefs.has(type) || fnTypeTypedefs.has(type); + // Always advance the positional index. An unparsed field (anonymous + // union, exotic declarator) still occupies one slot, and macro-expanded + // positional tables (redis' MAKE_CMD) only align if every field counts. + fields.push({ name: name ?? '', index: idx, isFnPtr: !!name && isFnPtr, type }); + idx++; + } + } + return fields; + }; + + // Register a parsed struct under `name` into the three indexes. + const registerStructLayout = (name: string, fields: FieldInfo[]): void => { + if (!allStructFields.has(name)) allStructFields.set(name, []); + allStructFields.get(name)!.push(fields); + for (const f of fields) { + if (f.name && f.isFnPtr) { + if (!fieldToStructs.has(f.name)) fieldToStructs.set(f.name, new Set()); + fieldToStructs.get(f.name)!.add(name); + } + } + if (fields.some((f) => f.isFnPtr)) structLayout.set(name, fields); + }; + for (const st of ctx.getNodesByKind('struct')) { if (!C_CPP_EXT.test(st.filePath)) continue; const s = srcCache.get(st.filePath) ?? src(st.filePath); @@ -121,37 +434,12 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo const open = body.indexOf('{'); const close = open >= 0 ? matchBrace(body, open) : -1; if (open < 0 || close < 0) continue; - const inner = body.slice(open + 1, close); - const fields: FieldInfo[] = []; - let idx = 0; - for (const rawDecl of splitTopLevel(inner, ';')) { - const decl = rawDecl.trim(); - if (!decl) continue; - let name: string | null = null; - let isFnPtr = false; - const ptr = decl.match(FNPTR_DECL_RE); - if (ptr) { - name = ptr[1]!; - isFnPtr = true; - } else { - // `TYPE [*]name` — fn-pointer when TYPE is a fn-pointer typedef. - const fm = decl.match(/(\w+)\s+\*?\s*(\w+)\s*$/); - if (fm) { - name = fm[2]!; - isFnPtr = fnPtrTypedefs.has(fm[1]!); - } - } - if (!name) continue; - fields.push({ name, index: idx, isFnPtr }); - if (isFnPtr) { - if (!fieldToStructs.has(name)) fieldToStructs.set(name, new Set()); - fieldToStructs.get(name)!.add(st.name); - } - idx++; - } - if (fields.some((f) => f.isFnPtr)) structLayout.set(st.name, fields); + registerStructLayout(st.name, parseStructFields(body.slice(open + 1, close))); } - if (structLayout.size === 0) return []; + // NB: no early return on an empty structLayout here — an inline `struct TAG + // { … } var[]` table whose struct never became a node (vim's `cmdname`, broken + // up by `#ifdef`) is discovered later during the unit scan. The `reg.size === 0` + // guard after registration still short-circuits when nothing bridges. const fnPtrFieldOf = (struct: string, field: string): boolean => !!structLayout.get(struct)?.some((f) => f.name === field && f.isFnPtr); @@ -184,11 +472,39 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo idToNode.set(fn.id, fn); }; + // Bare arrays-of-fn-pointers (no struct): array VARIABLE name → per-file sets + // of registered function ids. Multi-entry because a file-scope `static` table + // name can recur across files (SameBoy declares `static opcode_t *opcodes[256]` + // in BOTH sm83_cpu.c and sm83_disassembler.c), so dispatch resolves same-file. + const arrayReg = new Map }[]>(); + const addArrayReg = (name: string, file: string, fn: Node): void => { + let entries = arrayReg.get(name); + if (!entries) { entries = []; arrayReg.set(name, entries); } + let e = entries.find((x) => x.file === file); + if (!e) { e = { file, ids: new Set() }; entries.push(e); } + e.ids.add(fn.id); + idToNode.set(fn.id, fn); + }; + // A struct value `{ … }` (one element) — register its function entries to the // struct's fields, by `.field = fn` designators or by positional slot. - const registerStructValue = (struct: string, valueBody: string, file: string): void => { + const registerStructValue = ( + struct: string, + valueBody: string, + file: string, + env?: Map, + ): void => { const layout = structLayout.get(struct); if (!layout) return; + if (env && env.size) valueBody = expandMacroCalls(valueBody, env); + // A macro can expand to a whole brace-wrapped element (sqlite's + // `FUNCTION(…)` → `{nArg, …, xFunc, …}`); peel one outer layer so the + // positional slots are visible. + valueBody = valueBody.trim(); + if (valueBody.startsWith('{')) { + const e = matchBrace(valueBody, 0); + if (e > 0 && valueBody.slice(e + 1).trim() === '') valueBody = valueBody.slice(1, e); + } const items = splitTopLevel(valueBody, ','); let pos = 0; for (const rawItem of items) { @@ -216,45 +532,256 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo } }; + // Collect the literal function entries of an array-of-fn-pointers initializer + // and register them under the array's variable name. Entries may be positional + // (`fn`, `&fn`), designated by index (`[OP] = fn`), or cast-wrapped + // (`(handler_t)fn`, as in php's Zend dtor table). Non-identifier entries + // (`NULL`, `0`, a nested expression) are skipped — a miss, never a wrong edge. + // No index tracking: a runtime subscript fans the dispatch out to the whole + // set, exactly like a command table reaches every command. + const registerArrayValue = ( + name: string, + body: string, + file: string, + env?: Map, + ): void => { + if (env && env.size) body = expandMacroCalls(body, env); + for (const rawItem of splitTopLevel(body, ',')) { + let item = rawItem.trim(); + if (!item) continue; + const des = item.match(/^\[[^\]]*\]\s*=\s*([\s\S]*)$/); // `[IDX] = …` designator + if (des) item = des[1]!.trim(); + item = item.replace(/^\((?:[\w\s*]+)\)\s*/, '').replace(/^&\s*/, '').trim(); // (cast) / & + const id = item.match(/^(\w+)$/); + if (!id) continue; + const fn = resolveFn(id[1]!, file); + if (fn) addArrayReg(name, file, fn); + } + }; + + // Per-file macro + include parsing (any file, indexed or not), cached. + const fnMacroCache = new Map>(); + const fileFnMacros = (file: string): Map => { + let m = fnMacroCache.get(file); + if (!m) { m = parseFunctionMacros(src(file) ?? ''); fnMacroCache.set(file, m); } + return m; + }; + const objMacroCache = new Map>(); + const fileObjMacros = (file: string): Map => { + let m = objMacroCache.get(file); + if (!m) { m = parseObjectMacros(src(file) ?? ''); objMacroCache.set(file, m); } + return m; + }; + const definedCache = new Map>(); + const fileDefinedNames = (file: string): Set => { + let d = definedCache.get(file); + if (!d) { d = parseDefinedNames(src(file) ?? ''); definedCache.set(file, d); } + return d; + }; + const includeCache = new Map(); + const localIncludesOf = (file: string): string[] => { + let out = includeCache.get(file); + if (out) return out; + out = []; + const rawText = raw(file); + if (rawText && rawText.includes('include')) { + INCLUDE_RE.lastIndex = 0; + let im: RegExpExecArray | null; + while ((im = INCLUDE_RE.exec(rawText))) { + if (!INCLUDABLE_EXT.test(im[1]!)) continue; + const t = resolveInclude(file, im[1]!); + if (t) out.push(t); + } + } + includeCache.set(file, out); + return out; + }; + + // A file's effective macro environment = its own #defines PLUS those of the + // headers it #includes (redis' `MAKE_CMD` sits beside the table; sqlite's + // `FUNCTION` lives in `sqliteInt.h`, included by the file with the table). + // First writer wins, so the file's own defs override included ones; depth-2 + // covers a macro defined in a header-of-a-header. + const buildEnv = ( + file: string, + depth: number, + seen: Set, + fn: Map, + obj: Map, + def: Set, + ): void => { + if (depth < 0 || seen.has(file)) return; + seen.add(file); + for (const [k, v] of fileFnMacros(file)) if (!fn.has(k)) fn.set(k, v); + for (const [k, v] of fileObjMacros(file)) if (!obj.has(k)) obj.set(k, v); + for (const n of fileDefinedNames(file)) def.add(n); + for (const inc of localIncludesOf(file)) buildEnv(inc, depth - 1, seen, fn, obj, def); + }; + + // Registration units: every indexed C file, plus the local headers/tables it + // `#include`s. A non-indexed include (redis' generated `commands.def`) is + // always scanned; an INDEXED header is re-scanned in an includer's context + // ONLY when that includer switches on conditional code the header guards — it + // `#define`s a name the header itself doesn't and the header has `#if` (vim's + // `ex_cmds.h`, whose command table is behind `#ifdef DO_DECLARE_EXCMD` set by + // `ex_docmd.c`). The include is scanned with the includer's effective macro + // env (its `MAKE_CMD(…)` resolves there) and its conditionals evaluated + // against the includer's defined set. `reg` is a Set, so unioning across + // multiple includers is safe. + interface Unit { + text: string; + file: string; + env: Map; + objEnv: Map; + } + const indexedSet = new Set(files); + const units: Unit[] = []; + const seenInclude = new Set(); + for (const file of files) { + const env = new Map(); + const objEnv = new Map(); + const defined = new Set(); + buildEnv(file, 2, new Set(), env, objEnv, defined); + const s = src(file); + if (s) units.push({ text: s, file, env, objEnv }); + for (const target of localIncludesOf(file)) { + if (seenInclude.has(`${file}>${target}`)) continue; + const incSrc = src(target); + if (!incSrc) continue; + if (indexedSet.has(target)) { + // Re-scan an indexed header only when this includer unlocks guarded code. + const ownDef = fileDefinedNames(target); + const adds = [...defined].some((n) => !ownDef.has(n)); + if (!adds || !/#\s*if/.test(incSrc)) continue; + } + seenInclude.add(`${file}>${target}`); + // The include is pasted into the includer — evaluate its conditionals in + // the includer's defined set (a no-op when it has none). Re-parse the + // included file's OWN macros from that resolved text so a macro it defines + // conditionally (vim's `EXCMD`, whose plain last-wins parse picks the enum + // arm) overrides with the ARM THAT IS ACTUALLY ACTIVE here. + const text = evalConditionals(incSrc, defined); + const incEnv = new Map(env); + for (const [k, v] of parseFunctionMacros(text)) incEnv.set(k, v); + const incObjEnv = new Map(objEnv); + for (const [k, v] of parseObjectMacros(text)) incObjEnv.set(k, v); + units.push({ text, file: target, env: incEnv, objEnv: incObjEnv }); + } + } + + // Global variable → struct type, for resolving a dispatch through a file-scope + // table by subscript (`cmdnames[i].cmd_func(…)`). + const globalVarType = new Map(); + + // Process a `{ … }` initializer body (array of elements or a single struct). + const processInit = ( + struct: string, + body: string, + isArray: boolean, + file: string, + env: Map, + ): void => { + if (isArray) { + for (const el of splitTopLevel(body, ',')) { + const t = el.trim(); + if (t.startsWith('{')) { + const e = matchBrace(t, 0); + if (e > 0) registerStructValue(struct, t.slice(1, e), file, env); + } else if (t) { + // an element built by a macro (`MAKE_CMD(…)`/`FUNCTION(…)`) or a bare value + registerStructValue(struct, t, file, env); + } + } + } else { + registerStructValue(struct, body, file, env); + } + }; + // `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that // has ≥1 fn-pointer field. Handles both single (`= {…}`) and array - // (`[] = { {…}, {…} }`) forms. + // (`[] = { {…}, {…} }`) forms. Macro calls inside an element are expanded first. const INIT_RE = /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:struct\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g; - for (const file of files) { - const s = srcCache.get(file); - if (!s || !s.includes('=')) continue; + // `struct TAG { … } var[opt] [= {…}]` — the struct is defined INLINE with the + // table (vim's `cmdname`/`nv_cmd`); its layout never became a node, so parse it + // here and register it before reading the entries. No leading anchor: a + // `struct TAG {` with a brace body is always a definition (it may be preceded + // by a `#define …` line ending in a digit, as in vim), and the trailing + // `var … = {` check below is what distinguishes a TABLE from a plain type. + const INLINE_STRUCT_RE = /\bstruct\s+(\w+)\s*\{/g; + // `(?:static …)* ELEMTYPE [*] name[…] = { … }` — a bare array of function + // pointers (no struct wrapper). The optional `*` covers a function-TYPE + // typedef element (`opcode_t *opcodes[]`); a function-pointer typedef element + // (`zend_rc_dtor_func_t t[]`) needs none. The typedef-set membership gate + // (below) is what separates this from a plain data/struct array. + const ARRAY_TABLE_RE = + /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(\w+)\s+(\*\s*)?(\w+)\s*\[[^\]]*\]\s*=\s*\{/g; + for (const unit of units) { + const s = unit.text; + if (!s || !s.includes('{')) continue; + + INLINE_STRUCT_RE.lastIndex = 0; + let im: RegExpExecArray | null; + while ((im = INLINE_STRUCT_RE.exec(s))) { + const tag = im[1]!; + const sOpen = im.index + im[0].length - 1; // the struct body's `{` + const sClose = matchBrace(s, sOpen); + if (sClose < 0) continue; + // After `}`, expect `var [opt] [= {…}]` to be a table; else it's a plain type. + const after = s.slice(sClose + 1); + const vm = after.match(/^\s*(\w+)\s*(\[[^\]]*\])?\s*(=\s*\{)?/); + if (!vm || !vm[1]) continue; + const fields = parseStructFields(s.slice(sOpen + 1, sClose)); + if (!fields.some((f) => f.isFnPtr)) continue; // only tables of fn pointers matter + if (!structLayout.has(tag)) registerStructLayout(tag, fields); + globalVarType.set(vm[1]!, tag); + if (vm[3]) { + const aOpen = sClose + 1 + after.indexOf('{', vm[0].length - 1); + const aClose = matchBrace(s, aOpen); + if (aClose > 0) { + processInit(tag, s.slice(aOpen + 1, aClose), !!vm[2], unit.file, unit.env); + INLINE_STRUCT_RE.lastIndex = aClose; + } + } + } + + if (!s.includes('=')) continue; INIT_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = INIT_RE.exec(s))) { - const struct = m[1]!; + let struct = m[1]!; + if (!structLayout.has(struct)) struct = resolveTypeName(struct, unit.objEnv); if (!structLayout.has(struct)) continue; const isArray = !!m[3]; const open = m.index + m[0].length - 1; // points at the `{` const close = matchBrace(s, open); if (close < 0) continue; - const body = s.slice(open + 1, close); - if (isArray) { - // top-level `{ … }` element groups - for (const el of splitTopLevel(body, ',')) { - const t = el.trim(); - if (t.startsWith('{')) { - const e = matchBrace(t, 0); - if (e > 0) registerStructValue(struct, t.slice(1, e), file); - } else if (t) { - // array of bare values (rare for structs) — treat as one positional slot - registerStructValue(struct, t, file); - } - } - } else { - registerStructValue(struct, body, file); - } + globalVarType.set(m[2]!, struct); + processInit(struct, s.slice(open + 1, close), isArray, unit.file, unit.env); INIT_RE.lastIndex = close; } + + // Bare arrays-of-function-pointers (no struct, no field). Gated on the + // element type being a function typedef — a fn-TYPE typedef needs the `*` + // (array of pointers to it), a fn-pointer typedef does not. A data or + // struct array's element type is never in these sets, so it never fires. + ARRAY_TABLE_RE.lastIndex = 0; + let am: RegExpExecArray | null; + while ((am = ARRAY_TABLE_RE.exec(s))) { + const elemType = am[1]!; + const hasStar = !!am[2]; + if (!((fnTypeTypedefs.has(elemType) && hasStar) || fnPtrTypedefs.has(elemType))) continue; + const open = am.index + am[0].length - 1; // the `{` + const close = matchBrace(s, open); + if (close < 0) continue; + registerArrayValue(am[3]!, s.slice(open + 1, close), unit.file, unit.env); + ARRAY_TABLE_RE.lastIndex = close; + } } // ---- receiver-type resolution within a function's source ---- - // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known struct). + // `(?:struct )?TYPE [*]recv` declared in the params or body → TYPE (if a known + // fn-pointer-bearing struct). const recvTypeIn = (fnSrc: string, recv: string): string | null => { const re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); let m: RegExpExecArray | null; @@ -264,6 +791,38 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo return null; }; + // Declared type of a local/param `v` — ANY type token, not just fn-pointer + // structs (the base of a chained receiver needn't carry a fn pointer itself). + // Falls back to a file-scope table variable (`cmdnames` in `cmdnames[i].fn()`). + const escapeRe = (x: string): string => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const varTypeIn = (fnSrc: string, v: string): string | null => { + const re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + let m: RegExpExecArray | null; + while ((m = re.exec(fnSrc))) { + if (!C_TYPE_KEYWORDS.has(m[1]!)) return m[1]!; + } + return globalVarType.get(v) ?? null; + }; + + // Resolve a member-access chain (`c->cmd`, or just `p`) to a struct type, + // walking each segment's declared field type. `c->cmd->proc` dispatch: + // base chain `c->cmd` → client.cmd's type `redisCommand`, the proc owner. + // Array subscripts (`cmdnames[i]`) are stripped — an index yields one element. + const resolveChainType = (fnSrc: string, chain: string): string | null => { + const segs = chain.replace(/\s*\[[^\]]*\]/g, '').split(/\s*(?:->|\.)\s*/).filter(Boolean); + if (segs.length === 0) return null; + let t = varTypeIn(fnSrc, segs[0]!); + for (let i = 1; t && i < segs.length; i++) { + let next: string | null = null; + for (const fields of allStructFields.get(t) ?? []) { + const f = fields.find((fl) => fl.name === segs[i] && fl.type); + if (f) { next = f.type; break; } + } + t = next; + } + return t; + }; + // ---- Pass D: field←field propagation (`a->f = b->g`) ---- // Collected as (targetStruct.field ← sourceStruct.field) pairs, then merged to // a fixpoint so a hook slot inherits a registry field's handlers. @@ -301,11 +860,19 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo } if (!changed) break; } - if (reg.size === 0) return []; + if (reg.size === 0 && arrayReg.size === 0) return []; // ---- Pass E: dispatch sites → edges ---- - // recv->field( or recv.field( where field is a known fn-pointer field. - const DISPATCH_RE = /(\w+)\s*(?:->|\.)\s*(\w+)\s*\(/g; + // `base->…->field(` or `base.…field(` where `field` is a known fn-pointer field. + // The base may be a chain (`c->cmd->proc`) or carry array subscripts + // (`cmdnames[i].cmd_func`). An optional `)` before the call covers the + // parenthesized form `(cmdnames[i].cmd_func)(&ea)` vim uses. + const DISPATCH_RE = /((?:\w+(?:\s*\[[^\][]*\])?\s*(?:->|\.)\s*)+)(\w+)\s*\)?\s*\(/g; + // Bare-array dispatch: `tbl[i](…)` or the explicit-deref `(*tbl[i])(…)`. The + // subscript may itself contain a call (`tbl[GC_TYPE(p)](…)`), so the index + // class excludes only brackets. Precision comes from the `arrayReg` gate below + // — this fires only when `tbl` is a known fn-pointer array. + const ARRAY_DISPATCH_RE = /(?:\(\s*\*\s*)?\b(\w+)\s*\[[^\][]*\]\s*\)?\s*\(/g; const edges: Edge[] = []; const seen = new Set(); for (const fn of cFns) { @@ -316,13 +883,19 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo let m: RegExpExecArray | null; let added = 0; while ((m = DISPATCH_RE.exec(body)) && added < FANOUT_CAP) { - const recv = m[1]!; + const baseChain = m[1]!.replace(/\s*(?:->|\.)\s*$/, '').trim(); // receiver, minus the trailing arrow const field = m[2]!; const owners = fieldToStructs.get(field); if (!owners || owners.size === 0) continue; - // Resolve the receiver's struct type; else fall back to a field name that - // belongs to exactly one struct. - let struct = recvTypeIn(body, recv); + // 1) resolve the receiver chain's struct type precisely (handles c->cmd->proc); + // 2) else the last segment as a simple local/param of a fn-pointer-bearing struct; + // 3) else fall back to a field name that belongs to exactly one struct. + let struct = resolveChainType(body, baseChain); + if (!struct || !owners.has(struct)) { + const lastSeg = baseChain.replace(/\s*\[[^\]]*\]/g, '').split(/\s*(?:->|\.)\s*/).pop()!; + const t = recvTypeIn(body, lastSeg); + struct = t && owners.has(t) ? t : null; + } if (!struct || !owners.has(struct)) struct = owners.size === 1 ? [...owners][0]! : null; if (!struct) continue; const targets = reg.get(`${struct}.${field}`); @@ -348,6 +921,41 @@ export function cFnPointerDispatchEdges(queries: QueryBuilder, ctx: ResolutionCo if (++added >= FANOUT_CAP) break; } } + + // ---- bare array-of-fn-pointers dispatch (`tbl[i](…)`) ---- + if (arrayReg.size && added < FANOUT_CAP) { + ARRAY_DISPATCH_RE.lastIndex = 0; + while ((m = ARRAY_DISPATCH_RE.exec(body)) && added < FANOUT_CAP) { + const entries = arrayReg.get(m[1]!); + if (!entries) continue; + // Same-file table wins on a name collision (two file-local `opcodes`); + // a unique name resolves cross-file; otherwise ambiguous — bail. + const ids = entries.length === 1 + ? entries[0]!.ids + : (entries.find((e) => e.file === fn.filePath)?.ids ?? null); + if (!ids) continue; + const line = fn.startLine + body.slice(0, m.index).split('\n').length - 1; + for (const tid of ids) { + if (tid === fn.id) continue; + const key = `${fn.id}>${tid}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: fn.id, + target: tid, + kind: 'calls', + line, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'fn-pointer-dispatch', + via: `${m[1]}[]`, + registeredAt: `${fn.filePath}:${line}`, + }, + }); + if (++added >= FANOUT_CAP) break; + } + } + } } return edges; }