diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc0e9e8..536232873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, and the methods from that impl were left pointing at a type the graph did not contain. A `typedef union { … } Name;` in C now carries the typedef's name and remains distinguishable from a struct. Re-index after upgrading to replace the earlier struct-shaped union nodes. + - `codegraph_explore` now concentrates its answer on the code that actually answers your question instead of spreading it across files that merely share a word with it, so more of the answer arrives in a single call. Thanks @LeDuyViet for the detailed measurements and reproduction. (#1500) - Files only weakly related to your question now come back as a name, symbol and line number instead of spending the answer on their source — name one of them in a follow-up `codegraph_explore` to get it back in full. (#1500) - A generated CRUD or protobuf layer no longer crowds out the hand-written code sitting beside it: generated files are now recognized by the `// Code generated by … DO NOT EDIT.` style banner written at the top of the file, not just by a filename that looks generated. Re-index after upgrading to pick up the new detection. (#1500) diff --git a/CLAUDE.md b/CLAUDE.md index 1ae8ae969..4f24f1c0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the Defined in `src/types.ts`. Both extractors and resolvers must use these exact strings. -- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`. +- **NodeKind**: `file`, `module`, `class`, `struct`, `interface`, `trait`, `protocol`, `function`, `method`, `property`, `field`, `variable`, `constant`, `enum`, `enum_member`, `type_alias`, `namespace`, `parameter`, `import`, `export`, `route`, `component`, `union`. - **EdgeKind**: `contains`, `calls`, `imports`, `exports`, `extends`, `implements`, `references`, `type_of`, `returns`, `instantiates`, `overrides`, `decorates`. ### Multi-agent installer diff --git a/__tests__/c-fnptr-synthesizer.test.ts b/__tests__/c-fnptr-synthesizer.test.ts index 202c9638f..bba9fcadf 100644 --- a/__tests__/c-fnptr-synthesizer.test.ts +++ b/__tests__/c-fnptr-synthesizer.test.ts @@ -86,6 +86,45 @@ int dispatch(struct ops o) { return o.handler(); } expect(edges.every((e) => e.via === 'ops.handler')).toBe(true); }); + it('bridges function-pointer fields declared in a union', async () => { + write('union-ops.c', ` +union ops { int (*handler)(void); }; +static int on_open(void) { return 1; } +static union ops the_ops = { .handler = on_open }; + +int dispatch(union ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + expect(edges.every((e) => e.via === 'ops.handler')).toBe(true); + }); + + it('bridges an inline union table whose entries are macro-built', async () => { + write('inline-union.c', ` +#define SLOT(fn) { fn } +static int on_open(void) { return 1; } +static union inline_ops { int (*handler)(void); } ops[] = { SLOT(on_open) }; + +int dispatch(union inline_ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + }); + + it('bridges a union table declared through an object-macro type alias', async () => { + write('alias-union.c', ` +#define OPS_TYPE union ops +#define SLOT(fn) { fn } +union ops { int (*handler)(void); }; +static int on_open(void) { return 1; } +static OPS_TYPE ops[] = { SLOT(on_open) }; + +int dispatch(union ops o) { return o.handler(); } +`); + const edges = await load(); + expect(has(edges, 'dispatch', 'on_open')).toBe(true); + }); + it('bridges the typedef-field + field←field double-hop (the hook_demo.c shape)', async () => { write('hook.c', ` typedef void (*hook_func)(void); diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index 52dae1fe8..c46c612d7 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -135,10 +135,16 @@ export function validateEmail(email: string): boolean { ` ); + fs.writeFileSync( + path.join(srcDir, 'callback_ops.c'), + `union CallbackOps { int (*run)(int); }; +` + ); + // Initialize CodeGraph cg = CodeGraph.initSync(testDir, { config: { - include: ['**/*.ts'], + include: ['**/*.ts', '**/*.c'], exclude: [], }, }); @@ -194,6 +200,15 @@ export function validateEmail(email: string): boolean { ).toBe(true); }); + it('includes union definitions in the default context search', async () => { + const result = await cg.findRelevantContext('CallbackOps'); + const union = [...result.nodes.values()].find( + (node) => node.kind === 'union' && node.name === 'CallbackOps' + ); + + expect(union).toBeDefined(); + }); + it('should include edges in the result', async () => { const result = await cg.findRelevantContext('checkout', { traversalDepth: 2, diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..292658822 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1174,6 +1174,51 @@ impl Counter { ); expect(implRefs).toHaveLength(0); }); + + it('should extract union declarations and their impl edges', () => { + const code = ` +pub union Reg { + pub raw: u32, + pub halves: [u16; 2], +} + +pub trait Describe { + fn describe(&self) -> u32; +} + +impl Describe for Reg { + fn describe(&self) -> u32 { + unsafe { self.raw } + } +} +`; + const result = extractFromSource('reg.rs', code); + + // A union is a first-class type definition, not an alias — it must be a + // node, or the impl below has no source endpoint to hang off. + const reg = result.nodes.find((n) => n.name === 'Reg'); + expect(reg).toBeDefined(); + expect(reg?.kind).toBe('union'); + + const implRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'implements' && r.referenceName === 'Describe' + ); + expect(implRef).toBeDefined(); + expect(implRef?.fromNodeId).toBe(reg?.id); + + // The impl's method attaches to the union, not to the file — without a Reg + // node it was an orphan whose qualifiedName pointed at a type that did not + // exist in the graph. + const implMethod = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName?.includes('Reg') + ); + expect(implMethod).toBeDefined(); + expect( + result.edges.some( + (e) => e.kind === 'contains' && e.source === reg?.id && e.target === implMethod?.id + ) + ).toBe(true); + }); }); describe('Java Extraction', () => { @@ -5642,6 +5687,71 @@ std::string use() { }); }); +describe('C/C++ union declarations', () => { + it('extracts a named union as a type node, but not a forward declaration', () => { + const code = ` +union packet_hdr { + unsigned int raw; + unsigned short port; +}; + +/* forward declaration — not a definition */ +union opaque_hdr; + +static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } +`; + const result = extractFromSource('packet.c', code); + + const hdr = result.nodes.find((n) => n.name === 'packet_hdr'); + expect(hdr).toBeDefined(); + expect(hdr?.kind).toBe('union'); + + // Same rule as `struct Foo;`: bodiless is a forward declaration, so it must + // not mint a phantom node beside the real definition. + expect(result.nodes.some((n) => n.name === 'opaque_hdr')).toBe(false); + + // Exactly one node for the type — the definition — so a call site or a + // `union packet_hdr *` parameter has a single resolution target. + expect(result.nodes.filter((n) => n.name === 'packet_hdr')).toHaveLength(1); + }); + + it('gives a typedef union the typedef name, not a second node', () => { + const code = ` +typedef union { + unsigned int u; + float f; +} word_t; +`; + const result = extractFromSource('word.c', code); + + const word = result.nodes.find((n) => n.name === 'word_t'); + expect(word?.kind).toBe('union'); + // Resolved through the typedef the same way `typedef struct { … } X;` is, + // so the anonymous union body does not become its own node. + expect(result.nodes.some((n) => n.name === '')).toBe(false); + }); + + it('extracts a C++ union with member functions', () => { + const code = ` +union Value { + int i; + double d; + int as_int() const { return i; } +}; +`; + const result = extractFromSource('value.cpp', code); + + const value = result.nodes.find((n) => n.name === 'Value'); + expect(value?.kind).toBe('union'); + + const asInt = result.nodes.find((n) => n.name === 'as_int'); + expect(asInt).toBeDefined(); + expect( + result.edges.some((e) => e.kind === 'contains' && e.source === value?.id && e.target === asInt?.id) + ).toBe(true); + }); +}); + describe('Dart mixins and type references', () => { let tempDir: string; let cg: CodeGraph; @@ -8336,6 +8446,22 @@ void helperFunction(int count) { expect(imports).toContain('MyClass.h'); }); + it('extracts union declarations as first-class union nodes', () => { + const code = ` +typedef union { + unsigned int raw; + float value; +} NumberBits; + +union opaque_bits; +`; + const result = extractFromSource('NumberBits.m', code); + + const numberBits = result.nodes.find((n) => n.name === 'NumberBits'); + expect(numberBits?.kind).toBe('union'); + expect(result.nodes.some((n) => n.name === 'opaque_bits')).toBe(false); + }); + it('should record inheritance and protocol conformance', () => { const result = extractFromSource('App.m', sample); const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends'); diff --git a/__tests__/fixtures/kernel-parity/torture.c b/__tests__/fixtures/kernel-parity/torture.c index 407263748..57e04a912 100644 --- a/__tests__/fixtures/kernel-parity/torture.c +++ b/__tests__/fixtures/kernel-parity/torture.c @@ -152,3 +152,19 @@ static void ratelimited_warn(void) { static DEFINE_RATELIMIT_STATE(ratelimit, 5 * HZ, 5); use_ptr(&ratelimit, 0); } + +/* named union definition, forward declaration, and anonymous typedef union — + the definition is a node, the forward decl is not (#UNION) */ +union packet_hdr { + unsigned int raw; + struct { unsigned char ver, flags; } parts; +}; + +union opaque_hdr; + +typedef union { + unsigned int u; + float f; +} word_t; + +static unsigned int hdr_raw(union packet_hdr *h) { return h->raw; } diff --git a/__tests__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 0f7e80c4e..1e14b7b7a 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -209,3 +209,10 @@ fn mount() { } routes![top_level_h]; + +pub union Reg { + pub raw: u32, + pub halves: [u16; 2], +} + +impl Base for Reg {} diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..3bf260c8d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1020,6 +1020,59 @@ def bootstrap(): expect(callsToUserService).toHaveLength(0); }); + it('promotes calls→instantiates when target resolves to a C++ union', async () => { + // `Packet()` value-initializes the union. The extractor emits a calls + // reference for that expression, so resolution must preserve the + // class-like promotion that unions received when they were structs. + fs.writeFileSync( + path.join(tempDir, 'packet.cpp'), + `union Packet { unsigned int raw; }; + +void initialize() { Packet(); } +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const packet = cg.getNodesByKind('union').find((n) => n.name === 'Packet'); + const initialize = cg.getNodesByKind('function').find((n) => n.name === 'initialize'); + expect(packet).toBeDefined(); + expect(initialize).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(initialize!.id); + expect(outgoing.some((e) => e.kind === 'instantiates' && e.target === packet!.id)).toBe(true); + expect(outgoing.some((e) => e.kind === 'calls' && e.target === packet!.id)).toBe(false); + }); + + it('resolves a static call through an imported C++ union to its member', async () => { + fs.writeFileSync( + path.join(tempDir, 'ops.hpp'), + `union Ops { + static int run() { return 1; } +}; +` + ); + fs.writeFileSync( + path.join(tempDir, 'main.cpp'), + `#include "ops.hpp" + +int invoke() { return Ops::run(); } +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const invoke = cg.getNodesByKind('function').find((n) => n.name === 'invoke'); + const run = cg.getNodesByKind('method').find((n) => n.name === 'run'); + expect(invoke).toBeDefined(); + expect(run).toBeDefined(); + + const outgoing = cg.getOutgoingEdges(invoke!.id); + expect(outgoing.some((e) => e.kind === 'calls' && e.target === run!.id)).toBe(true); + }); + it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => { // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) // carry the constructor args directly on the declarator — there's no @@ -2169,6 +2222,28 @@ func main() { expect(result?.targetNodeId).toBe('class:logger.ts:Logger:10'); }); + it('prefers a union candidate over a function for `instantiates` refs', () => { + const fn: Node = { + id: 'func:packet.cpp:Packet:5', kind: 'function', name: 'Packet', + qualifiedName: 'packet.cpp::Packet', filePath: 'packet.cpp', language: 'cpp', + startLine: 5, endLine: 7, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const union: Node = { + id: 'union:packet.hpp:Packet:10', kind: 'union', name: 'Packet', + qualifiedName: 'packet.hpp::Packet', filePath: 'packet.hpp', language: 'cpp', + startLine: 10, endLine: 14, startColumn: 0, endColumn: 0, updatedAt: Date.now(), + }; + const ref = { + fromNodeId: 'func:main.cpp:initialize:1', + referenceName: 'Packet', + referenceKind: 'instantiates' as const, + line: 5, column: 0, filePath: 'main.cpp', language: 'cpp' as const, + }; + + const result = matchReference(ref, baseContext([fn, union])); + expect(result?.targetNodeId).toBe('union:packet.hpp:Packet:10'); + }); + it('prefers a function candidate over a non-function for `decorates` refs', () => { const variable: Node = { id: 'var:config.ts:Inject:5', kind: 'variable', name: 'Inject', diff --git a/codegraph-kernel/src/buffers.rs b/codegraph-kernel/src/buffers.rs index 66ae1401c..8934ed73e 100644 --- a/codegraph-kernel/src/buffers.rs +++ b/codegraph-kernel/src/buffers.rs @@ -77,7 +77,7 @@ pub const EDGE_ROW_SIZE: usize = 44; pub const REF_ROW_SIZE: usize = 40; /// Mirror of NODE_KINDS in src/types.ts — order is the wire contract. -pub const NODE_KINDS: [&str; 22] = [ +pub const NODE_KINDS: [&str; 23] = [ "file", "module", "class", @@ -100,6 +100,7 @@ pub const NODE_KINDS: [&str; 22] = [ "export", "route", "component", + "union", ]; /// Mirror of EDGE_KINDS in src/types.ts — order is the wire contract. diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index c95d3126e..9f058f495 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -455,7 +455,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "union" | "interface" | "trait" | "enum" | "module")) .unwrap_or(false) } @@ -561,7 +561,7 @@ impl<'t> Walker<'t> { let parent_ok = self .stack .last() - .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum")) + .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "union" | "enum")) .unwrap_or(false); if parent_ok { self.fs_values.insert(name.to_string(), row); @@ -813,7 +813,10 @@ impl<'t> Walker<'t> { self.extract_class(node); skip_children = true; } else if kind == "struct_specifier" { - self.extract_struct(node); + self.extract_aggregate(node, "struct"); + skip_children = true; + } else if kind == "union_specifier" { + self.extract_aggregate(node, "union"); skip_children = true; } else if kind == "enum_specifier" { self.extract_enum(node); @@ -925,7 +928,7 @@ impl<'t> Walker<'t> { .iter() .position(|m| { m.name == *receiver_type - && matches!(m.kind, "struct" | "class" | "enum" | "trait") + && matches!(m.kind, "struct" | "union" | "class" | "enum" | "trait") }) .map(|i| i as u32); if let Some(owner_row) = owner_row { @@ -971,8 +974,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractStruct: bodiless specifiers (fwd decls / elaborated refs) skip. - fn extract_struct(&mut self, node: Node<'t>) { + /// Extract a struct-like declaration while preserving its semantic kind. + fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { @@ -980,9 +983,9 @@ impl<'t> Walker<'t> { visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, ..Extra::default() }; - let Some(row) = self.create_node("struct", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { self.visit_node(c); @@ -1045,21 +1048,27 @@ impl<'t> Walker<'t> { resolved = Some("struct"); break; } + if child.kind() == "union_specifier" && child.child_by_field_name("body").is_some() { + resolved = Some("union"); + break; + } } - if resolved == Some("struct") { + if matches!(resolved, Some("struct") | Some("union")) { + let kind = resolved.unwrap(); let Some(row) = self.create_node( - "struct", + kind, &name, node, Extra { docstring, ..Extra::default() }, ) else { return true; }; - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); let type_child = node .child_by_field_name("type") - .or_else(|| self.find_child_by_kind(node, "struct_specifier")); + .or_else(|| self.find_child_by_kind(node, "struct_specifier")) + .or_else(|| self.find_child_by_kind(node, "union_specifier")); if let Some(tc) = type_child { self.extract_inheritance(tc, row); let body = tc.child_by_field_name("body").unwrap_or(tc); @@ -1557,7 +1566,11 @@ impl<'t> Walker<'t> { return; } if kind == "struct_specifier" { - self.extract_struct(node); + self.extract_aggregate(node, "struct"); + return; + } + if kind == "union_specifier" { + self.extract_aggregate(node, "union"); return; } if kind == "enum_specifier" { diff --git a/codegraph-kernel/src/cfnptr.rs b/codegraph-kernel/src/cfnptr.rs index bcee115f6..2ba81d130 100644 --- a/codegraph-kernel/src/cfnptr.rs +++ b/codegraph-kernel/src/cfnptr.rs @@ -432,8 +432,19 @@ struct InlineScan { fn scan_inline_structs(s: &[u8]) -> InlineScan { let mut out = InlineScan { ptr: false, types: Vec::new(), tags: Vec::new() }; let mut last = 0; - while let Some(t) = find_word(s, b"struct", last) { - let after_kw = t + 6; + loop { + let next_struct = find_word(s, b"struct", last); + let next_union = find_word(s, b"union", last); + let Some((t, keyword_len)) = (match (next_struct, next_union) { + (Some(st), Some(un)) if st < un => Some((st, 6)), + (Some(_), Some(un)) => Some((un, 5)), + (Some(st), None) => Some((st, 6)), + (None, Some(un)) => Some((un, 5)), + (None, None) => None, + }) else { + break; + }; + let after_kw = t + keyword_len; let ws = skip_jsws(s, after_kw); if ws == after_kw || !is_word_at(s, ws) { last = t + 1; @@ -569,10 +580,10 @@ fn init_body(s: &[u8], p: usize) -> Option<(String, usize)> { let i = skip_jsws(s, p); let mods = modifier_positions(s, i); for &pos in mods.iter().rev() { - for with_struct in [true, false] { - let q = if with_struct { - if s.len() >= pos + 6 && &s[pos..pos + 6] == b"struct" { - let e = pos + 6; + for keyword in [Some(b"struct".as_slice()), Some(b"union".as_slice()), None] { + let q = if let Some(keyword) = keyword { + if s.len() >= pos + keyword.len() && &s[pos..pos + keyword.len()] == keyword { + let e = pos + keyword.len(); let w = skip_jsws(s, e); if w == e { continue; @@ -729,12 +740,19 @@ fn alias_line(line: &[u8]) -> Option<&[u8]> { if v0 == name_end { return None; // [ \t]+ before the value } - // (?:struct[ \t]+)* greedy, k-descending on value failure. + // (?:(?:struct|union)[ \t]+)* greedy, k-descending on value failure. let mut stack = vec![v0]; loop { let cur = *stack.last().unwrap(); - if line.len() >= cur + 6 && &line[cur..cur + 6] == b"struct" { - let e = cur + 6; + let keyword_len = if line.len() >= cur + 6 && &line[cur..cur + 6] == b"struct" { + Some(6) + } else if line.len() >= cur + 5 && &line[cur..cur + 5] == b"union" { + Some(5) + } else { + None + }; + if let Some(keyword_len) = keyword_len { + let e = cur + keyword_len; let w2 = skip_sp_tab(line, e); if w2 > e { stack.push(w2); diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index b5b9da3a9..16447c243 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -217,7 +217,7 @@ impl<'t> Walker<'t> { fn inside_class_like(&self) -> bool { self.stack .last() - .map(|s| matches!(s.kind, "class" | "struct" | "interface" | "trait" | "enum" | "module")) + .map(|s| matches!(s.kind, "class" | "struct" | "union" | "interface" | "trait" | "enum" | "module")) .unwrap_or(false) } @@ -326,7 +326,7 @@ impl<'t> Walker<'t> { let parent_ok = self .stack .last() - .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "enum")) + .map(|s| matches!(s.kind, "file" | "class" | "module" | "struct" | "union" | "enum")) .unwrap_or(false); if parent_ok { self.fs_values.insert(name.to_string(), row); @@ -447,7 +447,10 @@ impl<'t> Walker<'t> { self.extract_interface(node); skip_children = true; } else if kind == "struct_item" { - self.extract_struct(node); + self.extract_aggregate(node, "struct"); + skip_children = true; + } else if kind == "union_item" { + self.extract_aggregate(node, "union"); skip_children = true; } else if kind == "enum_item" { self.extract_enum(node); @@ -529,7 +532,7 @@ impl<'t> Walker<'t> { .iter() .position(|m| { m.name == *receiver - && matches!(m.kind, "struct" | "class" | "enum" | "trait") + && matches!(m.kind, "struct" | "union" | "class" | "enum" | "trait") }) .map(|i| i as u32); if let Some(owner_row) = owner_row { @@ -579,9 +582,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractStruct — body field REQUIRED (unit structs mint no node; tuple - /// structs' ordered_field_declaration_list is a body). - fn extract_struct(&mut self, node: Node<'t>) { + /// Extract a Rust struct or union with a body; unit structs remain skipped. + fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) { let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); let extra = Extra { @@ -589,10 +591,10 @@ impl<'t> Walker<'t> { visibility: Some(self.visibility_of(node)), ..Extra::default() }; - let Some(row) = self.create_node("struct", &name, node, extra) else { return }; + let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); - self.stack.push(Scope { row, kind: "struct", name }); + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { self.visit_node(c); @@ -1057,7 +1059,7 @@ impl<'t> Walker<'t> { let target_row = self .nodes_meta .iter() - .position(|m| m.name == type_name && matches!(m.kind, "struct" | "enum" | "class")) + .position(|m| m.name == type_name && matches!(m.kind, "struct" | "union" | "enum" | "class")) .map(|i| i as u32); if let Some(target_row) = target_row { self.push_ref_at(target_row, &trait_name, edge_kind_index("implements").unwrap(), trait_node); @@ -1131,7 +1133,11 @@ impl<'t> Walker<'t> { // Structural nodes inside bodies. if kind == "struct_item" { - self.extract_struct(node); + self.extract_aggregate(node, "struct"); + return; + } + if kind == "union_item" { + self.extract_aggregate(node, "union"); return; } if kind == "enum_item" { diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 08811f32f..3fe0562b3 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -140,6 +140,8 @@ walker mirrors, with file:line anchors (as of `705e501`). Read WITH **cExtractor (line 180):** functionTypes=[function_definition]; NO class/method/interface types; structTypes=[struct_specifier]; +unionTypes=[union_specifier] (a named `union U { … };` is a definition, and +`typedef union { … } N;` resolves to a first-class `union` node); enumTypes=[enum_specifier]; enumMemberTypes=[enumerator]; typeAliasTypes=[type_definition]; importTypes=[preproc_include]; callTypes=[call_expression]; variableTypes=[declaration]; diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index d931e7c7a..8727d8598 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -54,7 +54,9 @@ Types: functionTypes=[`function_item`, **`function_signature_item`**] (the latter = a trait method DECLARATION `fn render(&self);` — extracted so a trait's method set is first-class); classTypes=[] (impl blocks instead); methodTypes = same two; interfaceTypes=[`trait_item`] with -**interfaceKind:'trait'**; structTypes=[`struct_item`]; enumTypes=[`enum_item`]; +**interfaceKind:'trait'**; structTypes=[`struct_item`]; +unionTypes=[`union_item`] (same body walk, distinct `union` node kind); +enumTypes=[`enum_item`]; enumMemberTypes=[`enum_variant`]; typeAliasTypes=[`type_item`]; importTypes=[`use_declaration`]; callTypes=[`call_expression`]; variableTypes=[`let_declaration`, `const_item`, `static_item`]. diff --git a/src/context/index.ts b/src/context/index.ts index 3e60723db..ad4d63bc0 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -157,7 +157,7 @@ const DEFAULT_BUILD_OPTIONS: Required = { * they tell you something exists, not how it works. */ const HIGH_VALUE_NODE_KINDS: NodeKind[] = [ - 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'trait', + 'function', 'method', 'class', 'interface', 'type_alias', 'struct', 'union', 'trait', 'component', 'route', 'variable', 'constant', 'enum', 'module', 'namespace', ]; @@ -503,7 +503,7 @@ export class ContextBuilder { // like RestController, BulkRequest, AllocationService — not nodes named exactly that. // Also tries stem variants: "caching" → "cache" finds Cache, CacheBuilder. if (symbolsFromQuery.length > 0) { - const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + const definitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Expand symbols with stem variants for broader definition matching const expandedSymbols = new Set(symbolsFromQuery); @@ -559,7 +559,7 @@ export class ContextBuilder { // but are almost never what exploration queries want. const searchKinds = opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds - : ['file', 'module', 'class', 'struct', 'interface', 'trait', 'protocol', + : ['file', 'module', 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'function', 'method', 'property', 'field', 'variable', 'constant', 'enum', 'enum_member', 'type_alias', 'namespace', 'export', 'route', 'component'] as NodeKind[]; @@ -754,7 +754,7 @@ export class ContextBuilder { // LIKE reliably finds these substring matches. Results are appended with // guaranteed slots so they don't compete with higher-scoring prefix matches. if (symbolsFromQuery.length > 0) { - const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'trait', + const camelDefinitionKinds: NodeKind[] = ['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias']; // Callable kinds participate too: in service-layer codebases the // camel-infix definers of a queried FIELD are methods/functions @@ -977,7 +977,7 @@ export class ContextBuilder { // before reaching extends/implements neighbors. This dedicated step // ensures subclasses and superclasses always appear in results. // Budget: up to maxNodes/4 hierarchy nodes to avoid flooding. - const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol']); + const typeHierarchyKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol']); const maxHierarchyNodes = Math.ceil(opts.maxNodes / 4); let hierarchyNodesAdded = 0; for (const result of filteredResults) { diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 6b5b45e9d..cdd573b5a 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -187,6 +187,9 @@ export const cExtractor: LanguageExtractor = { methodTypes: [], interfaceTypes: [], structTypes: ['struct_specifier'], + // A bodiless `union U;` is a forward declaration; the aggregate extractor + // applies the same body requirement it uses for C structs. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], // typedef @@ -207,12 +210,19 @@ export const cExtractor: LanguageExtractor = { resolveTypeAliasKind: (node, _source) => { // C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` // The inner enum_specifier/struct_specifier is anonymous, but we want the typedef name - // to become the enum/struct node name. + // to become the enum/struct node name. `typedef union { ... } name;` takes the + // same route — otherwise the union body would mint a second, `` node + // beside the alias. for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if ( + child.type === 'struct_specifier' && + getChildByField(child, 'body') + ) + return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, @@ -1552,6 +1562,9 @@ export const cppExtractor: LanguageExtractor = { methodTypes: ['function_definition'], interfaceTypes: [], structTypes: ['struct_specifier'], + // C++ unions additionally carry member functions, which extract through the + // same aggregate-body walk as structs while preserving their distinct kind. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition', 'alias_declaration'], // typedef and using @@ -1581,12 +1594,18 @@ export const cppExtractor: LanguageExtractor = { return undefined; }, resolveTypeAliasKind: (node, _source) => { - // C++ typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` + // C++ typedef: `typedef enum { ... } name;`, `typedef struct { ... } name;`, + // or `typedef union { ... } name;` — see the C extractor. for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if ( + child.type === 'struct_specifier' && + getChildByField(child, 'body') + ) + return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, diff --git a/src/extraction/languages/objc.ts b/src/extraction/languages/objc.ts index cf5ecc4d7..9bada343c 100644 --- a/src/extraction/languages/objc.ts +++ b/src/extraction/languages/objc.ts @@ -103,6 +103,8 @@ export const objcExtractor: LanguageExtractor = { interfaceTypes: ['protocol_declaration'], interfaceKind: 'protocol', structTypes: ['struct_specifier'], + // Objective-C is a C superset: union declarations preserve their own kind. + unionTypes: ['union_specifier'], enumTypes: ['enum_specifier'], enumMemberTypes: ['enumerator'], typeAliasTypes: ['type_definition'], @@ -128,7 +130,9 @@ export const objcExtractor: LanguageExtractor = { const child = node.namedChild(i); if (!child) continue; if (child.type === 'enum_specifier' && getChildByField(child, 'body')) return 'enum'; - if (child.type === 'struct_specifier' && getChildByField(child, 'body')) return 'struct'; + if (child.type === 'struct_specifier' && getChildByField(child, 'body')) + return 'struct'; + if (child.type === 'union_specifier' && getChildByField(child, 'body')) return 'union'; } return undefined; }, diff --git a/src/extraction/languages/rust.ts b/src/extraction/languages/rust.ts index bdc4477ba..6d91bf6b8 100644 --- a/src/extraction/languages/rust.ts +++ b/src/extraction/languages/rust.ts @@ -42,6 +42,9 @@ export const rustExtractor: LanguageExtractor = { methodTypes: ['function_item', 'function_signature_item'], interfaceTypes: ['trait_item'], structTypes: ['struct_item'], + // Unions share struct member syntax and impl attachment, but retain their + // distinct semantic kind in the graph. + unionTypes: ['union_item'], enumTypes: ['enum_item'], enumMemberTypes: ['enum_variant'], typeAliasTypes: ['type_item'], // Rust type aliases diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 2a02b47b7..6808895e1 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -102,6 +102,8 @@ export interface LanguageExtractor { interfaceTypes: string[]; /** Node types that represent structs */ structTypes: string[]; + /** Node types that represent unions */ + unionTypes?: string[]; /** Node types that represent enums */ enumTypes: string[]; /** Node types that represent enum members/cases (e.g. Swift: 'enum_entry', Rust: 'enum_variant') */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9e53e62da..8d71d7f18 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1066,6 +1066,11 @@ export class TreeSitterExtractor { this.extractStruct(node); skipChildren = true; // extractStruct visits body children } + // Check for union declarations + else if (this.extractor.unionTypes?.includes(nodeType)) { + this.extractUnion(node); + skipChildren = true; // extractUnion visits body children + } // Check for enum declarations else if (this.extractor.enumTypes.includes(nodeType)) { this.extractEnum(node); @@ -1487,7 +1492,7 @@ export class TreeSitterExtractor { /** * Check if the current node stack indicates we are inside a class-like node - * (class, struct, interface, trait). File nodes do not count as class-like. + * (class, struct, union, interface, trait). File nodes do not count as class-like. */ private isInsideClassLikeNode(): boolean { if (this.nodeStack.length === 0) return false; @@ -1498,6 +1503,7 @@ export class TreeSitterExtractor { return ( parentNode.kind === 'class' || parentNode.kind === 'struct' || + parentNode.kind === 'union' || parentNode.kind === 'interface' || parentNode.kind === 'trait' || parentNode.kind === 'enum' || @@ -1807,7 +1813,7 @@ export class TreeSitterExtractor { (n) => n.name === receiverType && n.filePath === this.filePath && - (n.kind === 'struct' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait') + (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class' || n.kind === 'enum' || n.kind === 'trait') ); if (ownerNode) { this.edges.push({ @@ -1873,6 +1879,16 @@ export class TreeSitterExtractor { * Extract a struct */ private extractStruct(node: SyntaxNode): void { + this.extractAggregate(node, 'struct'); + } + + /** Extract a union while sharing the member-walk behavior of aggregate types. */ + private extractUnion(node: SyntaxNode): void { + this.extractAggregate(node, 'union'); + } + + /** Extract a struct-like declaration without conflating its semantic kind. */ + private extractAggregate(node: SyntaxNode, kind: 'struct' | 'union'): void { if (!this.extractor) return; // Skip forward declarations and type references (no body = not a definition) @@ -1886,24 +1902,24 @@ export class TreeSitterExtractor { const visibility = this.extractor.getVisibility?.(node); const isExported = this.extractor.isExported?.(node, this.source); - const structNode = this.createNode('struct', name, node, { + const aggregateNode = this.createNode(kind, name, node, { docstring, visibility, isExported, }); - if (!structNode) return; + if (!aggregateNode) return; // Extract inheritance (e.g. Swift: struct HTTPMethod: RawRepresentable) - this.extractInheritance(node, structNode.id); + this.extractInheritance(node, aggregateNode.id); // C# primary-constructor parameter dependencies (`struct P(int x)`, and // `record struct M(decimal Amount)` which the grammar nests here). - this.extractCsharpPrimaryCtorParamRefs(node, structNode.id); + this.extractCsharpPrimaryCtorParamRefs(node, aggregateNode.id); // Push to stack for field extraction (bodiless positional records have // no members to visit) if (body) { - this.nodeStack.push(structNode.id); + this.nodeStack.push(aggregateNode.id); for (let i = 0; i < body.namedChildCount; i++) { const child = body.namedChild(i); if (child) { @@ -2905,17 +2921,20 @@ export class TreeSitterExtractor { // (e.g. Go: `type Foo struct { ... }` is a type_spec wrapping struct_type) const resolvedKind = this.extractor.resolveTypeAliasKind?.(node, this.source); - if (resolvedKind === 'struct') { - const structNode = this.createNode('struct', name, node, { docstring, isExported }); - if (!structNode) return true; + if (resolvedKind === 'struct' || resolvedKind === 'union') { + const aggregateNode = this.createNode(resolvedKind, name, node, { docstring, isExported }); + if (!aggregateNode) return true; // Visit body children for field extraction - this.nodeStack.push(structNode.id); - // Try Go-style 'type' field first, then find inner struct child (C typedef struct) + this.nodeStack.push(aggregateNode.id); + // Try Go-style 'type' field first, then find the matching inner aggregate child. const typeChild = getChildByField(node, 'type') - || this.findChildByTypes(node, this.extractor.structTypes); + || this.findChildByTypes( + node, + resolvedKind === 'union' ? (this.extractor.unionTypes ?? []) : this.extractor.structTypes + ); if (typeChild) { // Extract struct embedding (e.g. Go: `type DB struct { *Head; Queryable }`) - this.extractInheritance(typeChild, structNode.id); + this.extractInheritance(typeChild, aggregateNode.id); const body = getChildByField(typeChild, this.extractor.bodyField) || typeChild; for (let i = 0; i < body.namedChildCount; i++) { const child = body.namedChild(i); @@ -5271,6 +5290,10 @@ export class TreeSitterExtractor { this.extractStruct(node); return; } + if (this.extractor!.unionTypes?.includes(nodeType)) { + this.extractUnion(node); + return; + } if (this.extractor!.enumTypes.includes(nodeType)) { this.extractEnum(node); return; @@ -5745,7 +5768,7 @@ export class TreeSitterExtractor { */ private findNodeByName(name: string): string | undefined { for (const node of this.nodes) { - if (node.name === name && (node.kind === 'struct' || node.kind === 'enum' || node.kind === 'class')) { + if (node.name === name && (node.kind === 'struct' || node.kind === 'union' || node.kind === 'enum' || node.kind === 'class')) { return node.id; } } diff --git a/src/graph/queries.ts b/src/graph/queries.ts index 9169dcdf5..e2af59335 100644 --- a/src/graph/queries.ts +++ b/src/graph/queries.ts @@ -173,6 +173,7 @@ export class GraphQueryManager { const allNodes: Node[] = []; const kinds: Node['kind'][] = [ 'class', + 'union', 'function', 'method', 'interface', @@ -347,6 +348,7 @@ export class GraphQueryManager { 'module', 'class', 'struct', + 'union', 'interface', 'trait', 'function', diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 6cb00ac6b..5e9b354e9 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -564,7 +564,7 @@ export class GraphTraverser { // into their children so that callers of contained methods appear in impact const focalNode = this.queries.getNodeById(nodeId); if (focalNode) { - const containerKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol', 'module', 'enum']); + const containerKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'module', 'enum']); if (containerKinds.has(focalNode.kind)) { const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']); if (containsEdges.length > 0) { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4a6e1a5fe..0d18932d3 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -118,7 +118,7 @@ const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']); * multi-thousand-character wall of source that bloats the agent's context. */ const CONTAINER_NODE_KINDS = new Set([ - 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', + 'class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module', ]); /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */ @@ -343,7 +343,7 @@ export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget { */ export const RELEVANCE_KIND_WEIGHT: Readonly> = { // Callables and types: the answer lives in one of these. - function: 1, method: 1, class: 1, struct: 1, interface: 1, trait: 1, + function: 1, method: 1, class: 1, struct: 1, union: 1, interface: 1, trait: 1, protocol: 1, component: 1, route: 1, enum: 1, type_alias: 1, constructor: 1, // Containers: real structure, but a whole namespace/module matching a term is // a coarser signal than a callable matching it. @@ -2927,7 +2927,7 @@ export class ToolHandler { const ROOT_CAP = 5; // only the symbols the query actually targeted const FILE_CAP = 4; // caller files listed per symbol before "+N more" const MEANINGFUL = new Set([ - 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol', + 'function', 'method', 'class', 'interface', 'struct', 'union', 'trait', 'protocol', 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field', ]); const rel = (p: string) => p.replace(/\\/g, '/'); @@ -3452,7 +3452,7 @@ export class ToolHandler { // displaces a flow-central file. Bounded: only the few named seeds, only the // types in their signatures. const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']); - const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']); + const TYPE_KINDS = new Set(['class', 'struct', 'union', 'interface', 'trait', 'protocol', 'enum', 'type_alias']); const SIG_EDGE = new Set(['references', 'type_of', 'returns']); const changeSurfaceCandidates: Node[] = []; const seenChangeSurface = new Set(); @@ -3968,7 +3968,7 @@ export class ToolHandler { const superMany = new Map(); const definesPolymorphicSupertype = (nodes: Node[]): boolean => { for (const n of nodes) { - if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct' + if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct' && n.kind !== 'union' && n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias') continue; let many = superMany.get(n.id); if (many === undefined) { @@ -4538,7 +4538,7 @@ export class ToolHandler { // query actually asked about (#185 follow-up — Session.swift in // Alamofire is the canonical case: the `Session` class spans ~1,400 // lines). We want the granular symbols inside, not the envelope. - const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']); + const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'union', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']); // Cluster from this file's gathered nodes PLUS any callable the agent NAMED that // lives here. Explore's relevance gather can miss a named method def in a huge // non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237) diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 13055e2c4..1ab809918 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -296,7 +296,7 @@ function resolveTypeName(name: string, objEnv: Map | undefined): let n = name; for (let i = 0; objEnv && i < 5; i++) { const v = objEnv.get(n); - const t = v?.trim().match(/^(?:struct\s+)?(\w+)$/); + const t = v?.trim().match(/^(?:(?:struct|union)\s+)?(\w+)$/); if (!t) break; n = t[1]!; } @@ -370,20 +370,20 @@ const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i; * are excluded: `resolveTypeName` would rewrite to a dead-end token that can * never name a struct, so skipping them is exact, and it drops the register * flood. */ -const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:struct[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; +const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; /** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that * has ≥1 fn-pointer field. Handles both single (`= {…}`) and array * (`[] = { {…}, {…} }`) 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; + /(?:^|[;{}])\s*(?:(?:static|const|extern|register|volatile)\s+)*(?:(?:struct|union)\s+)?(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*=\s*\{/g; /** `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; +const INLINE_STRUCT_RE = /\b(?:struct|union)\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 @@ -703,7 +703,7 @@ export async function cFnPointerDispatchEdges( if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } const structs: CfnptrFileIn['structs'] = []; for (const st of fileNodes) { - if (st.kind !== 'struct') continue; + if (st.kind !== 'struct' && st.kind !== 'union') continue; // sliceLinesPre semantics ride along: falsy startLine never parses, // and `endLine ?? startLine` is applied here so the kernel sees the // exact slice bounds the JS sweep would use. @@ -740,7 +740,7 @@ export async function cFnPointerDispatchEdges( if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } let lines: string[] | null = null; for (const st of fileNodes) { - if (st.kind !== 'struct') continue; + if (st.kind !== 'struct' && st.kind !== 'union') continue; lines ??= s.split('\n'); const body = sliceLinesPre(lines, st.startLine, st.endLine); const open = body.indexOf('{'); @@ -854,12 +854,14 @@ export async function cFnPointerDispatchEdges( if (fields.some((f) => f.isFnPtr)) structLayout.set(name, fields); }; - for (const st of (ctx.iterateNodesByKind?.('struct') ?? ctx.getNodesByKind('struct'))) { - if ((++scannedFiles & 255) === 0) await onYield(); - if (!C_CPP_EXT.test(st.filePath)) continue; - const rawFields = rawFieldsByNode.get(st.id); - if (!rawFields) continue; // file unreadable or body unparsable at sweep time — the old pass skipped it too - registerStructLayout(st.name, classifyFields(rawFields)); + for (const kind of ['struct', 'union'] as const) { + for (const st of (ctx.iterateNodesByKind?.(kind) ?? ctx.getNodesByKind(kind))) { + if ((++scannedFiles & 255) === 0) await onYield(); + if (!C_CPP_EXT.test(st.filePath)) continue; + const rawFields = rawFieldsByNode.get(st.id); + if (!rawFields) continue; // file unreadable or body unparsable at sweep time — the old pass skipped it too + registerStructLayout(st.name, classifyFields(rawFields)); + } } rawFieldsByNode.clear(); if (prof) { prof.B = Date.now() - tPass; tPass = Date.now(); } @@ -1211,7 +1213,7 @@ export async function cFnPointerDispatchEdges( const recvTypeIn = (fnSrc: string, recv: string): string | null => { let re = recvReCache.get(recv); if (!re) { - re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${recv}\\b\\s*(?:[,)=;]|\\[)`, 'g'); recvReCache.set(recv, re); } re.lastIndex = 0; @@ -1230,7 +1232,7 @@ export async function cFnPointerDispatchEdges( const varTypeIn = (fnSrc: string, v: string): string | null => { let re = varReCache.get(v); if (!re) { - re = new RegExp(`(?:struct\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); + re = new RegExp(`(?:(?:struct|union)\\s+)?(\\w+)\\s*\\*?\\s*\\b${escapeRe(v)}\\b\\s*(?:[,)=;]|\\[)`, 'g'); varReCache.set(v, re); } re.lastIndex = 0; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..a32a97916 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1827,6 +1827,7 @@ function resolveRustPathReference( n.name === leaf && (n.kind === 'function' || n.kind === 'struct' || + n.kind === 'union' || n.kind === 'enum' || n.kind === 'trait' || n.kind === 'type_alias' || @@ -2191,7 +2192,7 @@ function findExportedSymbolWalk( /** Node kinds that own static members reachable as `Container.member`. */ const STATIC_MEMBER_CONTAINERS = new Set([ - 'class', 'struct', 'interface', 'enum', 'trait', 'protocol', + 'class', 'struct', 'union', 'interface', 'enum', 'trait', 'protocol', ]); /** diff --git a/src/resolution/index.ts b/src/resolution/index.ts index ef3a5fc23..01f615b28 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1079,13 +1079,16 @@ export class ReferenceResolver { } // Promote "calls" to "instantiates" when the resolved target is a - // class/struct. Languages without a `new` keyword (Python, Ruby) + // class/struct/union. Languages without a `new` keyword (Python, Ruby) // express instantiation as `Foo()` — extraction can't tell that // apart from a function call without symbol info, but resolution // can: if `Foo` resolves to a class, the call IS an instantiation. if (kind === 'calls') { const targetNode = this.queries.getNodeById(ref.targetNodeId); - if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) { + if ( + targetNode && + (targetNode.kind === 'class' || targetNode.kind === 'struct' || targetNode.kind === 'union') + ) { kind = 'instantiates'; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 967f0b19b..651051466 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -798,12 +798,12 @@ function lookupCalleeReturnType( return candidates.find((n) => n.kind === 'function')?.returnType ?? null; } -/** Does the graph contain a class/struct named `name`'s last segment? */ +/** Does the graph contain an aggregate type named `name`'s last segment? */ function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { const last = cppLastSegment(name); return context .getNodesByName(last) - .some((n) => (n.kind === 'class' || n.kind === 'struct') && n.language === ref.language); + .some((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union') && n.language === ref.language); } /** @@ -1771,7 +1771,7 @@ export function matchMethodCall( ); for (const classNode of classCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { + if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') { // Skip cross-language class matches if (classNode.language !== ref.language) continue; @@ -1807,7 +1807,7 @@ export function matchMethodCall( ref.filePath, ); for (const classNode of fuzzyClassCandidates) { - if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') { + if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') { // Skip cross-language class matches if (classNode.language !== ref.language) continue; @@ -2107,6 +2107,7 @@ function findBestMatch( if ( candidate.kind === 'class' || candidate.kind === 'struct' || + candidate.kind === 'union' || candidate.kind === 'interface' ) { score += 25; diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 1a7b121fc..e3db25b1a 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -393,6 +393,7 @@ export function kindBonus(kind: Node['kind']): number { interface: 9, type_alias: 6, struct: 6, + union: 6, trait: 9, enum: 5, component: 8, diff --git a/src/types.ts b/src/types.ts index a1861bba4..b0ebfe433 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export const NODE_KINDS = [ 'export', 'route', 'component', + 'union', ] as const; export type NodeKind = (typeof NODE_KINDS)[number];