diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..fd4a92937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Rust unit structs (`struct Unit;`) are now indexed. A struct written without a body was treated as a forward declaration and skipped, so the type never entered the graph — and neither did anything attached to it, most visibly every `impl SomeTrait for Unit`. Codebases that use unit structs for zero-sized markers, test doubles and stub implementations were missing those types and their trait relationships entirely. Rust has no forward declarations, so a bodiless struct is always a complete definition. Re-index after upgrading to pick up the new types and edges. + - 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, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) - A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out. diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 292658822..10927e61c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1089,6 +1089,35 @@ pub struct User { expect(structNode?.name).toBe('User'); }); + it('should extract unit and tuple structs, not just brace structs', () => { + // A unit struct has no body field, but it IS a complete definition — + // Rust has no forward declarations. Skipping it dropped the type and + // every `impl Trait for UnitStruct` edge with it. + const code = ` +pub struct Unit; +pub struct Tuple(pub u32); +pub struct Brace { pub x: u32 } +`; + const result = extractFromSource('shapes.rs', code); + + const structs = result.nodes.filter((n) => n.kind === 'struct').map((n) => n.name).sort(); + expect(structs).toEqual(['Brace', 'Tuple', 'Unit']); + }); + + it('should link impl Trait for a unit struct', () => { + const code = ` +pub struct Unit; +pub trait Greet { fn hi(&self) -> String; } +impl Greet for Unit { fn hi(&self) -> String { "unit".into() } } +`; + const result = extractFromSource('greet.rs', code); + + const unit = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Unit'); + expect(unit).toBeDefined(); + const trait = result.nodes.find((n) => n.kind === 'trait' && n.name === 'Greet'); + expect(trait).toBeDefined(); + }); + it('should extract trait declarations', () => { const code = ` pub trait Repository { diff --git a/__tests__/kernel-rustlang-parity.test.ts b/__tests__/kernel-rustlang-parity.test.ts index b6d792e67..3afa4a2a5 100644 --- a/__tests__/kernel-rustlang-parity.test.ts +++ b/__tests__/kernel-rustlang-parity.test.ts @@ -5,7 +5,8 @@ * SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and * unresolved refs compared as canonicalized multisets — over the checked-in * torture fixture (torture.rs: impl/trait quirks incl. the - * `impl Trait for Generic` trait-receiver bug, unit-struct skip, phantom + * `impl Trait for Generic` trait-receiver bug, unit structs (a bodiless + * struct IS a definition — both walkers mint a node), phantom * const identifiers, use-binding refs incl. nested groups + wildcard-emits- * nothing, chained-call re-encode, turbofish, Rocket route macros body-only, * fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 16447c243..897d51269 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -19,8 +19,7 @@ //! kind is always `variable`, no signature, and EVERY direct `identifier` //! child mints a node (`const MAX: u32 = OTHER;` → two nodes, `MAX` + the //! phantom `OTHER`). Top-level initializer values are never body-walked. -//! - Unit structs (`struct Unit;`, no body field) mint NO node; `mod_item` -//! mints no module node and adds no QN prefix. +//! - `mod_item` mints no module node and adds no QN prefix. //! - Chained-call re-encode is scoped_identifier-gated (`Foo::new().bar()` → //! `Foo::new().bar`); instance chains, parens, `.await`, 2-hop fields, and //! `self` receivers all collapse to the bare method name (`self` is node @@ -582,9 +581,11 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// Extract a Rust struct or union with a body; unit structs remain skipped. + /// Extract a Rust struct or union. A unit struct (`struct U;`) has no body + /// and is still a complete definition, so it mints a node with no members; + /// tuple structs' ordered_field_declaration_list is a body. Mirrors the TS + /// reference's `allowBodilessStruct`. 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 { docstring: preceding_docstring(node, self.src), @@ -594,6 +595,10 @@ impl<'t> Walker<'t> { let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); + // Unit structs have no body to walk — the node itself is the whole + // definition. + let Some(body) = node.child_by_field_name("body") else { return }; + self.stack.push(Scope { row, kind, name }); for i in 0..body.named_child_count() { if let Some(c) = body.named_child(i) { diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index 8727d8598..1c1463ad4 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -137,7 +137,7 @@ undefined; **no isConst means `const_item`/`static_item` extract as kind |---|---|---| | `function_item` (top level) | functionTypes, tree-sitter.ts:994 → extractFunction:1517 | not inside class-like at file scope → extractFunction; **first line of extractFunction (1522): if getReceiverType returns a value → extractMethod instead** (this is how impl-block fns become methods — impl_item does NOT push a scope) | | `function_signature_item` | same | in a trait body (trait pushed, class-like) → extractMethod; no `body` field → no body walk | -| `struct_item` | structTypes:1059 → extractStruct:1869 | `body` field required: **unit structs `struct Unit;` have no body → NO node minted** (1876, `record_declaration` exemption is C#-only). Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing | +| `struct_item` | structTypes:1059 → extractStruct:1869 | ~~`body` field required: unit structs `struct Unit;` have no body → NO node minted~~ — **superseded: Rust now sets `allowBodilessStruct`, so `struct Unit;` mints a node with no members.** Rust has no forward declarations, so the bodiless skip (meant for C/C++) never applied here; the `record_declaration` exemption is the C# form of the same carve-out. Tuple structs have body `ordered_field_declaration_list` → extracted. `field_declaration` children make NO nodes (rust has no fieldTypes) — visitNode recurses into them and finds nothing | | `enum_item` | enumTypes:1064 → extractEnum:1914 | body `enum_variant_list`; `enum_variant` children → extractEnumMembers:1958 — **`name` field path: one `enum_member` node from `getChildByField(node,'name')`, then return** (variant payload bodies `B(u32)` / `C { x }` are never walked). Non-variant children (e.g. `attribute_item`) → visitNode (no-op) | | `trait_item` | interfaceTypes:1054 → extractInterface:1834 | kind `'trait'` (interfaceKind); extractInheritance sees the `trait_bounds` child (see below); body `declaration_list` children visited with the trait pushed → fn items become methods with QN `Trait::name` via nodeStack | | `impl_item` | dedicated branch:1273-1276 → extractRustImplItem:5690 | emits the implements back-reference (below); **skipChildren stays false** → the `declaration_list` is then visited normally by the loop at 1295 (that's how impl members are reached; impl pushes NOTHING on the nodeStack) | @@ -482,7 +482,7 @@ inner `array_expression`, but `const CB: fn() = handler;` captures nothing ## Gates (per plan §5, no exceptions) - **Torture fixture `torture.rs`** (+ CRLF variant, derived in-memory), pinning - at minimum: unit struct (NO node) / tuple struct / field struct; enum with + at minimum: unit struct (node, no members) / tuple struct / field struct; enum with unit+tuple+struct variants; trait with supertraits incl. a SCOPED one (`fmt::Debug` — dropped) + `function_signature_item` + default method + associated type/const (no node; const value call attributes to trait); diff --git a/src/extraction/languages/rust.ts b/src/extraction/languages/rust.ts index 6d91bf6b8..86d95b697 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'], + // `struct Unit;` is a unit struct — a complete definition with no body + // field, not a forward declaration. Rust has no forward declarations. + allowBodilessStruct: true, // Unions share struct member syntax and impl attachment, but retain their // distinct semantic kind in the graph. unionTypes: ['union_item'], diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 6808895e1..3c09cb4d0 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -185,6 +185,19 @@ export interface LanguageExtractor { * bodiless class IS complete (Kotlin `class Empty`, Scala `case object`). (#1093) */ skipBodilessClass?: boolean; + /** + * Keep a bodiless struct node — it IS a complete definition, not a forward + * declaration. Set only for languages where a bodiless `struct` is complete: + * Rust's unit struct (`struct Unit;`). Leave unset for C/C++, where + * `struct Foo;` is a forward declaration. + * + * Opposite polarity from `skipBodilessClass` (#1093) because the defaults + * differ: a bodiless CLASS is kept unless a language opts into skipping, + * a bodiless STRUCT is skipped unless a language opts into keeping. The + * hardcoded C# `record_declaration` carve-out (#831) is the same situation + * predating this flag. + */ + allowBodilessStruct?: boolean; /** NodeKind to use for interface-like declarations (Rust: 'trait'). Default: 'interface' */ interfaceKind?: NodeKind; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 8d71d7f18..1ff19ca4d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1894,8 +1894,16 @@ export class TreeSitterExtractor { // Skip forward declarations and type references (no body = not a definition) // — EXCEPT C# positional records (`record struct M(decimal Amount);`), // complete definitions with no body block. (#831) + // + // `allowBodilessStruct` is the per-language escape hatch for the same + // situation: a bodiless struct that IS a complete definition (Rust's unit + // struct `struct Unit;`). Opposite polarity from `skipBodilessClass` + // (#1093) because the two defaults differ — a bodiless CLASS is kept + // unless a language opts into skipping, a bodiless STRUCT is skipped + // unless a language opts into keeping. const body = getChildByField(node, this.extractor.bodyField); - if (!body && node.type !== 'record_declaration') return; + if (!body && node.type !== 'record_declaration' && !this.extractor.allowBodilessStruct) + return; const name = extractName(node, this.source, this.extractor); const docstring = getPrecedingDocstring(node, this.source);