Skip to content

fix(rust): a variant path references its enum (#2328) - #2330

Closed
danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2328-rust-variant-enum-ref
Closed

danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2328-rust-variant-enum-ref

Conversation

@danusha2345

Copy link
Copy Markdown
Contributor

Fixes #2328. Thanks @mg-mg-mg for the report and the repro script.

Problem

A Rust enum used only through its variants records no reference to the enum. This covers mode::Mode::A in an expression and Mode::B => …, Mode::C(x) or Mode::D { .. } in a pattern. A type annotation (fn takes(_m: Mode)) does record one. So the functions that build or match on variants without naming the type in a signature were missing from the enum's callers and impact.

Fix

Extraction (wasm extractor and native kernel together). Rust joins the static-member pass that the other capitalized-type languages already run (Enum.value, Type.CONST). A path whose member is capitalized, meaning a variant or an associated const, emits a references ref to its receiver. The receiver is the segment before the member, and the ref sits where it is written. This is the same bare-name ref a type annotation produces, so a mode:: / other:: prefix scopes it the way it scopes fn f(_: other::Mode).

  • Value reads (Mode::A, mode::Mode::A.flip(), .map(Mode::C)), match arms, if let / | patterns, tuple-variant patterns and struct-variant patterns (Mode::D { x }).
  • Self::A inside an impl names the impl's type.
  • These emit nothing:
    • A lowercase receiver, which is a module (util::take(3), u8::MAX), or a lowercase member, which is a function (Foo::new as a value).
    • A call's callee (Mode::C(1), Foo::new()) and a struct literal's name (Mode::D { x: 1 }). Both are already linked to their member by calls / instantiates, as in the other languages. So Type::new() still does not reference Type.
    • The prefix of a longer path, and use trees.

Resolution. In isRustNameInScope, a type read through one of its items is a type that declares that item. That means an enum with that variant, or a type whose file defines that associated const. Without this, a bare name shared across crates landed on a same-named struct that lacks the member. On ripgrep, ignore's Match::None and Error::Partial went to the matcher's struct Match and globset's struct Error. A loose path match did the same on tokio, where scheduler::Handle::MultiThread went to current_thread's struct Handle. With the check, the right enum is the only candidate left, or nothing links.

EXTRACTION_VERSION 27 → 28.

Tests

  • New __tests__/rust-enum-variant-reference.test.ts builds real temp Cargo projects in SQLite with no mocks:
    • the issue's repro;
    • a bare Mode::A after use, with the edge's line and column;
    • tuple and struct variant patterns, and a variant passed as a value;
    • Self::A in the enum's file and in an impl in another file;
    • picking the type that declares the variant over a same-named struct, and an external termcolor::ColorChoice::AlwaysAnsi that does not reach the file's own ColorChoice;
    • an associated const (Limits::MAX);
    • negative controls: util::take(3), mode::Mode::new(), and other::Mode::A against mode::Mode;
    • two cases that are unchanged on purpose: constructing Mode::C(3) / Mode::D { x } stays linked to the variant, and a variant imported with use Mode::* and used bare gets no edge.
  • Results: 7 of 9 fail on main in both modes, and all 9 pass with the kernel (CODEGRAPH_KERNEL_EXPECT=1) and with wasm (CODEGRAPH_KERNEL=0). With main's kernel binary under this branch's TypeScript, the native run fails the extraction cases again, so the kernel half is exercised.
  • kernel-rustlang-parity has a new case covering every shape above, plus a non-ASCII column and CRLF.
  • All kernel-*-parity, rust-*, resolution, extraction and the other Rust-touching suites pass in both modes.

A/B (origin/main → this branch)

repo nodes edges new references → enum → struct (assoc const) removed
ripgrep @3fce3b5 4141 = +825 822 3 0
tokio @5d5cd8b 14168 = +887 665 222 (Interest::READABLE, Ready::*, Semaphore::MAX_PERMITS) 0
two private Rust GUI/CLI crates = +505 / +122 504 / 122 1 / 0 0
  • Every new enum edge targets an enum that declares the variant written. The exceptions are 5 edges in the private crates, which go through an enum's own associated const (Column::ALL). Every struct edge targets a struct whose file defines the const.
  • New distinct function→enum pairs: ripgrep +220, tokio +254.
  • I spot-checked 10 edges by hand and all were correct.
  • One wrong edge remains, on tokio. oneshot::error::TryRecvError::Empty goes to mpsc's TryRecvError, which also has Empty. The path-scope check accepts any file named error.rs for the error:: segment, and type annotations already have the same issue.
  • Index time is within noise (ripgrep 1.8–1.9 s → 1.9–2.2 s, tokio 7.0–7.2 s → 7.1–7.2 s).

Not in this PR

  • A variant brought in by use Mode::* or use Mode::A and used bare (A =>). There is no path to read the enum from, and covering it would mean a ref for every capitalized identifier (None, Some, …).
  • Paths inside macro token trees (matches!(m, Mode::A)). tree-sitter does not parse them, and calls there are not extracted either.
  • A -> Self / &Self type annotation still records no reference.

🤖 Generated with Claude Code

An enum used only through its variants - `mode::Mode::A` in an
expression, `Mode::B => ...` / `Mode::C(x)` / `Mode::D { .. }` in a
pattern - recorded no reference to the enum, so the functions doing it
were missing from the enum's callers and impact.

Rust joins the static-member pass the other capitalized-type languages
already run: a path whose member is capitalized (a variant or an
associated const) emits a `references` ref to its receiver, the segment
before the member, at the place it is written - the same bare-name ref a
type annotation produces, so the `mode::` / `other::` prefix scopes it
the same way. `Self::A` names the impl's type. A lowercase receiver
(module) or member (function), a call's callee and a struct literal's
name (both already linked to their member), the prefix of a longer path
and `use` trees emit nothing. Native kernel and wasm extractor change
together.

Resolution: a type read through one of its items is a type that
declares that item - an enum with that variant, or a type whose file
defines that associated const. Without it, a bare name shared across
crates (ripgrep's `Match::None`, `Error::Partial`) or a looser path
match (tokio's `scheduler::Handle::MultiThread`) landed on a same-named
struct without that member.

EXTRACTION_VERSION 27 -> 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danusha2345 pushed a commit to danusha2345/codegraph that referenced this pull request Oct 3, 2026
# Conflicts:
#	CHANGELOG.md
#	src/extraction/extraction-version.ts
danusha2345 pushed a commit to danusha2345/codegraph that referenced this pull request Oct 3, 2026
colbymchenry added a commit that referenced this pull request Oct 5, 2026
Using an enum only through its variants (`mode::Mode::A` in an expression,
`mode::Mode::A => 1` / `Mode::B => ...` in a match, a `Mode::C(x)` or
`Mode::D { .. }` pattern) recorded no reference to the enum, so code that
builds or matches variants was missing from the enum's callers and impact.
Only a type annotation (`fn takes(_m: Mode)`) produced one.

Cause: Rust was not in the static-member pass, which turns `Enum.value` /
`Type::CONST` reads into references for Java, C#, Kotlin, Swift, Scala,
Dart, PHP and C++, and a path to a variant that is not a call or a struct
literal reaches no other extractor.

Fix:
- Extraction (wasm extractor and native kernel, mirrored): a path whose
  last segment is capitalized emits a `references` ref named by its
  receiver (the segment before it; `Self` read as the impl's type, nothing
  in a trait), positioned at the receiver, so a `mode::` prefix scopes it
  exactly as it scopes a type annotation. Covers value reads, match arms,
  `|` / `if let` patterns, tuple- and struct-variant patterns and a variant
  passed as a value. A call's callee (`Mode::C(1)`, `Foo::new()`), a struct
  literal, a longer path's prefix, a `use` tree and a lowercase receiver or
  member emit nothing.
- Resolution (isRustNameInScope): a reference written `Name::Variant` or
  `Self::Variant` lands only on an enum that declares that variant, after
  the usual Rust scoping (module path, `use`, glob, prelude, std and
  dependency imports). ripgrep's `Match::None` reaches `ignore`'s enum, not
  the matcher's `struct Match`; std variants (`Ordering::Less`,
  `Option::Some`) never reach a same-named project enum; associated consts
  and functions (`Limits::MAX`, `Mode::ALL`, `Foo::new()`) reference no
  type. `Self::X` is read as the impl's type, never through a path written
  elsewhere on its line.

Adapted from #2330 by @danusha2345: the extraction half (both walkers) and
most of the tests are theirs. Changed here: the resolver accepts a declared
variant only. #2330 also accepted any type whose file holds a variable or
const of the item's name; impl consts are file-level variables in the graph,
so that cannot tell a type's associated const from another type's or a
top-level one. EXTRACTION_VERSION is not bumped again (already 28 on main).

Verification: __tests__/rust-enum-variant-reference.test.ts (10 tests):
6 fail on main in wasm mode, in kernel mode, and with this TypeScript over
main's kernel binary; 10/10 pass in both modes. The kernel parity case
covers every shape and asserts the refs exist; the kernel-parity sweep over
ripgrep, alacritty and tokio is 1004/1006 files byte-parity, 0 diffs (2
parse-error files defer to wasm by policy).

Validation (native kernel loaded, main -> branch, edges keyed by
QN/kind/file/line): ripgrep +822/-0, alacritty +917/-0, tokio +665/-0, all
`references -> enum`, node counts unchanged. Every added edge's source
reads `Name::Variant` / `Self::Variant` at its column and its target enum
declares that variant; no std enum targets. New symbol->enum pairs:
ripgrep +220, alacritty +253, tokio +254; enums with no referencer before
and one now: 31 / 13 / 31. One wrong target on tokio
(`oneshot::error::TryRecvError::Empty` -> mpsc's `TryRecvError`) comes
from the existing path rule that checks only the last module segment, which
type annotations share.

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 5, 2026
Using an enum only through its variants (`mode::Mode::A` in an expression,
`mode::Mode::A => 1` / `Mode::B => ...` in a match, a `Mode::C(x)` or
`Mode::D { .. }` pattern) recorded no reference to the enum, so code that
builds or matches variants was missing from the enum's callers and impact.
Only a type annotation (`fn takes(_m: Mode)`) produced one.

Cause: Rust was not in the static-member pass, which turns `Enum.value` /
`Type::CONST` reads into references for Java, C#, Kotlin, Swift, Scala,
Dart, PHP and C++, and a path to a variant that is not a call or a struct
literal reaches no other extractor.

Fix:
- Extraction (wasm extractor and native kernel, mirrored): a path whose
  last segment is capitalized emits a `references` ref named by its
  receiver (the segment before it; `Self` read as the impl's type, nothing
  in a trait), positioned at the receiver, so a `mode::` prefix scopes it
  exactly as it scopes a type annotation. Covers value reads, match arms,
  `|` / `if let` patterns, tuple- and struct-variant patterns and a variant
  passed as a value. A call's callee (`Mode::C(1)`, `Foo::new()`), a struct
  literal, a longer path's prefix, a `use` tree and a lowercase receiver or
  member emit nothing.
- Resolution (isRustNameInScope): a reference written `Name::Variant` or
  `Self::Variant` lands only on an enum that declares that variant, after
  the usual Rust scoping (module path, `use`, glob, prelude, std and
  dependency imports). ripgrep's `Match::None` reaches `ignore`'s enum, not
  the matcher's `struct Match`; std variants (`Ordering::Less`,
  `Option::Some`) never reach a same-named project enum; associated consts
  and functions (`Limits::MAX`, `Mode::ALL`, `Foo::new()`) reference no
  type. `Self::X` is read as the impl's type, never through a path written
  elsewhere on its line.

Adapted from #2330 by @danusha2345: the extraction half (both walkers) and
most of the tests are theirs. Changed here:
- The resolver accepts a declared variant only. #2330 also accepted any type
  whose file holds a variable or const of the item's name; impl consts are
  file-level variables in the graph, so that cannot tell a type's associated
  const from another type's or a top-level one.
- The walkers check the node kind before looking at its parent, and the
  body walk hands the parent down: #2330 called `parent()`, which walks down
  from the tree's root, for every node of every body, which nearly doubled
  the kernel's Rust extraction time on tokio (2.1 s -> 3.9 s); now within
  noise of main.
- EXTRACTION_VERSION is not bumped again (already 28 on main).

Verification: __tests__/rust-enum-variant-reference.test.ts (10 tests):
6 fail on main in wasm mode, in kernel mode, and with this TypeScript over
main's kernel binary; 10/10 pass in both modes. The kernel parity case
covers every shape and asserts the refs exist; the kernel-parity sweep over
ripgrep, alacritty and tokio is 1004/1006 files byte-parity, 0 diffs (2
parse-error files defer to wasm by policy).

Validation (native kernel loaded, main -> branch, edges keyed by
QN/kind/file/line): ripgrep +822/-0, alacritty +917/-0, tokio +665/-0, all
`references -> enum`, node counts unchanged. Every added edge's source
reads `Name::Variant` / `Self::Variant` at its column and its target enum
declares that variant; no std enum targets. New symbol->enum pairs:
ripgrep +220, alacritty +253, tokio +254; enums with no referencer before
and one now: 31 / 13 / 31. One wrong target on tokio
(`oneshot::error::TryRecvError::Empty` -> mpsc's `TryRecvError`) comes
from the existing path rule that checks only the last module segment, which
type annotations share. Index time on tokio stays within noise of main
(interleaved runs; resolveOne time 27.7 s vs 31.2 s summed over workers).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 5, 2026
Using an enum only through its variants (`mode::Mode::A` in an expression,
`mode::Mode::A => 1` / `Mode::B => ...` in a match, a `Mode::C(x)` or
`Mode::D { .. }` pattern) recorded no reference to the enum, so code that
builds or matches variants was missing from the enum's callers and impact.
Only a type annotation (`fn takes(_m: Mode)`) produced one.

Cause: Rust was not in the static-member pass, which turns `Enum.value` /
`Type::CONST` reads into references for Java, C#, Kotlin, Swift, Scala,
Dart, PHP and C++, and a path to a variant that is not a call or a struct
literal reaches no other extractor.

Fix:
- Extraction (wasm extractor and native kernel, mirrored): a path whose
  last segment is capitalized emits a `references` ref named by its
  receiver (the segment before it; `Self` read as the impl's type, nothing
  in a trait), positioned at the receiver, so a `mode::` prefix scopes it
  exactly as it scopes a type annotation. Covers value reads, match arms,
  `|` / `if let` patterns, tuple- and struct-variant patterns and a variant
  passed as a value. A call's callee (`Mode::C(1)`, `Foo::new()`), a struct
  literal, a longer path's prefix, a `use` tree and a lowercase receiver or
  member emit nothing.
- Resolution (isRustNameInScope): a reference written `Name::Variant` or
  `Self::Variant` lands only on an enum that declares that variant, after
  the usual Rust scoping (module path, `use`, glob, prelude, std and
  dependency imports). ripgrep's `Match::None` reaches `ignore`'s enum, not
  the matcher's `struct Match`; std variants (`Ordering::Less`,
  `Option::Some`) never reach a same-named project enum; associated consts
  and functions (`Limits::MAX`, `Mode::ALL`, `Foo::new()`) reference no
  type. `Self::X` is read as the impl's type, never through a path written
  elsewhere on its line.

Adapted from #2330 by @danusha2345: the extraction half (both walkers) and
most of the tests are theirs. Changed here:
- The resolver accepts a declared variant only. #2330 also accepted any type
  whose file holds a variable or const of the item's name; impl consts are
  file-level variables in the graph, so that cannot tell a type's associated
  const from another type's or a top-level one.
- The walkers check the node kind before looking at its parent, and the
  body walk hands the parent down: #2330 called `parent()`, which walks down
  from the tree's root, for every node of every body, which nearly doubled
  the kernel's Rust extraction time on tokio (2.1 s -> 3.9 s); now within
  noise of main.
- EXTRACTION_VERSION is not bumped again (already 28 on main).

Verification: __tests__/rust-enum-variant-reference.test.ts (10 tests):
6 fail on main in wasm mode, in kernel mode, and with this TypeScript over
main's kernel binary; 10/10 pass in both modes. The kernel parity case
covers every shape and asserts the refs exist; the kernel-parity sweep over
ripgrep, alacritty and tokio is 1004/1006 files byte-parity, 0 diffs (2
parse-error files defer to wasm by policy).

Validation (native kernel loaded, main -> branch, edges keyed by
QN/kind/file/line): ripgrep +822/-0, alacritty +917/-0, tokio +665/-0, all
`references -> enum`, node counts unchanged. Every added edge's source
reads `Name::Variant` / `Self::Variant` at its column and its target enum
declares that variant; no std enum targets. New symbol->enum pairs:
ripgrep +220, alacritty +253, tokio +254; enums with no referencer before
and one now: 31 / 13 / 31. One wrong target on tokio
(`oneshot::error::TryRecvError::Empty` -> mpsc's `TryRecvError`) comes
from the existing path rule that checks only the last module segment, which
type annotations share. Index time on tokio stays within noise of main
(interleaved runs; resolveOne time 27.7 s vs 31.2 s summed over workers).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 5, 2026
#2354)

Using an enum only through its variants (`mode::Mode::A` in an expression,
`mode::Mode::A => 1` / `Mode::B => ...` in a match, a `Mode::C(x)` or
`Mode::D { .. }` pattern) recorded no reference to the enum, so code that
builds or matches variants was missing from the enum's callers and impact.
Only a type annotation (`fn takes(_m: Mode)`) produced one.

Cause: Rust was not in the static-member pass, which turns `Enum.value` /
`Type::CONST` reads into references for Java, C#, Kotlin, Swift, Scala,
Dart, PHP and C++, and a path to a variant that is not a call or a struct
literal reaches no other extractor.

Fix:
- Extraction (wasm extractor and native kernel, mirrored): a path whose
  last segment is capitalized emits a `references` ref named by its
  receiver (the segment before it; `Self` read as the impl's type, nothing
  in a trait), positioned at the receiver, so a `mode::` prefix scopes it
  exactly as it scopes a type annotation. Covers value reads, match arms,
  `|` / `if let` patterns, tuple- and struct-variant patterns and a variant
  passed as a value. A call's callee (`Mode::C(1)`, `Foo::new()`), a struct
  literal, a longer path's prefix, a `use` tree and a lowercase receiver or
  member emit nothing.
- Resolution (isRustNameInScope): a reference written `Name::Variant` or
  `Self::Variant` lands only on an enum that declares that variant, after
  the usual Rust scoping (module path, `use`, glob, prelude, std and
  dependency imports). ripgrep's `Match::None` reaches `ignore`'s enum, not
  the matcher's `struct Match`; std variants (`Ordering::Less`,
  `Option::Some`) never reach a same-named project enum; associated consts
  and functions (`Limits::MAX`, `Mode::ALL`, `Foo::new()`) reference no
  type. `Self::X` is read as the impl's type, never through a path written
  elsewhere on its line.

Adapted from #2330 by @danusha2345: the extraction half (both walkers) and
most of the tests are theirs. Changed here:
- The resolver accepts a declared variant only. #2330 also accepted any type
  whose file holds a variable or const of the item's name; impl consts are
  file-level variables in the graph, so that cannot tell a type's associated
  const from another type's or a top-level one.
- The walkers check the node kind before looking at its parent, and the
  body walk hands the parent down: #2330 called `parent()`, which walks down
  from the tree's root, for every node of every body, which nearly doubled
  the kernel's Rust extraction time on tokio (2.1 s -> 3.9 s); now within
  noise of main.
- EXTRACTION_VERSION is not bumped again (already 28 on main).

Verification: __tests__/rust-enum-variant-reference.test.ts (10 tests):
6 fail on main in wasm mode, in kernel mode, and with this TypeScript over
main's kernel binary; 10/10 pass in both modes. The kernel parity case
covers every shape and asserts the refs exist; the kernel-parity sweep over
ripgrep, alacritty and tokio is 1004/1006 files byte-parity, 0 diffs (2
parse-error files defer to wasm by policy).

Validation (native kernel loaded, main -> branch, edges keyed by
QN/kind/file/line): ripgrep +822/-0, alacritty +917/-0, tokio +665/-0, all
`references -> enum`, node counts unchanged. Every added edge's source
reads `Name::Variant` / `Self::Variant` at its column and its target enum
declares that variant; no std enum targets. New symbol->enum pairs:
ripgrep +220, alacritty +253, tokio +254; enums with no referencer before
and one now: 31 / 13 / 31. One wrong target on tokio
(`oneshot::error::TryRecvError::Empty` -> mpsc's `TryRecvError`) comes
from the existing path rule that checks only the last module segment, which
type annotations share. Index time on tokio stays within noise of main
(interleaved runs; resolveOne time 27.7 s vs 31.2 s summed over workers).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner

Thanks @danusha2345! This landed in #2354, which keeps your extraction code in both the TypeScript extractor and the native kernel, and most of your tests. Three changes on top. Associated consts like Limits::MAX no longer reference the type: impl consts are file-level variables in the graph, so a same-named constant can't be tied to that type. Both walkers now pass each node's parent down instead of looking it up, which brings kernel extraction on tokio back from about 3.9 s to 2.1 s. And the EXTRACTION_VERSION bump was dropped because it was already bumped this release. You're credited as a co-author on the commit and in the changelog. Closing this one in favor of #2354.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rust: enum variant paths (Mode::A) are not references to the enum

2 participants