Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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

Expand Down
35 changes: 35 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 28 additions & 6 deletions codegraph-kernel/src/rustlang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<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.
Expand Down Expand Up @@ -838,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();
}
}
Expand Down
10 changes: 7 additions & 3 deletions docs/design/rust-lang-kernel-port-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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' ||
Expand Down
Loading