From 796367268970b485638d49cd969cd69afa1d0d1c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 26 Aug 2026 10:37:50 -0500 Subject: [PATCH] fix(rust): resolve `self.field.method()` on the field's declared type instead of a same-named method (#1585) (#1599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1585. **Stacked on #1596** (the base branch is `fix/1588-rust-impl-type-qualification`; this PR's own diff is the second commit). Merge #1596 first, then retarget/merge this one. ## What was wrong ```rust impl Outer { pub fn run(&mut self) { self.inner.run(); // inner: Inner } } ``` produced `Outer::run -> Outer::run` — recursion the source doesn't contain. The extractor collapsed every `self..()` receiver to the bare method name (`run`), so the resolver only ever saw `run` and exact-matched the nearest same-named method — the calling method itself, or a method of an unrelated type. Nothing marked the edge as a guess, and no row stayed in `unresolved_refs`, so a consumer had no way to tell. The same happened when the field's type isn't a project type at all (`its: std::vec::IntoIter<_>` → `self.its.next()`, `matcher: Regex` → `self.matcher.is_match()`): the bare `next` / `is_match` attached to whatever local method shared the name. ripgrep had 279 self-edges on `main`; the issue lists three sites, all of this shape. (The issue's C++ control — "`Outer::run -> Inner::run` resolves correctly" — doesn't actually hold on `main`: `inner.h` is classified as C by the `.h` heuristic, so `Inner::run` never exists and the C++ repro self-edges too. That's #1592, fixed separately.) ## What this does Rust struct fields are not graph nodes, so the field's type can only come from the struct's declaration text. This follows the Go 2-hop precedent exactly (`matchGoFieldChainCall`, #1276), including its exclusivity rule: 1. **Extraction (TS walker + native kernel, identical, parity-tested):** a call whose receiver is `self.` keeps the owner-field shape — `self.inner.run()` is emitted as `self.inner.run`. Deeper chains (`self.a.b.m()`), call receivers (`self.f().m()`), parenthesized receivers and bare `self` keep the bare name, exactly as before. 2. **Resolution (`matchRustSelfFieldCall`):** owner type = the calling method's qualified-name prefix (`Outer::run` → `Outer`); the field's declared type is read from the owner struct's **own declaration lines** (comment-stripped, line by line — same discipline as the Go helper); the method is resolved **and validated** on that type by `resolveMethodOnType` (confidence 0.85, `instance-method`). 3. **Exclusive:** when the field is declared with an external type, a generic parameter (`T`), a container that doesn't auto-deref (`Option`/`Vec`/`Mutex`/…), or can't be found, the ref **stays unresolved** — it never falls through to the bare-name strategies. That is the safe behaviour the issue asks for, and it is what #1276 already chose for Go. `rustFieldTypeName` looks through exactly the layers Rust's method-call auto-deref looks through: references (`&`, `&'a mut`) and the owning smart pointers `Box`/`Rc`/`Arc`. `Box` yields the trait, whose method node the interface-impl synthesizer then fans out to every implementation. `Option` is left alone — `self.inner.take()` is Option's method and must not become `Inner::take`. Why it stacks on #1596: the owner is taken from the method's qualified name, which for a generic/lifetime impl was the trait's name before that fix. ## Measured on ripgrep (110 `.rs` files, #1596 build vs this branch) | | #1596 | this PR | |---|---|---| | nodes | 4029 | 4029 | | `calls` self-edges | 279 | **146** (none of the `self.` shape remain — 116 bare-receiver, 30 other dotted) | | `self..m()` calls resolved through a validated field type | — | **292** (`DecompressionMatcher::command -> GlobSet::matches`, `Parser::find_long -> FlagMap::find`, `Haystack::path -> DirEntry::path`, …) | | `self..m()` calls left unresolved | — | **417** — every sampled one is a std/container method: `self.commands.push`, `self.child.wait`, `self.pre.is_some`, `self.colors.clone`, `self.path_terminator.unwrap_or` | | `calls` edges total | 9150 | 8878 (the 272 removed are the former bare-name guesses for those 417) | The issue's three sites: `walk.rs:824` now resolves to `IgnoreBuilder::add_custom_ignore_filename` (was a self-edge); `walk.rs:1195` (`self.its.next`, `IntoIter`) and `globset/lib.rs:983` (`self.matcher.is_match`, `Regex`) are parked as unresolved instead of guessed. The issue's repro gives `Outer::run -> Inner::run` (`instance-method`, confidence 0.85) on both the kernel path and `CODEGRAPH_KERNEL=0`. ## Tests - `__tests__/extraction.test.ts`: only the single-hop `self..()` call keeps the prefix; deeper / call / parenthesized / bare-`self` receivers and a local receiver are unchanged. - `__tests__/resolution.test.ts` (end-to-end, Cargo layout): the issue's repro → `Outer::run -> Inner::run`, no self-edge; an external field type (`std::vec::IntoIter`) with a local `next` decoy → no edge at all; `Box` and `&'a mut Inner` resolve, `Option` does not (even though `Inner` declares the method); a generic `T` field → no edge; genuine `self.run()` recursion keeps its self-edge; the #1588 repro's `UsesFile::go` / `UsesBuf::go` resolve to `FileSource::read` / `BufSource::read`, and a `Box` field lands on `Source::read` with the synthesizer fanning out to both impls. - `__tests__/fixtures/kernel-parity/torture.rs` grows the receiver shapes; all 15 kernel parity suites pass against the rebuilt kernel (147 tests). - Full `npm test` on this branch: 189 files, 3187 passed, 9 skipped, 0 failed. Re-index after upgrading. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 35 ++++++ __tests__/fixtures/kernel-parity/torture.rs | 10 ++ __tests__/resolution.test.ts | 109 ++++++++++++++++ codegraph-kernel/src/rustlang.rs | 34 ++++- .../design/rust-lang-kernel-port-checklist.md | 10 +- src/extraction/tree-sitter.ts | 20 +++ src/resolution/name-matcher.ts | 116 ++++++++++++++++++ 8 files changed, 326 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc166..99c12bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - C/C++ function-pointer analysis now bounds its compiled-pattern caches, so very large repositories can no longer exhaust the JavaScript engine's regular-expression code space during indexing. (#1559) - JSX rendering analysis now runs only on JavaScript-family files, so JSX-looking strings in C/C++ (or any other language) no longer create impossible call edges — in pure-C projects and in mixed-language monorepos alike. (#1560) - Methods implemented in a generic or lifetime-parameterized `impl` block (`impl Source for BufSource`, `impl<'a> Iterator for Parents<'a>`) are now recorded under the implementing type instead of the trait. Previously such a method could not be found by its type — "who calls `BufSource::read`" had no answer — and it collided with the trait's own declaration, which could even invent a call-graph edge out of an impl body that contains no call at all. Impls on a reference (`impl Trait for &Foo`) and on a module-qualified type (`impl Trait for m::Foo`) are attributed to their type too. Re-index after upgrading. Thanks @Dshuishui. (#1588) (Rust) +- A method call on a struct field — `self.inner.run()` with `inner: Inner` — now resolves to the method on the field's declared type. Previously the call was reduced to the bare method name and matched whichever same-named method was nearest, which was often the calling method itself, recording recursion that isn't in the source (a few hundred such self-edges in ripgrep alone), or a method of an unrelated type. References and `Box`/`Rc`/`Arc` fields are looked through, as Rust's own method calls are; a field whose type is external (a std or third-party type), a generic parameter, or a container like `Option`/`Vec` is left unresolved rather than guessed. Re-index after upgrading. Thanks @Dshuishui. (#1585) (Rust) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c460905..cd60b85 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1216,6 +1216,41 @@ impl From for Own { ).toBe(true); }); + it('keeps the owner-field shape for `self..()` and collapses every other receiver (#1585)', () => { + const code = ` +pub struct Outer { pub inner: Inner, pub deep: Deep } +impl Outer { + pub fn run(&mut self) { + self.inner.run(); + self.deep.inner.run(); + self.make().run(); + (self.inner).run(); + self.run(); + let local = Inner { n: 0 }; + local.run(); + } +} +`; + const result = extractFromSource('outer.rs', code); + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + // Exactly one call keeps the `self.` prefix — the single-hop field + // receiver whose type the resolver can read off the owner struct. + expect(calls.filter((c) => c.startsWith('self.'))).toEqual(['self.inner.run']); + // A local receiver keeps its name as before… + expect(calls).toContain('local.run'); + // …and the deeper chain, the call receiver, the parenthesized receiver and + // the bare `self` receiver all still collapse to the method name. + expect(calls.filter((c) => c === 'run')).toHaveLength(4); + expect(calls).toContain('make'); + const outerRun = result.nodes.find((n) => n.qualifiedName === 'Outer::run'); + expect(outerRun).toBeDefined(); + const fieldRef = result.unresolvedReferences.find((r) => r.referenceName === 'self.inner.run'); + expect(fieldRef?.fromNodeId).toBe(outerRun!.id); + expect(fieldRef?.line).toBe(5); + }); + it('gives no receiver to an impl whose target names no single type', () => { // A tuple / `dyn Trait` / primitive implementing type has no struct to // hang the methods off, so they are extracted as plain functions — the diff --git a/__tests__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 9efc472..8fb8382 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -72,6 +72,16 @@ impl Widget { self.n * mul() } + /// Receiver shapes (#1585): only `self..()` keeps the + /// owner-field prefix; deeper / parenthesized / call / bare-self collapse. + fn via_field(&self) -> u32 { + self.field.deep_call(); + self.field.z.clone(); + self.method_a().chain_b(); + (self.field).deep_call(); + self.area() + } + fn clone_self(&self) -> Self { Self::assoc(); Widget { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 12260c4..494dea4 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1172,6 +1172,115 @@ impl Source for BufSource { expect(synth(bufImpl!.id)).toHaveLength(0); }); + // ── Rust `self..()` receivers (#1585) ─────────────────── + // A Cargo layout (Cargo.toml + src/) so `use crate::…` paths resolve. + function writeRustCrate(root: string, files: Record): void { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[package]\nname = "repro"\nversion = "0.1.0"\nedition = "2021"\n' + ); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + for (const [rel, content] of Object.entries(files)) { + fs.writeFileSync(path.join(root, 'src', rel), content); + } + } + const callsFrom = (qualifiedName: string) => { + const from = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + expect(from, qualifiedName).toBeDefined(); + return cg + .getOutgoingEdges(from!.id) + .filter((e) => e.kind === 'calls') + .map((e) => ({ + target: cg.getNode(e.target)?.qualifiedName, + resolvedBy: (e.metadata as { resolvedBy?: string } | undefined)?.resolvedBy, + provenance: e.provenance ?? undefined, // a resolved (non-synthesized) edge stores NULL + })); + }; + + it("resolves `self.field.method()` to the method on the field's declared type, never to the caller itself (#1585)", async () => { + // The issue's repro: `Outer::run` forwards to `Inner::run` through the + // typed field `inner`. The call used to collapse to the bare name `run` + // and exact-match the nearest same-named method — the calling method — + // recording recursion the source does not contain. + writeRustCrate(tempDir, { + 'lib.rs': 'pub mod inner;\npub mod outer;\n', + 'inner.rs': 'pub struct Inner {\n pub n: usize,\n}\n\nimpl Inner {\n pub fn run(&mut self) {\n self.n += 1;\n }\n}\n', + 'outer.rs': 'use crate::inner::Inner;\n\npub struct Outer {\n pub inner: Inner,\n}\n\nimpl Outer {\n pub fn run(&mut self) {\n self.inner.run();\n }\n}\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callsFrom('Outer::run')).toEqual([ + { target: 'Inner::run', resolvedBy: 'instance-method', provenance: undefined }, + ]); + }); + + it('leaves a `self.field.method()` call unresolved when the field type is external, instead of guessing a same-named local method', async () => { + // `its` is a std type with no project node. Before, `self.its.next()` + // became the bare `next`, which exact-matched a local `next` — the + // calling method (self-edge) or the unrelated `Other::next` decoy. + writeRustCrate(tempDir, { + 'lib.rs': + 'pub struct Scanner {\n its: std::vec::IntoIter,\n}\n\nimpl Scanner {\n pub fn next(&mut self) -> Option {\n self.its.next()\n }\n}\n\n' + + 'pub struct Other { pub n: u8 }\nimpl Other {\n pub fn next(&mut self) -> Option {\n None\n }\n}\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callsFrom('Scanner::next')).toEqual([]); + }); + + it('looks through references and owning smart pointers, but not through containers (#1585)', async () => { + // Method-call auto-deref reaches the pointee of `Box`/`&mut`, so those + // fields resolve to `Inner::run`. `Option` does not auto-deref — + // `self.inner.take()` is Option's method, so it must NOT become + // `Inner::take` even though Inner declares a `take` too. + writeRustCrate(tempDir, { + 'lib.rs': + 'pub struct Inner { pub n: usize }\nimpl Inner {\n pub fn run(&mut self) { self.n += 1; }\n pub fn take(&mut self) {}\n}\n\n' + + 'pub struct Boxed { inner: Box }\nimpl Boxed {\n pub fn go(&mut self) { self.inner.run(); }\n}\n\n' + + "pub struct Borrowed<'a> { inner: &'a mut Inner }\nimpl<'a> Borrowed<'a> {\n pub fn go(&mut self) { self.inner.run(); }\n}\n\n" + + 'pub struct Optional { inner: Option }\nimpl Optional {\n pub fn go(&mut self) { self.inner.take(); }\n}\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callsFrom('Boxed::go').map((c) => c.target)).toEqual(['Inner::run']); + expect(callsFrom('Borrowed::go').map((c) => c.target)).toEqual(['Inner::run']); + expect(callsFrom('Optional::go')).toEqual([]); + }); + + it('leaves a call through a generic-typed field unresolved, and keeps genuine `self.method()` recursion (#1585)', async () => { + writeRustCrate(tempDir, { + 'lib.rs': + 'pub struct Inner { pub n: usize }\nimpl Inner {\n pub fn run(&mut self) {}\n}\n\n' + + 'pub struct Holder { item: T }\nimpl Holder {\n pub fn go(&mut self) { self.item.run(); }\n}\n\n' + + 'pub struct Countdown { pub n: usize }\nimpl Countdown {\n pub fn run(&mut self) {\n if self.n > 0 {\n self.n -= 1;\n self.run();\n }\n }\n}\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + // `T` names no project type: no edge, and in particular not `Inner::run`. + expect(callsFrom('Holder::go')).toEqual([]); + // A bare `self` receiver is untouched — real recursion stays a self-edge. + expect(callsFrom('Countdown::run').map((c) => c.target)).toEqual(['Countdown::run']); + }); + + it('resolves a trait-object field to the trait method and typed fields to the right implementation (#1585, #1588)', async () => { + // The #1588 repro's second half: `UsesFile::go` / `UsesBuf::go` each + // forward through a typed field, and a `Box` field lands on + // the trait's declaration — from which the interface-impl synthesizer + // fans out to every implementation. + writeRustCrate(tempDir, { + 'lib.rs': + 'pub trait Source {\n fn read(&mut self) -> usize;\n}\n\n' + + 'pub struct FileSource { pub n: usize }\nimpl Source for FileSource {\n fn read(&mut self) -> usize { self.n }\n}\n\n' + + 'pub struct BufSource { pub inner: T }\nimpl Source for BufSource {\n fn read(&mut self) -> usize { 0 }\n}\n\n' + + 'pub struct UsesFile { pub src: FileSource }\nimpl UsesFile {\n pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' + + 'pub struct UsesBuf { pub src: BufSource }\nimpl UsesBuf {\n pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' + + 'pub struct UsesDyn { pub src: Box }\nimpl UsesDyn {\n pub fn go(&mut self) -> usize { self.src.read() }\n}\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(callsFrom('UsesFile::go').map((c) => c.target)).toEqual(['FileSource::read']); + expect(callsFrom('UsesBuf::go').map((c) => c.target)).toEqual(['BufSource::read']); + expect(callsFrom('UsesDyn::go').map((c) => c.target)).toEqual(['Source::read']); + // …and dispatch continues from the trait declaration to both impls. + const fanOut = callsFrom('Source::read').filter((c) => c.provenance === 'heuristic').map((c) => c.target).sort(); + expect(fanOut).toEqual(['BufSource::read', 'FileSource::read']); + }); + it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => { // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init) // carry the constructor args directly on the declarator — there's no diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 007072f..5731eb3 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/codegraph-kernel/src/rustlang.rs @@ -23,10 +23,12 @@ //! - Unit structs (`struct Unit;`, no body field) mint NO node; `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 -//! kind `self`, not `identifier`, so it dodges SKIP_RECEIVERS by falling -//! through). Turbofish callees keep the raw `helper::` text. +//! `Foo::new().bar`); a call through a field of the enclosing type keeps +//! the owner-field shape (`self.inner.run()` → `self.inner.run`, #1585); +//! instance chains, parens, `.await`, deeper/non-self field chains, and +//! bare `self` receivers all collapse to the bare method name (`self` is +//! node kind `self`, not `identifier`, so it dodges SKIP_RECEIVERS by +//! falling through). Turbofish callees keep the raw `helper::` text. //! - `use` emits an import node named by the ROOT module (`crate`/`self`/…), //! one root `imports` ref, then one FULL-path `imports` ref per binding; //! `use x::*` (use_wildcard) emits nothing at all. @@ -838,9 +840,29 @@ impl<'t> Walker<'t> { callee_name = method_name.to_string(); } } + "field_expression" => { + // `self..()` — a call through a + // field of the enclosing type (#1585): keep the + // `self.` prefix so the resolver can type the + // field from the owner struct's declaration + // (or leave it unresolved). Any other + // field_expression receiver — a deeper chain, + // a non-self base — keeps the bare name. + let base = r.child_by_field_name("value"); + let field = r.child_by_field_name("field"); + match (base, field) { + (Some(b), Some(f)) + if b.kind() == "self" && f.kind() == "field_identifier" => + { + let field_name = self.text(f); + callee_name = format!("self.{field_name}.{method_name}"); + } + _ => callee_name = method_name.to_string(), + } + } _ => { - // field_expression 2-hop, parenthesized, - // await_expression, `self` — bare method name. + // parenthesized, await_expression, `self` — + // bare method name. callee_name = method_name.to_string(); } } diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index 05b5151..831e3ca 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/docs/design/rust-lang-kernel-port-checklist.md @@ -226,9 +226,13 @@ Generic else-branch (4312+), `func = childForFieldName('function') ?? namedChild (4455) → `Foo::new().bar()` → ref `Foo::new().bar`; an instance chain `x.foo().bar()` (innerFn field_expression) → bare `bar`. When not re-encoding, calleeName = bare methodName. - - receiver anything else (`field_expression` 2-hop `v.field.method()`, - `parenthesized_expression`, `await_expression`, `self`) → bare - methodName (probed all four). + - receiver `field_expression` whose `value` is `self` and whose `field` is + a `field_identifier` (`self.inner.run()`) → `self.inner.run` — the + owner-field shape the resolver types from the struct declaration + (#1585, both sides together). + - receiver anything else (`field_expression` with a non-self base + `v.field.method()` / deeper `self.a.b.m()`, `parenthesized_expression`, + `await_expression`, `self`) → bare methodName (probed all four). 2. `func.type === 'scoped_identifier'` (4499) → calleeName = FULL text (`Foo::new`, `m::helper2`, `std::mem::swap` — whatever the source spells, whitespace included). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a7e7c92..2f18575 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4431,6 +4431,26 @@ export class TreeSitterExtractor { } else { calleeName = methodName; } + } else if ( + this.language === 'rust' && + receiver && + receiver.type === 'field_expression' && + getChildByField(receiver, 'value')?.type === 'self' && + getChildByField(receiver, 'field')?.type === 'field_identifier' + ) { + // Rust `self..()` — a call through a field of the + // enclosing type (#1585). Keep the `self.` prefix: the resolver + // recognizes the shape, reads the field's declared type off the + // owner struct's declaration, and resolves the method on THAT + // type — or leaves the ref unresolved when the type is external + // or unknown. Previously this collapsed to the bare method name, + // which exact-matched whichever same-named method was nearest — + // often the calling method itself, a self-edge not in the source. + // Deeper chains (`self.a.b.m()`), `self.f().m()` and parenthesized + // receivers keep the bare name. Mirrored in the kernel's + // extract_call (rustlang.rs). + const fieldName = getNodeText(getChildByField(receiver, 'field')!, this.source); + calleeName = `self.${fieldName}.${methodName}`; } else if ( (this.language === 'cpp' || this.language === 'c' || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6510514..064d158 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1731,6 +1731,18 @@ export function matchMethodCall( return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context); } + // Rust call through a field of the enclosing type — `self.inner.run()`, + // emitted as `self.inner.run` (#1585). Same discipline as the Go branch + // above, and EXCLUSIVE for the same reason: validated field-type inference + // or nothing. Letting this shape reach the bare-name strategies below is + // how `self.inner.run()` resolved to a same-named method on an unrelated + // type — or to the calling method itself, a self-edge the source doesn't + // contain — whenever the field's type was external or merely shared a + // method name with something nearby. + if (ref.language === 'rust' && dotMatch && objectOrClass!.startsWith('self.')) { + return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context); + } + // Java/Kotlin: receiver may be a field whose name doesn't match the type by // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up // the field in the enclosing class to get its declared type, then resolve @@ -1992,6 +2004,110 @@ function matchGoFieldChainCall( return null; } +// Rust primitives and the prelude's own types: a field of one of these never +// names a project type, so a `self..()` on it stays unresolved. +const RUST_NON_PROJECT_FIELD_TYPES = new Set([ + 'bool', 'char', 'str', 'String', + 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', + 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', + 'f32', 'f64', + 'Self', 'self', +]); + +/** + * Reduce a Rust field's declared type text to the simple name of the type a + * method call on that field auto-derefs to, or null when there is none we can + * name. Only the layers Rust's method-call auto-deref looks through are + * unwrapped: references (`&`, `&'a mut`) and the owning smart pointers + * (`Box`, `Rc`, `Arc`) — `self.inner.run()` with `inner: Box` calls + * `Inner::run`. Containers that do NOT auto-deref to their parameter + * (`Option`, `Vec`, `Mutex`, `RefCell`) keep their + * own name and, having no project node, resolve to nothing — `self.items.push()` + * must never become `Inner::push`. A trait object (`Box`) yields + * the trait, whose method node the interface-impl synthesizer fans out. A + * generic parameter (`T`), a primitive, a tuple / array / raw pointer / fn + * type, or a non-identifier yields null. + */ +export function rustFieldTypeName(raw: string): string | null { + let t = raw.trim(); + for (;;) { + const before = t; + t = t.replace(/^&\s*(?:'\w+\s+)?(?:mut\s+)?/, ''); + t = t.replace(/^(?:Box|Rc|Arc)\s*<\s*/, ''); + t = t.replace(/^(?:dyn|impl)\s+/, ''); + if (t === before) break; + } + // Drop generic args, the closing `>`s of unwrapped pointers, and trait-object + // bounds (`dyn Source + Send`); keep the last path segment. + t = t.replace(/[<>+].*$/, '').trim(); + const seg = t.split('::').filter(Boolean).pop(); + if (!seg || !/^[A-Za-z_]\w*$/.test(seg)) return null; + if (RUST_NON_PROJECT_FIELD_TYPES.has(seg)) return null; + if (/^[A-Z]$/.test(seg)) return null; // bare single-letter generic parameter + return seg; +} + +/** + * Resolve a Rust call through a field of the enclosing type — + * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585). + * Mirrors the Go 2-hop precedent above (#1276): the owner type is the calling + * method's qualified-name prefix (`Outer::run` → `Outer`), the field's declared + * type comes from the owner struct's OWN declaration lines, and the method is + * resolved AND VALIDATED on that type by resolveMethodOnType. The caller + * treats this branch as exclusive for `self.` receivers: a field whose + * type is external (`std::vec::IntoIter`, `regex::Regex`), a generic + * parameter, or not declared where we can see it yields null and the ref stays + * unresolved. Rust struct fields are not graph nodes, so the declaration text + * is the only place the type lives. + */ +function matchRustSelfFieldCall( + field: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + // The extractor only ever emits a single field hop; anything else is not ours. + if (!field || field.includes('.')) return null; + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller) return null; + const sep = caller.qualifiedName.lastIndexOf('::'); + if (sep <= 0) return null; // a free fn has no `self` + const owner = caller.qualifiedName.slice(0, sep).split('::').pop(); + if (!owner) return null; + + const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( + (n) => + (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class') && + n.language === 'rust' + ); + const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // `pub inner: Inner,` / `inner: Box,` / `pub(crate) inner: T }` — + // the type text runs to the field separator. A comma inside generic args + // (`HashMap`) truncates the capture, which rustFieldTypeName then + // reduces to the container's own name — exactly the non-deref case it + // refuses anyway. + const fieldRe = new RegExp(`\\b${fieldEsc}\\s*:\\s*([^,{}]+)`); + for (const s of owners) { + const source = context.readFile(s.filePath); + if (!source) continue; + // Only the struct's own declaration lines, comment-stripped line by line — + // same discipline as the Go helper: prose or a same-named identifier + // elsewhere in the file can never donate a type. + const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine); + for (const rawLine of declLines) { + const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''); + const m = line.match(fieldRe); + if (!m || !m[1]) continue; + const fieldType = rustFieldTypeName(m[1]); + // The field is declared here; whether or not its type names a project + // symbol, this owner is the answer — no other same-named struct applies. + if (!fieldType) return null; + return resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method'); + } + } + return null; +} + /** * Split a camelCase or PascalCase string into words. */