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
This commit is contained in:
Colby Mchenry
2026-08-26 10:37:50 -05:00
committed by GitHub
parent 12f7a59f26
commit 7963672689
8 changed files with 326 additions and 9 deletions
+35
View File
@@ -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
@@ -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
View File
@@ -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