Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion __tests__/kernel-rustlang-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` trait-receiver bug, unit-struct skip, phantom
* `impl Trait for Generic<T>` 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
Expand Down
13 changes: 9 additions & 4 deletions codegraph-kernel/src/rustlang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions docs/design/rust-lang-kernel-port-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/extraction/languages/rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
13 changes: 13 additions & 0 deletions src/extraction/tree-sitter-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 9 additions & 1 deletion src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down