From 62b1491134d197e133fecbae9dbe2de4b64f15a9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 29 Oct 2026 15:52:26 -0500 Subject: [PATCH] fix(rust): calls through a type a path or a use names reach that type's own function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Rust call through a type, `sim::items::ItemPool::for_def(…)` into another crate of the workspace or `ItemPool::for_def(…)` under `use sim::items::ItemPool;`, was matched by its qualified name alone, so a namesake type's function in another crate or module could take it (a workspace's `client` crate's `ItemPool::for_def`). The module walk stopped at the type, a `use` of a `pub const` or `static` (`use sim::RANK_COUNT;`) linked nothing, and a type rustfmt wrapped below its path (`h: &scheduler::\n Handle`) linked by the file's `use` of its last name. The fix, on the module walk: - rustPathTarget reads `Type::member` calls: the type is the item the path's modules name, or the one the `use` that binds its name names (rustUseItemsAt), and rustTypeMember finds its member: an enum's variant; else the type's own (inherent) function or `const` in the type's crate, a trait impl's only when it is the one of that name; a trait's own declared method. A type alias, `Self`, a type the module declares itself and a type path in any other position are left as they were. - Module items include `variable`s (a module-level `pub const`/`static`). - matchRustUseBoundName reads a qualifier written on the line above a type (rust-scopes' blanked code, so a commented-out path is none) and resolves the whole path. - Memoized per (type, member) and per path. - Manifest paths stay inside the project: a workspace member that leaves it (`../sibling`, an absolute path) is neither read nor listed (escapesRoot, in expandMembers), and rustCrateGraph drops a `[lib]`/`[[bin]]` path that leaves it, as it already did a dependency's. Validation: - rust-cross-crate.test.ts (PR 2631's 9 cases, credited), rust-type-member-paths.test.ts and rust-manifest-paths.test.ts: on main 10 of 15 fail; all pass. Every rust-* suite passes. On main the workspace map read `../outside/Cargo.toml`, `../vendor/x/Cargo.toml` and `/abs/crate/Cargo.toml` and listed `../vendor`; now only the project's. - A/B against main, native kernel, 7 Rust repos (every site the same as against PR #3014's own commit; re-checked on 60b0d7a9): tokio +205 links / 139 retargeted / 230 chains gaining their head call, ripgrep +155 / 13 / 1, axum +25, clap +179 / 0 / 295, serde +28, dioxus +142 / 6 / 31, gleam +114 / 47 / 0; 0 removed. Retargets go to the type the code imports or its own crate's (tokio's Semaphore::new and loom mocks to tokio::sync, ripgrep's Error::from, gleam's Printer::new); express and flask byte-identical. - Cost, native kernel, medians of 3 interleaved runs (dioxus of 7): tokio CPU 32.1 -> 29.7 s, peak RSS 1,000 -> 1,010 MB; gleam 29.1 -> 28.1 s, 1,010 -> 1,004 MB; dioxus 38.7 -> 40.1 s, 1,111 -> 1,115 MB (timed on one thread, rustPathTarget 1.24 -> 1.32 s over 17,198 calls). Builds on PR #2631 by @BGamboa13. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-cross-crate.test.ts | 242 +++++++++++++++++++ __tests__/rust-manifest-paths.test.ts | 60 +++++ __tests__/rust-type-member-paths.test.ts | 91 +++++++ src/resolution/frameworks/cargo-workspace.ts | 4 + src/resolution/name-matcher.ts | 156 ++++++++++-- 6 files changed, 532 insertions(+), 22 deletions(-) create mode 100644 __tests__/rust-cross-crate.test.ts create mode 100644 __tests__/rust-manifest-paths.test.ts create mode 100644 __tests__/rust-type-member-paths.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 492f28af5..842a67fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328) - In Rust, a call written through a module path, like `crate::util::take(3)`, `super::util::take(3)` or `util::take(3)`, and a `use` of a function, like `use crate::util::take;`, now link to the function that module declares. Before, they could land on a method of the same name declared above it in that file, like an `impl Buf`'s `take`, or on a function written inside another function, and a call through a module the standard library provides, like `fs::read(…)` under `use std::fs;`, could link to your own `fs` module's `read`. Re-index Rust projects after upgrading. Thanks @danusha2345. (#2308) - In Rust, a name a `use` renames, like `consume()` after `use crate::util::take as consume;`, now links to the item its path names, and a `use` written inside a function or a block counts only there, ahead of the file's own `use`s and items, so `fn first() { use crate::util::take; take() }` reaches util's `take` and the next function's own `use` its own. A call to a parameter, a closure or a local that shares a function's name, like `take()` inside `fn run(take: fn())` or after `let take = || {};`, no longer links to that function, while `let test = test::;` still calls `test`, and a `use` of an enum's variant, like `use crate::models::Mode::Fast;`, links `Fast(…)` to that enum's variant. A path through a module, like `super::helper()`, `crate::config()`, `super::shared::helper()` inside a `mod tests { … }` or `tokio::task::yield_now()`, now reaches the item that module declares or passes on with a `pub use`, in your own crate or another crate of the workspace, and its `use` links to it; that includes binaries whose manifest gives them a path of their own, like ripgrep's, and packages whose manifest lists `name` after other fields, like axum-core's. A `use` written inside a string no longer counts. Re-index Rust projects after upgrading. Thanks @danusha2345. (#2308) +- In Rust, a call through a type that a path or a `use` names, like `sim::items::ItemPool::for_def(…)` into another crate of your workspace, `ItemPool::for_def(…)` under `use sim::items::ItemPool;` or `crate::models::Mode::Fast(1)`, now links to that type's own function or variant, even when a type of the same name in another crate or module has one too, and a `use` of a `pub const` or `static`, like `use sim::RANK_COUNT;`, links to it. A type written on the line below its module path, as rustfmt wraps a long signature (`scheduler::` then `Handle`), now links by that path rather than by a `use` of its name. A workspace member listed outside the project, like `../shared`, is no longer read. Re-index Rust projects after upgrading. Thanks @BGamboa13. (#2631) - Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326) - In Play projects, a route or a call like `MediaType.parse(...)` now links to a method of the class or object it names, and a call to an overloaded one to the overload its arguments fit. Before, it went to the first method of that name anywhere in the class's file, often a nested or sibling class's. When a Scala and a Java type share a name, as Play's own `RequestAuthority` does, each language's calls now reach their own. Thanks @danusha2345. - In Spring projects, a route now links to the method its mapping annotation is written on when that method has no access modifier, like `@GetMapping List
list()` in a package-private controller, and a method-level `@RequestMapping(value = "/feed", method = GET)` on such a method now makes a route. Before, such a handler was skipped, so its route linked the next method declared `public`, `private` or `protected`, or nothing, and the handler looked unused. An overloaded handler under a tall stack of annotations, like `@Operation` and `@ApiResponses` above `@GetMapping`, now links the overload it annotates rather than its neighbour. Re-index Spring projects after upgrading. Thanks @Liurchen. diff --git a/__tests__/rust-cross-crate.test.ts b/__tests__/rust-cross-crate.test.ts new file mode 100644 index 000000000..685b76bae --- /dev/null +++ b/__tests__/rust-cross-crate.test.ts @@ -0,0 +1,242 @@ +/** + * Cross-crate references in a Cargo workspace: a path written from one member + * crate into another (`sim::items::ItemPool::for_def(…)`), through a re-export + * (`pub use rng::Rng`), through a renamed or non-member path dependency, and + * the `crate::` / `super::` paths inside a member crate. Cases from PR #2631 + * by @BGamboa13. + */ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; + +let root: string; +let cg: CodeGraph | undefined; +beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-cross-crate-')); }); +afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); }); + +async function index(files: Record) { + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + cg = await CodeGraph.init(root, { index: true }); +} + +/** `file:qualifiedName` of everything that has an edge of `kind` into the named symbol. */ +function sources(file: string, qualifiedName: string, kind = 'calls'): string[] { + const target = cg!.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName); + expect(target, `${file}:${qualifiedName}`).toBeDefined(); + return cg!.getIncomingEdges(target!.id).filter((e) => e.kind === kind).map((e) => { + const src = cg!.getNode(e.source)!; + return `${src.filePath}:${src.qualifiedName}`; + }).sort(); +} + +const WORKSPACE = `[workspace] +resolver = "2" +members = ["crates/sim", "crates/server", "crates/client"] + +[workspace.dependencies] +sim = { path = "crates/sim" } +`; +const SIM_TOML = '[package]\nname = "sim"\nversion = "0.1.0"\nedition = "2021"\n'; +const SIM_LIB = `pub mod items; +mod rng; +pub use rng::Rng; +pub const RANK_COUNT: usize = 6; +pub fn median_u32(v: &[u32]) -> u32 { v[0] } +`; +const SIM_ITEMS = `pub struct ItemPool { pub n: u32 } +impl ItemPool { + pub fn for_def(def: u32) -> ItemPool { ItemPool { n: def } } +} +pub enum Drop { Gold(u32), Nothing } +pub fn roll_item(p: &ItemPool) -> u32 { p.n } +`; +const SIM_RNG = 'pub struct Rng { s: u64 }\nimpl Rng {\n pub fn new(s: u64) -> Rng { Rng { s } }\n}\n'; + +const base = (server: Record, serverToml = '[package]\nname = "server"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nsim.workspace = true\n') => ({ + 'Cargo.toml': WORKSPACE, + 'crates/sim/Cargo.toml': SIM_TOML, + 'crates/sim/src/lib.rs': SIM_LIB, + 'crates/sim/src/items.rs': SIM_ITEMS, + 'crates/sim/src/rng.rs': SIM_RNG, + // A decoy: the same type and method names in another member crate. + 'crates/client/Cargo.toml': '[package]\nname = "client"\nversion = "0.1.0"\nedition = "2021"\n', + 'crates/client/src/lib.rs': 'pub mod items;\n', + 'crates/client/src/items.rs': 'pub struct ItemPool;\nimpl ItemPool {\n pub fn for_def(def: u32) -> ItemPool { ItemPool }\n}\npub fn roll_item() -> u32 { 0 }\n', + 'crates/server/Cargo.toml': serverToml, + ...Object.fromEntries(Object.entries(server).map(([f, t]) => [`crates/server/src/${f}`, t])), +}); + +it('resolves a fully qualified call into another workspace crate to that crate', async () => { + await index(base({ + 'lib.rs': 'pub mod rift;\npub mod econ;\n', + 'rift.rs': 'pub fn open() -> u32 {\n let pool = sim::items::ItemPool::for_def(3);\n sim::items::roll_item(&pool)\n}\n', + 'econ/mod.rs': 'pub mod offline;\n', + 'econ/offline.rs': 'pub fn tick() {\n let _ = sim::items::ItemPool::for_def(\n 4,\n );\n}\n', + })); + expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual([ + 'crates/server/src/econ/offline.rs:tick', + 'crates/server/src/rift.rs:open', + ]); + expect(sources('crates/sim/src/items.rs', 'roll_item')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/client/src/items.rs', 'ItemPool::for_def')).toEqual([]); +}); + +it('resolves a crate-root item, a re-export, a constant, and an enum variant across crates', async () => { + await index(base({ + 'lib.rs': 'pub mod rift;\n', + 'rift.rs': `use sim::RANK_COUNT; +pub fn open(v: &[u32]) -> usize { + let _r = sim::Rng::new(7); + let _m = sim::median_u32(v); + let _d = sim::items::Drop::Gold(3); + RANK_COUNT +} +`, + })); + expect(sources('crates/sim/src/rng.rs', 'Rng::new')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/lib.rs', 'median_u32')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/items.rs', 'Drop::Gold')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/lib.rs', 'RANK_COUNT', 'imports')).toEqual(['crates/server/src/rift.rs:crates/server/src/rift.rs']); +}); + +it('resolves `use` imports of another crate and the calls through them', async () => { + await index(base({ + 'lib.rs': 'pub mod rift;\n', + 'rift.rs': `use sim::items::{roll_item, ItemPool}; +use sim::Rng; +pub fn open() -> u32 { + let _r = Rng::new(1); + roll_item(&ItemPool::for_def(2)) +} +`, + })); + expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/items.rs', 'roll_item')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/rng.rs', 'Rng::new')).toEqual(['crates/server/src/rift.rs:open']); + expect(sources('crates/sim/src/items.rs', 'ItemPool', 'imports').length).toBeGreaterThan(0); + expect(sources('crates/sim/src/rng.rs', 'Rng', 'imports').length).toBeGreaterThan(0); +}); + +it('resolves a renamed path dependency and a path dependency outside the workspace members', async () => { + await index({ + ...base( + { 'lib.rs': 'pub fn open() -> u32 {\n let p = core_sim::items::ItemPool::for_def(1);\n vendored::helper(p.n)\n}\n' }, + '[package]\nname = "server"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\ncore_sim = { path = "../sim", package = "sim" }\nvendored = { path = "../../libs/vendored" }\n', + ), + 'libs/vendored/Cargo.toml': '[package]\nname = "vendored"\nversion = "0.1.0"\nedition = "2021"\n', + 'libs/vendored/src/lib.rs': 'pub fn helper(n: u32) -> u32 { n }\n', + }); + expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual(['crates/server/src/lib.rs:open']); + expect(sources('libs/vendored/src/lib.rs', 'helper')).toEqual(['crates/server/src/lib.rs:open']); +}); + +it('resolves `crate::` and `super::` paths inside a member crate, constants included', async () => { + await index(base({ + 'lib.rs': 'pub mod ledger;\npub mod econ;\n', + 'ledger.rs': 'pub const KEY_RANK_COUNT: usize = 6;\npub static LIMIT: u32 = 9;\npub fn persist() {}\n', + 'econ/mod.rs': 'pub mod sinks;\npub fn shared() {}\n', + 'econ/sinks.rs': `use crate::ledger::{KEY_RANK_COUNT, LIMIT}; +pub fn drain() -> usize { + crate::ledger::persist(); + super::shared(); + KEY_RANK_COUNT + LIMIT as usize +} +`, + })); + expect(sources('crates/server/src/ledger.rs', 'persist')).toEqual(['crates/server/src/econ/sinks.rs:drain']); + expect(sources('crates/server/src/econ/mod.rs', 'shared')).toEqual(['crates/server/src/econ/sinks.rs:drain']); + expect(sources('crates/server/src/ledger.rs', 'KEY_RANK_COUNT', 'imports').length).toBeGreaterThan(0); + expect(sources('crates/server/src/ledger.rs', 'LIMIT', 'imports').length).toBeGreaterThan(0); +}); + +it('resolves a module-qualified type by its own path, not through a same-name `use` import', async () => { + // tokio shape: `scheduler::Handle` / `driver::Handle` in a file that also + // `use`s a third `Handle` — the qualifier decides, never the file-level binding. + await index(base({ + 'lib.rs': 'pub mod scheduler;\npub mod driver;\npub mod multi;\n', + 'scheduler.rs': 'pub enum Handle { A, B }\n', + 'driver.rs': 'pub struct Handle { pub n: u32 }\n', + 'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n', + 'multi/handle.rs': 'pub struct Handle { pub n: u32 }\n', + 'user.rs': `use crate::multi::Handle; +use crate::{driver, scheduler}; +pub fn sched(h: &scheduler::Handle) -> u32 { match h { scheduler::Handle::A => 1, _ => 2 } } +pub fn drv(h: &driver::Handle) -> u32 { h.n } +pub fn own(h: &Handle) -> u32 { h.n } +`, + })); + const refs = (file: string, qn: string) => sources(file, qn, 'references'); + expect(refs('crates/server/src/scheduler.rs', 'Handle')).toContain('crates/server/src/user.rs:sched'); + expect(refs('crates/server/src/driver.rs', 'Handle')).toEqual(['crates/server/src/user.rs:drv']); + expect(refs('crates/server/src/multi/handle.rs', 'Handle')).not.toContain('crates/server/src/user.rs:sched'); + expect(refs('crates/server/src/multi/handle.rs', 'Handle')).not.toContain('crates/server/src/user.rs:drv'); + expect(refs('crates/server/src/multi/handle.rs', 'Handle')).toContain('crates/server/src/user.rs:own'); +}); + +it('reads a module qualifier written across lines, instead of falling back to the same-name `use` import', async () => { + await index(base({ + 'lib.rs': 'pub mod scheduler;\npub mod multi;\n', + 'scheduler.rs': 'pub enum Handle { A, B }\n', + 'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n', + 'multi/handle.rs': 'pub struct Handle { pub n: u32 }\n', + 'user.rs': `use crate::multi::Handle; +use crate::scheduler; +pub fn sched( + h: &scheduler:: + Handle, +) -> u32 { 1 } +`, + })); + expect(sources('crates/server/src/scheduler.rs', 'Handle', 'references')).toContain('crates/server/src/user.rs:sched'); + expect(sources('crates/server/src/multi/handle.rs', 'Handle', 'references')).not.toContain('crates/server/src/user.rs:sched'); +}); + +it('does not read a commented-out path above a leaf as its qualifier', async () => { + await index(base({ + 'lib.rs': 'pub mod scheduler;\npub mod multi;\n', + 'scheduler.rs': 'pub struct Handle { pub n: u32 }\nimpl Handle { pub fn new() -> Handle { Handle { n: 0 } } }\n', + 'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n', + 'multi/handle.rs': 'pub struct Handle { pub n: u32 }\nimpl Handle { pub fn new() -> Handle { Handle { n: 1 } } }\n', + 'user.rs': `use crate::multi::Handle; +pub fn make() -> Handle { + // crate::scheduler:: + Handle { n: 3 } +} +pub fn make_block() -> Handle { + /* crate::scheduler:: */ + Handle { n: 4 } +} +`, + })); + /** Every file:function with any edge into the file's `Handle` type or its `new`. */ + const touches = (file: string) => cg!.getNodesInFile(file) + .filter((n) => n.name === 'Handle' || n.name === 'new') + .flatMap((n) => cg!.getIncomingEdges(n.id).map((e) => cg!.getNode(e.source)!)) + .map((src) => `${src.filePath}:${src.qualifiedName}`); + for (const fn of ['make', 'make_block']) { + expect(touches('crates/server/src/scheduler.rs')).not.toContain(`crates/server/src/user.rs:${fn}`); + expect(touches('crates/server/src/multi/handle.rs')).toContain(`crates/server/src/user.rs:${fn}`); + } +}); + +it('leaves a path-dependency key unresolved when two crates bind it to different directories', async () => { + const pkg = (name: string, dep = '') => `[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n${dep}`; + await index({ + 'Cargo.toml': '[workspace]\nmembers = ["crates/server", "crates/client"]\n', + 'crates/server/Cargo.toml': pkg('server', '\n[dependencies]\nutil = { path = "../../libs/util_a", package = "util_a" }\n'), + 'crates/server/src/lib.rs': 'pub fn s() -> u32 { util::unique_a(1) }\n', + 'crates/client/Cargo.toml': pkg('client', '\n[dependencies]\nutil = { path = "../../libs/util_b", package = "util_b" }\n'), + 'crates/client/src/lib.rs': 'pub fn c() -> u32 { util::unique_b(2) }\n', + 'libs/util_a/Cargo.toml': pkg('util_a'), + 'libs/util_a/src/lib.rs': 'pub fn unique_a(n: u32) -> u32 { n }\npub fn unique_b(n: u32) -> u32 { n + 1 }\n', + 'libs/util_b/Cargo.toml': pkg('util_b'), + 'libs/util_b/src/lib.rs': 'pub fn unique_b(n: u32) -> u32 { n }\n', + }); + // `client`'s `util` is util_b — it must never be answered with util_a's `unique_b`. + expect(sources('libs/util_a/src/lib.rs', 'unique_b')).not.toContain('crates/client/src/lib.rs:c'); +}); diff --git a/__tests__/rust-manifest-paths.test.ts b/__tests__/rust-manifest-paths.test.ts new file mode 100644 index 000000000..c5535cc91 --- /dev/null +++ b/__tests__/rust-manifest-paths.test.ts @@ -0,0 +1,60 @@ +/** + * A Cargo workspace's members are read only inside the project: a member a + * manifest writes as `../sibling`, or as an absolute path, is neither read + * nor listed, so the cross-crate paths built on them stay in the project. + */ +import { describe, expect, it } from 'vitest'; +import * as path from 'node:path'; +import { getCargoWorkspaceCrateMap } from '../src/resolution/frameworks/cargo-workspace'; +import type { ResolutionContext } from '../src/resolution/types'; +import { escapesRoot } from '../src/utils'; + +function stubContext(files: Record, dirs: Record) { + const read: string[] = []; + const listed: string[] = []; + const context = { + readFile: (p: string) => { + read.push(p); + return files[p] ?? null; + }, + listDirectories: (p: string) => { + listed.push(p); + return dirs[p] ?? []; + }, + fileExists: (p: string) => p in files, + } as unknown as ResolutionContext; + return { context, read, listed }; +} + +const outside = (p: string): boolean => escapesRoot(path.posix.normalize(p)); + +describe('Cargo workspace members stay inside the project', () => { + it('reads and lists no member outside the project', () => { + const { context, read, listed } = stubContext( + { + 'Cargo.toml': '[workspace]\nmembers = ["crates/*", "app", "../outside", "../vendor/*", "/abs/crate", "crates/../../escape"]\n', + 'crates/core/Cargo.toml': '[package]\nname = "core-crate"\n', + 'app/Cargo.toml': '[package]\nname = "app"\n', + '../outside/Cargo.toml': '[package]\nname = "outside"\n', + '/abs/crate/Cargo.toml': '[package]\nname = "abs"\n', + }, + { crates: ['core'], '../vendor': ['x'] }, + ); + const map = getCargoWorkspaceCrateMap(context); + expect([...map.keys()].sort()).toEqual(['app', 'core-crate', 'core_crate']); + expect(read.filter(outside)).toEqual([]); + expect(listed.filter(outside)).toEqual([]); + }); + + it('reads a member whose folder only starts with two dots', () => { + const { context, read } = stubContext( + { + 'Cargo.toml': '[workspace]\nmembers = ["..generated/crate"]\n', + '..generated/crate/Cargo.toml': '[package]\nname = "generated"\n', + }, + {}, + ); + expect([...getCargoWorkspaceCrateMap(context).keys()]).toEqual(['generated']); + expect(read).toContain('..generated/crate/Cargo.toml'); + }); +}); diff --git a/__tests__/rust-type-member-paths.test.ts b/__tests__/rust-type-member-paths.test.ts new file mode 100644 index 000000000..bde513053 --- /dev/null +++ b/__tests__/rust-type-member-paths.test.ts @@ -0,0 +1,91 @@ +/** + * A Rust call through a type a path or a `use` names — `Handle::current()` + * under `use crate::runtime::Handle;`, `crate::models::Mode::Fast(1)`, + * `Codec::encode(&x)` under `use crate::codec::Codec;` — is to that type's + * own associated function, variant, or (for a trait) declared method, never a + * namesake type's in another module. A type from the standard library or a + * dependency links nothing, and a type alias is left as it was. + */ +import { afterAll, beforeAll, describe, expect, it } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-type-member-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nserde_json = "1"\n', + 'src/lib.rs': 'pub mod runtime;\npub mod driver;\npub mod models;\npub mod codec;\npub mod alias;\npub mod user;\n', + 'src/runtime/mod.rs': 'mod handle;\npub use handle::Handle;\n', + 'src/runtime/handle.rs': 'pub struct Handle;\n\nimpl Handle {\n pub fn current() -> Handle {\n Handle\n }\n}\n', + 'src/driver.rs': 'pub struct Handle;\n\nimpl Handle {\n pub fn current() -> Handle {\n Handle\n }\n}\n', + 'src/models.rs': 'pub enum Mode {\n Fast(u8),\n Slow,\n}\n\npub struct Fast(pub u8);\n', + 'src/codec.rs': 'pub trait Codec {\n fn encode(&self) -> u8;\n}\n\npub struct Bytes;\n\nimpl Codec for Bytes {\n fn encode(&self) -> u8 {\n 1\n }\n}\n', + 'src/alias.rs': 'pub type Shortcut = crate::driver::Handle;\n', + 'src/user.rs': `use std::collections::HashMap; +use serde_json::Value; +use crate::runtime::Handle; +use crate::codec::{Bytes, Codec}; +use crate::alias::Shortcut; + +pub fn current() { + let _ = Handle::current(); +} + +pub fn variant() { + let _ = crate::models::Mode::Fast(1); +} + +pub fn through_trait() { + let _ = Codec::encode(&Bytes); +} + +pub fn outside() { + let _ = HashMap::::new(); + let _ = Value::from(1); +} + +pub fn through_alias() { + let _ = Shortcut::current(); +} +`, + }; + for (const [relative, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }); + fs.writeFileSync(path.join(root, relative), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const calls = (name: string): string[] => { + const fn = cg.getNodesInFile('src/user.rs').find((n) => n.name === name && n.kind === 'function')!; + return cg.getOutgoingEdgesFrom([fn.id]).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.qualifiedName} ${t.filePath}:${t.startLine}`).sort(); +}; + +describe('Rust calls through a type a path or a use names', () => { + it('reach that type\'s own function, not a namesake type\'s', () => { + expect(calls('current')).toEqual(['method Handle::current src/runtime/handle.rs:4']); + }); + + it('reach an enum\'s variant through a module path', () => { + expect(calls('variant')).toEqual(['enum_member Mode::Fast src/models.rs:2']); + }); + + it('reach a trait\'s own method through the trait', () => { + expect(calls('through_trait')).toEqual(['method Codec::encode src/codec.rs:2']); + }); + + it('link nothing for a standard library or dependency type', () => { + expect(calls('outside')).toEqual([]); + }); +}); diff --git a/src/resolution/frameworks/cargo-workspace.ts b/src/resolution/frameworks/cargo-workspace.ts index 6c47f5be0..9a161c913 100644 --- a/src/resolution/frameworks/cargo-workspace.ts +++ b/src/resolution/frameworks/cargo-workspace.ts @@ -7,8 +7,10 @@ * into workspace member crates. */ +import * as path from 'path'; import picomatch from 'picomatch'; import { ResolutionContext } from '../types'; +import { escapesRoot } from '../../utils'; const GLOB_CHARS = /[*?[\]{}!]/; const SKIP_DIRS = new Set(['target', 'node_modules', '.git', 'dist', 'build']); @@ -224,6 +226,8 @@ function expandMembers(members: string[], context: ResolutionContext): string[] const expanded: string[] = []; const seen = new Set(); for (const member of members) { + // A member outside the project (`../shared`, an absolute path) is neither read nor listed. + if (escapesRoot(path.posix.normalize(member.replace(/\\/g, '/')))) continue; const candidates = GLOB_CHARS.test(member) ? expandGlobMember(member, context) : [member]; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index eba5b22c9..7a5d027b8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Language, Node, IMPLICIT_RECORD_ACCESSOR_DOC } from '../types'; -import { escapeRegExp } from '../utils'; +import { escapeRegExp, escapesRoot } from '../utils'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { isJsSuperCallSite } from './js-super-calls'; @@ -8878,6 +8878,12 @@ function rustCrateGraph(context: ResolutionContext): RustCrateGraph { const crates = new Map(); const manifests = new Map(); const byName = new Map(); + // A path a manifest in `dir` writes, as a project path; null when it leaves + // the project, which nothing here reads or names. + const join = (dir: string, rel: string): string | null => { + const joined = path.posix.normalize(dir === '' ? rel : `${dir}/${rel}`).replace(/\/+$/, ''); + return joined === '.' ? '' : escapesRoot(joined) ? null : joined; + }; const add = (dir: string): boolean => { if (crates.has(dir)) return true; const text = context.readFile(dir === '' ? 'Cargo.toml' : `${dir}/Cargo.toml`); @@ -8885,21 +8891,20 @@ function rustCrateGraph(context: ResolutionContext): RustCrateGraph { const name = text ? parsePackageName(text)?.replace(/-/g, '_') : undefined; if (!name) return false; const targets = parseTargetPaths(text!); - const file = (rel: string): string => path.posix.normalize(dir === '' ? rel : `${dir}/${rel}`); - crates.set(dir, { name, keys: new Map(), deps: new Set(), lib: targets.lib && file(targets.lib), bins: targets.bins.map(file) }); + const lib = targets.lib === undefined ? null : join(dir, targets.lib); + const bins = targets.bins.map((bin) => join(dir, bin)).filter((bin): bin is string => bin !== null); + crates.set(dir, { name, keys: new Map(), deps: new Set(), lib: lib ?? undefined, bins }); manifests.set(dir, text!); if (!byName.has(name)) byName.set(name, dir); return true; }; add(''); - for (const dir of getCargoWorkspaceCrateMap(context).values()) add(dir.replace(/^\.(?:\/|$)/, '')); + for (const member of getCargoWorkspaceCrateMap(context).values()) { + const dir = join('', member); + if (dir !== null) add(dir); + } const inherited = new Map(rustManifestDependencies(context.readFile('Cargo.toml') ?? '') .filter((dep) => dep.kind === 'workspace').map((dep) => [dep.key, dep])); - // A path from `dir`, as a project directory; null when it leaves the project. - const join = (dir: string, rel: string): string | null => { - const joined = path.posix.normalize(dir === '' ? rel : `${dir}/${rel}`).replace(/\/+$/, ''); - return joined === '.' ? '' : joined === '..' || joined.startsWith('../') || path.posix.isAbsolute(joined) ? null : joined; - }; const queue = [...crates.keys()]; for (let i = 0; i < queue.length; i++) { const dir = queue[i]!; @@ -9912,11 +9917,28 @@ export function matchRustUseBoundName(ref: UnresolvedRef, context: ResolutionCon // or last segment — and a call's own name, with its arguments after it: a // chained call is recorded where its receiver starts, which may be a local // of the method's name (`deadline.as_ref().deadline()`). - const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n'); + const line = lines?.[ref.line - 1]; if (line === undefined || !line.startsWith(name, ref.column) || /(?:\.|::)\s*$/.test(line.slice(0, ref.column))) return undefined; const after = line.slice(ref.column + name.length).trimStart(); if (ref.referenceKind === 'calls' ? !/^(?:\(|::\s*<)/.test(after) : /^(?:\.|::)/.test(after)) return undefined; const offset = rustRefOffset(ref, context); + // A type rustfmt wrapped below its path — `h: &scheduler::\n Handle` — is + // the path's, not the `use`'s of its last name: read back over the line + // break, comments blanked (`// crate::scheduler::` above it is no path). + if (ref.referenceKind === 'references' && /^\s*$/.test(line.slice(0, ref.column)) && ref.line > 1 && + /::\s*(?:\/\/.*|\/\*.*)?$/.test(lines![ref.line - 2] ?? '')) { + const scopes = rustScopesOf(ref.filePath, context); + const qualifier = scopes ? rustWrittenQualifier(scopes.code, offset) : []; + if (qualifier.length > 0) { + const target = rustPathTarget({ ...ref, referenceName: [...qualifier, name].join('::') }, context); + if (!target) return undefined; + let scoped = RUST_SCOPED_REFS.get(context); + if (!scoped) RUST_SCOPED_REFS.set(context, (scoped = new WeakSet())); + scoped.add(ref); + return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }; + } + } const visible = rustVisibleUse(name, ref.filePath, offset, context); const local = ref.referenceKind === 'references' ? null : rustShadowingLocal(name, ref, offset, visible, context); let target: Node | null | undefined; @@ -9947,6 +9969,31 @@ export function matchRustUseBoundName(ref: UnresolvedRef, context: ResolutionCon /** References matchRustUseBoundName answered from the scope around them: a rename, a `let` that holds a function. */ const RUST_SCOPED_REFS = new WeakMap>(); +/** + * The module path written right before `offset` in a Rust file's blanked + * code, read back over whitespace and line breaks: `scheduler::` before + * `Handle` gives `['scheduler']`. Empty when a `::` follows no plain name + * (a turbofish's `>::`, a qualified self). + */ +function rustWrittenQualifier(code: string, offset: number): string[] { + const segs: string[] = []; + let i = offset; + for (;;) { + let j = i - 1; + while (j >= 0 && /\s/.test(code[j]!)) j--; + if (j < 1 || code[j] !== ':' || code[j - 1] !== ':') break; + j -= 2; + while (j >= 0 && /\s/.test(code[j]!)) j--; + let k = j; + while (k >= 0 && /\w/.test(code[k]!)) k--; + const seg = code.slice(k + 1, j + 1); + if (!/^[A-Za-z_]\w*$/.test(seg)) return []; + segs.unshift(seg); + i = k + 1; + } + return segs; +} + /** * The local a bare name at `offset` of `ref`'s function is there (rust-scopes.ts * reads the bindings): a parameter, a `let`, a closure's, a pattern's — unless @@ -9983,8 +10030,12 @@ function rustShadowingLocal(name: string, ref: UnresolvedRef, offset: number, vi return local; } -/** Kinds a Rust path to a module's item names: never a method, nor a variable (an impl's `const`, a `static`). */ -const RUST_PATH_ITEM_KINDS: ReadonlySet = new Set(['function', 'struct', 'union', 'enum', 'trait', 'type_alias', 'constant']); +/** + * Kinds a Rust path to a module's item names: a module-level `pub const` or + * `static` (extracted as a variable) too, never a method — the module's own + * items only (rustDeclaredItems), so never an impl's `const`. + */ +const RUST_PATH_ITEM_KINDS: ReadonlySet = new Set(['function', 'struct', 'union', 'enum', 'trait', 'type_alias', 'constant', 'variable']); const RUST_PATH_TARGETS = new WeakMap>(); @@ -10005,11 +10056,16 @@ export function rustPathTarget(ref: UnresolvedRef, context: ResolutionContext): const segs = ref.referenceName.replace(/^::/, '').split('::'); if (segs.length < 2 || !segs.every((s) => /^[A-Za-z_]\w*$/.test(s))) return null; const variants = ref.referenceKind === 'imports'; - // `Foo::new(…)`, `io::Error::new(…)`: through a type, by its name, to no - // module's item — only a `use` names a variant that way. (Rust names its - // modules in snake_case.) - if (!variants && /^[A-Z]/.test(segs[segs.length - 2]!)) return null; const offset = rustRefOffset(ref, context); + // `sim::items::ItemPool::for_def(…)`, `ItemPool::for_def(…)` under `use + // sim::items::ItemPool;`: a call through a type its path or a `use` names + // (Rust names its modules in snake_case, its types in CamelCase) is to that + // type's associated function or variant. A type the module declares itself, + // `Self`, and a type's path in any other position are left as they were. + const owner = segs[segs.length - 2]!; + const typed = !variants && /^[A-Z]/.test(owner); + const ownerUse = typed && segs.length === 2 ? rustVisibleUse(owner, ref.filePath, offset, context) : undefined; + if (typed && (ref.referenceKind !== 'calls' || owner === 'Self' || (segs.length === 2 && !ownerUse?.paths.length))) return null; const from = rustModuleAt(ref.filePath, offset, context); const head = segs[0]!; const bare = head !== 'crate' && head !== 'self' && head !== 'super'; @@ -10019,19 +10075,74 @@ export function rustPathTarget(ref: UnresolvedRef, context: ResolutionContext): } let memo = RUST_PATH_TARGETS.get(context); if (!memo) RUST_PATH_TARGETS.set(context, (memo = new Map())); - const key = `${ref.filePath}\0${from.block?.open ?? -1}\0${variants ? 1 : 0}\0${segs.join('::')}`; + // (A type a `use` binds is that `use`'s, wherever it is written.) + const scope = ownerUse ? `${ownerUse.block}\0${ownerUse.paths[0]!.at}` : ''; + const key = `${ref.filePath}\0${from.block?.open ?? -1}\0${variants ? 1 : 0}\0${scope}\0${segs.join('::')}`; const hit = memo.get(key); if (hit !== undefined) return hit === null ? null : context.getNodeById?.(hit) ?? null; - let items = rustUsePathItems(segs, from, context, new Set()); - if (items === null && bare) { - const root = rustCrateRootModule(ref.filePath, context); - if (root) items = rustUsePathItems(segs, root, context, new Set()); + // The items the path's segments name — up to its type's, for a typed one. + const named = (path: readonly string[]): Node[] | null => { + // A type a `use` binds, written alone: the item that `use` names. + if (path.length === 1) { + const ids = rustUseItemsAt(path[0]!, ref, context); + return ids === null ? null : [...ids].map((id) => context.getNodeById?.(id)).filter((n): n is Node => !!n); + } + let items = rustUsePathItems(path, from, context, new Set()); + if (items === null && bare) { + const root = rustCrateRootModule(ref.filePath, context); + if (root) items = rustUsePathItems(path, root, context, new Set()); + } + return items; + }; + let target: Node | null = null; + if (typed) { + for (const type of named(segs.slice(0, -1)) ?? []) { + target = rustTypeMember(type, segs[segs.length - 1]!, context); + if (target) break; + } + } else { + target = named(segs)?.find((n) => RUST_PATH_ITEM_KINDS.has(n.kind) || (variants && n.kind === 'enum_member')) ?? null; } - const target = items?.find((n) => RUST_PATH_ITEM_KINDS.has(n.kind) || (variants && n.kind === 'enum_member')) ?? null; memo.set(key, target?.id ?? null); return target; } +/** Kinds a type's associated item is extracted as: an impl's functions, a `const`, a variant. */ +const RUST_MEMBER_KINDS: ReadonlySet = new Set(['method', 'function', 'variable', 'constant', 'enum_member']); + +const RUST_TYPE_MEMBERS = new WeakMap>(); + +/** + * The associated item `member` of a Rust type, as `Type::member` reaches it: + * an enum's variant; else the type's own (inherent) function or `const`, a + * trait impl's only when it is the one item of that name; a trait's own + * declared method. An impl of the type is written in the type's crate, so a + * namesake type's impl in another crate is never it. Null when none is. + */ +function rustTypeMember(type: Node, member: string, context: ResolutionContext): Node | null { + if (type.language !== 'rust' || !RUST_DECLARED_TYPE_KINDS.has(type.kind) || type.kind === 'type_alias') return null; + let memo = RUST_TYPE_MEMBERS.get(context); + if (!memo) RUST_TYPE_MEMBERS.set(context, (memo = new Map())); + const key = `${type.id}\0${member}`; + const hit = memo.get(key); + if (hit !== undefined) return hit === null ? null : context.getNodeById?.(hit) ?? null; + let found: Node | null = null; + if (type.kind === 'enum') found = rustEnumVariants(type, member, context)[0] ?? null; + if (!found) { + const graph = rustCrateGraph(context); + const crate = rustCrateDirOf(type.filePath, graph); + const within = (n: Node): boolean => type.kind !== 'trait' || + (n.filePath === type.filePath && n.startLine >= type.startLine && n.startLine <= (type.endLine ?? type.startLine)); + const all = context.getNodesByName(member).filter((n) => n.language === 'rust' && RUST_MEMBER_KINDS.has(n.kind) && + n.qualifiedName === `${type.name}::${member}` && rustCrateDirOf(n.filePath, graph) === crate && within(n)); + const inherent = all.filter((n) => !isRustTraitImplMethod(n, context)); + const pool = inherent.length > 0 ? inherent : all.length === 1 ? all : []; + found = pool.find((n) => n.filePath === type.filePath) ?? pool[0] ?? null; + } + memo.set(key, found?.id ?? null); + return found; +} + /** * Whether a bare Rust name where `ref` is was brought in from the standard * library: by the `use` that binds it there (rustVisibleUse), or with none, @@ -20414,6 +20525,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUST_USE_ITEMS.delete(context); RUST_VISIBLE_ITEMS.delete(context); RUST_PATH_TARGETS.delete(context); + RUST_TYPE_MEMBERS.delete(context); RUST_SCOPED_REFS.delete(context); RUST_GLOB_ITEMS.delete(context); RUST_CRATES.delete(context);