From eea046da1b0ad9e4c9843a400c48b6ecf062142c Mon Sep 17 00:00:00 2001 From: BGamboa13 Date: Sat, 17 Oct 2026 08:48:02 -0400 Subject: [PATCH] fix(rust): resolve paths into other crates of a Cargo workspace A call written with its path into another workspace crate, like `sim::items::ItemPool::for_def(...)`, never connected: only a bare imported name resolved, so callers/impact missed most cross-crate uses. Resolve a Rust path by walking the module tree from its anchor (`crate`, `self`, `super`, a workspace crate name, or the file's `use` binding), to an item or a `Type::member`, following `pub use` and glob re-exports. Cargo path dependencies (including renamed ones and non-members) map to their crate directory; a dependency key that two crates bind to different directories is left unresolved rather than guessed. A module-qualified type (`scheduler::Handle`) resolves by its own written path, read back across lines and ignoring comments, before any same-named `use` import. The Rust path code moves out of import-resolver.ts into rust-paths.ts. --- CHANGELOG.md | 1 + __tests__/rust-cross-crate.test.ts | 241 ++++++++++++ src/resolution/frameworks/cargo-workspace.ts | 92 ++++- src/resolution/import-resolver.ts | 203 +--------- src/resolution/rust-paths.ts | 394 +++++++++++++++++++ 5 files changed, 735 insertions(+), 196 deletions(-) create mode 100644 __tests__/rust-cross-crate.test.ts create mode 100644 src/resolution/rust-paths.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d50f1de81..fcbfcef5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading. +- In a Rust Cargo workspace, a call into another crate written with its path, like `sim::items::ItemPool::for_def(…)`, now links to that crate's definition. That covers `use sim::…` imports, `pub use` re-exports, constants, enum variants, `crate::` and `super::` paths, and crates pulled in as path dependencies, including renamed ones. Before, only a call through a bare imported name connected, so callers, impact and `codegraph_explore` missed most cross-crate uses. A type written through a module, like `scheduler::Handle`, keeps resolving by its own path rather than to a same-named `use` import, and a dependency name that two crates point at different folders is left unresolved instead of guessed. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/rust-cross-crate.test.ts b/__tests__/rust-cross-crate.test.ts new file mode 100644 index 000000000..2f84c913e --- /dev/null +++ b/__tests__/rust-cross-crate.test.ts @@ -0,0 +1,241 @@ +/** + * 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. + */ +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/src/resolution/frameworks/cargo-workspace.ts b/src/resolution/frameworks/cargo-workspace.ts index 2a81284bd..c8a45330c 100644 --- a/src/resolution/frameworks/cargo-workspace.ts +++ b/src/resolution/frameworks/cargo-workspace.ts @@ -214,12 +214,66 @@ function expandMembers(members: string[], context: ResolutionContext): string[] return expanded; } +/** Join a manifest directory and a relative `path = "…"`, posix and normalized; null when it leaves the project. */ +function joinInsideProject(manifestDir: string, relPath: string): string | null { + const parts: string[] = []; + for (const seg of `${manifestDir}/${relPath}`.replace(/\\/g, '/').split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + if (parts.length === 0) return null; + parts.pop(); + continue; + } + parts.push(seg); + } + return parts.length > 0 ? parts.join('/') : null; +} + +/** + * The `path` dependencies a manifest declares, by the name code writes them + * (the dependency KEY, which a `package = "…"` rename makes differ from the + * crate's own name): `sim = { path = "crates/sim" }` in `[dependencies]`, + * `[dev-dependencies]`, `[build-dependencies]`, target-specific tables, and a + * workspace root's `[workspace.dependencies]`; also the `[dependencies.sim]` + * table form. Paths resolve against the manifest's directory. + */ +function parsePathDependencies(cargoToml: string, manifestDir: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + let inDeps = false; + let tableDep: string | null = null; + for (const raw of cargoToml.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').trim(); + const header = /^\[([^\]]+)\]$/.exec(line); + if (header) { + const table = header[1]!.trim(); + tableDep = /(?:^|\.)(?:dev-|build-)?dependencies\.([A-Za-z0-9_-]+)$/.exec(table)?.[1] ?? null; + inDeps = /(?:^|\.)(?:dev-|build-)?dependencies$/.test(table); + continue; + } + if (tableDep) { + const p = /^path\s*=\s*["']([^"']+)["']/.exec(line)?.[1]; + const dir = p ? joinInsideProject(manifestDir, p) : null; + if (dir) out.push([tableDep, dir]); + continue; + } + if (!inDeps) continue; + const entry = /^([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*$/.exec(line); + const p = entry ? /\bpath\s*=\s*["']([^"']+)["']/.exec(entry[2]!)?.[1] : undefined; + const dir = p ? joinInsideProject(manifestDir, p) : null; + if (entry && dir) out.push([entry[1]!, dir]); + } + return out; +} + /** * Build a map from crate-name aliases to workspace member directory paths. * Example: "mytool-core" and "mytool_core" -> "crates/mytool-core" * * Supports glob members (e.g. `members = ["crates/*"]`) via picomatch - * when the context exposes `listDirectories`. + * when the context exposes `listDirectories`. Also maps every in-project + * `path` dependency the root or a member manifest declares, by its dependency + * key — a renamed (`core_sim = { path = "../sim", package = "sim" }`) or + * non-member (`vendor/foo`) crate is reached the same way a member is. */ export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map { const result = new Map(); @@ -228,11 +282,13 @@ export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map = [['', rootCargoToml]]; for (const memberPath of members) { const memberCargoPath = `${memberPath}/Cargo.toml`; const memberCargoToml = context.readFile(memberCargoPath); if (!memberCargoToml) continue; + manifests.push([memberPath, memberCargoToml]); const packageName = parsePackageName(memberCargoToml); if (!packageName) continue; @@ -240,5 +296,39 @@ export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map>(); + for (const [manifestDir, manifest] of manifests) { + for (const [depName, depDir] of parsePathDependencies(manifest, manifestDir)) { + if (result.has(depName) || !context.readFile(`${depDir}/Cargo.toml`)) continue; + const key = depName.replace(/-/g, '_'); + const dirs = depDirs.get(key) ?? new Set(); + dirs.add(depDir); + depDirs.set(key, dirs); + } + } + for (const [manifestDir, manifest] of manifests) { + for (const [depName, depDir] of parsePathDependencies(manifest, manifestDir)) { + if (result.has(depName) || !context.readFile(`${depDir}/Cargo.toml`)) continue; + if (depDirs.get(depName.replace(/-/g, '_'))!.size > 1) continue; + addCrateAlias(result, depName, depDir); + } + } + return result; } + +/** + * The directory holding a crate's library root (`lib.rs`), relative to the + * project: `[lib] path = "…"`'s directory when the manifest sets one, else + * `/src`. + */ +export function cargoCrateSourceDir(crateDir: string, context: ResolutionContext): string { + const base = crateDir === '.' ? '' : crateDir; + const lib = getSection(context.readFile(base ? `${base}/Cargo.toml` : 'Cargo.toml') ?? '', 'lib'); + const libPath = lib ? /^\s*path\s*=\s*["']([^"']+)["']/m.exec(lib)?.[1] : undefined; + const libDir = libPath ? joinInsideProject(base, libPath.replace(/\/?[^/]*$/, '')) : null; + return libDir ?? (base ? `${base}/src` : 'src'); +} diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 24a273290..edb3ccf1b 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -15,6 +15,7 @@ import { stripCommentsForRegex } from './strip-comments'; import { makeLineAt } from './synth-utils'; import { dartDirectiveFile } from './dart-libraries'; import { moduleTail } from '../db/reference-tail'; +import { clearRustPathMemos, collectRustUseBindings, resolveRustModuleFile, resolveRustPathReference } from './rust-paths'; import { resolveMethodOnType, resolveObjectLiteralMember, @@ -324,6 +325,7 @@ export function clearImportResolverMemos(context: ResolutionContext): void { pythonModuleFileMemos.delete(context); PY_MODULE_SYMBOLS.delete(context); PROJECT_FOLDER_SUFFIXES.delete(context); + clearRustPathMemos(context); } export function resolveImportPath( @@ -1986,11 +1988,12 @@ export function resolveViaImport( if (pyModResult) return pyModResult; } - // Rust qualified path: resolve the module prefix of `crate::m::Item` / - // `self::sub::Item` / `super::m::func` to a file, then find the leaf symbol in - // it. Disambiguates common-name `pub use self::read::read` re-exports that - // name-matching would land on the wrong same-named symbol. - if (ref.language === 'rust' && ref.referenceName.includes('::')) { + // Rust path: walk the module prefix of `crate::m::Item` / `self::sub::Item` / + // `super::m::func` / `other_crate::m::Type::method` to a file, then find the + // item in it — or a bare name through the file's `use` of it. Disambiguates + // common-name `pub use self::read::read` re-exports and same-named items in + // two crates of a workspace, which name-matching would land on the wrong one. + if (ref.language === 'rust') { const rustResult = resolveRustPathReference(ref, context); if (rustResult) return rustResult; } @@ -2460,132 +2463,6 @@ function resolvePythonAbsoluteModule( return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null; } -/** - * Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix - * (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust - * analog of {@link resolvePythonModuleMember} / {@link resolveGoCrossPackageReference} - * and the precise answer to common-name re-exports (`pub use self::read::read`) - * that name-matching can't disambiguate. Returns null when the prefix isn't a - * real module path (e.g. `Widget::new` — `Widget` is a struct, not a module), - * so associated-function calls and enum-variant paths fall through untouched. - */ -function resolveRustPathReference( - ref: UnresolvedRef, - context: ResolutionContext -): ResolvedRef | null { - const segments = ref.referenceName.split('::').filter((s) => s.length > 0); - if (segments.length < 2) return null; - const leaf = segments[segments.length - 1]!; - const modSegs = segments.slice(0, -1); - - const file = resolveRustModuleFile(modSegs, ref.filePath, context); - if (!file || file === ref.filePath) return null; - - const target = context.getNodesInFile(file).find( - (n) => - n.name === leaf && - (n.kind === 'function' || - n.kind === 'struct' || - n.kind === 'union' || - n.kind === 'enum' || - n.kind === 'trait' || - n.kind === 'type_alias' || - n.kind === 'constant' || - n.kind === 'method' || - n.kind === 'class' || - n.kind === 'interface') - ); - if (target) { - return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }; - } - return null; -} - -/** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */ -function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null { - const projectRoot = context.getProjectRoot(); - const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/'); - let dir = path.dirname(fromFileAbs); - for (let i = 0; i < 64; i++) { - if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) || - context.fileExists(toRel(path.join(dir, 'main.rs')))) { - return dir; - } - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } - return null; -} - -/** Directory under which the current file's module declares its SUBMODULES. */ -function rustSelfModuleDir(fromFileAbs: string): string { - const base = path.basename(fromFileAbs); - const dir = path.dirname(fromFileAbs); - // mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`. - if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir; - return path.join(dir, base.replace(/\.rs$/, '')); -} - -/** - * Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of - * the last module segment — `crate::a::b` → `/a/b.rs` (or `.../b/mod.rs`). - * Anchors on `crate` / `self` / `super`; a bare path is tried crate-relative. - */ -function resolveRustModuleFile( - segments: string[], - fromFile: string, - context: ResolutionContext -): string | null { - if (segments.length === 0) return null; - const projectRoot = context.getProjectRoot(); - const fromAbs = path.join(projectRoot, fromFile); - const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/'); - - // Walk a sequence of module segments down from `startDir`, mapping each to a - // `.rs` or `/mod.rs` file. Returns the leaf module's file, or null - // if `startDir` is null or any segment has no file on disk. - const resolveUnder = (startDir: string | null, rest: string[]): string | null => { - if (!startDir) return null; - let dir = startDir; - let targetFile: string | null = null; - for (const seg of rest) { - if (seg === 'self' || seg === 'crate' || seg === 'super') continue; - const asFile = toRel(path.join(dir, seg + '.rs')); - const asMod = toRel(path.join(dir, seg, 'mod.rs')); - if (context.fileExists(asFile)) targetFile = asFile; - else if (context.fileExists(asMod)) targetFile = asMod; - else return null; - dir = path.join(dir, seg); - } - return targetFile; - }; - - const first = segments[0]!; - if (first === 'crate') { - return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1)); - } - if (first === 'self') { - return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1)); - } - if (first === 'super') { - let supers = 0; - while (segments[supers] === 'super') supers++; - let dir: string | null = rustSelfModuleDir(fromAbs); - for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir); - return resolveUnder(dir, segments.slice(supers)); - } - // Bare path. In expression position (`submodule::item()` — the router-assembly - // and general cross-module-call pattern) the prefix is a SUBMODULE of the - // current module, i.e. 2018 `self::`-relative — so try self-relative FIRST. - // Fall back to crate-relative for 2015-edition / crate-root items. External - // crate paths (`serde::de::Error`) miss both and fall through to name-matching. - return ( - resolveUnder(rustSelfModuleDir(fromAbs), segments) ?? - resolveUnder(rustCrateRootDir(fromAbs, context), segments) - ); -} - /** * Resolve a Java/Kotlin reference whose receiver is the simple name of * an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The @@ -2987,70 +2864,6 @@ function resolveStaticMember( return candidates[0]; } -/** - * Rust `use` declarations, flattened to `localName → full path`. - * - * Rust is the one supported language with NO `ImportMapping` extraction (see - * `extractImportMappings`), so this is the only channel that can tell whether - * a bare type name in a Rust file was brought in by a `use`. Handles nested - * groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single - * name), and `as` aliases. - */ -function collectRustUseBindings(content: string): Map { - const out = new Map(); - - // Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens. - const expand = (spec: string): string[] => { - const open = spec.indexOf('{'); - if (open === -1) return [spec.trim()]; - const prefix = spec.slice(0, open); - let depth = 0; - let close = -1; - for (let i = open; i < spec.length; i++) { - if (spec[i] === '{') depth++; - else if (spec[i] === '}') { - depth--; - if (depth === 0) { close = i; break; } - } - } - if (close === -1) return []; - const suffix = spec.slice(close + 1); - const inner = spec.slice(open + 1, close); - const parts: string[] = []; - let depth2 = 0; - let start = 0; - for (let i = 0; i <= inner.length; i++) { - const ch = inner[i]; - if (ch === '{') depth2++; - else if (ch === '}') depth2--; - if (i === inner.length || (ch === ',' && depth2 === 0)) { - const seg = inner.slice(start, i).trim(); - if (seg) parts.push(seg); - start = i + 1; - } - } - return parts.flatMap((p) => expand(prefix + p + suffix)); - }; - - // `use` items end at the first `;`. Attributes/visibility (`pub use`) are - // irrelevant to the binding itself. - const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g; - let m: RegExpExecArray | null; - while ((m = useRe.exec(content)) !== null) { - for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) { - const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec); - const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim(); - if (!rawPath || rawPath.endsWith('*')) continue; - const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean); - const leaf = segments[segments.length - 1]; - if (!leaf) continue; - const local = aliasMatch ? aliasMatch[2]! : leaf; - out.set(local, segments.join('::')); - } - } - return out; -} - /** * Is `name`, as used in `ref`'s file, bound by an import whose module lives * OUTSIDE the repository? diff --git a/src/resolution/rust-paths.ts b/src/resolution/rust-paths.ts new file mode 100644 index 000000000..e05932ca9 --- /dev/null +++ b/src/resolution/rust-paths.ts @@ -0,0 +1,394 @@ +/** + * Rust path resolution. + * + * Maps a Rust path — `crate::m::Item`, `super::f`, `self::sub::Item`, a bare + * `sub::Item`, and a path into ANOTHER crate of the project + * (`sim::items::ItemPool::for_def`) — to the node it names, by walking the + * module tree on disk (`.rs` / `/mod.rs`) from the path's anchor: + * + * - `crate` / `self` / `super` — the current crate root / module / parent module; + * - a project crate's name — a workspace member, or any in-project `path` + * dependency by the key its dependents write — anchors at that crate's + * library root (see `getCargoWorkspaceCrateMap`); + * - a name the file binds with `use` (`use sim::items::ItemPool;` makes + * `ItemPool::for_def` mean `sim::items::ItemPool::for_def`). + * + * What remains after the module segments is an item of the last module + * (`Item`), or an associated item of a type (`Type::member`). A name the + * module does not define itself is followed through its `pub use` + * re-exports (`pub use rng::Rng;`, `pub use prelude::*;`). + */ + +import * as path from 'path'; +import { Node } from '../types'; +import { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; +import { cargoCrateSourceDir, getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace'; +import { stripCommentsForRegex } from './strip-comments'; + +/** A file's `use` declarations: each bound name's full path, and each glob's module path. */ +interface RustUseTree { + bindings: Map; + globs: string[]; +} + +/** Path anchors that never name a crate, a module or a `use` binding. */ +const RUST_PATH_KEYWORDS = new Set(['crate', 'self', 'super', 'Self']); +/** How many re-export / `use` hops one path may take (a cycle stops here too). */ +const MAX_RUST_PATH_HOPS = 4; +/** Kinds a module-level Rust item is extracted as (`pub const`/`static` are `variable`). */ +const RUST_ITEM_KINDS: ReadonlySet = new Set([ + 'function', 'struct', 'union', 'enum', 'trait', 'type_alias', 'constant', 'variable', 'class', 'interface', +]); +/** Kinds an associated item (`Type::member`) is extracted as. */ +const RUST_MEMBER_KINDS: ReadonlySet = new Set([ + 'method', 'function', 'enum_member', 'constant', 'variable', 'type_alias', 'property', 'field', +]); + +const RUST_FILE_USES = new WeakMap>(); +const RUST_CRATE_SOURCES = new WeakMap>(); + +/** Drop the per-context memos (see clearImportResolverMemos). */ +export function clearRustPathMemos(context: ResolutionContext): void { + RUST_FILE_USES.delete(context); + RUST_CRATE_SOURCES.delete(context); +} + +/** + * Rust `use` declarations: each bound name flattened to its full path, and + * each glob (`use a::b::*`) to its module path. Handles nested groups + * (`use a::{b::C, d as E}`) and `as` aliases. + */ +function parseRustUseTree(content: string): RustUseTree { + const bindings = new Map(); + const globs: string[] = []; + + // Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens. + const expand = (spec: string): string[] => { + const open = spec.indexOf('{'); + if (open === -1) return [spec.trim()]; + const prefix = spec.slice(0, open); + let depth = 0; + let close = -1; + for (let i = open; i < spec.length; i++) { + if (spec[i] === '{') depth++; + else if (spec[i] === '}') { + depth--; + if (depth === 0) { close = i; break; } + } + } + if (close === -1) return []; + const suffix = spec.slice(close + 1); + const inner = spec.slice(open + 1, close); + const parts: string[] = []; + let depth2 = 0; + let start = 0; + for (let i = 0; i <= inner.length; i++) { + const ch = inner[i]; + if (ch === '{') depth2++; + else if (ch === '}') depth2--; + if (i === inner.length || (ch === ',' && depth2 === 0)) { + const seg = inner.slice(start, i).trim(); + if (seg) parts.push(seg); + start = i + 1; + } + } + return parts.flatMap((p) => expand(prefix + p + suffix)); + }; + + // `use` items end at the first `;`. Attributes/visibility (`pub use`) are + // irrelevant to the binding itself. + const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g; + let m: RegExpExecArray | null; + while ((m = useRe.exec(content)) !== null) { + for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) { + const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec); + const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim(); + if (!rawPath) continue; + const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean); + if (segments[segments.length - 1] === '*') { + if (segments.length > 1) globs.push(segments.slice(0, -1).join('::')); + continue; + } + // `use a::b::{self, C}` binds `b` itself. + if (segments[segments.length - 1] === 'self') segments.pop(); + const leaf = segments[segments.length - 1]; + if (!leaf) continue; + const local = aliasMatch ? aliasMatch[2]! : leaf; + bindings.set(local, segments.join('::')); + } + } + return { bindings, globs }; +} + +/** + * Rust `use` declarations, flattened to `localName → full path`. Rust is the + * one supported language with NO `ImportMapping` extraction, so this is the + * only channel that can tell what a name in a Rust file was brought in by. + */ +export function collectRustUseBindings(content: string): Map { + return parseRustUseTree(content).bindings; +} + +/** The `use` tree of an indexed file, comments stripped, memoized per context. */ +function rustUsesInFile(filePath: string, context: ResolutionContext): RustUseTree { + let memo = RUST_FILE_USES.get(context); + if (!memo) { + memo = new Map(); + RUST_FILE_USES.set(context, memo); + } + let uses = memo.get(filePath); + if (!uses) { + uses = parseRustUseTree(stripCommentsForRegex(context.readFile(filePath) ?? '', 'rust')); + memo.set(filePath, uses); + } + return uses; +} + +/** + * Each project crate's name, as code writes it (`my_crate`), to the + * project-relative directory of its library root: workspace members, in-project + * `path` dependencies, and the root package itself. + */ +function rustCrateSources(context: ResolutionContext): Map { + const hit = RUST_CRATE_SOURCES.get(context); + if (hit) return hit; + const sources = new Map(); + for (const [name, dir] of getCargoWorkspaceCrateMap(context)) { + const ident = name.replace(/-/g, '_'); + if (!sources.has(ident)) sources.set(ident, cargoCrateSourceDir(dir, context)); + } + const rootName = /\[package\][^[]*?\bname\s*=\s*"([^"]+)"/.exec(context.readFile('Cargo.toml') ?? '')?.[1]; + if (rootName && !sources.has(rootName.replace(/-/g, '_'))) { + sources.set(rootName.replace(/-/g, '_'), cargoCrateSourceDir('', context)); + } + RUST_CRATE_SOURCES.set(context, sources); + return sources; +} + +/** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */ +function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null { + const projectRoot = context.getProjectRoot(); + const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/'); + let dir = path.dirname(fromFileAbs); + for (let i = 0; i < 64; i++) { + if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) || + context.fileExists(toRel(path.join(dir, 'main.rs')))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + +/** Directory under which the current file's module declares its SUBMODULES. */ +function rustSelfModuleDir(fromFileAbs: string): string { + const base = path.basename(fromFileAbs); + const dir = path.dirname(fromFileAbs); + // mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`. + if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir; + return path.join(dir, base.replace(/\.rs$/, '')); +} + +/** Where a module path got to: the last module's file, and how many segments named modules. */ +interface RustModuleAt { + file: string; + consumed: number; +} + +/** + * Every reading of a path's leading MODULE segments, most specific anchor + * first: each names the file of the deepest module reached and how many + * segments it consumed. A bare path is tried as a submodule of the current + * module (2018), as a project crate's name, then crate-relative (2015). + */ +function rustModuleAnchors(segments: string[], fromFile: string, context: ResolutionContext): RustModuleAt[] { + if (segments.length === 0) return []; + const projectRoot = context.getProjectRoot(); + const fromAbs = path.join(projectRoot, fromFile); + const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/'); + const existing = (abs: string): string | null => { + const rel = toRel(abs); + return context.fileExists(rel) ? rel : null; + }; + // The file of the module that owns directory `dir`: `dir.rs`, `dir/mod.rs`, or a crate root. + const moduleFileOf = (dir: string): string | null => + existing(dir + '.rs') ?? existing(path.join(dir, 'mod.rs')) ?? + existing(path.join(dir, 'lib.rs')) ?? existing(path.join(dir, 'main.rs')); + // Consume module segments down from `dir` while `.rs` / `/mod.rs` exists. + const descend = (dir: string, file: string, consumed: number): RustModuleAt => { + while (consumed < segments.length) { + const seg = segments[consumed]!; + const next = existing(path.join(dir, seg + '.rs')) ?? existing(path.join(dir, seg, 'mod.rs')); + if (!next) break; + file = next; + dir = path.join(dir, seg); + consumed++; + } + return { file, consumed }; + }; + + const first = segments[0]!; + if (first === 'crate') { + const root = rustCrateRootDir(fromAbs, context); + const file = root ? moduleFileOf(root) : null; + return root && file ? [descend(root, file, 1)] : []; + } + if (first === 'self') return [descend(rustSelfModuleDir(fromAbs), fromFile, 1)]; + if (first === 'super') { + let supers = 0; + while (segments[supers] === 'super') supers++; + let dir = rustSelfModuleDir(fromAbs); + for (let s = 0; s < supers; s++) dir = path.dirname(dir); + const file = moduleFileOf(dir); + return file ? [descend(dir, file, supers)] : []; + } + + const anchors: RustModuleAt[] = []; + const selfRelative = descend(rustSelfModuleDir(fromAbs), fromFile, 0); + if (selfRelative.consumed > 0) anchors.push(selfRelative); + const crateSrc = rustCrateSources(context).get(first); + if (crateSrc !== undefined) { + const dir = path.join(projectRoot, crateSrc); + const file = existing(path.join(dir, 'lib.rs')) ?? existing(path.join(dir, 'main.rs')); + if (file) anchors.push(descend(dir, file, 1)); + } + const root = rustCrateRootDir(fromAbs, context); + if (root) { + const file = moduleFileOf(root); + const crateRelative = file ? descend(root, file, 0) : null; + if (crateRelative && crateRelative.consumed > 0) anchors.push(crateRelative); + } + return anchors; +} + +/** + * Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of + * the last module segment — `crate::a::b` → `/a/b.rs` (or `.../b/mod.rs`), + * `sim::items` → `crates/sim/src/items.rs`. Null unless every segment is a module. + */ +export function resolveRustModuleFile( + segments: string[], + fromFile: string, + context: ResolutionContext +): string | null { + return rustModuleAnchors(segments, fromFile, context).find((at) => at.consumed === segments.length)?.file ?? null; +} + +/** A module-level item `name` of `file`, defined there or re-exported by one of its `use`s. */ +function findRustItem(file: string, name: string, context: ResolutionContext, hops: number): Node | null { + const named = context.getNodesInFileNamed?.(file, name) ?? context.getNodesInFile(file).filter((n) => n.name === name); + const own = named.find((n) => RUST_ITEM_KINDS.has(n.kind) && n.qualifiedName === name); + if (own) return own; + if (hops >= MAX_RUST_PATH_HOPS) return null; + const uses = rustUsesInFile(file, context); + const reexport = uses.bindings.get(name); + if (reexport) { + const target = resolveRustPath(reexport.split('::'), file, context, hops + 1); + if (target) return target; + } + for (const glob of uses.globs) { + const target = resolveRustPath([...glob.split('::'), name], file, context, hops + 1); + if (target) return target; + } + return null; +} + +/** + * An associated item `Type::member`: in the type's own file first, else in an + * `impl` elsewhere in the same crate. + */ +function findRustMember(owner: Node, member: string, context: ResolutionContext): Node | null { + const qualified = `${owner.name}::${member}`; + const isMember = (n: Node) => RUST_MEMBER_KINDS.has(n.kind) && n.qualifiedName === qualified; + const local = context.getNodesInFile(owner.filePath).find(isMember); + if (local) return local; + const projectRoot = context.getProjectRoot(); + const crateRoot = rustCrateRootDir(path.join(projectRoot, owner.filePath), context); + const crateDir = crateRoot === null ? null : path.relative(projectRoot, crateRoot).replace(/\\/g, '/'); + const inCrate = (n: Node) => crateDir === null || crateDir === '' || n.filePath.startsWith(`${crateDir}/`); + const elsewhere = context.getNodesByQualifiedName(qualified) + .filter((n) => n.language === 'rust' && isMember(n) && inCrate(n)); + return elsewhere.length === 1 ? elsewhere[0]! : null; +} + +/** The node a Rust path names, read from `fromFile`; null when it isn't the project's. */ +function resolveRustPath(segments: string[], fromFile: string, context: ResolutionContext, hops: number): Node | null { + if (segments.length < 2) return null; + const first = segments[0]!; + // A name the file binds with `use` means what the `use` names. + if (!RUST_PATH_KEYWORDS.has(first) && hops < MAX_RUST_PATH_HOPS) { + const bound = rustUsesInFile(fromFile, context).bindings.get(first); + if (bound && bound !== first) { + const target = resolveRustPath([...bound.split('::'), ...segments.slice(1)], fromFile, context, hops + 1); + if (target) return target; + } + } + for (const at of rustModuleAnchors(segments, fromFile, context)) { + const rest = segments.slice(at.consumed); + if (rest.length === 0 || rest.length > 2) continue; + const item = findRustItem(at.file, rest[0]!, context, hops); + if (!item) continue; + if (rest.length === 1) return item; + const member = findRustMember(item, rest[1]!, context); + if (member) return member; + } + return null; +} + +/** + * The `a::b` written directly before a reference's leaf (`scheduler` for + * `&scheduler::Handle`), or null when it is written bare or its position + * can't be read back from the source. + */ +function rustWrittenQualifier(ref: UnresolvedRef, context: ResolutionContext): string[] | null { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const line = lines?.[ref.line - 1]; + if (line === undefined || !line.startsWith(ref.referenceName, ref.column)) return null; + // Comments are blanked (offset-preserving) over the lines read back, so a + // `// crate::scheduler::` note above the leaf is not taken as its qualifier. + const first = Math.max(0, ref.line - 4); + const window = [...lines!.slice(first, ref.line - 1), line.slice(0, ref.column)]; + const stripped = stripCommentsForRegex(window.join('\n'), 'rust').split('\n'); + let before = stripped[stripped.length - 1]!; + // `scheduler::\n Handle`: the leaf starts its line, the qualifier ends the previous one. + for (let i = stripped.length - 2; i >= 0 && before.trim() === ''; i--) { + before = stripped[i]! + before; + } + const qualified = /((?:[A-Za-z_]\w*\s*::\s*)+)$/.exec(before); + if (!qualified) return null; + return qualified[1]!.split('::').map((s) => s.trim()).filter((s) => s.length > 0); +} + +/** + * Resolve a Rust reference written as a path (`crate::m::Item`, + * `sim::items::ItemPool::for_def`, `super::f`), or a bare name the file binds + * with a `use` (`roll_item` under `use sim::items::roll_item;`), to the node + * it names. This is the precise answer for common-name items that name + * matching can't disambiguate — `pub use self::read::read` re-exports, and the + * same type and method names in two crates of one workspace. Returns null + * when the path isn't the project's (`serde::de::Error`, `std::mem::take`), + * so external paths, associated functions of local variables, and the rest + * fall through untouched. Paths that land in the referencing file itself are + * left to the name matcher. + */ +export function resolveRustPathReference(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + let segments = ref.referenceName.split('::').filter((s) => s.length > 0); + if (segments.length === 1) { + // The extractor keeps only the leaf of `&scheduler::Handle`; the source says + // whether it was written through a path. A path resolves by its own + // segments — a file-level `use …::Handle` is a different `Handle`. + const qualifier = rustWrittenQualifier(ref, context); + if (qualifier) { + segments = [...qualifier, segments[0]!]; + } else { + const bound = rustUsesInFile(ref.filePath, context).bindings.get(segments[0]!); + if (!bound) return null; + segments = bound.split('::'); + } + } + const target = resolveRustPath(segments, ref.filePath, context, 0); + if (!target || target.filePath === ref.filePath) return null; + return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }; +}