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