Преглед изворни кода

fix(rust): resolve `self.field.method()` on the field's declared type instead of a same-named method (#1585) (#1599)

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.<field>.<method>()` 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.<field>` 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<dyn Source>` yields the trait, whose method node the interface-impl synthesizer then fans out to every implementation. `Option<Inner>` 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.<field>` shape remain — 116 bare-receiver, 30 other dotted) |
| `self.<field>.m()` calls resolved through a validated field type | — | **292** (`DecompressionMatcher::command -> GlobSet::matches`, `Parser::find_long -> FlagMap::find`, `Haystack::path -> DirEntry::path`, …) |
| `self.<field>.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.<field>.<method>()` 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<Inner>` and `&'a mut Inner` resolve, `Option<Inner>` 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<dyn Source>` 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
Colby Mchenry пре 1 недеља
родитељ
комит
7963672689

+ 1 - 0
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<T> Source for BufSource<T>`, `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
 

+ 35 - 0
__tests__/extraction.test.ts

@@ -1216,6 +1216,41 @@ impl From<u32> for Own {
     ).toBe(true);
   });
 
+  it('keeps the owner-field shape for `self.<field>.<method>()` 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.<field>` 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

+ 10 - 0
__tests__/fixtures/kernel-parity/torture.rs

@@ -72,6 +72,16 @@ impl Widget {
         self.n * mul()
     }
 
+    /// Receiver shapes (#1585): only `self.<field>.<method>()` 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 {

+ 109 - 0
__tests__/resolution.test.ts

@@ -1172,6 +1172,115 @@ impl<T> Source for BufSource<T> {
       expect(synth(bufImpl!.id)).toHaveLength(0);
     });
 
+    // ── Rust `self.<field>.<method>()` receivers (#1585) ───────────────────
+    // A Cargo layout (Cargo.toml + src/) so `use crate::…` paths resolve.
+    function writeRustCrate(root: string, files: Record<string, string>): 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<u8>,\n}\n\nimpl Scanner {\n    pub fn next(&mut self) -> Option<u8> {\n        self.its.next()\n    }\n}\n\n' +
+          'pub struct Other { pub n: u8 }\nimpl Other {\n    pub fn next(&mut self) -> Option<u8> {\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<Inner>` 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<Inner> }\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<Inner> }\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<T> { item: T }\nimpl<T> Holder<T> {\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<dyn Source>` 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<T> { pub inner: T }\nimpl<T> Source for BufSource<T> {\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<u8> }\nimpl UsesBuf {\n    pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' +
+          'pub struct UsesDyn { pub src: Box<dyn Source> }\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

+ 28 - 6
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::<T>` 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::<T>` 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.<field>.<method>()` — 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();
                             }
                         }

+ 7 - 3
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).

+ 20 - 0
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.<field>.<method>()` — 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' ||

+ 116 - 0
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.<field>.<method>()` 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<Inner>` calls
+ * `Inner::run`. Containers that do NOT auto-deref to their parameter
+ * (`Option<Inner>`, `Vec<Inner>`, `Mutex<Inner>`, `RefCell<Inner>`) keep their
+ * own name and, having no project node, resolve to nothing — `self.items.push()`
+ * must never become `Inner::push`. A trait object (`Box<dyn Source>`) 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.<field>` 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<dyn Source>,` / `pub(crate) inner: T }` —
+  // the type text runs to the field separator. A comma inside generic args
+  // (`HashMap<K, V>`) 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.
  */