Parcourir la source

Merge remote-tracking branch 'origin/main' into HEAD

Colby McHenry il y a 1 semaine
Parent
commit
f083fb2dca

+ 2 - 0
CHANGELOG.md

@@ -72,6 +72,8 @@ 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)
 - Calls to the methods of an exported object-literal constant — `export const api = { call() { … } }` used as a module's namespace, a common way to organize a TypeScript API surface — now resolve to the method, both in the defining file and through imports. Previously such a call linked to nothing (or to the constant itself), so `codegraph callers` and impact analysis reported zero callers for methods that are called from everywhere. Re-index after upgrading to pick up the edges. Thanks @IAliceBobI for the precise report and root-cause. (#1573)
+- 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
 

+ 140 - 0
__tests__/extraction.test.ts

@@ -1131,6 +1131,146 @@ impl Cache for MyCache {
     expect(implRef?.fromNodeId).toBe(myCacheNode?.id);
   });
 
+  it('qualifies methods of a generic or lifetime impl by the implementing type, not the trait (#1588)', () => {
+    const code = `
+pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource { pub n: usize }
+impl Source for FileSource {
+    fn read(&mut self) -> usize { self.n }
+}
+
+pub struct BufSource<T> { pub inner: T }
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize { 0 }
+}
+
+pub struct Parents<'a> { cur: &'a u32 }
+impl<'a> Iterator for Parents<'a> {
+    type Item = u32;
+    fn next(&mut self) -> Option<u32> { None }
+}
+
+pub struct Wrapper { pub n: usize }
+impl Source for &Wrapper {
+    fn read(&mut self) -> usize { 1 }
+}
+
+pub mod m { pub struct Scoped { pub n: usize } }
+impl Source for m::Scoped {
+    fn read(&mut self) -> usize { 2 }
+}
+
+pub struct Own { pub n: usize }
+impl From<u32> for Own {
+    fn from(n: u32) -> Self { Own { n: n as usize } }
+}
+`;
+    const result = extractFromSource('src.rs', code);
+
+    // Every impl method is qualified by the IMPLEMENTING type. Before, a
+    // parameterized implementing type (`BufSource<T>`, `Parents<'a>`, `&Wrapper`)
+    // left the trait's identifier as the only bare type_identifier child of the
+    // impl, so those methods were recorded as `Source::read` / `Iterator::next`.
+    const methodQns = result.nodes
+      .filter((n) => n.kind === 'method')
+      .map((n) => n.qualifiedName)
+      .sort();
+    expect(methodQns).toEqual([
+      'BufSource::read',
+      'FileSource::read',
+      'Own::from',
+      'Parents::next',
+      'Scoped::read',
+      'Source::read',
+      'Wrapper::read',
+    ]);
+    // The trait's qualified name now names exactly one node: its declaration.
+    const traitRead = result.nodes.filter((n) => n.qualifiedName === 'Source::read');
+    expect(traitRead).toHaveLength(1);
+    expect(traitRead[0]!.startLine).toBe(3);
+
+    // The implements back-reference comes FROM the implementing type's node
+    // for every impl shape, named by the trait's full text.
+    const implementsFrom = (typeName: string): string[] => {
+      const typeNode = result.nodes.find((n) => n.name === typeName && n.kind === 'struct');
+      expect(typeNode, typeName).toBeDefined();
+      return result.unresolvedReferences
+        .filter((r) => r.referenceKind === 'implements' && r.fromNodeId === typeNode!.id)
+        .map((r) => r.referenceName);
+    };
+    expect(implementsFrom('FileSource')).toEqual(['Source']);
+    expect(implementsFrom('BufSource')).toEqual(['Source']);
+    expect(implementsFrom('Parents')).toEqual(['Iterator']);
+    expect(implementsFrom('Wrapper')).toEqual(['Source']);
+    expect(implementsFrom('Scoped')).toEqual(['Source']);
+    expect(implementsFrom('Own')).toEqual(['From<u32>']);
+
+    // …and the owner `contains` edge lands on the implementing type too.
+    const buf = result.nodes.find((n) => n.name === 'BufSource' && n.kind === 'struct')!;
+    const bufRead = result.nodes.find((n) => n.qualifiedName === 'BufSource::read')!;
+    expect(
+      result.edges.some((e) => e.kind === 'contains' && e.source === buf.id && e.target === bufRead.id)
+    ).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
+    // pre-#1588 behavior for these shapes, minus the trait mis-qualification.
+    const code = `
+pub trait Base { fn id(&self) -> u32; }
+impl Base for (u32, u32) {
+    fn id(&self) -> u32 { 0 }
+}
+impl Base for dyn Base {
+    fn id(&self) -> u32 { 1 }
+}
+`;
+    const result = extractFromSource('src.rs', code);
+    const ids = result.nodes.filter((n) => n.name === 'id');
+    expect(ids.map((n) => n.qualifiedName).sort()).toEqual(['Base::id', 'id', 'id']);
+    expect(ids.filter((n) => n.kind === 'function')).toHaveLength(2);
+    expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'implements')).toHaveLength(0);
+  });
+
   it('should extract trait supertraits as extends references', () => {
     const code = `
 pub trait Display {}

+ 75 - 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 {
@@ -107,6 +117,71 @@ impl Render for Container<u32> {
     fn render(&self) {}
 }
 
+/// Receiver = the impl_item's `type` field (#1588): generic, lifetime,
+/// reference, scoped, and generic-trait impls all qualify by the TYPE.
+pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource {
+    pub n: usize,
+}
+
+impl Source for FileSource {
+    fn read(&mut self) -> usize {
+        self.n
+    }
+}
+
+pub struct BufSource<T> {
+    pub inner: T,
+}
+
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize {
+        0
+    }
+}
+
+pub struct Parents<'a> {
+    cur: &'a u32,
+}
+
+impl<'a> Iterator for Parents<'a> {
+    type Item = u32;
+    fn next(&mut self) -> Option<u32> {
+        None
+    }
+}
+
+impl<T: Clone> Container<T> {
+    fn dup(&self) -> T {
+        self.item.clone()
+    }
+}
+
+impl Base for &Widget {}
+
+impl<T> Render for &mut BufSource<T> {
+    fn render(&self) {}
+}
+
+impl Base for self::Deep {}
+
+impl From<u32> for FileSource {
+    fn from(n: u32) -> Self {
+        FileSource { n: n as usize }
+    }
+}
+
+impl Base for (u32, u32) {}
+
+impl Render for dyn Base {
+    fn render(&self) {}
+}
+
+impl Base for u32 {}
+
 impl Later {
     fn touch(&self) {}
 }

+ 2 - 2
__tests__/kernel-rustlang-parity.test.ts

@@ -4,8 +4,8 @@
  * Asserts the native walker (codegraph-kernel/src/rustlang.rs) produces the
  * SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
  * unresolved refs compared as canonicalized multisets — over the checked-in
- * torture fixture (torture.rs: impl/trait quirks incl. the
- * `impl Trait for Generic<T>` trait-receiver bug, unit-struct skip, phantom
+ * torture fixture (torture.rs: impl/trait quirks incl. generic / lifetime /
+ * reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom
  * const identifiers, use-binding refs incl. nested groups + wildcard-emits-
  * nothing, chained-call re-encode, turbofish, Rocket route macros body-only,
  * fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code

+ 162 - 0
__tests__/resolution.test.ts

@@ -1119,6 +1119,168 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } }
       ).toBe('interface-impl');
     });
 
+    it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => {
+      // `impl<T> Source for BufSource<T>`: the implementing type parses as a
+      // generic_type, so the old positional receiver scan picked the TRAIT.
+      // The impl's `read` was recorded as `Source::read` — unaddressable as
+      // `BufSource::read` — and, carrying the trait's name, the interface-impl
+      // synthesizer treated its body (`{ 0 }`, no call at all) as a second
+      // declaration and gave it a dispatch edge to FileSource's implementation.
+      fs.writeFileSync(
+        path.join(tempDir, 'lib.rs'),
+        `pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource { pub n: usize }
+impl Source for FileSource {
+    fn read(&mut self) -> usize { self.n }
+}
+
+pub struct BufSource<T> { pub inner: T }
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize { 0 }
+}
+`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      const methods = cg.getNodesByKind('method');
+      const traitDecls = methods.filter((n) => n.qualifiedName === 'Source::read');
+      expect(traitDecls, 'only the declaration carries the trait-qualified name').toHaveLength(1);
+      const traitMethod = traitDecls[0]!;
+      expect(traitMethod.startLine).toBe(2);
+      const fileImpl = methods.find((n) => n.qualifiedName === 'FileSource::read');
+      const bufImpl = methods.find((n) => n.qualifiedName === 'BufSource::read');
+      expect(fileImpl).toBeDefined();
+      expect(bufImpl, 'the generic impl is addressable by its type').toBeDefined();
+
+      const synth = (id: string) =>
+        cg.getOutgoingEdges(id).filter((e) => e.kind === 'calls' && e.provenance === 'heuristic');
+      // Dispatch fans out from the declaration to BOTH implementations…
+      const fromTrait = synth(traitMethod.id);
+      expect(new Set(fromTrait.map((e) => e.target))).toEqual(new Set([fileImpl!.id, bufImpl!.id]));
+      for (const e of fromTrait) {
+        expect(
+          (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy
+        ).toBe('interface-impl');
+        expect(e.line, 'registered at the declaration, never at an impl body').toBe(2);
+      }
+      // …and neither implementation body sprouts a synthesized call of its own.
+      expect(synth(fileImpl!.id)).toHaveLength(0);
+      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

+ 68 - 60
codegraph-kernel/src/rustlang.rs

@@ -11,10 +11,11 @@
 //! - impl blocks push NO scope: members re-dispatch at file scope, so an impl
 //!   associated `const` becomes a FILE-level `variable`, and the method↔owner
 //!   `contains` edge is a source-order name scan (an impl ABOVE its struct
-//!   gets no edge). `impl Trait for Generic<T>`'s receiver resolves to the
-//!   TRAIT (the only direct type_identifier), and methods get QN
-//!   `Trait::method` — preserve, never "fix" via the grammar's trait:/type:
-//!   fields.
+//!   gets no edge). The receiver (method QN prefix, `contains` owner,
+//!   `implements` source) is the impl_item's `type` field via
+//!   impl_type_name — both sides moved to the grammar's trait:/type: fields
+//!   together in #1588 (the earlier positional scan qualified every
+//!   parameterized impl's methods by the TRAIT).
 //! - `const_item`/`static_item` ride the generic extractVariable fallback:
 //!   kind is always `variable`, no signature, and EVERY direct `identifier`
 //!   child mints a node (`const MAX: u32 = OTHER;` → two nodes, `MAX` + the
@@ -22,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.
@@ -399,33 +402,35 @@ impl<'t> Walker<'t> {
         Some(if last == "Self" { "self".to_string() } else { last.to_string() })
     }
 
-    /// rustExtractor.getReceiverType: parent-walk to the nearest impl_item;
-    /// LAST direct type_identifier child wins (for `impl Trait for Generic<T>`
-    /// that's the TRAIT — bug preserved); else the first generic_type's inner
-    /// type_identifier.
+    /// rustImplTypeName (languages/rust.ts) — the implementing type's simple
+    /// name for an impl block, from the grammar's `type` field (#1588):
+    /// `impl<T> Tr for G<T>` / `impl<'a> Iterator for Parents<'a>` /
+    /// `impl Tr for &Foo` / `impl Tr for m::Foo` → `G` / `Parents` / `Foo` /
+    /// `Foo`. Shapes naming no single type (tuple, `dyn Tr`, pointer,
+    /// primitive, fn type…) → None. Mirrored byte-for-byte — change both.
+    fn impl_type_name(&self, ty: Option<Node>) -> Option<String> {
+        let ty = ty?;
+        match ty.kind() {
+            "type_identifier" | "identifier" => Some(self.text(ty).to_string()),
+            "generic_type" => self.impl_type_name(ty.child_by_field_name("type")),
+            "scoped_type_identifier" | "scoped_identifier" => {
+                self.impl_type_name(ty.child_by_field_name("name"))
+            }
+            "reference_type" => self.impl_type_name(ty.child_by_field_name("type")),
+            _ => None,
+        }
+    }
+
+    /// rustExtractor.getReceiverType: parent-walk to the nearest impl_item and
+    /// read its `type` field (impl_type_name). The pre-#1588 rule took the
+    /// LAST direct type_identifier child, which for `impl Trait for Generic<T>`
+    /// was the TRAIT — so every parameterized impl's methods were qualified by
+    /// the trait.
     fn receiver_type_of(&self, node: Node) -> Option<String> {
         let mut parent = node.parent();
         while let Some(p) = parent {
             if p.kind() == "impl_item" {
-                let type_idents: Vec<Node> = (0..p.named_child_count())
-                    .filter_map(|i| p.named_child(i))
-                    .filter(|c| c.kind() == "type_identifier")
-                    .collect();
-                if let Some(last) = type_idents.last() {
-                    return Some(self.text(*last).to_string());
-                }
-                let generic = (0..p.named_child_count())
-                    .filter_map(|i| p.named_child(i))
-                    .find(|c| c.kind() == "generic_type");
-                if let Some(g) = generic {
-                    let inner = (0..g.named_child_count())
-                        .filter_map(|i| g.named_child(i))
-                        .find(|c| c.kind() == "type_identifier");
-                    if let Some(inner) = inner {
-                        return Some(self.text(inner).to_string());
-                    }
-                }
-                return None;
+                return self.impl_type_name(p.child_by_field_name("type"));
             }
             parent = p.parent();
         }
@@ -835,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();
                             }
                         }
@@ -1024,36 +1049,19 @@ impl<'t> Walker<'t> {
         }
     }
 
-    /// extractRustImplItem — `impl Trait for Type` back-reference: positional
-    /// type-node filter (NEVER the grammar's trait:/type: fields), ≥2 needed,
-    /// target found by FIRST earlier node of kind struct/enum/class (never
-    /// trait); ref FROM the type's node, named by the trait's full text.
+    /// extractRustImplItem — `impl Trait for Type` back-reference from the
+    /// grammar's `trait` / `type` fields (#1588; an inherent impl has no
+    /// `trait` field and emits nothing). Target = FIRST earlier node of kind
+    /// struct/union/enum/class (never trait) named by impl_type_name; ref FROM
+    /// the type's node, named by the trait's full text (scoped path / generic
+    /// args kept), at the trait node's position.
     fn extract_rust_impl_item(&mut self, node: Node<'t>) {
-        let has_for = (0..node.child_count())
-            .filter_map(|i| node.child(i))
-            .any(|c| c.kind() == "for" && !c.is_named());
-        if !has_for {
+        let Some(trait_node) = node.child_by_field_name("trait") else {
             return;
-        }
-        let type_idents: Vec<Node> = (0..node.named_child_count())
-            .filter_map(|i| node.named_child(i))
-            .filter(|c| matches!(c.kind(), "type_identifier" | "generic_type" | "scoped_type_identifier"))
-            .collect();
-        if type_idents.len() < 2 {
-            return;
-        }
-        let trait_node = type_idents[0];
-        let type_node = type_idents[type_idents.len() - 1];
-
+        };
         let trait_name = self.text(trait_node).to_string();
-        let type_name = if type_node.kind() == "generic_type" {
-            (0..type_node.named_child_count())
-                .filter_map(|i| type_node.named_child(i))
-                .find(|c| c.kind() == "type_identifier")
-                .map(|c| self.text(c).to_string())
-                .unwrap_or_else(|| self.text(type_node).to_string())
-        } else {
-            self.text(type_node).to_string()
+        let Some(type_name) = self.impl_type_name(node.child_by_field_name("type")) else {
+            return;
         };
 
         let target_row = self

+ 5 - 3
docs/design/rust-kernel-migration-plan.md

@@ -148,9 +148,11 @@ them are the ORIGINAL plan and carry expectations that measurement later correct
       tokio node sections IDENTICAL, small precision-positive edge churn only,
       full suite green), walker `codegraph-kernel/src/rustlang.rs` (survey
       artifact: rust-lang-kernel-port-checklist.md — isAsync dead-code,
-      impl-pushes-no-scope, trait-receiver bug on `impl Trait for Generic<T>`,
-      phantom const identifiers, use-binding triple emission, all preserved
-      bug-for-bug). Gates: parity sweeps **0 diffs** on ripgrep (101/101,
+      impl-pushes-no-scope, trait-receiver bug on `impl Trait for Generic<T>`
+      (fixed on both sides together in #1588 — receiver now comes from the
+      impl_item's `type` field), phantom const identifiers, use-binding
+      triple emission, all preserved bug-for-bug). Gates: parity sweeps
+      **0 diffs** on ripgrep (101/101,
       0 deferred) / tokio (790/790, 0 deferred) / rust-analyzer (1217/1488,
       0 diffs; 271 deferrals are token-macro-table sources — `T![~]`, `[$]` —
       that error on BOTH arms, grammar-inherent like fmt's C++ 42%); full-init

+ 25 - 17
docs/design/rust-lang-kernel-port-checklist.md

@@ -89,18 +89,21 @@ Hooks PRESENT (port each exactly):
 - **getVisibility (rust.ts:74)** — direct child of type `visibility_modifier`:
   text `.includes('pub')` → `'public'` else `'private'`; no modifier →
   `'private'` (so `pub(crate)`/`pub(super)` are all `'public'`).
-- **getReceiverType (rust.ts:83)** — walk PARENT chain to the nearest
-  `impl_item`; there: filter DIRECT namedChildren of type `type_identifier`;
-  if ≥1, return the LAST one's source text (`source.substring(startIndex,
-  endIndex)` — UTF-16 units). If none, find the first `generic_type` child and
-  return its inner `type_identifier` text; else undefined. Never an impl parent
-  → undefined. QUIRK/BUG, PRESERVE: for `impl Trait for Generic<T>` the only
-  direct type_identifier is the TRAIT (probe: `impl Render for Container<T>` →
-  typeIdents=[`Render`] → receiver = **`Render`**, the trait name — methods get
-  qualifiedName `Render::render` and a contains edge from the trait node if one
-  exists in-file). `impl fmt::Display for Fields` is fine
-  (scoped_type_identifier isn't type_identifier → [Fields]). `impl<T>
-  Container<T>` → no direct type_identifiers → generic branch → `Container`.
+- **getReceiverType (rust.ts)** — walk PARENT chain to the nearest
+  `impl_item`; there, read the grammar's `type` field through
+  `rustImplTypeName` (kernel: `impl_type_name`): `type_identifier`/`identifier`
+  → text; `generic_type` → its `type` field (bare name, never the args);
+  `scoped_type_identifier`/`scoped_identifier` → its `name` field (last
+  segment); `reference_type` → its `type` field; anything else (tuple, `dyn`,
+  pointer, primitive, fn type) → undefined. Never an impl parent → undefined.
+  **Changed in #1588 on both sides together**: the original rule took the LAST
+  direct `type_identifier` child, so for `impl Trait for Generic<T>` /
+  `Parents<'a>` / `&Foo` the only bare identifier was the TRAIT's (probe:
+  `impl Render for Container<T>` → receiver **`Render`** → methods
+  `Render::render`, colliding with the trait declaration and feeding the
+  interface-impl synthesizer a phantom declaration). Now `Container`.
+  `impl fmt::Display for Fields` → `Fields`; `impl<T> Container<T>` →
+  `Container`; `impl Tr for m::Foo` → `Foo` (was: no receiver).
   Note `<T>` type_parameters is its own child, its inner T is NOT a direct
   impl child.
 - **extractImport (rust.ts:120)** — signature = trimmed full `use …;` text.
@@ -187,8 +190,9 @@ undefined; **no isConst means `const_item`/`static_item` extract as kind
   present AND not class-like — finds the FIRST node in `this.nodes` with
   `name === receiverType && filePath === this.filePath && kind ∈
   {struct,class,enum,trait}`. Source-order dependent: an impl ABOVE its struct
-  gets no contains edge. `impl Trait for Generic<T>` (receiver=trait bug) links
-  to the TRAIT node if it's in-file.** Then type annotations, decorators
+  gets no contains edge. Since #1588 `impl Trait for Generic<T>` links to the
+  implementing TYPE's node (it used to link to the TRAIT node, the receiver
+  bug).** Then type annotations, decorators
   (no-op), body walk with the method pushed.
 - **Nested `fn` inside an impl-method's body**: visitFunctionBody:5245 →
   named → extractFunction → getReceiverType walks parents THROUGH the outer fn
@@ -222,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).

+ 43 - 26
src/extraction/languages/rust.ts

@@ -32,6 +32,45 @@ function extractRustReturnType(node: SyntaxNode, source: string): string | undef
   return last === 'Self' ? 'self' : last;
 }
 
+/**
+ * The implementing type's simple name for an `impl` block, read from the
+ * grammar's `type` field (#1588). Mirrored byte-for-byte by the native
+ * kernel's `impl_type_name` (codegraph-kernel/src/rustlang.rs) — change both.
+ *
+ * `impl<T> Source for BufSource<T>`, `impl<'a> Iterator for Parents<'a>`,
+ * `impl Trait for &Foo`, `impl Trait for m::Foo` all yield the implementing
+ * TYPE (`BufSource`, `Parents`, `Foo`, `Foo`). The previous rule took the last
+ * bare `type_identifier` child of the `impl_item`; once the implementing type
+ * carries parameters it parses as a `generic_type`, so the only bare
+ * identifier left was the TRAIT's — every parameterized impl's methods were
+ * qualified by the trait (`Source::read`), unaddressable by their type and
+ * colliding with the trait's own declaration.
+ *
+ * Shapes that name no single type (tuples, `dyn Trait`, pointers, primitives,
+ * function types…) yield undefined: no receiver, and the fn is extracted
+ * exactly as before.
+ */
+export function rustImplTypeName(typeNode: SyntaxNode | null, source: string): string | undefined {
+  if (!typeNode) return undefined;
+  switch (typeNode.type) {
+    case 'type_identifier':
+    case 'identifier':
+      return getNodeText(typeNode, source);
+    // `Foo<T>` — the `type` field is the bare (or scoped) name, never the args.
+    case 'generic_type':
+      return rustImplTypeName(getChildByField(typeNode, 'type'), source);
+    // `m::Foo` — the last segment is the type's name.
+    case 'scoped_type_identifier':
+    case 'scoped_identifier':
+      return rustImplTypeName(getChildByField(typeNode, 'name'), source);
+    // `&Foo` / `&'a mut Foo` — the referenced type.
+    case 'reference_type':
+      return rustImplTypeName(getChildByField(typeNode, 'type'), source);
+    default:
+      return undefined;
+  }
+}
+
 export const rustExtractor: LanguageExtractor = {
   // `function_signature_item` is a trait method DECLARATION (`fn render(&self);`,
   // no body). Extracting it makes a trait's method set first-class, which
@@ -88,32 +127,10 @@ export const rustExtractor: LanguageExtractor = {
     let parent = node.parent;
     while (parent) {
       if (parent.type === 'impl_item') {
-        // For `impl Type { ... }` — the type is a direct type_identifier child
-        // For `impl Trait for Type { ... }` — the type is the LAST type_identifier
-        // (the first is part of the trait path)
-        const children = parent.namedChildren;
-        // Find all direct type_identifier children (not nested in scoped paths)
-        const typeIdents = children.filter(
-          (c: SyntaxNode) => c.type === 'type_identifier'
-        );
-        if (typeIdents.length > 0) {
-          // Last type_identifier is always the implementing type
-          const typeNode = typeIdents[typeIdents.length - 1]!;
-          return source.substring(typeNode.startIndex, typeNode.endIndex);
-        }
-        // Handle generic types: impl<T> MyStruct<T> { ... }
-        const genericType = children.find(
-          (c: SyntaxNode) => c.type === 'generic_type'
-        );
-        if (genericType) {
-          const innerType = genericType.namedChildren.find(
-            (c: SyntaxNode) => c.type === 'type_identifier'
-          );
-          if (innerType) {
-            return source.substring(innerType.startIndex, innerType.endIndex);
-          }
-        }
-        return undefined;
+        // The grammar names the implementing type directly (the `type` field)
+        // for both `impl Type { … }` and `impl Trait for Type { … }` — see
+        // rustImplTypeName for why the old positional scan was wrong (#1588).
+        return rustImplTypeName(getChildByField(parent, 'type'), source);
       }
       parent = parent.parent;
     }

+ 35 - 32
src/extraction/tree-sitter.ts

@@ -22,6 +22,7 @@ import { isGeneratedFile } from './generated-detection';
 import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
 import { EXTRACTORS } from './languages';
 import { stripCppTemplateArgs } from './languages/c-cpp';
+import { rustImplTypeName } from './languages/rust';
 import { LiquidExtractor } from './liquid-extractor';
 import { RazorExtractor } from './razor-extractor';
 import { SvelteExtractor } from './svelte-extractor';
@@ -4430,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' ||
@@ -5717,38 +5738,20 @@ export class TreeSitterExtractor {
    * For plain `impl Type { ... }` (no trait), no inheritance edge is needed.
    */
   private extractRustImplItem(node: SyntaxNode): void {
-    // Check if this is `impl Trait for Type` by looking for a `for` keyword
-    const hasFor = node.children.some(
-      (c: SyntaxNode) => c.type === 'for' && !c.isNamed
-    );
-    if (!hasFor) return;
-
-    // In `impl Trait for Type`, the type_identifiers are:
-    // first = Trait name, last = implementing Type name
-    // Also handle generic types like `impl<T> Trait for MyStruct<T>`
-    const typeIdents = node.namedChildren.filter(
-      (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'generic_type' || c.type === 'scoped_type_identifier'
-    );
-    if (typeIdents.length < 2) return;
-
-    const traitNode = typeIdents[0]!;
-    const typeNode = typeIdents[typeIdents.length - 1]!;
-
-    // Get the trait name (handle scoped paths like std::fmt::Display)
-    const traitName = traitNode.type === 'scoped_type_identifier'
-      ? this.source.substring(traitNode.startIndex, traitNode.endIndex)
-      : getNodeText(traitNode, this.source);
-
-    // Get the implementing type name (extract inner type_identifier for generics)
-    let typeName: string;
-    if (typeNode.type === 'generic_type') {
-      const inner = typeNode.namedChildren.find(
-        (c: SyntaxNode) => c.type === 'type_identifier'
-      );
-      typeName = inner ? getNodeText(inner, this.source) : getNodeText(typeNode, this.source);
-    } else {
-      typeName = getNodeText(typeNode, this.source);
-    }
+    // `impl Trait for Type` carries the trait in the grammar's `trait` field;
+    // an inherent `impl Type { … }` has none and needs no inheritance edge.
+    const traitNode = getChildByField(node, 'trait');
+    if (!traitNode) return;
+
+    // Full text, so a scoped path (`std::fmt::Display`) and a generic trait
+    // (`From<u32>`) keep their spelling.
+    const traitName = getNodeText(traitNode, this.source);
+
+    // The implementing type from the `type` field (#1588). The old positional
+    // scan took the LAST type-shaped child, which for a parameterized
+    // implementing type (`BufSource<T>`, `Parents<'a>`, `&Foo`) was the trait.
+    const typeName = rustImplTypeName(getChildByField(node, 'type'), this.source);
+    if (!typeName) return;
 
     // Find the struct/type node for the implementing type
     const typeNodeId = this.findNodeByName(typeName);

+ 116 - 0
src/resolution/name-matcher.ts

@@ -1818,6 +1818,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
@@ -2100,6 +2112,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.
  */