fix(rust): a call keeps its whole receiver chain, and links through method calls in it (#2846)

A Rust method call made through more than one field, or on what another
method returns, was recorded by its bare method name in both extractors:
self.services.store.put("a") became `put`, as did self.store().put("m").
Only a single field kept its receiver (self.store.put). The edge's
refName then said nothing of which value the call goes through, and a
call on a method call's value was never typed, so self.store().put("m")
stayed unresolved. PR #2831 had already made the field-chain calls link
by reading the receiver from the source text, but their names stayed
bare.

Both extractors now keep a receiver that is a chain of fields and method
calls on `self` or on a name, a call written `name()` with its arguments
and turbofish dropped, up to 16 links: self.services.store.put,
self.store().put, vis.map.get, self.0.len, x.get().put. The shapes that
already kept a receiver (x.m, self.m, self.<field>.m, Foo::new().m) are
unchanged; a parenthesized receiver, `?`, `.await`, an index and a longer
chain rooted at a path keep the bare name. The kernel's receiver_chain
mirrors the wasm extractor's rustReceiverChain byte for byte.

Resolution reads such a call as the bare-named call was read before
(rustChainedCallByName, at the top of resolveOneIn), so the frameworks,
the strategies and the scope gates see what they saw, and the edge keeps
the call as written. The chain goes along: the written-receiver hook
reads the receiver the chain names where the call is written
(rustChainedReceiver), so a method call's value is typed from that
method's signature, and a later link that calls the same name again is
told apart instead of being left to a guess by the name.

Validation:
- New __tests__/rust-receiver-chains.test.ts (10 tests): the issue's
  workspace verbatim plus self.store().put("m"), chains whose links call
  the same name through self, a tuple field and a parameter, a call on a
  method's std return value, the recorded shapes and the 16-link cap,
  and a sync that adds the missing method. Red on main's sources, 7 of
  10 failing on both the wasm and the kernel path; green with the fix.
  The Rust kernel parity suite gains a chain case. The extraction test
  that pinned the old collapse now pins the chains.
- A/B against main 865ddf4a (same groups against d8338275), both arms
  with the native kernel, every changed call edge read. Per repo: edges
  that only gained their receiver in refName / same target now by type /
  wrong removed / right added / wrong to right:
  ripgrep 3fce3b5b: 159 / 45 / 6 / 6 / 15
  axum 86aa6153: 115 / 25 / 0 / 14 / 7
  tokio 8a2408ed: 1039 / 124 / 36 / 29 / 27, and 1 right link lost
  lemmy 92e077b5: 190 / 24 / 3 / 1 / 3, 1 right lost, 1 wrong added
  The two lost links and the wrong one follow rules the typed-receiver
  reading already applies to typed locals on main: a function-local glob
  `use` does not shadow a file-level named `use` (tokio), a call on a
  dependency's type reaches only impls of project traits, and `x.into()`
  takes the type's explicit `impl Into` (lemmy). Controls gin and
  express: byte-identical dumps. Nodes are identical on every repo.
- Kernel parity (Rust), ripgrep, axum, tokio, lemmy: 1752/1752 files at
  byte parity, 0 deferred.
- Index cost, interleaved median of 3 (wall): ripgrep 3.15 s -> 3.15 s,
  axum 3.39 s -> 3.42 s, tokio 10.48 s -> 10.54 s, lemmy 5.14 s ->
  5.15 s; CPU within noise.

Fixes #2836

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 14:07:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 865ddf4ada
commit 9c4794d694
8 changed files with 548 additions and 29 deletions
+1
View File
@@ -323,6 +323,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Rust, a `for` loop's variable now has the type of an item of what the loop goes over, like `for item in &self.items` over a `Vec<Item>`, `for (key, value) in &map` over a `HashMap`, or `for sc in cmd.get_subcommands()` over the `impl Iterator` a method returns, and each name in a tuple pattern like `let (tx, rx) = mpsc::channel(8);` has the type of its part of the tuple, so calls on them link to that type's methods. Before, such calls were linked by name: tokio's `tx.send(…)` calls on channel senders went to an internal type's `send`. Re-index Rust projects after upgrading.
- In Rust, a call on a field of `self`, like `self.store.save()` or `self.inner.lock()`, now links to the method of the type the struct declares for that field, also when the field's type is a parameter the `impl` bounds by a trait (`impl<S: Store> Worker<S>`), a trait object, or held in an `Arc` or a `Box`. A field whose type comes from the standard library or a dependency, like `jar: cookie::CookieJar` or `inner: std::sync::Mutex<…>`, no longer links to the project's own type of the same name, which often made a wrapper's method look like it called itself. Re-index Rust projects after upgrading.
- In Rust, a method called through a chain of fields or on a struct literal, like `vis.map.get("a")`, `self.inner.config.port()` or `Xyz { x: 1.0 }.to_val()`, now links to the method of the type the last field is declared with, or of the literal's own type. A call through a type from the standard library or a dependency, like `J::from(i)` after `use serde_json::Value as J` or `Value::default()` after `use serde_json::Value`, no longer links to a project function of the same name. Before, such calls were linked by the method's name alone, so a `HashMap` field's `get` reached an unrelated project `get` and a struct literal's `to_val()` reached another type's. Re-index Rust projects after upgrading. Thanks @Egrn for the report and @danusha2345. (#2614)
- In Rust, a method call made through more than one field, or on what another method returns, like `self.services.store.put(…)` or `self.store().put(…)`, now keeps that whole receiver in the name the index records for the call, on one line or split over several by rustfmt, as a call through one field like `self.store.put(…)` already did. A call on what a method returns now links to the method of the type that method is declared to return, so builder chains like `builder.line_terminator(…).binary_detection(…)` reach the builder's own methods and a call on a standard-library value, like `buf.filled().len()`, no longer links to a project method of the same name. In a chain that calls the same method twice, like `self.table.get("a").get(0)`, each call links to its own receiver's method. Before, the index kept only the method's name, so tools reading a call couldn't tell which field it went through, `self.store().put(…)` linked to nothing, and the second `get` was guessed by its name. Re-index Rust projects after upgrading. Thanks @jocax for the report. (#2836)
- In Rust, a call written with a turbofish, like `matches.get_one::<String>("name")`, `parse_config::<Settings>()` or `Pool::<u8>::new()`, now links to the function it calls. Before, the type arguments stayed in the call's name, so no such call was linked. Re-index Rust projects after upgrading.
- In Rust, a call through a value's declared type, like `reader.as_mut().poll_read(cx)` on `reader: Pin<&mut R>`, now reads the declaration in scope at the call. Before, a `let` of the same name inside an earlier block, or a struct literal's field of that name written above the call, could be taken for it. Re-index Rust projects after upgrading.
- In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name.
+11 -10
View File
@@ -1670,7 +1670,7 @@ impl From<u32> for Own {
).toBe(true);
});
it('keeps the owner shape for `self.<method>()` and `self.<field>.<method>()`, and collapses every other receiver (#1585, #1861)', () => {
it('keeps the owner shape for `self.<method>()` and `self.<field>.<method>()`, a longer receiver chain whole, and collapses a parenthesized receiver (#1585, #1861, #2836)', () => {
const code = `
pub struct Outer { pub inner: Inner, pub deep: Deep }
impl Outer {
@@ -1689,22 +1689,23 @@ impl Outer {
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName);
// Two shapes keep an owner the resolver can act on: the single-hop field
// receiver, whose type it reads off the owner struct (#1585), and the bare
// `self` receiver, whose type is the calling method's own owner (#1861).
// `self.make().run()` contributes `self.make` — the inner call — and its
// OUTER call collapses, because a method's return type is not read here.
// The single-hop field receiver keeps its owner, whose type the resolver
// reads off the owner struct (#1585), and so does the bare `self`
// receiver, whose type is the calling method's own owner (#1861). A
// longer chain of fields and method calls is kept whole, a call written
// `name()` (#2836): `self.make().run()` contributes `self.make`, the inner
// call, and `self.make().run`.
expect(calls.filter((c) => c.startsWith('self.')).sort()).toEqual([
'self.deep.inner.run',
'self.inner.run',
'self.make',
'self.make().run',
'self.run',
]);
// A local receiver keeps its name as before…
expect(calls).toContain('local.run');
// …and the deeper chain, the call receiver and the parenthesized receiver
// still collapse to the method name. `self.run()` no longer does, so this
// is three rather than four.
expect(calls.filter((c) => c === 'run')).toHaveLength(3);
// …and the parenthesized receiver still collapses to the method name.
expect(calls.filter((c) => c === 'run')).toHaveLength(1);
const outerRun = result.nodes.find((n) => n.qualifiedName === 'Outer::run');
expect(outerRun).toBeDefined();
const fieldRef = result.unresolvedReferences.find((r) => r.referenceName === 'self.inner.run');
+36
View File
@@ -153,6 +153,42 @@ fn uses() {
]);
});
it('a call keeps its receiver chain of fields and method calls (#2836)', () => {
const links = Array.from({ length: 17 }, (_, i) => `l${i}()`).join('.');
const source = `
pub struct Svc { services: Services, store: Box<dyn Store>, pair: (u8, Config) }
impl Svc {
fn chains(&self, vis: &Vis) {
self.services.store.put("a"); let _ = self.services.store.put("ü");
self
.services // rustfmt
.store
.put("c");
self.store().put("m"); self.store.put("d"); self.put("e"); vis.put(1);
vis.map.get("k").get(0); self.0.len(); self.pair.1.port();
vis.get::<u8>(1).put::<u16>(2); vis.a.b().c.d();
Foo::new().a().b(); (vis).a.b(); self.a?.b(); self.a.b().await.c(); vis[0].a.b();
r#vis.a.b(); vis.${links}.m();
}
}
`;
assertParity('chains.rs', source);
assertParity('chains.rs', source.replace(/\n/g, '\r\n'));
// Not vacuous: the chains are kept, the other receivers keep their old names.
process.env.CODEGRAPH_KERNEL = '0';
const names = new Set(extractFromSource('chains.rs', source, 'rust').unresolvedReferences
.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName));
delete process.env.CODEGRAPH_KERNEL;
for (const name of [
'self.services.store.put', 'self.store().put', 'self.store.put', 'self.put', 'vis.put', 'vis.map.get',
'vis.map.get().get', 'self.0.len', 'self.pair.1.port', 'vis.get', 'vis.get().put', 'vis.a.b().c.d',
'Foo::new().a', 'b', 'c', 'm',
]) {
expect(names, name).toContain(name);
}
});
it('torture fixture: impl/trait quirks, use bindings, chains, fn-refs, value-refs, route macros', () => {
const file = path.join(FIXTURE_DIR, 'torture.rs');
assertParity('fixtures/torture.rs', fs.readFileSync(file, 'utf8'), 20);
+281
View File
@@ -0,0 +1,281 @@
/**
* A Rust method call keeps the receiver chain it is written on (#2836): a
* chain of fields and method calls on `self` or on a name —
* `self.services.store.put(…)`, `self.store().put(…)`, `vis.map.get(…)` — is
* recorded with that chain, calls written `name()`, as the one-field
* `self.store.put(…)` always was. The extractor used to keep the bare method
* name, so the edge's `refName` said nothing of which value the call goes
* through, and the receiver of a call on a method call's value was never read.
*
* Resolution reads the receiver's type through the whole chain, where the
* call is written: `self.services` is a `Services`, its `store` a
* `Box<dyn Store>`, so the call is `Store::put`; `self.store()` is what
* `Svc::store` returns. A later link calling the same name again is a call of
* its own (`self.table.get("a").get(0)` is `Table::get`, then `Row::get`).
*/
import { describe, it, expect, afterAll, beforeAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
const roots: string[] = [];
const graphs: Record<'issue' | 'chains', CodeGraph> = {} as never;
function write(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
}
async function index(name: keyof typeof graphs, files: Record<string, string>): Promise<void> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `cg-rust-chain-${name}-`));
roots.push(root);
write(root, files);
graphs[name] = await CodeGraph.init(root, { index: true });
}
const crate = (name: string, deps = ''): string =>
`[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n${deps}`;
beforeAll(async () => {
// The issue's workspace, with the call on a method call's value it names.
await index('issue', {
'Cargo.toml': '[workspace]\nmembers = ["api", "app"]\nresolver = "2"\n',
'api/Cargo.toml': crate('api'),
'api/src/lib.rs': 'pub trait Store {\n fn put(&self, key: &str);\n}\n',
'app/Cargo.toml': crate('app', 'api = { path = "../api" }\n'),
'app/src/lib.rs': [
'use api::Store;',
'',
'pub struct Services { pub store: Box<dyn Store> }',
'pub struct Svc { pub services: Services, pub store: Box<dyn Store> }',
'',
'impl Svc {',
' pub fn a(&self) { self.services.store.put("a"); }',
' pub fn b(&self) { let _ = self.services.store.put("b"); }',
' pub fn c(&self) {',
' self',
' .services',
' .store',
' .put("c");',
' }',
' pub fn d(&self) { self.store.put("d"); }',
' pub fn e(&self) {',
' self',
' .store',
' .put("e");',
' }',
' pub fn m(&self) { self.store().put("m"); }',
' pub fn store(&self) -> &dyn Store { &*self.store }',
'}',
'',
].join('\n'),
});
// Chains whose links call the same name, through `self`, a parameter and a
// tuple field, and a call on a method call's std value — beside a project
// namesake a guess by the name would take.
await index('chains', {
'Cargo.toml': crate('chains'),
'src/lib.rs': [
'pub struct Row { cells: Vec<u8> }',
'',
'impl Row {',
' pub fn get(&self, i: usize) -> u8 { self.cells[i] }',
'}',
'',
'pub struct Table { rows: Vec<Row>, names: Vec<String> }',
'',
'impl Table {',
' pub fn get(&self, _k: &str) -> &Row { &self.rows[0] }',
' pub fn names(&self) -> &Vec<String> { &self.names }',
'}',
'',
'pub struct Db { pub table: Table }',
'pub struct Wrap(pub Table);',
'',
'/// A project namesake of std\'s `len`.',
'pub struct Counter;',
'',
'impl Counter {',
' pub fn len(&self) -> usize { 0 }',
'}',
'',
'impl Db {',
' pub fn cell(&self) -> u8 { self.table.get("a").get(0) }',
' pub fn count(&self) -> usize { self.table.names().len() }',
'}',
'',
'impl Wrap {',
' pub fn first(&self) -> u8 { self.0.get("c").get(2) }',
'}',
'',
'pub fn local(db: &Db) -> u8 {',
' db.table',
' .get("b")',
' .get(1)',
'}',
'',
].join('\n'),
});
}, 120_000);
afterAll(() => {
for (const cg of Object.values(graphs)) cg?.close();
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
/** The calls on `line` of `file`, as `qualifiedName resolvedBy refName`, sorted. */
const callsAt = (name: keyof typeof graphs, file: string, line: number): string[] => {
const cg = graphs[name];
const out: string[] = [];
for (const node of cg.getNodesInFile(file)) {
for (const edge of cg.getOutgoingEdges(node.id)) {
if (edge.kind !== 'calls' || edge.line !== line || edge.provenance === 'heuristic') continue;
out.push(`${cg.getNode(edge.target)!.qualifiedName} ${String(edge.metadata?.resolvedBy)} ${String(edge.metadata?.refName)}`);
}
}
return [...new Set(out)].sort();
};
describe('the issue\'s workspace (#2836)', () => {
it('a call on two fields keeps its receiver: on one line, after `let _ =`, split by rustfmt', () => {
expect(callsAt('issue', 'app/src/lib.rs', 7)).toEqual(['Store::put instance-method self.services.store.put']);
expect(callsAt('issue', 'app/src/lib.rs', 8)).toEqual(['Store::put instance-method self.services.store.put']);
expect(callsAt('issue', 'app/src/lib.rs', 10)).toEqual(['Store::put instance-method self.services.store.put']);
});
it('a call on a method call\'s value keeps the path up to that call, and reaches what it returns', () => {
expect(callsAt('issue', 'app/src/lib.rs', 21)).toEqual([
'Store::put instance-method self.store().put',
'Svc::store qualified-name self.store',
]);
});
it('a call on one field keeps what it had', () => {
expect(callsAt('issue', 'app/src/lib.rs', 15)).toEqual(['Store::put instance-method self.store.put']);
expect(callsAt('issue', 'app/src/lib.rs', 17)).toEqual(['Store::put instance-method self.store.put']);
});
it('leaves no call unresolved', () => {
expect(graphs.issue.getUnresolvedReferencesInFile('app/src/lib.rs').filter((r) => r.referenceKind === 'calls')).toEqual([]);
});
});
describe('a Rust call on a receiver chain', () => {
it('is told from a later link calling the same name: through `self`, a tuple field and a parameter', () => {
expect(callsAt('chains', 'src/lib.rs', 25)).toEqual([
'Row::get instance-method self.table.get().get',
'Table::get instance-method self.table.get',
]);
expect(callsAt('chains', 'src/lib.rs', 30)).toEqual([
'Row::get instance-method self.0.get().get',
'Table::get instance-method self.0.get',
]);
expect(callsAt('chains', 'src/lib.rs', 34)).toEqual([
'Row::get instance-method db.table.get().get',
'Table::get instance-method db.table.get',
]);
});
it('on a method call\'s std value is no project method', () => {
expect(callsAt('chains', 'src/lib.rs', 26)).toEqual(['Table::names instance-method self.table.names']);
});
});
describe('Rust call references keep their receiver chain', () => {
const ENV_KEYS = ['CODEGRAPH_KERNEL'] as const;
let savedEnv: Record<string, string | undefined>;
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['rust']);
});
beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
process.env.CODEGRAPH_KERNEL = '0';
});
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
/** The names of the `calls` references extraction records for `body`, a method body's statements, sorted. */
const callNames = (body: string): string[] => {
const source = `impl Svc {\n fn shapes(&self, x: &X) {\n${body}\n }\n}\n`;
return extractFromSource('shapes.rs', source, 'rust').unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => r.referenceName)
.sort();
};
it('of fields and method calls on `self` or a name, calls written `name()`', () => {
expect(callNames('self.services.store.put("a");')).toEqual(['self.services.store.put']);
expect(callNames('self.store().put("m");')).toEqual(['self.store', 'self.store().put']);
expect(callNames('self.0.len();')).toEqual(['self.0.len']);
expect(callNames('x.map.get("k");')).toEqual(['x.map.get']);
expect(callNames('x.get::<u8>(1).put(2);')).toEqual(['x.get', 'x.get().put']);
expect(callNames('self\n .services // the services\n .store\n .put("c");')).toEqual(['self.services.store.put']);
});
it('and keep the shapes they had', () => {
expect(callNames('self.store.put("d");')).toEqual(['self.store.put']);
expect(callNames('x.put(1);')).toEqual(['x.put']);
expect(callNames('self.put(1);')).toEqual(['self.put']);
expect(callNames('Foo::new().a().b();')).toEqual(['Foo::new', 'Foo::new().a', 'b']);
});
it('but not through parentheses, `?` or `.await`, nor past sixteen links', () => {
expect(callNames('(x).a.b();')).toEqual(['b']);
expect(callNames('self.a?.b();')).toEqual(['b']);
expect(callNames('self.a.b().await.c();')).toEqual(['c', 'self.a.b']);
const links = Array.from({ length: 17 }, (_, i) => `l${i}()`);
const names = callNames(`x.${links.join('.')}.m();`);
expect(names).toHaveLength(18);
expect(names).toContain('x.l0');
expect(names).toContain(`x.${links.slice(0, 16).join('.')}.l16`);
expect(names).toContain('m');
});
});
describe('a Rust call on a receiver chain that names no method yet', () => {
let root: string;
let cg: CodeGraph | undefined;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-chain-sync-')); });
afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); });
it('waits under its method\'s name, and resolves once a sync adds the method', async () => {
write(root, {
'Cargo.toml': crate('later'),
'src/lib.rs': [
'pub mod disk;',
'',
'pub struct Services { pub disk: disk::Disk }',
'pub struct Svc { pub services: Services }',
'',
'impl Svc {',
' pub fn save(&self) { self.services.disk.flush(); }',
'}',
'',
].join('\n'),
'src/disk.rs': 'pub struct Disk;\n',
});
cg = await CodeGraph.init(root, { index: true });
const parked = cg.getUnresolvedReferencesInFile('src/lib.rs').filter((r) => r.referenceKind === 'calls');
expect(parked.map((r) => r.referenceName)).toEqual(['self.services.disk.flush']);
fs.writeFileSync(path.join(root, 'src/disk.rs'), 'pub struct Disk;\n\nimpl Disk {\n pub fn flush(&self) {}\n}\n');
await cg.sync();
const save = cg.getNodesInFile('src/lib.rs').find((n) => n.name === 'save')!;
const calls = cg.getOutgoingEdges(save.id).filter((e) => e.kind === 'calls');
expect(calls.map((e) => `${cg!.getNode(e.target)!.qualifiedName} ${String(e.metadata?.refName)}`)).toEqual([
'Disk::flush self.services.disk.flush',
]);
});
});
+84 -11
View File
@@ -24,10 +24,13 @@
//! - Chained-call re-encode is scoped_identifier-gated (`Foo::new().bar()` →
//! `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.
//! a longer chain of fields and method calls on `self` or a name keeps
//! that chain, calls as `name()` (`self.services.store.put()` →
//! `self.services.store.put`, `self.store().put()` → `self.store().put`,
//! #2836); other instance chains, parens, `?`, `.await`, and longer
//! path-rooted chains collapse to the bare method name. A bare `self`
//! receiver keeps `self.` (#1861). Turbofish callees drop their type
//! arguments (`helper::<T>` → `helper`).
//! - `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.
@@ -927,10 +930,13 @@ impl<'t> Walker<'t> {
}
}
"call_expression" => {
// Chained-call re-encode: ONLY an associated-
// Chained-call re-encode: an associated-
// function chain (`Foo::new().bar()`, inner
// callee a scoped_identifier). Instance chains
// keep the bare method name.
// callee a scoped_identifier). A chain of
// fields and method calls on `self` or a name
// (`self.store().put()`, #2836) keeps that
// chain; any other instance chain the bare
// method name.
let inner_fn = r.child_by_field_name("function").map(unwrap_generic_function);
let reencode =
inner_fn.map(|f| f.kind() == "scoped_identifier").unwrap_or(false);
@@ -943,6 +949,8 @@ impl<'t> Walker<'t> {
.collect();
let inner = strip_turbofish(&inner);
callee_name = format!("{inner}().{method_name}");
} else if let Some(chain) = receiver_chain(r, self.src) {
callee_name = format!("{chain}.{method_name}");
} else {
callee_name = method_name.to_string();
}
@@ -952,9 +960,11 @@ impl<'t> Walker<'t> {
// 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.
// (or leave it unresolved). A longer chain of
// fields and method calls on `self` or a name
// (`self.services.store.put()`, `vis.map.get()`,
// #2836) keeps that chain; any other
// field_expression receiver the bare name.
let base = r.child_by_field_name("value");
let field = r.child_by_field_name("field");
match (base, field) {
@@ -964,7 +974,12 @@ impl<'t> Walker<'t> {
let field_name = self.text(f);
callee_name = format!("self.{field_name}.{method_name}");
}
_ => callee_name = method_name.to_string(),
_ => {
callee_name = match receiver_chain(r, self.src) {
Some(chain) => format!("{chain}.{method_name}"),
None => method_name.to_string(),
}
}
}
}
// `self.method()` — keep the `self.` prefix so the
@@ -1628,6 +1643,64 @@ fn unwrap_generic_function(func: Node<'_>) -> Node<'_> {
}
}
/// The most links a receiver chain is kept with (receiver_chain).
const RECEIVER_CHAIN_MAX_LINKS: usize = 16;
fn is_rust_ident(s: &str) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// The receiver chain a method call is written on, when it is fields and
/// method calls on `self` or on a name: `self.services.store`,
/// `self.store()`, `vis.map`, `self.0`, `x.get::<u8>(1)` — each field by its
/// name or index, each call as `name()`, its arguments and turbofish
/// dropped. None for any other receiver (a path's call, a literal,
/// parentheses, `?`, `.await`, an index, a macro), and past
/// RECEIVER_CHAIN_MAX_LINKS links. Mirrors rustReceiverChain in
/// TreeSitterExtractor (#2836): both emit the same bytes.
fn receiver_chain(node: Node<'_>, src: &str) -> Option<String> {
let mut links: Vec<String> = Vec::new();
let mut cur = node;
while links.len() <= RECEIVER_CHAIN_MAX_LINKS {
if cur.kind() == "self" || cur.kind() == "identifier" {
let root = &src[cur.byte_range()];
if links.is_empty() || !is_rust_ident(root) {
return None;
}
links.reverse();
let mut out = root.to_string();
for link in &links {
out.push('.');
out.push_str(link);
}
return Some(out);
}
let call = cur.kind() == "call_expression";
let field_of = if call {
unwrap_generic_function(cur.child_by_field_name("function")?)
} else {
cur
};
if field_of.kind() != "field_expression" {
return None;
}
let name = field_of.child_by_field_name("field").map(|f| &src[f.byte_range()]).unwrap_or("");
let ok = if call {
is_rust_ident(name)
} else {
is_rust_ident(name) || (!name.is_empty() && name.chars().all(|c| c.is_ascii_digit()))
};
if !ok {
return None;
}
links.push(if call { format!("{name}()") } else { name.to_string() });
cur = field_of.child_by_field_name("value")?;
}
None
}
/// A callee path without its turbofish type arguments: `Vec::<u8>::new` →
/// `Vec::new`. The brackets nest (`::<Vec<u8>>`), and the `>` of a `->` is
/// no closing one (`::<fn() -> u8>`). Mirrors stripRustTurbofish in
+52 -3
View File
@@ -534,6 +534,45 @@ function stripRustTurbofish(text: string): string {
return out;
}
/** The most links a Rust receiver chain is kept with (rustReceiverChain). */
const RUST_RECEIVER_CHAIN_MAX_LINKS = 16;
/**
* The receiver chain a Rust method call is written on, when it is fields and
* method calls on `self` or on a name: `self.services.store`, `self.store()`,
* `vis.map`, `self.0`, `x.get::<u8>(1)` — each field by its name or index,
* each call as `name()`, its arguments and turbofish dropped. Null for any
* other receiver (a path's call, a literal, parentheses, `?`, `.await`, an
* index, a macro), and past RUST_RECEIVER_CHAIN_MAX_LINKS links, which keep
* the bare method name. Mirrored in the kernel's receiver_chain
* (rustlang.rs): both emit the same bytes.
*/
function rustReceiverChain(node: SyntaxNode, source: string): string | null {
const links: string[] = [];
let cur: SyntaxNode | null = node;
while (cur && links.length <= RUST_RECEIVER_CHAIN_MAX_LINKS) {
if (cur.type === 'self' || cur.type === 'identifier') {
const root = getNodeText(cur, source);
if (links.length === 0 || !/^[A-Za-z_]\w*$/.test(root)) return null;
return [root, ...links.reverse()].join('.');
}
let fieldOf: SyntaxNode | null = cur;
let call = false;
if (cur.type === 'call_expression') {
fieldOf = getChildByField(cur, 'function');
if (fieldOf?.type === 'generic_function') fieldOf = getChildByField(fieldOf, 'function');
call = true;
}
if (fieldOf?.type !== 'field_expression') return null;
const field = getChildByField(fieldOf, 'field');
const name = field ? getNodeText(field, source) : '';
if (call ? !/^[A-Za-z_]\w*$/.test(name) : !/^(?:[A-Za-z_]\w*|\d+)$/.test(name)) return null;
links.push(call ? `${name}()` : name);
cur = getChildByField(fieldOf, 'value');
}
return null;
}
/**
* A Swift receiver's text from its innermost wrapped operand on: the wrappers
* sit on the chain's left edge, so `!Settings.Defaults` reads
@@ -5995,6 +6034,7 @@ export class TreeSitterExtractor {
}
const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
let constructed: string | null = null;
let rustChain: string | null = null;
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
const receiverName = getNodeText(receiver, this.source);
if (!SKIP_RECEIVERS.has(receiverName)) {
@@ -6028,11 +6068,20 @@ export class TreeSitterExtractor {
// 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).
// 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 === 'rust' && receiver && (rustChain = rustReceiverChain(receiver, this.source)) !== null) {
// Rust call on a longer chain of fields and method calls —
// `self.services.store.put()`, `self.store().put()`,
// `vis.map.get()`, split over lines by rustfmt or not (#2836).
// Keep the chain, calls written `name()`: the bare method name
// this used to emit said nothing of which value the call is
// made on. The resolver reads the receiver where the call is
// written. Parenthesized receivers, `?`, `.await` and a path's
// call (`Foo::new().a().b()`) keep the bare name. Mirrored in
// the kernel's extract_call (rustlang.rs).
calleeName = `${rustChain}.${methodName}`;
} else if (
(this.language === 'cpp' ||
this.language === 'c' ||
+12 -1
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
@@ -1421,6 +1421,17 @@ export class ReferenceResolver {
}
private resolveOneIn(ref: UnresolvedRef): ResolvedRef | null {
// A Rust call kept with its receiver chain — `self.services.store.put`,
// `self.store().put` (#2836) — resolves as the call by its method's name
// did before the extractor kept the chain: every strategy and gate reads
// that name the same way, and the receiver's type is read through the
// whole chain where the call is written (matchRustWrittenReceiverCall).
// The edge keeps the call as written.
const byName = rustChainedCallByName(ref);
if (byName) {
const resolved = this.resolveOneIn(byName);
return resolved && { ...resolved, original: ref };
}
// A C/C++ "call" whose name is a function-like macro visible in this
// translation unit is a macro expansion, not a call — it must never bind
// to a same-named function in another file (#1838).
+71 -4
View File
@@ -24489,9 +24489,14 @@ function matchRustAsMutChainCall(ref: UnresolvedRef, context: ResolutionContext)
* the reporter's `vis.map.get("a")` on a `HashMap<String, i32>` field went to
* the project's one `get`, `Ext::get`, an associated function at that, and
* `Xyz { … }.to_val()` to `Stat::to_val`, the same trait's other impl.
* Undefined, for the name strategies to decide as before, where the receiver
* is anything else (a call's value, an index, a macro) or its type can't be
* read.
* A call the extractor keeps with its receiver's chain of fields and method
* calls — `self.services.store.put`, `self.store().put` (#2836) — comes here
* by its method's name with that chain (rustChainedCallByName), and its
* receiver is the chain where the call writes it: a method call's value is
* what the method's signature returns, and a later link calling the same
* name is a call of its own. Undefined, for the name strategies to decide as
* before, where the receiver is anything else (an index, a macro, a call on
* a path) or its type can't be read.
*/
function matchRustWrittenReceiverCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
const scopes = rustScopesOf(ref.filePath, context);
@@ -24499,7 +24504,10 @@ function matchRustWrittenReceiverCall(ref: UnresolvedRef, context: ResolutionCon
if (!scopes || lineStart === undefined || ref.column < 0) return undefined;
const start = lineStart + ref.column;
if (start >= (scopes.lineStarts[ref.line] ?? scopes.code.length)) return undefined;
const receiver = rustWrittenCallReceiver(scopes, start, ref.referenceName);
const chain = RUST_CHAINED_CALLS.get(ref);
const receiver = chain
? rustChainedReceiver(scopes, start, chain, ref.referenceName)
: rustWrittenCallReceiver(scopes, start, ref.referenceName);
if (receiver === null) return undefined;
const written = rustValueType(receiver, start, scopes, ref, context, 0);
if (!written) return undefined;
@@ -24699,6 +24707,65 @@ function rustWrittenCallReceiver(scopes: RustScopes, start: number, name: string
return code.slice(start, dot).trim();
}
/**
* A Rust call the extractor keeps with the chain of fields and method calls
* its receiver is — `self.services.store.put`, `self.store().put`,
* `vis.map.get`, `self.0.len` (#2836): the chain's root, its links (a call
* written `name()`) and the method. The one-field `self.<field>.<method>`
* has a reading of its own (matchMethodCall), and isn't one.
*/
const RUST_CHAINED_CALL = /^(self|[A-Za-z_]\w*)((?:\.(?:[A-Za-z_]\w*(?:\(\))?|\d+))+)\.([A-Za-z_]\w*)$/;
/** The receiver chain, root first, of a Rust call resolution reads by its method's name (rustChainedCallByName). */
const RUST_CHAINED_CALLS = new WeakMap<UnresolvedRef, readonly string[]>();
/**
* What a Rust call kept with its receiver chain is resolved as: the call by
* its method's name, as the extractor recorded it before it kept the chain
* (#2836), so the frameworks, the strategies and the scope gates read it as
* they did; the chain goes with it, and the receiver's type is read through
* all of it (matchRustWrittenReceiverCall). Null for any other reference.
*/
export function rustChainedCallByName(ref: UnresolvedRef): UnresolvedRef | null {
if (ref.language !== 'rust' || ref.referenceKind !== 'calls') return null;
const m = RUST_CHAINED_CALL.exec(ref.referenceName);
if (!m) return null;
const links = m[2]!.slice(1).split('.');
if (m[1] === 'self' && links.length === 1 && /^[A-Za-z_]\w*$/.test(links[0]!)) return null;
const byName: UnresolvedRef = { ...ref, referenceName: m[3]! };
RUST_CHAINED_CALLS.set(byName, [m[1]!, ...links]);
return byName;
}
/**
* The receiver of the Rust call `name(…)` whose expression starts at `start`,
* where its reference keeps the receiver's chain (`chain`, root first — `self`,
* `store()`): that chain as the file's blanked text writes it, a call's
* arguments and turbofish included, over lines too. Null where the text
* doesn't read as the chain and then the call.
*/
function rustChainedReceiver(scopes: RustScopes, start: number, chain: readonly string[], name: string): string | null {
const { code } = scopes;
const word = (at: number, id: string): boolean => code.startsWith(id, at) && !/\w/.test(code[at + id.length] ?? '');
if (/\w/.test(code[start - 1] ?? '') || !word(start, chain[0]!)) return null;
let p = start + chain[0]!.length;
let end = -1;
// Each link — `.services`, `.store` and its `(…)` — and then the call itself.
for (const link of [...chain.slice(1), `${name}()`]) {
end = p;
const call = link.endsWith('()');
const id = call ? link.slice(0, -2) : link;
const dot = rustSpaceEnd(code, p);
if (code[dot] !== '.' || code[dot + 1] === '.') return null;
const at = rustSpaceEnd(code, dot + 1);
if (!word(at, id)) return null;
const args = rustCallArgsEnd(scopes, at + id.length);
if (call !== args >= 0) return null;
p = call ? args : at + id.length;
}
return code.slice(start, end);
}
/** Std pointers whose `as_ref` / `as_mut` lend what they hold: `Box<T>`, `NonNull<T>` → `&mut T`. */
const RUST_LENDING_POINTERS: ReadonlySet<string> = new Set(['Box', 'NonNull', 'Rc', 'Arc', 'Cow']);