diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc166bf..99c12bc33 100644 --- a/CHANGELOG.md +++ b/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 Source for BufSource`, `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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c460905b7..cd60b854c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1216,6 +1216,41 @@ impl From for Own { ).toBe(true); }); + it('keeps the owner-field shape for `self..()` 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.` 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 diff --git a/__tests__/fixtures/kernel-parity/torture.rs b/__tests__/fixtures/kernel-parity/torture.rs index 9efc47275..8fb8382be 100644 --- a/__tests__/fixtures/kernel-parity/torture.rs +++ b/__tests__/fixtures/kernel-parity/torture.rs @@ -72,6 +72,16 @@ impl Widget { self.n * mul() } + /// Receiver shapes (#1585): only `self..()` 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 { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 12260c48e..494dea42e 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1172,6 +1172,115 @@ impl Source for BufSource { expect(synth(bufImpl!.id)).toHaveLength(0); }); + // ── Rust `self..()` receivers (#1585) ─────────────────── + // A Cargo layout (Cargo.toml + src/) so `use crate::…` paths resolve. + function writeRustCrate(root: string, files: Record): 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,\n}\n\nimpl Scanner {\n pub fn next(&mut self) -> Option {\n self.its.next()\n }\n}\n\n' + + 'pub struct Other { pub n: u8 }\nimpl Other {\n pub fn next(&mut self) -> Option {\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` 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 }\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 }\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 { item: T }\nimpl Holder {\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` 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 { pub inner: T }\nimpl Source for BufSource {\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 }\nimpl UsesBuf {\n pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' + + 'pub struct UsesDyn { pub src: Box }\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 diff --git a/codegraph-kernel/src/rustlang.rs b/codegraph-kernel/src/rustlang.rs index 007072f65..5731eb330 100644 --- a/codegraph-kernel/src/rustlang.rs +++ b/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::` 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::` 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..()` — 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(); } } diff --git a/docs/design/rust-lang-kernel-port-checklist.md b/docs/design/rust-lang-kernel-port-checklist.md index 05b5151b3..831e3ca18 100644 --- a/docs/design/rust-lang-kernel-port-checklist.md +++ b/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). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a7e7c9228..2f18575fe 100644 --- a/src/extraction/tree-sitter.ts +++ b/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..()` — 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' || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 651051466..064d15840 100644 --- a/src/resolution/name-matcher.ts +++ b/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..()` 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` calls + * `Inner::run`. Containers that do NOT auto-deref to their parameter + * (`Option`, `Vec`, `Mutex`, `RefCell`) keep their + * own name and, having no project node, resolve to nothing — `self.items.push()` + * must never become `Inner::push`. A trait object (`Box`) 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.` 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,` / `pub(crate) inner: T }` — + // the type text runs to the field separator. A comma inside generic args + // (`HashMap`) 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. */