From e259d207e41175ed3d3ef40fc257125485e60a53 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 06:43:06 -0500 Subject: [PATCH 1/2] fix(c,cpp): a struct, class, union or enum defined in a variable declaration is indexed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `struct Foo { … } foo;`, `class Bar { … } bar;` and `static struct { … } SPT;` outside any function or class body went to the variable extractor, which skipped the declaration's children, so the type, its methods and its enumerators were never indexed in either extraction engine. Both engines now walk such a type before the variables (C always; C++ when the tree has no errors), and the declaration's function-as-value scan skips the walked type, which captured its own. An unnamed struct, union or enum takes the name of the first variable its declaration declares (also inside function bodies, where it was ``), as `typedef struct { … } Name;` already takes the typedef name, and the comment above the declaration is the type's docstring. cDeclaratorIdentifier moves to languages/c-cpp.ts. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/c-cpp-declaration-types.test.ts | 257 +++++++++++++++++++ __tests__/fixtures/kernel-parity/torture.c | 20 ++ __tests__/fixtures/kernel-parity/torture.cpp | 15 ++ codegraph-kernel/src/ccpp/mod.rs | 88 ++++++- docs/design/ccpp-kernel-port-checklist.md | 3 +- src/extraction/languages/c-cpp.ts | 81 +++++- src/extraction/tree-sitter.ts | 54 ++-- 8 files changed, 478 insertions(+), 41 deletions(-) create mode 100644 __tests__/c-cpp-declaration-types.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4c1d081..4840840078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) +- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, only the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/c-cpp-declaration-types.test.ts b/__tests__/c-cpp-declaration-types.test.ts new file mode 100644 index 0000000000..76e3b66433 --- /dev/null +++ b/__tests__/c-cpp-declaration-types.test.ts @@ -0,0 +1,257 @@ +/** + * A class, struct, union or enum DEFINED in the type of a C or C++ variable + * declaration was never indexed: + * + * namespace n { struct Foo { int a; void f() {} } foo; } // only n::foo + * static struct { int argc; char **argv; } SPT; // nothing at all + * enum { MODE_IDLE, MODE_BUSY } current_mode; // no enumerators + * + * Both extractors handed such a declaration to the variable extractor and + * skipped its children, so the type, its methods and its enumerators never + * became nodes, a base class defined this way had nothing to resolve to, and + * calls on its methods linked to nothing. + * + * The type is now walked like one written on its own, beside the variables + * (which keep their nodes), and the comment above the declaration documents + * it. An unnamed one takes the name of the first variable its declaration + * declares, as `typedef struct { … } Name;` takes the typedef name: no code + * can name the type of `SPT`, only `SPT`. Runs against the native kernel + * (when built) and the wasm extractor, which must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS', 'CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT'] as const; + +/** ` ` for every symbol, file and imports aside. */ +function symbols(result: ExtractionResult): string[] { + return result.nodes + .filter((n) => n.kind !== 'file' && n.kind !== 'import') + .map((n) => `${n.kind} ${n.qualifiedName}`); +} + +/** ` > ` for every containment edge below the file. */ +function containment(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.edges + .filter((e) => e.kind === 'contains' && byId.get(e.source)?.kind !== 'file') + .map((e) => `${byId.get(e.source)?.qualifiedName} > ${byId.get(e.target)?.qualifiedName}`); +} + +describe('a type defined in a C/C++ declaration', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['c', 'cpp']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`C++ classes and structs beside their variables: %s${label}`, (backend) => { + const source = [ + 'namespace n {', + 'struct Foo { int a; void f() {} } foo;', + 'class Bar { void g() {} } bar, *pbar;', + '}', + '', + ].join('\n'); + const result = extract(backend, 'src/decl.cpp', eol(source), 'cpp'); + expect(symbols(result)).toEqual([ + 'struct n::Foo', + 'method n::Foo::f', + 'variable n::foo', + 'class n::Bar', + 'method n::Bar::g', + 'variable n::bar', + ]); + expect(containment(result)).toEqual(['n::Foo > n::Foo::f', 'n::Bar > n::Bar::g']); + }); + + it.each(backends)(`C structs, unions and enums; an unnamed one takes its variable's name: %s${label}`, (backend) => { + const source = [ + 'struct Foo { int a; } foo;', + 'static struct { int argc; char **argv; } SPT;', + 'static const struct { const char *name; int code; } errors[] = { { "none", 0 } };', + 'struct { int x; } *first_point, second_point;', + 'union { int i; float f; } scratch = { 0 };', + 'enum { MODE_IDLE, MODE_BUSY } current_mode;', + 'typedef struct Bar { int c; } Bar;', + '', + ].join('\n'); + const result = extract(backend, 'src/decl.c', eol(source), 'c'); + expect(symbols(result)).toEqual([ + 'struct Foo', + 'struct SPT', + 'struct errors', + 'constant errors', + 'struct first_point', + 'variable first_point', + 'union scratch', + 'variable scratch', + 'enum current_mode', + 'enum_member current_mode::MODE_IDLE', + 'enum_member current_mode::MODE_BUSY', + 'struct Bar', + ]); + }); + + it.each(backends)(`a type whose declaration declares no variable stays unnamed: %s${label}`, (backend) => { + // A function's return type: naming it after the function would be wrong. + const result = extract(backend, 'src/proto.c', eol('struct { int unused; } make_unnamed(void);\n'), 'c'); + expect(symbols(result)).toEqual(['struct ']); + }); + + it.each(backends)(`the comment above the declaration documents the type: %s${label}`, (backend) => { + const source = [ + 'namespace app {', + '/** The handler registry. */', + 'struct Registry { int size() const { return 2; } } registry;', + '}', + '', + ].join('\n'); + const result = extract(backend, 'src/registry.cc', eol(source), 'cpp'); + const docs = result.nodes + .filter((n) => n.name === 'Registry' || n.name === 'registry') + .map((n) => `${n.kind} ${n.docstring}`); + expect(docs).toEqual(['struct The handler registry.', 'variable The handler registry.']); + }); + + it.each(backends)(`an unnamed type in a function body takes its variable's name too: %s${label}`, (backend) => { + const source = 'int hits(void) {\n static struct { int count; } local_stats;\n return ++local_stats.count;\n}\n'; + const result = extract(backend, 'src/stats.c', eol(source), 'c'); + expect(symbols(result)).toEqual(['function hits', 'struct hits::local_stats']); + }); + + it.each(backends)(`a file the parser misreads: C keeps the type, C++ leaves it alone: %s${label}`, (backend) => { + // The kernel defers a file whose tree has errors to wasm; its + // error-extract hatch walks one anyway, and must agree. + process.env.CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT = '1'; + const broken = 'int broken( { return 1; }\n'; + const c = extract(backend, 'src/spt.c', eol(`static struct { int argc; } SPT;\n${broken}`), 'c'); + expect(symbols(c)).toContain('struct SPT'); + // In C++, error recovery can close a namespace or a class at the wrong + // `}`, so a class walked from a declaration could land in the wrong + // scope: the variable is kept and the class is not walked. + const source = `namespace n {\nstruct Config { int retries; void apply() {} } config;\n}\n${broken}`; + const cpp = extract(backend, 'src/config.cc', eol(source), 'cpp'); + expect(symbols(cpp)).toEqual(['variable n::config']); + }); + + it.each(backends)(`a function value in the type's body is captured once, by the type: %s${label}`, (backend) => { + const source = [ + 'void on_open() {}', + 'struct Handlers { void (*all[1])() = {&on_open}; } handlers;', + '', + ].join('\n'); + const result = extract(backend, 'src/handlers.cc', eol(source), 'cpp'); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + const refs = result.unresolvedReferences + .filter((r) => r.referenceKind === 'function_ref') + .map((r) => `${byId.get(r.fromNodeId)?.name} ${r.referenceName}`); + expect(refs).toEqual(['Handlers on_open']); + }); + } +}); + +describe('an indexed C/C++ project with types defined in declarations', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + it('links base classes and calls to them, and finds an unnamed struct by its variable', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-declaration-types-')); + const files: Record = { + 'src/registry.cc': [ + 'namespace app {', + 'struct Registry { int size() const { return 2; } } registry;', + 'class Base { public: virtual int run() { return 1; } } base_instance;', + 'class Worker : public Base { public: int run() override { return registry.size(); } };', + 'int tick() { Worker w; return w.run() + registry.size(); }', + '}', + '', + ].join('\n'), + 'src/setproctitle.c': [ + '/* Saved argv. */', + 'static struct { int argc; char **argv; } SPT;', + 'int spt_init(int argc) { SPT.argc = argc; return SPT.argc; }', + '', + ].join('\n'), + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const one = (qualifiedName: string) => { + const found = graph.getNodesByName(qualifiedName.split('::').pop()!).filter((n) => n.qualifiedName === qualifiedName); + expect(found, qualifiedName).toHaveLength(1); + return found[0]!; + }; + const edges = (id: string, kind: 'calls' | 'extends') => + graph + .getIncomingEdgesTo([id], [kind]) + .map((e) => graph.getNode(e.source)?.qualifiedName) + .sort(); + + expect(edges(one('app::Registry::size').id, 'calls')).toEqual(['app::Worker::run', 'app::tick']); + expect(edges(one('app::Base').id, 'extends')).toEqual(['app::Worker']); + + const spt = graph.searchNodes('SPT', { limit: 5 }).map((r) => `${r.node.kind} ${r.node.name}`); + expect(spt).toContain('struct SPT'); + expect(one('SPT').docstring).toBe('Saved argv.'); + }, 60_000); +}); diff --git a/__tests__/fixtures/kernel-parity/torture.c b/__tests__/fixtures/kernel-parity/torture.c index 57e04a912c..a785980512 100644 --- a/__tests__/fixtures/kernel-parity/torture.c +++ b/__tests__/fixtures/kernel-parity/torture.c @@ -168,3 +168,23 @@ typedef union { } word_t; static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } + +/* struct, union and enum definitions in a declaration's type: each is a node + beside the declaration's variables, documented by the comment above the + declaration; an unnamed one takes the name of its first variable */ +static void noop_builtin(void) {} + +/* The parser's state. */ +static struct parse_state { int depth; const char *at; } parse_state, *active_state; +static struct { int argc; char **argv; } SPT; +static const struct { const char *name; void (*fn)(void); } builtin_table[] = { + { "noop", noop_builtin }, +}; +union { int i; float f; } scratch_word = { 0 }; +enum { MODE_IDLE, MODE_BUSY } current_mode; +struct { int unused; } make_unnamed(void); + +static int declared_types_local(void) { + static struct { int hits; } local_stats; + return ++local_stats.hits + current_mode; +} diff --git a/__tests__/fixtures/kernel-parity/torture.cpp b/__tests__/fixtures/kernel-parity/torture.cpp index b6b160f7ae..b2ba1ea089 100644 --- a/__tests__/fixtures/kernel-parity/torture.cpp +++ b/__tests__/fixtures/kernel-parity/torture.cpp @@ -108,6 +108,21 @@ void Widget::Tick(float Delta) { Health += Delta; } Config GlobalConfig; int build_number = 7; +// class/struct/union/enum definitions in a declaration's type — nodes beside +// the variables; an unnamed one takes its first variable's name +void on_open() {} +void on_close() {} +namespace decl_types { +/** The handler registry. */ +struct Registry { + int size() const { return 2; } + void (*handlers[2])() = {&on_open, &on_close}; +} registry; +class Bar { void g() {} } bar, *pbar; +static struct { int retries; } settings; +enum { kIdle, kBusy } state; +} // namespace decl_types + template T compute_seed(T v) { return v + 1; diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index d92bc33141..710a6c9850 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -33,6 +33,12 @@ //! (loses uninit scalars by design); cpp declarations instead take the TS //! GENERIC fallback (direct identifier children only → `int x;` extracts, //! `int x = 5;` does not — bug-for-bug). +//! - a class/struct/union/enum defined in a declaration's `type` +//! (`struct Foo { … } foo;`) is walked before the variables — in C, and in +//! C++ unless the tree has errors (walkDeclaredTypes); an unnamed one takes +//! the name of the first declarator identifier (`static struct { … } SPT;` +//! → `SPT`, in function bodies too), and the comment above the declaration +//! is its docstring (c/cpp getDeclarationWrapper). //! - static-member/value-read pass (cpp only): `field_expression` is in //! MEMBER_ACCESS_TYPES (listed for Scala, same node kind in cpp), so //! `Capitalized.member` / `Capitalized->member` VALUE reads emit @@ -353,6 +359,10 @@ pub struct Walker<'t> { fs_values: HashMap, fs_value_counts: HashMap, value_scopes: Vec>, + /// walkDeclaredTypes (tree-sitter.ts): a class-like type defined in a + /// declaration is walked in C, and in C++ unless the tree has errors + /// (reachable here only under the error-extract hatch). + walk_declared_types: bool, } pub fn extract(file_path: &str, source: &str, language: &str) -> Result { @@ -397,6 +407,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> { return hook; } } + if let Some(name) = self.unnamed_type_variable_name(node) { + return name; + } if let Some(name_node) = node.child_by_field_name("declarator") { let mut resolved = name_node; // Unwrap pointer/reference declarators (`int* f()`, `T& f()`). @@ -645,6 +659,21 @@ impl<'t> Walker<'t> { "".to_string() } + /// unnamedTypeVariableName (languages/c-cpp.ts): an unnamed class, struct, + /// union or enum defined in a declaration takes the name of the first + /// variable the declaration declares (`static struct { … } SPT;` → `SPT`). + fn unnamed_type_variable_name(&self, node: Node) -> Option { + if node.child_by_field_name("name").is_some() { + return None; + } + let declaration = defining_declaration(node)?; + let mut cursor = declaration.walk(); + let identifier = declaration + .children_by_field_name("declarator", &mut cursor) + .find_map(c_declarator_identifier)?; + Some(self.text(identifier).to_string()) + } + /// extractCppQualifiedMethodName (languages/c-cpp.ts:75). fn extract_cpp_qualified_method_name(&self, node: Node) -> Option { if let Some(n) = self.recover_cpp_macro_defined_name(node) { @@ -965,8 +994,31 @@ impl<'t> Walker<'t> { { skip_children = self.extract_type_alias(node); } else if kind == "declaration" && !self.inside_class_like() { + // A class, struct, union or enum defined in the declaration's type + // (`struct Foo { … } foo;`) is a definition like one written on its + // own, and the variables keep their nodes beside it. The fn-ref + // scan skips the walked type, which captured its own (the TS + // walked set). + let defined_type = node + .child_by_field_name("type") + .filter(|t| self.walk_declared_types && is_class_like_definition(*t)); + if let Some(t) = defined_type { + self.visit_node(t); + } self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + match defined_type { + Some(t) => { + self.maybe_capture_fn_refs(node); + for i in 0..node.named_child_count() { + if let Some(c) = node.named_child(i) { + if c.id() != t.id() { + self.scan_fn_ref_subtree(c, 1); + } + } + } + } + None => self.scan_fn_ref_subtree(node, 0), + } skip_children = true; } else if self.variant == Variant::Cpp && kind == "field_declaration" @@ -1113,13 +1165,19 @@ impl<'t> Walker<'t> { self.stack.pop(); } + /// docstringFor a class-like node: the comment above the declaration it is + /// defined in (the c/cpp getDeclarationWrapper), else above the node. + fn class_like_docstring(&self, node: Node<'t>) -> Option { + preceding_docstring(defining_declaration(node).unwrap_or(node), self.src) + } + /// extractClass for cpp class_specifier (skipBodilessClass, #1093). fn extract_class(&mut self, node: Node<'t>) { stack_guard!(); let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { - docstring: preceding_docstring(node, self.src), + docstring: self.class_like_docstring(node), visibility: self.visibility_of(node), ..Extra::default() }; @@ -1140,7 +1198,7 @@ impl<'t> Walker<'t> { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { - docstring: preceding_docstring(node, self.src), + docstring: self.class_like_docstring(node), visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, ..Extra::default() }; @@ -1160,7 +1218,7 @@ impl<'t> Walker<'t> { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { - docstring: preceding_docstring(node, self.src), + docstring: self.class_like_docstring(node), visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, ..Extra::default() }; @@ -2199,7 +2257,7 @@ fn find_declarator_qualified_id(declarator: Node) -> Option { None } -/// cDeclaratorIdentifier (tree-sitter.ts:234): resolve the declared identifier +/// cDeclaratorIdentifier (languages/c-cpp.ts): resolve the declared identifier /// through init/pointer/array/parenthesized declarator wrappers; a /// function_declarator means prototype/fn-ptr — null. (The C grammar's /// parenthesized_declarator exposes no `declarator` field, so that arm always @@ -2225,6 +2283,26 @@ fn c_declarator_identifier(node: Node) -> Option { None } +/// isClassLikeDefinition (languages/c-cpp.ts): a class/struct/union/enum +/// specifier that carries a body defines the type; `struct Foo *p` and the +/// forward declaration `class Foo;` only name it. +fn is_class_like_definition(node: Node) -> bool { + matches!(node.kind(), "class_specifier" | "struct_specifier" | "union_specifier" | "enum_specifier") + && node.child_by_field_name("body").is_some() +} + +/// definingDeclaration (languages/c-cpp.ts): the `declaration` a class-like +/// definition is the type of (`struct Foo { … } foo;`). Its comment documents +/// the type, and an unnamed type takes the name of its first variable. +fn defining_declaration(node: Node) -> Option { + if !is_class_like_definition(node) { + return None; + } + let parent = node.parent()?; + let is_type = parent.child_by_field_name("type").map(|t| t.id()) == Some(node.id()); + (parent.kind() == "declaration" && is_type).then_some(parent) +} + /// isMacroMisparsedTypeDecl (languages/c-cpp.ts:261): `class MACRO Name {…}` /// misparse residue — bodyless class/struct specifier in `type` + a /// non-function_declarator declarator. diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 148bad5252..82c492f13d 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -178,7 +178,7 @@ type_identifier + ≥2 params + NO other lone-identifier param; gtest recoverMangledCppName (line 406 — only already-mangled names, `Ret (name)` idiom left alone, last token before `(`, primitive/keyword guard); stripCppTemplateArgs (line 157 — depth-counted removal of every balanced -`<…>`); cDeclaratorIdentifier (tree-sitter.ts:234 — declarator chain walk, +`<…>`); cDeclaratorIdentifier (languages/c-cpp.ts — declarator chain walk, function_declarator → null, 12-hop guard). ## tree-sitter.ts branches (anchors as of `705e501`) @@ -187,6 +187,7 @@ function_declarator → null, 12-hop guard). |---|---|---| | 962 | cpp namespace prefix stack (#1291) | named `namespace_definition` pushes its name (C++17 `a::b` as written) onto the QN prefix while walking children; anonymous falls through bare | | 2795 | C file-scope variables | only when NO function ancestor; iterate declarators; accept ONLY init_declarator / pointer_declarator / array_declarator — a BARE identifier declarator is a macro-prototype misparse, skip (loses uninit scalars by design); name via cDeclaratorIdentifier; signature `= `; kind constant/variable via isConst | +| — | class-like types defined in declarations | a `declaration` outside any body whose `type` is a class/struct/union/enum specifier WITH a body: visit the type before extracting the variables, in C always and in C++ only when the tree has no errors (`walkDeclaredTypes` ↔ `walk_declared_types`); the declaration's fn-ref scan skips the walked type. An unnamed specifier whose parent declaration has it as `type` takes the first declarator's cDeclaratorIdentifier name — in function bodies too (the c/cpp `resolveName` ↔ `extract_name_raw`) — and the declaration's preceding comment is its docstring (`getDeclarationWrapper` ↔ `class_like_docstring`) | | 4313 | explicit operator calls (#1247) | callee = `function` field + ERROR-wrapped `operator_name` sibling; compact symbolic spacing (`operator *`→`operator*`, word forms keep space); receiver `->`→`.`; DROP unless receiver is `this` (bare name) or simple identifier/member chain (silent miss over wrong edge) | | ~4340 | field_expression method calls | `recv.method`/`ptr->method` → `recv.method`; SKIP_RECEIVERS {self,this,cls,super} → bare name; LITERAL receiver → emit nothing (#1230) | | 4398 | call-result receivers (#645/#608) | receiver is call_expression → `().` re-encode (c AND cpp in the gate list) | diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index ac238978d6..ef8d41aaa7 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -2,6 +2,77 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; import { getChildByField, getNodeText } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; +/** + * Resolve the declared identifier inside a C declarator. A `declaration`'s + * `declarator` field nests the name through `init_declarator` (with value), + * `pointer_declarator`/`array_declarator`/`parenthesized_declarator` + * wrappers (each via their own `declarator` field) down to an `identifier`. + * A `function_declarator` means the declaration is a function prototype (or a + * function-pointer var) — return null so it isn't extracted as a variable. + */ +export function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | null { + let cur: SyntaxNode | null = node; + let guard = 0; + while (cur && guard++ < 12) { + switch (cur.type) { + case 'identifier': + return cur; + case 'function_declarator': + return null; + case 'init_declarator': + case 'pointer_declarator': + case 'array_declarator': + case 'parenthesized_declarator': + cur = getChildByField(cur, 'declarator'); + break; + default: + return null; + } + } + return null; +} + +/** C and C++ specifiers that define a class-like type, or an enum, when they carry a body (C has no `class_specifier`). */ +const CLASS_LIKE_SPECIFIERS = new Set(['class_specifier', 'struct_specifier', 'union_specifier', 'enum_specifier']); + +/** + * Whether a C/C++ type specifier defines a class, struct, union or enum, as + * opposed to naming one (`struct Foo *p`, the forward declaration `class Foo;`). + */ +export function isClassLikeDefinition(node: SyntaxNode): boolean { + return CLASS_LIKE_SPECIFIERS.has(node.type) && getChildByField(node, 'body') !== null; +} + +/** + * The `declaration` a class, struct, union or enum is defined in the type of — + * `struct Foo { … } foo;` — or null for one written on its own, in a typedef or + * in a member's type. + */ +function definingDeclaration(node: SyntaxNode): SyntaxNode | null { + if (!isClassLikeDefinition(node)) return null; + const parent = node.parent; + return parent?.type === 'declaration' && getChildByField(parent, 'type')?.id === node.id ? parent : null; +} + +/** + * The name of an unnamed class, struct, union or enum that a declaration + * defines: the first variable the declaration declares. No code can name the + * type of `static struct { … } SPT;` (only `SPT`), so the struct is indexed as + * `SPT`, the way `typedef struct { … } Name;` is indexed as `Name`. Undefined + * for a named type, and when the declaration declares no variable (a + * function's return type). Mirrored in the kernel (ccpp/mod.rs). + */ +function unnamedTypeVariableName(node: SyntaxNode, source: string): string | undefined { + if (getChildByField(node, 'name')) return undefined; + const declaration = definingDeclaration(node); + if (!declaration) return undefined; + for (const declarator of declaration.childrenForFieldName('declarator')) { + const identifier = cDeclaratorIdentifier(declarator); + if (identifier) return getNodeText(identifier, source); + } + return undefined; +} + /** * Find the function NAME's `qualified_identifier` (`Foo::bar`) inside a * declarator, skipping the `parameter_list` — a parameter with a qualified type @@ -256,7 +327,11 @@ function extractCppReturnType(node: SyntaxNode, source: string): string | undefi } export const cExtractor: LanguageExtractor = { - resolveName: recoverSingleArgMacroDefinedName, + resolveName: (node, source) => + recoverSingleArgMacroDefinedName(node, source) ?? unnamedTypeVariableName(node, source), + // A struct, union or enum defined in a declaration's type is documented by + // the comment above the declaration. + getDeclarationWrapper: (node) => definingDeclaration(node) ?? undefined, // CUDA in C-detected headers (content-gated blank; see preParseCSource). preParse: preParseCSource, // Universal net: recover a real name from any macro-mangled function name. @@ -1920,7 +1995,9 @@ export const cppExtractor: LanguageExtractor = { nameField: 'declarator', bodyField: 'body', paramsField: 'parameters', - resolveName: extractCppQualifiedMethodName, + resolveName: (node, source) => + extractCppQualifiedMethodName(node, source) ?? unnamedTypeVariableName(node, source), + getDeclarationWrapper: (node) => definingDeclaration(node) ?? undefined, getReceiverType: extractCppReceiverType, getReturnType: extractCppReturnType, // Constructors (definitions and class-body declarations) carry their diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 997d735170..48d405ce0e 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -21,7 +21,7 @@ import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandida import { isGeneratedFile, isMinifiedContent } from './generated-detection'; import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; -import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; +import { stripCppTemplateArgs, isCppConstructorDeclaration, cDeclaratorIdentifier, isClassLikeDefinition } from './languages/c-cpp'; import { rustImplTypeName } from './languages/rust'; import { goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; @@ -264,36 +264,6 @@ function csharpClassTypeName(node: SyntaxNode | null, source: string): string | } } -/** - * Resolve the declared identifier inside a C declarator. A `declaration`'s - * `declarator` field nests the name through `init_declarator` (with value), - * `pointer_declarator`/`array_declarator`/`parenthesized_declarator` - * wrappers (each via their own `declarator` field) down to an `identifier`. - * A `function_declarator` means the declaration is a function prototype (or a - * function-pointer var) — return null so it isn't extracted as a variable. - */ -function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | null { - let cur: SyntaxNode | null = node; - let guard = 0; - while (cur && guard++ < 12) { - switch (cur.type) { - case 'identifier': - return cur; - case 'function_declarator': - return null; - case 'init_declarator': - case 'pointer_declarator': - case 'array_declarator': - case 'parenthesized_declarator': - cur = getChildByField(cur, 'declarator'); - break; - default: - return null; - } - } - return null; -} - /** First `simple_identifier` in `node`'s subtree (breadth-ish, first-found). * Swift's property name nests as `property_declaration → pattern → * bound_identifier → simple_identifier`; this resolves it (and the bound name of @@ -600,6 +570,12 @@ export class TreeSitterExtractor { // files, and a node per block would flood search with same-named symbols // (the #1093 crowd-out failure mode). Always empty outside C/C++. private namespacePrefix: string[] = []; + // C/C++: whether a class, struct, union or enum defined in the type of a + // declaration outside any body is walked (see visitNode). Not in a C++ file + // whose tree has errors: error recovery there can close a namespace or a + // class at the wrong `}`, or run a class past its own, so the walked class + // would land in the wrong scope. + private walkDeclaredTypes = false; // C++ local function-pointer bindings, per enclosing symbol: // `auto kernel = &flash_fwd_kernel<…>;` recorded as callerId → kernel → // {flash_fwd_kernel}, so a later `kernel<<>>(params)` (or plain @@ -696,6 +672,8 @@ export class TreeSitterExtractor { if (!this.tree) { throw new Error('Parser returned null tree'); } + this.walkDeclaredTypes = + this.language === 'c' || (this.language === 'cpp' && !this.tree.rootNode.hasError); // Create file node representing the source file const fileNode: Node = { @@ -1403,6 +1381,15 @@ export class TreeSitterExtractor { this.extractor.variableTypes.includes(nodeType) && (!this.isInsideClassLikeNode() || this.isClassScopeConstantAssignment(node)) ) { + // C/C++: a class, struct, union or enum defined in the declaration's + // type (`struct Foo { … } foo;`, `static struct { … } SPT;`) is a + // definition like one written on its own, and the variables keep their + // nodes beside it. In a class or function body the children walk + // reaches it; here, this branch skips that walk. Mirrored in the kernel + // (ccpp/mod.rs visit_node). + const declaredType = this.walkDeclaredTypes ? getChildByField(node, 'type') : null; + const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null; + if (definedType) this.visitNode(definedType); this.extractVariable(node); // extractVariable doesn't walk every initializer shape (object literals // are deliberately skipped; Python/Ruby don't walk at all), so scan the @@ -1410,8 +1397,9 @@ export class TreeSitterExtractor { // { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts // at nested function definitions (their bodies are walked — and // attributed — separately) and flush-time dedup absorbs any overlap with - // initializers extractVariable DOES walk. - this.scanFnRefSubtree(node, 0); + // initializers extractVariable DOES walk. A type walked above captured + // its own. + this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : undefined); skipChildren = true; // extractVariable handles children } // Swift properties inside a type. A stored instance property becomes a `field` From 2efa276f725bbeceed2683c746a8a1858ab86a0f Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 08:09:29 -0500 Subject: [PATCH 2/2] docs(c,cpp): word the declaration-types changelog for C, note brace scopes in the test header Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/c-cpp-declaration-types.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916caec8d2..b6b5e5d66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,7 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. -- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, only the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. +- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/c-cpp-declaration-types.test.ts b/__tests__/c-cpp-declaration-types.test.ts index d4f68ef77a..4ae348593e 100644 --- a/__tests__/c-cpp-declaration-types.test.ts +++ b/__tests__/c-cpp-declaration-types.test.ts @@ -15,8 +15,10 @@ * (which keep their nodes), and the comment above the declaration documents * it. An unnamed one takes the name of the first variable its declaration * declares, as `typedef struct { … } Name;` takes the typedef name: no code - * can name the type of `SPT`, only `SPT`. Runs against the native kernel - * (when built) and the wasm extractor, which must agree. + * can name the type of `SPT`, only `SPT`. In a C++ file whose tree has errors + * the type is walked in the scopes the file's braces open (#2426), and not at + * all when the braces don't balance. Runs against the native kernel (when + * built) and the wasm extractor, which must agree. */ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs';