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:
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user