diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e75921c..03df0fa4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -232,6 +232,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Swift, a call written after `!` or another prefix operator, or chained after `try` or `await`, now links to what it calls. `if !isReady()` links to `isReady`, `!store.contains(id)` and `try! JSONDecoder().decode(…)` link to the method of the type they're called on rather than to whichever `contains` or `decode` came first, and `!Settings.Defaults.isOn()` lands on the type it names. Re-index Swift projects after upgrading. - In Swift, a call on a local created through `try`, `try!`, `try?` or `await`, or with a generic type's constructor, now links to that type's method: after `let storage = try! DiskStorage.Backend(config: config)`, `storage.store(…)` links to `Backend`'s `store`. Before, such a local had no known type, so the call was left unlinked or matched by name to another type's method. Re-index Swift projects after upgrading. - In Rust, a call written `Self::new(…)`, `Self::from_std(…)` or `Self::Text(…)` now links to that function or variant of the type whose `impl` it is written in, even when other types have one of the same name. Before, such a call was linked only when nothing else in the project shared the name, so most `Self::new(…)` constructors and many helper calls were missing from callers and impact. Re-index Rust projects after upgrading. +- In Rust, a call through a generic type parameter, like `Key::get(…)` in protocolbuffers/protobuf's `fn map_get`, now links to the method the trait its bound names declares, or a trait that trait extends: `trait MapKey: … + FfiMapKey` makes it `FfiMapKey::get`, whether the bound is written in `<…>`, in a `where` clause or on the `impl`. A parameter bounded only by traits from outside your project, like `T::default()` under `T: Default`, no longer links to one of your types' unrelated `default` functions, and two bound traits that both declare the method link neither. Re-index Rust projects after upgrading. - In Rust, a call like `String::from("…")`, `Vec::from([…])` or `NonNull::from(x)` no longer links to your project's own `impl From for String` (or for `Vec`, `NonNull`, …) unless what it passes is a `YourType`. A method call on a standard-library value, like `files.iter()` or `buf.extend(…)`, links to your trait's implementation for that type only where the trait is imported. Before, every `String::from("…")` call in dioxus's CLI linked to one such conversion, and tokio's `vec.iter()` calls to an internal trait's `Vec` impl. Re-index Rust projects after upgrading. - In Rust, a method called through `self: Pin<&mut Self>`, `self: Box`, `self: Rc` or `self: Arc`, or on a value declared `Pin<&mut …>`, now links the way Rust looks it up: to the type's own method, like `self.poll_flush(cx)` inside tokio's `impl AsyncWrite for File`, rather than to a catch-all `impl AsyncWrite for Pin

`, while `Pin`'s own methods, like `self.get_mut()`, stay unlinked. A type's own method now also comes before a trait's of the same name, so tokio's `Arc::from_raw(…)` calls are the standard library's rather than its internal `Link` trait's, and a method called on a tuple, like serde's `(self.ip(), self.port()).serialize(…)`, no longer links to an unrelated type's. Before, most calls made through such a `self` linked to nothing. Re-index Rust projects after upgrading. - In Rust, a method called on what `.as_mut()` or `.as_ref()` hands back now links by the type its receiver is declared with: `self.delay.as_mut().reset(…)` on a `Pin>` field reaches `Sleep::reset`, a field pin-project hands out reaches its own type's method, and a `Box` or `NonNull` lends what it holds. A method on an `Option` reached this way, like `self.child.as_mut().map(…)`, or on a generic type no longer links to an unrelated project method that shares its name. Before, these calls were matched by name alone, so tokio's `self.delay.as_mut().reset(…)` linked to the calling `Interval::reset`, and dioxus's `Option` calls to its own `map` and `filter` helpers. Re-index Rust projects after upgrading. diff --git a/__tests__/rust-generic-bound-paths.test.ts b/__tests__/rust-generic-bound-paths.test.ts new file mode 100644 index 000000000..7760d6353 --- /dev/null +++ b/__tests__/rust-generic-bound-paths.test.ts @@ -0,0 +1,173 @@ +/** + * A Rust call through a generic type parameter — `Key::get(…)` in + * protocolbuffers/protobuf's `fn map_get` — is to the method the + * traits its bounds name declare, or the traits they extend: `trait MapKey: + * … + FfiMapKey` makes it `FfiMapKey::get`. + * + * Before, such a call linked only when a declaration's qualified name happened + * to end with the written text (`FfiMapKey::get` ends with `Key::get`), which + * #2723 stopped; a bound from outside the project (`T: Default`) let a guess by + * the method's name take a project type's unrelated `default`. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-rust-bound-paths-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => r.node.qualifiedName) + .sort(); +} + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +const CARGO = lines('[package]', 'name = "pb"', 'version = "0.1.0"', 'edition = "2021"'); + +/** protobuf's map key traits: the FFI methods live on a supertrait of the bound. */ +const KEYS = lines( + 'pub struct RawMap;', + '', + 'pub trait FfiMapKey: Sized {', + ' unsafe fn get(map: RawMap, key: Self, value: *mut u8) -> bool;', + ' unsafe fn insert(map: RawMap, key: Self, value: u8) -> bool;', + '}', + '', + 'pub trait MapKey: Clone + FfiMapKey {}', +); + +describe('a Rust call through a generic parameter reaches its bound trait\'s method', () => { + it('reproduction: `Key::get(…)` in `fn map_get` is FfiMapKey::get, through the supertrait', async () => { + const cg = await indexed({ + 'Cargo.toml': CARGO, + 'src/lib.rs': lines('pub mod keys;', 'pub mod map;'), + 'src/keys.rs': KEYS, + 'src/map.rs': lines( + 'use crate::keys::{MapKey, RawMap};', + '', + 'pub struct Map;', + '', + 'impl Map {', + ' pub fn map_get(key: Key) -> bool {', + ' unsafe { Key::get(RawMap, key, std::ptr::null_mut()) }', + ' }', + '}', + ), + }); + try { + expect(calls(cg, 'Map::map_get')).toEqual(['FfiMapKey::get']); + } finally { + cg.close(); + } + }); + + it('a bound in a `where` clause, and one on the `impl`, count the same', async () => { + const cg = await indexed({ + 'Cargo.toml': CARGO, + 'src/lib.rs': lines('pub mod keys;', 'pub mod map;'), + 'src/keys.rs': KEYS, + 'src/map.rs': lines( + 'use crate::keys::{MapKey, RawMap};', + '', + 'pub struct Map;', + '', + 'impl Map {', + ' pub fn map_insert(key: Key) -> bool', + ' where', + ' Key: MapKey,', + ' {', + ' unsafe { Key::insert(RawMap, key, 1) }', + ' }', + '}', + '', + 'pub struct Typed(K);', + '', + 'impl Typed {', + ' pub fn lookup(&self) -> bool {', + ' unsafe { K::get(RawMap, self.0.clone(), std::ptr::null_mut()) }', + ' }', + '}', + ), + }); + try { + expect(calls(cg, 'Map::map_insert')).toEqual(['FfiMapKey::insert']); + expect(calls(cg, 'Typed::lookup')).toEqual(['FfiMapKey::get']); + } finally { + cg.close(); + } + }); + + it('a bound from outside the project, `T: Default`, links no project `default`', async () => { + const cg = await indexed({ + 'Cargo.toml': CARGO, + 'src/lib.rs': lines( + 'pub struct Defaults;', + '', + 'impl Defaults {', + ' pub fn default() -> Defaults {', + ' Defaults', + ' }', + '}', + '', + 'pub fn make() -> T {', + ' T::default()', + '}', + ), + }); + try { + expect(calls(cg, 'make')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('two bound traits that both declare the method link neither', async () => { + const cg = await indexed({ + 'Cargo.toml': CARGO, + 'src/lib.rs': lines( + 'pub trait Engine {', + ' fn run() -> u8;', + '}', + '', + 'pub trait Task {', + ' fn run() -> u8;', + '}', + '', + 'pub fn start() -> u8 {', + ' T::run()', + '}', + ), + }); + try { + expect(calls(cg, 'start')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 50486f203..72fb0603d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9243,6 +9243,35 @@ function matchRustSelfPath(name: string, ref: UnresolvedRef, context: Resolution return { original: ref, targetNodeId: target.id, confidence: target.filePath === ref.filePath ? 0.9 : 0.8, resolvedBy: 'qualified-name' }; } +/** + * A Rust call through a generic type parameter of the function or `impl` + * around it — `Key::get(…)` in protobuf's `fn map_get`, with + * `trait MapKey: FfiMapKey` — is to the method one of the traits its bounds + * name declares, or one of the traits they extend: `FfiMapKey::get`. Bounds + * only from outside the project (`T: Default`) name no project method, nor do + * two traits that both declare it: null. Undefined when `name` is no generic + * parameter there. + */ +function matchRustGenericPath(name: string, method: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + const scopes = rustScopesOf(ref.filePath, context); + if (!scopes) return undefined; + const written: RustWrittenType = { text: name, file: ref.filePath, at: (scopes.lineStarts[ref.line - 1] ?? 0) + Math.max(0, ref.column) }; + const bounds = rustGenericBounds(name, written, context); + if (bounds === undefined) return undefined; + const traits = bounds.filter((b) => !/^['?]/.test(b.trim()) && !RUST_MARKER_TRAITS.has(rustPathHead(b)?.name ?? '')); + if (traits.length === 0) return null; + const result = rustTraitsMethodCall(traits, written, method, ref, context); + if (result === 'unread') return null; + // (The bound names the trait, so its method is reached as a typed call is: + // its missing `pub` doesn't hide it from another module.) + if (result) { + let typed = RUST_TYPED_REFS.get(context); + if (!typed) RUST_TYPED_REFS.set(context, (typed = new WeakSet())); + typed.add(ref); + } + return result; +} + /** * Whether `candidate` is declared directly in an inline `mod { … }` of * its own file — the only way a path through `name` reaches it, since an @@ -18745,6 +18774,11 @@ export function matchMethodCall( const own = matchRustSelfPath(methodName!, ref, context); if (own !== undefined) return own; } + // A Rust `T::x(…)` through a generic parameter: the method its bounds' traits declare (matchRustGenericPath). + if (ref.language === 'rust' && match === colonMatch && /^[A-Z]\w*$/.test(objectOrClass!) && objectOrClass !== 'Self') { + const bounded = matchRustGenericPath(objectOrClass!, methodName!, ref, context); + if (bounded !== undefined) return bounded; + } // Strategy 1: Direct class name match (existing logic). When the receiver // names a class that exists in several files (`Logger.log()` / `Logger::log()`