From 7324faf69fd951effef5e4f2f765fcc8653fe17b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 23:04:17 +0000 Subject: [PATCH] fix(rust): a call on a field of self reaches the method of the field's declared type (#2719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rust `self.field.m()` call was resolved by the last segment of the field's type name alone. `inner: multer::Field` linked to axum's own `Field::name`, `std: std::time::Instant` to tokio's `Instant` (the calling method itself), `inner: std::sync::Mutex<…>` to tokio's loom mock or async `Mutex::lock`, and a field generic over the impl's `S: Store` to nothing. The field's type is now read the way field values already are: from the struct's declaration, through the wrappers, by the `use`s of the struct's file. A struct's own parameter is known by the bounds that the impl around the call gives the parameter it is instantiated with (`struct Worker` under `impl Worker`). The method is the one the typed receiver path picks. The plain-name reading stays only where the field's type can't be read. A call on a std or dependency value reaches a project trait's impl for its type only when the impl is for that type. tokio's `impl Kill for Child` in process/windows.rs is for its own `struct Child`, not `std::process::Child`. The impl's type is read as the impl writes it: gleam's `impl LowestVersion for pubgrub::Range` is for pubgrub's `Range`. A/B with fresh indexes against main on axum, serde, ripgrep, clap, dioxus, tokio and gleam, every changed edge triaged: 361 calls linked that were not (fields bounded by the impl's traits, dioxus's signal fields, trait objects), 104 removed (calls on std and dependency fields, and 5 on tokio values whose methods sit inside `cfg_*!` macros), 59 moved to the right type of a shared name, 2,844 kept at a higher confidence; 62 fewer self-loops and none added. gin, serilog, flask and express are byte-identical. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-self-field-types.test.ts | 176 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 113 ++++++++++++++- 3 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 __tests__/rust-self-field-types.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fac1ef..7e0d41f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, a method call on a local variable or a parameter, like `opts.build()`, `server.run()` or `serializer.serialize_str(…)`, now links to the method of the type the variable is declared with: its parameter type or `let` annotation, the value it is built from (`Server::new()`, `Opts { … }`, `Arc::new(…)`), or the field a match arm takes from a variant (`Commands::Build(opts) => opts.build()`). A call through `&`, `Box`, `Rc` or `Arc` reaches the value inside, a call on an `impl Trait`, a `dyn Trait` or a bounded generic reaches the trait's method, and among types that share a name, the one the file imports wins. A call on a standard-library or dependency value, like `cx.waker()` on a `Context` or `path.join(…)` on a `PathBuf`, no longer links to an unrelated project method of the same name. Before, such calls were linked by the method's name alone, so dioxus's `opts.build()` lost its link once another type had a `build`. Re-index Rust projects after upgrading. Thanks @Egrn for the report and @danusha2345. (#2614) - In Rust, a variable that holds what a method call returns, like `let server = builder.port(80).build();`, `let settings = config.load()?;` or `let conn = client.connect().await;`, now has the type that method is declared to return, so calls on the variable link to that type's methods. A generic method's type comes from the call's turbofish, like `arg.get::()`, and a call on a type that implements one trait twice, like `Encoder` and `Encoder<&[u8]>`, links to the implementation that takes the argument given. Before, such calls were linked by name and could reach another type's method of the same name. Re-index Rust projects after upgrading. - 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`, `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 Worker`), 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 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. - In C++, a call on a parameter, a local lambda or functor, a lambda's parameter or a range-`for` variable no longer links to a function or method elsewhere that only shares its name, like protocolbuffers/protobuf's code generators calling `format(…)` on their `Formatter& format` parameter, which linked to the conformance suite's `TestResult::format`, or facebook/rocksdb's tests calling a local `auto key = [](int i) {…};`. C++ finds the local first, so that is what the call calls. - In TypeScript and JavaScript, an import of a package your project declares itself with `declare module '…'` in a `.d.ts` file, like a `typings/mammoth/index.d.ts` written for an untyped package or VS Code's own `vscode.d.ts`, now links to that declaration, so `codegraph callers`, impact and `codegraph affected` show the code that uses your typings instead of nothing or a project symbol that only shares a name. Named, default and namespace imports, `export =` and renaming export lists are read the way TypeScript reads them, a function such a file declares without a body (`export function convertToHtml(…): Promise;`) is now indexed so calls to it have something to link to, and only an import of its package reaches it. A `declare module` that augments a package's own types, a wildcard like `declare module '*.svg'`, and copies of typings kept in test fixtures are left alone. Re-index TypeScript projects after upgrading. diff --git a/__tests__/rust-self-field-types.test.ts b/__tests__/rust-self-field-types.test.ts new file mode 100644 index 00000000..ea09937c --- /dev/null +++ b/__tests__/rust-self-field-types.test.ts @@ -0,0 +1,176 @@ +/** + * A Rust call on a field of the impl's own type — `self.store.save()` — is a + * call on the type the struct declares for the field, read as a written + * type: the struct's own parameter by the bounds the impl gives it, a trait + * object by its trait, a pointer's own methods its own (`Arc::clone`), a + * type name by the `use` that brings it into the struct's file. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +const config = [ + 'pub struct Config;', + '', + 'impl Config {', + ' pub fn load(&self) {}', + '}', + '', +].join('\n'); + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-self-field-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nsemverlib = "1"\n', + 'src/main.rs': 'mod config_a;\nmod config_b;\nmod model;\nmod proc;\nmod range;\nmod use_range;\nmod ver;\nmod worker;\n\nfn main() {}\n', + 'src/range.rs': '/// The project\'s own `Range`, a namesake of the dependency\'s.\npub struct Range;\n', + 'src/ver.rs': [ + 'pub trait Lowest {', + ' fn lowest(&self) -> u32;', + '}', + '', + 'impl Lowest for semverlib::Range {', + ' fn lowest(&self) -> u32 {', + ' 0', + ' }', + '}', + '', + ].join('\n'), + 'src/use_range.rs': [ + 'use semverlib::Range;', + '', + 'use crate::ver::Lowest;', + '', + 'pub fn check(r: Range) -> u32 {', + ' r.lowest()', + '}', + '', + ].join('\n'), + 'src/proc.rs': [ + 'pub trait Kill {', + ' fn kill(&mut self) -> bool;', + '}', + '', + '/// The project\'s own `Child`, holding std\'s.', + 'pub struct Child {', + ' child: std::process::Child,', + '}', + '', + 'impl Kill for Child {', + ' fn kill(&mut self) -> bool {', + ' self.child.kill().is_ok()', + ' }', + '}', + '', + ].join('\n'), + 'src/config_a.rs': config, + 'src/config_b.rs': config, + 'src/model.rs': [ + 'pub trait Store {', + ' fn save(&self);', + '}', + '', + 'pub trait Render {', + ' fn render(&self);', + '}', + '', + 'pub struct State;', + '', + 'impl Clone for State {', + ' fn clone(&self) -> Self {', + ' State', + ' }', + '}', + '', + 'pub struct Inner;', + '', + 'impl Inner {', + ' pub fn run(&self) {}', + '}', + '', + '// Namesakes, which a guess by name alone may take.', + 'pub struct Decoy;', + '', + 'impl Decoy {', + ' pub fn save(&self) {}', + ' pub fn run(&self) {}', + '}', + '', + ].join('\n'), + 'src/worker.rs': [ + 'use std::sync::Arc;', + '', + 'use crate::config_b::Config;', + 'use crate::model::{Inner, Render, State, Store};', + '', + 'pub struct Worker {', + ' store: S,', + ' shared: Arc,', + ' cfg: Config,', + ' view: Box,', + '}', + '', + 'impl Worker {', + ' pub fn tick(&self) {', + ' self.store.save();', + ' let _ = self.shared.clone();', + ' self.cfg.load();', + ' self.view.render();', + ' }', + '}', + '', + ].join('\n'), + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** What the `calls` edges out of `fn`, declared in `file`, reach, as `file@line`, sorted. */ +const callsFrom = (file: string, fn: string): string[] => { + const out: string[] = []; + for (const node of cg.getNodesInFile(file).filter((n) => n.name === fn)) { + for (const edge of cg.getOutgoingEdges(node.id)) { + if (edge.kind !== 'calls') continue; + const target = cg.getNode(edge.target)!; + out.push(`${target.filePath}@${target.startLine}`); + } + } + return [...new Set(out)].sort(); +}; + +describe('a Rust call on a field of `self`', () => { + it('is the method of the type the struct declares for the field', () => { + // `S: Store` → `Store::save`; `Arc`'s `clone` is the `Arc`'s; + // `Config` is the one `config_b` declares, which the file uses; + // `Box` → `Render::render`. + expect(callsFrom('src/worker.rs', 'tick')).toEqual(['src/config_b.rs@4', 'src/model.rs@2', 'src/model.rs@6']); + }); + + it('is no project impl for a project type that only shares the std type\'s name', () => { + // `std::process::Child::kill` is std's; the project's `impl Kill for + // Child` is for its own `Child`, so the call is no self-loop. (The + // trait's `kill` declaration links to that impl on its own.) + const impl = cg.getNodesInFile('src/proc.rs').find((n) => n.name === 'kill' && n.startLine === 11)!; + expect(cg.getOutgoingEdges(impl.id).filter((e) => e.kind === 'calls')).toEqual([]); + }); +}); + +describe('a Rust call on a dependency\'s type', () => { + it('is a project trait\'s impl for that type, read by the path the impl writes', () => { + // `impl Lowest for semverlib::Range`, though the project has a `Range` of its own. + expect(callsFrom('src/use_range.rs', 'check')).toEqual(['src/ver.rs@6']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 217d717a..acc50587 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8952,6 +8952,8 @@ interface RustImplHeader { self: string; /** The type names its arguments write (`Arc` → `Handle`, `Pin

` → `P`). */ selfArgs: string[]; + /** The implementing type as written, path and arguments: `pubgrub::Range`. */ + selfText: string; /** The impl's own type parameters (`impl<'a, P: DerefMut>` → `P`). */ params: string[]; } @@ -9002,6 +9004,7 @@ function parseRustImplHeader(text: string): RustImplHeader | null { traitArgs: args(traitPart), self: head(selfPart), selfArgs: args(selfPart.trim().replace(/^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|dyn\s+)+/, '')), + selfText: selfPart.replace(/\{[\s\S]*$/, '').replace(/\bwhere\b[\s\S]*$/, '').trim(), params: params.map((p) => /^\s*(?:const\s+)?([A-Za-z_]\w*)/.exec(p)?.[1]).filter((p): p is string => p !== undefined), }; } @@ -9409,6 +9412,12 @@ interface RustWrittenType { * value that is one of them is unread, whatever its bound. */ opaque?: string[]; + /** + * What a declaration's own parameters are known by where the type is + * reached: through `self` in `impl Worker`, the struct's `S` + * is the impl's, a `Store`. The traits are read where the impl is. + */ + bounds?: ReadonlyMap; } /** @@ -9787,6 +9796,12 @@ function rustReadType(written: RustWrittenType, ref: UnresolvedRef, context: Res } if (segments.length === 0) { if (written.opaque?.includes(name)) return { kind: 'unread' }; + const known = written.bounds?.get(name); + if (known) { + const traits = known.traits.filter((b) => !/^['?]/.test(b.trim()) && !RUST_MARKER_TRAITS.has(rustPathHead(b)?.name ?? '')); + if (traits.length === 0) return { kind: 'unread' }; + return { kind: 'traits', traits, wrappers, byRef, written: { text: written.text, file: known.file, at: known.at } }; + } const bounds = rustGenericBounds(name, written, context); if (bounds !== undefined) { const traits = bounds.filter((b) => !RUST_MARKER_TRAITS.has(rustPathHead(b)?.name ?? '')); @@ -10181,7 +10196,56 @@ function rustFieldValue( const fields = rustDeclaredFields(decl, context); const text = /^\d+$/.test(field) ? fields?.tuple?.[Number(field)] : fields?.named?.get(field); if (!fields || !text) return null; - return { text, file: decl.filePath, at: rustNodeOffset(decl, context), self: decl.name, generics: fields.generics }; + const value: RustWrittenType = { text, file: decl.filePath, at: rustNodeOffset(decl, context), self: decl.name, generics: fields.generics }; + if (recv === 'self') { + const bounds = rustSelfParamBounds(fields.generics, scopes, at, ref.filePath); + if (bounds) value.bounds = bounds; + } + return value; +} + +/** + * What the struct's own type parameters are known by inside the impl around + * `at`: the bounds of the impl's parameter each one is instantiated with, + * by position — `struct Worker` under `impl Worker` gives + * `S` → `Store`. Undefined where the impl's type arguments don't line up + * with the struct's parameters, or none is a bounded parameter of the impl. + */ +function rustSelfParamBounds(structGenerics: string, scopes: RustScopes, at: number, file: string): Map | undefined { + if (structGenerics.trim() === '') return undefined; + const block = rustImplAt(scopes, at); + if (!block || block.kind !== 'impl') return undefined; + // `impl [Trait for] Worker`: the type after the impl's generics and a top-level ` for `. + let header = scopes.code.slice(block.at, block.from).replace(/^impl\b/, '').trim(); + if (header.startsWith('<')) { + const end = rustAngleEnd(header, 0); + if (end < 0) return undefined; + header = header.slice(end + 1).trim(); + } + let depth = 0; + for (let i = 0; i < header.length; i++) { + const c = header[i]; + if (c === '<') depth++; + else if (c === '>' && header[i - 1] !== '-') depth--; + else if (depth === 0 && /\s/.test(c!) && /^for\s/.test(header.slice(i + 1))) { + header = header.slice(i + 4).trim(); + break; + } + } + const self = rustPathHead(header.replace(/\bwhere\b[\s\S]*$/, '').trim()); + if (!self) return undefined; + const args = splitRustTopLevel(self.args, ',').map((a) => a.trim()).filter((a) => a !== ''); + const params = splitRustTopLevel(structGenerics, ',').map((p) => /^\s*(?:const\s+)?('?[A-Za-z_]\w*)/.exec(p)?.[1] ?? ''); + if (params.length !== args.length) return undefined; + const own = new Set(splitRustTopLevel(block.generics, ',').map((p) => /^\s*(?:const\s+)?([A-Za-z_]\w*)/.exec(p)?.[1])); + const bounds = new Map(); + params.forEach((p, i) => { + const arg = args[i]!; + if (p === '' || p.startsWith("'") || !own.has(arg)) return; + const traits = rustBoundsOf(arg, [{ generics: block.generics, where: block.where }]); + if (traits && traits.length > 0) bounds.set(p, { traits, file, at }); + }); + return bounds.size > 0 ? bounds : undefined; } /** The `index`th field of a Rust tuple type as written — `(K, V)` → `V` for 1 — through `&`; null for any other type. */ @@ -10715,10 +10779,21 @@ function rustOutsideTypeCall(name: string, args: string, byRef: boolean, method: // its name (`fn local_addr(&self) { Self::local_addr(self) }` in axum's // `impl Listener for TcpListener`) shows the type has that method itself, // which a call reaches first. + // An impl for a project type of the name is that type's, not the outside + // one's: tokio's `impl Kill for Child` in process/windows.rs is for its own + // `struct Child`, never `std::process::Child`. Its type is read as the impl + // writes it: hexpm's `impl LowestVersion for pubgrub::Range` is + // pubgrub's `Range`'s. + const forOutside = (m: Node): boolean => { + const written = rustTraitImplHeader(m, context)?.selfText.replace(/^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?)+/, '') ?? ''; + const head = rustPathHead(written); + const lookup = rustTypeLookup(head?.name ?? name, head?.segments ?? [], m.filePath, context, rustNodeOffset(m, context)); + return lookup.outside || !lookup.nodes.some((n) => RUST_OWNER_KINDS.has(n.kind)); + }; const impls = rustSpecialized(rustMethodsNamed(name, method, context).filter((m) => { const impl = rustTraitImplHeader(m, context); return impl !== null && impl.self === name && isRustProjectNamed(impl.trait, RUST_TRAIT_KIND, context) && - isRustTraitInScope(impl.trait, ref, m, context) && !rustDelegatesToOwn(m, name, context); + isRustTraitInScope(impl.trait, ref, m, context) && !rustDelegatesToOwn(m, name, context) && forOutside(m); }), args, context); if (impls.length === 1) return { original: ref, targetNodeId: impls[0]!.id, confidence: 0.85, resolvedBy: 'instance-method' }; if (impls.length > 1) { @@ -10888,6 +10963,32 @@ function matchRustTypedReceiverCall(receiver: string, method: string, ref: Unres /** The refs matchRustTypedReceiverCall resolved: through a typed receiver, a trait's own method is in reach. */ const RUST_TYPED_REFS = new WeakMap>(); +/** + * A Rust call on a field of the enclosing impl's type — `self.inner.run()`, + * `self.0.len()` — as a call on the type the struct declares for the field: + * through `Arc`, `Box`, `Pin` and the lock guards, by a bounded generic's + * traits or a trait object's, with the struct's `use`s naming the type + * (rustFieldValue, rustTypedMethodCall). Undefined where the field's type + * can't be told: an enum's `self`, a trait's default method, a struct the + * index doesn't hold. + */ +function matchRustTypedSelfFieldCall(field: string, method: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (!/^(?:[A-Za-z_]\w*|\d+)$/.test(field)) return undefined; + const scopes = rustScopesOf(ref.filePath, context); + if (!scopes) return undefined; + const at = (scopes.lineStarts[ref.line - 1] ?? 0) + Math.max(0, ref.column); + const written = rustFieldValue('self', field, at, scopes, ref, context, 0); + if (!written) return undefined; + const result = rustTypedMethodCall(rustReadType(written, ref, context), method, ref, context); + if (result === 'unread') return undefined; + if (result) { + let typed = RUST_TYPED_REFS.get(context); + if (!typed) RUST_TYPED_REFS.set(context, (typed = new WeakSet())); + typed.add(ref); + } + return result; +} + function rustTypedReceiverCall(receiver: string, method: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | { unread: number } | undefined { const scopes = rustScopesOf(ref.filePath, context); if (!scopes) return undefined; @@ -18525,7 +18626,13 @@ export function matchMethodCall( // 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); + // The field's type as the impl's struct declares it, read as any written + // type is (matchRustTypedSelfFieldCall); the plain-name reading below + // only where that can't be told. + const field = objectOrClass!.slice('self.'.length); + const typed = nmTimedT('mc-rustselffield', ref, () => matchRustTypedSelfFieldCall(field, methodName!, ref, context)); + if (typed !== undefined) return typed; + return matchRustSelfFieldCall(field, methodName!, ref, context); } // TS/JS call through a field of the enclosing class — `this.mailer.send()`,