From a94c9dc94eee348bf63c7e2dd567c0b43577678a Mon Sep 17 00:00:00 2001 From: ctype_lab Date: Thu, 6 Aug 2026 14:38:03 +0900 Subject: [PATCH] =?UTF-8?q?fix(rust):=20index=20unit=20structs=20=E2=80=94?= =?UTF-8?q?=20bodiless=20is=20a=20definition,=20not=20a=20forward=20decl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractStruct` skips a struct with no body as a forward declaration. That is right for C/C++ (`struct Foo;`), but Rust has no forward declarations — `struct Unit;` is a complete unit struct. The type never entered the graph, and neither did anything attached to it: every `impl SomeTrait for Unit` lost its edge because the source endpoint did not exist. Brace and tuple structs were unaffected — both carry a body field (`field_declaration_list` / `ordered_field_declaration_list`), so only the unit form fell through. This is the same situation two earlier patches already handled, just a case each of them left uncovered: #831 hardcoded a carve-out for C# positional records — "complete definitions with no body block". #1093 introduced `skipBodilessClass` so a bodiless CLASS is kept unless a language opts into skipping it. Struct kept the unconditional skip. This adds `allowBodilessStruct`, the per-language opt-in for the struct side, and sets it for Rust only. The polarity is inverted relative to `skipBodilessClass` because the defaults differ — that is documented on both flags. The kernel's Rust walker had the same guard, placed before create_node, and its header listed the behavior as a mirrored bug-for-bug quirk. Node creation now happens first and the body is walked only when present, which is how extract_interface in the same file was already shaped. Kernel↔wasm parity is preserved (the parity suite compares the two, and passes). Four lines of logic across four source files; the rest is comments, docs and the two regression tests. The kernel header's quirk list, the parity suite's docstring and the R7b port checklist all documented the old behavior as intended, so each is updated — the checklist declares itself the living quirk reference for the walker, so a stale entry there would misinform the next reader. Effect on a real Rust/TS repository (2669 nodes): 13 structs and 9 implements edges recovered. Tests: two regression cases in extraction.test.ts. Verified failing without the fix on the wasm path (CODEGRAPH_KERNEL=0) and passing with it, on both the kernel and wasm paths. Full suite green — 170 files, 2922 tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ __tests__/extraction.test.ts | 29 +++++++++++++++++++ __tests__/kernel-rustlang-parity.test.ts | 3 +- codegraph-kernel/src/rustlang.rs | 14 +++++---- .../design/rust-lang-kernel-port-checklist.md | 4 +-- src/extraction/languages/rust.ts | 3 ++ src/extraction/tree-sitter-types.ts | 13 +++++++++ src/extraction/tree-sitter.ts | 10 ++++++- 8 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc0e9e8..0ffe74733 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 +- 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. + - `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/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..f67a8b135 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 b5b9da3a9..7cf50fbb4 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 @@ -579,10 +578,11 @@ 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). + /// extractStruct — the body field is OPTIONAL. 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_struct(&mut self, node: Node<'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), @@ -592,6 +592,10 @@ impl<'t> Walker<'t> { let Some(row) = self.create_node("struct", &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: "struct", 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 d931e7c7a..ddc6cbf73 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -135,7 +135,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) | @@ -480,7 +480,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 bdc4477ba..6aa7ad298 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, 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..f05067ae7 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -183,6 +183,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 9e53e62da..20fce3e3c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1878,8 +1878,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);