瀏覽代碼

fix(rust): index unit structs — bodiless is a definition, not a forward decl (#1800)

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 <cksgud1226@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Colby Mchenry 17 小時之前
父節點
當前提交
71d049cd28

+ 1 - 0
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)

+ 29 - 0
__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 {

+ 2 - 1
__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

+ 9 - 4
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) {

+ 2 - 2
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);

+ 3 - 0
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'],

+ 13 - 0
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;
 

+ 9 - 1
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);