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
+116
View File
@@ -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.
*/