From bddc6857dbc042a525eb51a960e4f76733976232 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 3 Oct 2026 19:34:15 +0300 Subject: [PATCH] fix(rust): a variant path references its enum (#2328) 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 --- CHANGELOG.md | 4 + __tests__/kernel-rustlang-parity.test.ts | 23 ++ __tests__/rust-enum-variant-reference.test.ts | 277 ++++++++++++++++++ codegraph-kernel/src/rustlang.rs | 62 ++++ src/extraction/extraction-version.ts | 2 +- src/extraction/tree-sitter.ts | 38 ++- src/resolution/name-matcher.ts | 13 + 7 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 __tests__/rust-enum-variant-reference.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..873b7c3027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In Rust, a function that uses an enum only through its variants, like `Mode::A`, `mode::Mode::B` or a `Mode::C(x) =>` match arm, now counts as referencing the enum, so it shows up in the enum's callers and impact; reading a type's associated constant, like `Limits::MAX`, references that type too. Re-index Rust projects after upgrading. (#2328) + ## [1.6.2] - 2026-10-03 diff --git a/__tests__/kernel-rustlang-parity.test.ts b/__tests__/kernel-rustlang-parity.test.ts index 3380ae67a8..b37a0c300d 100644 --- a/__tests__/kernel-rustlang-parity.test.ts +++ b/__tests__/kernel-rustlang-parity.test.ts @@ -113,6 +113,29 @@ fn next() {} assertParity('commands.rs', source.replace(/\n/g, '\r\n')); }); + it('variant and associated-const paths reference their type (#2328)', () => { + const source = ` +use crate::mode::{self, Mode}; +const DEFAULT: Mode = Mode::A; +impl Mode { + fn flip(&self) -> u8 { + let _s = "ü"; let _x = Self::B; + match self { Self::A => 1, Mode::C(x) => *x, mode::Mode::D { x } => *x, _ => 0 } + } +} +trait Tr { fn t(&self) { let _ = Self::K; } } +fn uses() { + use crate::mode::Mode::A; + let _a = crate::mode::Mode::A; let _b = Mode::C(1); let _c = Mode::D { x: 2 }; + let _d = Mode::new(); let _e = util::take(3); let _f = u8::MAX; let _g = Limits::MAX; + let _h = xs.map(Mode::C); let _i = Option::::None; let _j = Mode::A.flip(); + if let Mode::C(x) | Mode::B = m {} +} +`; + assertParity('variants.rs', source); + assertParity('variants.rs', source.replace(/\n/g, '\r\n')); + }); + it('torture fixture: impl/trait quirks, use bindings, chains, fn-refs, value-refs, route macros', () => { const file = path.join(FIXTURE_DIR, 'torture.rs'); assertParity('fixtures/torture.rs', fs.readFileSync(file, 'utf8'), 20); diff --git a/__tests__/rust-enum-variant-reference.test.ts b/__tests__/rust-enum-variant-reference.test.ts new file mode 100644 index 0000000000..bbfad5fdb2 --- /dev/null +++ b/__tests__/rust-enum-variant-reference.test.ts @@ -0,0 +1,277 @@ +/** + * A Rust enum used only through its variants is a dependency of the code that + * uses them (#2328). `mode::Mode::A` in an expression, `Mode::B => …` in a + * `match`, a `Mode::C(x)` / `Mode::D { .. }` pattern and `Self::A` in the + * enum's impl reference the enum the path names — the same `references` edge a + * type annotation (`fn takes(_m: Mode)`) produces. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; + +let root: string; +let cg: CodeGraph | undefined; +beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-variant-ref-')); }); +afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); }); + +async function index(files: Record) { + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'Cargo.toml'), '[package]\nname = "repro"\nversion = "0.1.0"\nedition = "2021"\n'); + for (const [file, text] of Object.entries(files)) fs.writeFileSync(path.join(root, 'src', file), text); + cg = await CodeGraph.init(root, { index: true }); +} + +/** The enum named `name` declared in `src/`. */ +function enumIn(file: string, name = 'Mode') { + const node = cg!.getNodesByKind('enum').find((n) => n.filePath === `src/${file}` && n.name === name); + expect(node, `enum ${name} in ${file}`).toBeDefined(); + return node!; +} + +/** `file:name` of every symbol with a `references` edge to the node. */ +function referencers(nodeId: string): string[] { + const names = cg!.getIncomingEdges(nodeId) + .filter((e) => e.kind === 'references') + .map((e) => cg!.getNode(e.source)!) + .map((n) => `${n.filePath.replace(/^src\//, '')}:${n.name}`); + return [...new Set(names)].sort(); +} + +const MODE = `pub enum Mode { + A, + B, + C(u8), + D { x: u8 }, +} + +pub fn takes(_m: Mode) {} + +pub fn current() -> Mode { Mode::B } +`; + +describe('Rust enum variant paths reference their enum (#2328)', () => { + it('the issue: a variant path in an expression and in match arms, through the module', async () => { + await index({ + 'mode.rs': MODE, + 'variant_use.rs': `use crate::mode; + +pub fn code(flag: bool) -> u8 { + let m = if flag { mode::Mode::A } else { mode::Mode::B }; + match m { + mode::Mode::A => 1, + mode::Mode::B => 2, + _ => 3, + } +} +`, + 'main.rs': `mod mode; +mod variant_use; + +fn main() { + mode::takes(mode::Mode::A); + let _ = variant_use::code(true); +} +`, + }); + expect(referencers(enumIn('mode.rs').id)).toEqual(['main.rs:main', 'mode.rs:current', 'mode.rs:takes', 'variant_use.rs:code']); + }); + + it('a bare `Mode::A` after `use crate::mode::Mode;`', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod bare;\n', + 'mode.rs': MODE, + 'bare.rs': `use crate::mode::{self, Mode}; + +pub fn pick() -> u8 { + let _m = Mode::B; + 0 +} + +pub fn score() -> u8 { + match mode::current() { + Mode::A => 1, + _ => 0, + } +} +`, + }); + expect(referencers(enumIn('mode.rs').id)).toEqual(['bare.rs:pick', 'bare.rs:score', 'mode.rs:current', 'mode.rs:takes']); + const pick = cg!.getNodesByKind('function').find((n) => n.name === 'pick')!; + const at = cg!.getOutgoingEdges(pick.id).filter((e) => e.kind === 'references').map((e) => `${e.line}:${e.column}`); + expect(at).toEqual(['4:13']); + }); + + it('tuple and struct variant patterns, and a variant read as a value', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod pat;\n', + 'mode.rs': MODE, + 'pat.rs': `use crate::mode::{self, Mode}; + +pub fn tuple() -> u8 { + match mode::current() { + Mode::C(x) => x, + _ => 0, + } +} + +pub fn structure() -> u8 { + if let crate::mode::Mode::D { x } = mode::current() { x } else { 0 } +} + +pub fn ctor_value(xs: Vec) -> usize { + xs.into_iter().map(Mode::C).count() +} +`, + }); + expect(referencers(enumIn('mode.rs').id)).toEqual([ + 'mode.rs:current', 'mode.rs:takes', 'pat.rs:ctor_value', 'pat.rs:structure', 'pat.rs:tuple', + ]); + }); + + it('constructing a tuple or struct variant stays linked to the variant itself', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod build;\n', + 'mode.rs': MODE, + 'build.rs': `use crate::mode::Mode; + +pub fn build() { + let _c = Mode::C(3); + let _d = Mode::D { x: 2 }; +} +`, + }); + const build = cg!.getNodesByKind('function').find((n) => n.name === 'build')!; + const out = cg!.getOutgoingEdges(build.id).map((e) => `${e.kind}:${cg!.getNode(e.target)!.qualifiedName}`).sort(); + expect(out).toEqual(['calls:Mode::C', 'instantiates:Mode::D']); + }); + + it('`Self::A` inside `impl Mode`, in the enum\'s file and in another one', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod ops;\n', + 'mode.rs': `${MODE} +impl Mode { + pub fn flip(&self) -> u8 { + match self { + Self::A => 1, + _ => 0, + } + } +} +`, + 'ops.rs': `use crate::mode::Mode; + +impl Mode { + pub fn is_b(&self) -> bool { + if let Self::B = self { true } else { false } + } +} +`, + }); + expect(referencers(enumIn('mode.rs').id)).toEqual(['mode.rs:current', 'mode.rs:flip', 'mode.rs:takes', 'ops.rs:is_b']); + }); + + it('a variant imported by name and used bare has no path to read the enum from', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod other;\npub mod glob;\n', + 'mode.rs': MODE, + 'other.rs': 'pub enum Mode {\n A,\n Z,\n}\n', + 'glob.rs': `use crate::mode::Mode::*; + +pub fn bare(m: u8) -> u8 { + match m { + 0 => { let _ = A; 1 } + _ => 0, + } +} +`, + }); + // Out of scope (#2328 is about paths): a bare name gives no receiver, and + // it must never land on the other module's same-named enum either. + expect(referencers(enumIn('mode.rs').id)).toEqual(['mode.rs:current', 'mode.rs:takes']); + expect(referencers(enumIn('other.rs').id)).toEqual([]); + }); + + it('the path picks the type that declares the variant, never a same-named type without it', async () => { + await index({ + 'lib.rs': 'pub mod matching;\npub mod other;\npub mod user;\n', + 'matching.rs': 'pub enum Match {\n None,\n Ignore,\n}\n', + 'other.rs': 'pub struct Match;\n', + 'user.rs': `use crate::matching::Match; + +pub enum ColorChoice { + Auto, + Ansi, +} + +pub fn first() -> u8 { + let _m = Match::None; + 0 +} + +pub fn color() -> u8 { + let _c = termcolor::ColorChoice::AlwaysAnsi; + 0 +} +`, + }); + expect(referencers(enumIn('matching.rs', 'Match').id)).toEqual(['user.rs:first']); + const struct = cg!.getNodesByKind('struct').find((n) => n.filePath === 'src/other.rs')!; + expect(referencers(struct.id)).toEqual([]); + // `termcolor`'s ColorChoice, not the file's own enum, which has no `AlwaysAnsi`. + expect(referencers(enumIn('user.rs', 'ColorChoice').id)).toEqual([]); + }); + + it('an associated const read through its type references the type', async () => { + await index({ + 'lib.rs': 'pub mod limits;\npub mod user;\n', + 'limits.rs': 'pub struct Limits;\n\nimpl Limits {\n pub const MAX: u8 = 3;\n}\n', + 'user.rs': 'use crate::limits::Limits;\n\npub fn cap() -> u8 {\n Limits::MAX\n}\n', + }); + const limits = cg!.getNodesByKind('struct').find((n) => n.name === 'Limits')!; + expect(referencers(limits.id)).toEqual(['user.rs:cap']); + }); + + it('a module path, an associated function and a same-named enum elsewhere are not references to the enum', async () => { + await index({ + 'lib.rs': 'pub mod mode;\npub mod other;\npub mod util;\npub mod paths;\n', + 'mode.rs': `${MODE} +impl Mode { + pub fn new() -> Mode { Mode::A } +} +`, + 'other.rs': 'pub enum Mode {\n A,\n Z,\n}\n', + 'util.rs': 'pub fn take(x: u8) -> u8 { x }\n', + 'paths.rs': `use crate::mode; +use crate::other; +use crate::util; + +pub fn via_module() -> u8 { + util::take(3) +} + +pub fn via_ctor() { + let _m = mode::Mode::new(); +} + +pub fn via_other() -> u8 { + match other::Mode::A { + other::Mode::Z => 1, + _ => 0, + } +} + +pub fn via_mode() { + let _m = mode::Mode::A; +} +`, + }); + const viaModule = cg!.getNodesByKind('function').find((n) => n.name === 'via_module')!; + expect(cg!.getOutgoingEdges(viaModule.id).filter((e) => e.kind === 'references')).toEqual([]); + expect(referencers(enumIn('other.rs').id)).toEqual(['paths.rs:via_other']); + // `new`'s own `Mode::A` and return type; `via_ctor` calls an associated + // function, which links the function, not the enum. + expect(referencers(enumIn('mode.rs').id)).toEqual(['mode.rs:current', 'mode.rs:new', 'mode.rs:takes', 'paths.rs:via_mode']); + }); +}); diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 1dbc689b41..cd52843da5 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -66,6 +66,11 @@ fn simple_ident_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| Regex::new(r"^[A-Za-z_][0-9A-Za-z_]*$").unwrap()) } +/// extractStaticMemberRef's capitalized-receiver test. +fn capitalized_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[A-Z][A-Za-z0-9_]*$").unwrap()) +} struct Scope { row: u32, @@ -1201,6 +1206,7 @@ impl<'t> Walker<'t> { } else if kind == "struct_expression" { self.extract_instantiation(node); } + self.extract_static_member_ref(node); // Nested NAMED fns become their own nodes (a nested fn inside an impl // method walks up to the impl and indexes as a METHOD). @@ -1237,6 +1243,62 @@ impl<'t> Walker<'t> { } } + /// extractStaticMemberRef — the rust branch (#2328): a variant or an + /// associated const written as a path (`Mode::A`, `mode::Mode::B`, a + /// `Mode::C(x)` / `Mode::D { .. }` pattern, `Self::A` in an impl) references + /// the receiver — the segment before the member, at the place it is + /// written. A lowercase receiver (module) or member (fn), a call's callee + /// or a struct literal's name (both already linked to their member), a + /// path's prefix and a `use` tree emit nothing. Mirrored byte-for-byte — + /// change both. + fn extract_static_member_ref(&mut self, node: Node<'t>) { + if self.stack.is_empty() { + return; + } + let Some(parent) = node.parent() else { return }; + let member_path = match node.kind() { + "scoped_identifier" => !matches!( + parent.kind(), + "scoped_identifier" + | "scoped_type_identifier" + | "use_declaration" + | "use_list" + | "scoped_use_list" + | "use_as_clause" + | "use_wildcard" + ), + "scoped_type_identifier" => parent.kind() == "struct_pattern", + _ => false, + }; + if !member_path { + return; + } + if parent.kind() == "call_expression" + && parent.child_by_field_name("function").map(|f| f.start_byte()) == Some(node.start_byte()) + { + return; + } + let Some(member) = node.child_by_field_name("name") else { return }; + let mut recv = node.child_by_field_name("path"); + if let Some(r) = recv { + if r.kind() == "scoped_identifier" { + recv = r.child_by_field_name("name"); + } + } + let Some(recv) = recv else { return }; + if recv.kind() != "identifier" || !self.text(member).starts_with(|c: char| c.is_ascii_uppercase()) { + return; + } + let mut text = self.text(recv).to_string(); + if text == "Self" { + text = self.receiver_type_of(node).unwrap_or_default(); + } + if capitalized_re().is_match(&text) { + let owner = self.top_row(); + self.push_ref_at(owner, &text, edge_kind_index("references").unwrap(), recv); + } + } + // --- fn refs (RUST_SPEC) ---------------------------------------------------- /// maybeCaptureFnRefs with RUST_SPEC's dispatch: arguments→args, diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 3691e95f0a..e41b5f6149 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 27; +export const EXTRACTION_VERSION = 28; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5c27523acc..756cc935a2 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -357,9 +357,20 @@ const MEMBER_ACCESS_TYPES: ReadonlySet = new Set([ * static read is pure duplication) — while adding real graph noise (+1813 edges / * +2448 `references` on excalidraw, the retrieval-perf benchmark, all pointing at * already-covered types). Don't re-add `member_expression`/`attribute` here. + * Rust imports too, but a path names its module where it is written + * (`mode::Mode::A` under `use crate::mode;`), so often no `use` names the type (#2328). */ const STATIC_MEMBER_LANGS: ReadonlySet = new Set([ - 'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp', + 'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp', 'rust', +]); + +/** + * Parents of a Rust `scoped_identifier` that is not a member written as a + * value or a pattern: the prefix of a longer path, or a `use` tree. + */ +const RUST_NON_MEMBER_PATH_PARENTS: ReadonlySet = new Set([ + 'scoped_identifier', 'scoped_type_identifier', + 'use_declaration', 'use_list', 'scoped_use_list', 'use_as_clause', 'use_wildcard', ]); /** @@ -5423,6 +5434,31 @@ export class TreeSitterExtractor { return; } + // Rust writes the member as a path: a variant or an associated const read + // (`Mode::A`, `mode::Mode::B`), matched (`Mode::C(x)`, `Mode::D { .. }`), or + // `Self::A` in an impl (#2328). The receiver is the segment before the + // member, referenced where it is written so a `mode::` / `other::` prefix + // scopes it as it does a type annotation. A lowercase receiver is a module + // and a lowercase member a function (`util::take`, `Foo::new`); a call's + // callee (`Mode::C(1)`) and a struct literal's name are already linked to + // their member; the prefix of a longer path and a `use` tree name no member. + // Mirrored by the native kernel's `extract_static_member_ref` — change both. + if (this.language === 'rust') { + const parent = node.parent; + if (!parent) return; + if (node.type === 'scoped_type_identifier' ? parent.type !== 'struct_pattern' + : node.type !== 'scoped_identifier' || RUST_NON_MEMBER_PATH_PARENTS.has(parent.type)) return; + if (parent.type === 'call_expression' && getChildByField(parent, 'function')?.startIndex === node.startIndex) return; + const member = getChildByField(node, 'name'); + let recv = getChildByField(node, 'path'); + if (recv?.type === 'scoped_identifier') recv = getChildByField(recv, 'name'); + if (!member || recv?.type !== 'identifier' || !/^[A-Z]/.test(getNodeText(member, this.source))) return; + let text = getNodeText(recv, this.source); + if (text === 'Self') text = this.extractor!.getReceiverType?.(node, this.source) ?? ''; + if (/^[A-Z][A-Za-z0-9_]*$/.test(text)) this.pushStaticMemberRef(text, ownerId, recv); + return; + } + if (!MEMBER_ACCESS_TYPES.has(node.type)) return; // Skip `Type.method()` — the access is the callee of a call, already linked. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..278f995818 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5079,6 +5079,12 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: // Bare in the SOURCE: the index keeps `crate::error::Result` by its last // segment, and a path is not a prelude lookup. const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + // A type read through one of its items — `Mode::A`, `Limits::MAX`, `Self::A` + // (#2328) — is a type that declares that item: ripgrep's `Match::None` is + // `ignore`'s enum, not the matcher's `struct Match`. + const item = ref.referenceKind === 'references' && line !== undefined + ? new RegExp(`^(?:${name}|Self)\\s*::\\s*([A-Z]\\w*)`).exec(line.slice(ref.column))?.[1] : undefined; + if (item !== undefined && !declaresRustItem(candidate, item, context)) return false; // Written through a path on its line (`jsont::SubMatch { … }`, `io::Result<…>`), // wherever the reference's column points. const pathed = line === undefined ? null @@ -5136,6 +5142,13 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: return uses.names.has(name) || rustGlobCovers(uses, candidate, ref); } +/** Whether a Rust type declares `item`: a variant of the enum, or an associated const in its file. */ +function declaresRustItem(type: Node, item: string, context: ResolutionContext): boolean { + const named = context.getNodesInFileNamed?.(type.filePath, item) ?? context.getNodesInFile(type.filePath); + return named.some((n) => n.name === item && + (n.kind === 'enum_member' ? n.qualifiedName === `${type.qualifiedName}::${item}` : n.kind === 'variable' || n.kind === 'constant')); +} + /** The line of the `impl` / `trait` header above `ref` in its file (0 for none). */ function rustEnclosingImplLine(ref: UnresolvedRef, context: ResolutionContext): number { const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? [];