From 71d049cd2804e9be06e365d562ae192cf6a5c54c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 16:53:08 -0500 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?= =?UTF-8?q?=20(#1800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land upstream PR #1514 for issue #1513 by cherry-picking ctype_lab's a94c9dc94eee348bf63c7e2dd567c0b43577678a. Keep allowBodilessStruct as a Rust-only opt-in, with matching behavior in the wasm/TypeScript walker and native kernel. Create the node before checking for a body and walk members only when present. Resolve against main's shared struct/union walker while retaining its stack guard, kinds, fields, and existing impl-receiver fixes. Verified FAIL to PASS on Linux x64 with Node 22.19.0 after rebuilding via tsc, copy-assets, and build:kernel. The identical fixture on main 7b339373 had two structs and two implements edges; fresh wasm (CODEGRAPH_KERNEL=0) and native indexes now have three of each. UnitStruct and its Greet implements edge are recovered; tuple and brace structs remain intact. The native run loaded the rebuilt kernel and completed without fallback. Focused extraction.test.ts and kernel-rustlang-parity.test.ts runs: 645 tests passed with the kernel disabled, and 645 with it enabled; all three parity tests ran in each configuration, with no skips. (cherry picked from commit a94c9dc94eee348bf63c7e2dd567c0b43577678a) Co-authored-by: ctype_lab Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 29 +++++++++++++++++++ __tests__/kernel-rustlang-parity.test.ts | 3 +- codegraph-kernel/src/rustlang.rs | 13 ++++++--- .../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, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 076e1a6..427599c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- Rust unit structs (`struct Unit;`) and their trait implementation relationships now appear in the graph after re-indexing. (#1513, #1514) - Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537) - Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 02c069c..83593e8 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1252,6 +1252,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 07c897b..7ba73fe 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. generic / lifetime / - * reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom + * reference / scoped / generic-trait impl receivers (#1588), 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 3e880fa..f078cf9 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -20,8 +20,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`); a call through a field of the enclosing type keeps //! the owner-field shape (`self.inner.run()` → `self.inner.run`, #1585); @@ -590,10 +589,12 @@ 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 — 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_aggregate(&mut self, node: Node<'t>, kind: &'static str) { 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), @@ -603,6 +604,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 831e3ca..6b44e6e 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -140,7 +140,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) | @@ -490,7 +490,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 cef4c20..9c47100 100644 --- a/src/extraction/languages/rust.ts +++ b/src/extraction/languages/rust.ts @@ -81,6 +81,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 5a4f8d3..19f749c 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -187,6 +187,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 8e5784a..7063782 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1974,8 +1974,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);