diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cdfbec7f..1cb2b006a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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) +- In Rust, a module loaded with `#[path = "…"]`, like tests kept in another folder as `#[path = "../../../tests/unit/scaling_tests.rs"] mod tests;`, is now part of the module that declares it: its `use super::*` brings in that module's items, private ones included, `super::` and `crate::` start from there, and the path is read as Rust reads it, from the declaring file's folder or an inline module's own. A call through such a module, like `support::make()` in each test that loads a shared helper, reaches the file it names. A module whose `#[cfg]` alternatives each name a file of their own, or whose path a `cfg_attr` sets, depends on the build and is left alone. An example, test or bench that `Cargo.toml` places at a path of its own, like `examples/07-fullstack/handling_errors.rs`, is now its own crate, so its `crate::` starts from it. Re-index Rust projects after upgrading. Thanks @seonghobae. (#1892) - 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-path-attribute-modules.test.ts b/__tests__/rust-path-attribute-modules.test.ts new file mode 100644 index 000000000..617d7c515 --- /dev/null +++ b/__tests__/rust-path-attribute-modules.test.ts @@ -0,0 +1,148 @@ +/** + * A Rust module loaded with `#[path = "…"]` is the module that declares it + * (the Rust Reference, "The path attribute"): its `super` is the declaring + * module, wherever the file lies, so `use super::*` brings in that module's + * items, private ones included, and `crate::` is the declaring file's crate. + * The path is read from the declaring file's directory, or from an inline + * module's own directory. A module whose cfg alternatives each name a file, + * or whose path a `cfg_attr` sets, depends on the build and stays unread. + * A file several declarations load is each one's from its own side, and an + * example the manifest places at a path of its own is its own crate's root. + */ +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-path-attr-')); + const files: Record = { + 'Cargo.toml': '[workspace]\nmembers = ["crates/core", "crates/py"]\nresolver = "2"\n', + 'crates/core/Cargo.toml': '[package]\nname = "core-crate"\nversion = "0.1.0"\nedition = "2021"\n\n[[example]]\nname = "demo"\npath = "examples/nested/demo.rs"\n', + 'crates/core/src/lib.rs': 'pub mod scaling;\npub mod platform;\npub mod util;\n\npub fn root_helper() -> i32 {\n 0\n}\n\npub mod gen_user;\n', + 'crates/core/src/util.rs': 'pub fn helper() -> i32 {\n 10\n}\n', + // A folder whose name only starts with two dots is inside the project. + 'crates/core/src/gen_user.rs': '#[path = "..generated/gen.rs"]\nmod gen;\n\npub fn via_gen() -> i32 {\n gen::make_gen()\n}\n', + 'crates/core/src/..generated/gen.rs': 'pub fn make_gen() -> i32 {\n 13\n}\n', + // An example the manifest places at a path of its own: its own crate, whose `mod util;` is its sibling. + 'crates/core/examples/nested/demo.rs': 'mod util;\n\nfn main() {\n let _ = crate::util::helper();\n}\n', + 'crates/core/examples/nested/util.rs': 'pub fn helper() -> i32 {\n 11\n}\n', + // Two tests load one helper: from each, `support` is that file. + 'crates/core/tests/a.rs': '#[path = "support/helpers.rs"]\nmod support;\n\n#[test]\nfn uses_support() {\n let _ = support::make();\n}\n', + 'crates/core/tests/b.rs': '#[path = "support/helpers.rs"]\nmod support;\n\n#[test]\nfn uses_support_too() {\n let _ = support::make();\n}\n', + 'crates/core/tests/support/helpers.rs': 'pub fn make() -> i32 {\n 12\n}\n', + 'crates/core/src/scaling.rs': `pub fn predict_rating_multi(x: i32) -> i32 { + x + 1 +} + +fn private_helper() -> i32 { + 2 +} + +#[cfg(test)] +#[path = "../../../tests/unit/scaling_tests.rs"] +mod tests; +`, + 'crates/core/src/platform.rs': `#[cfg(unix)] +#[path = "platform/unix_impl.rs"] +mod imp; +#[cfg(windows)] +#[path = "platform/windows_impl.rs"] +mod imp; + +pub fn open() -> i32 { + 3 +} + +pub fn via_imp() -> i32 { + imp::open_impl() +} + +mod inline { + #[path = "other.rs"] + mod inner; +} + +#[cfg_attr(docsrs, path = "docs_impl.rs")] +mod documented; + +pub fn via_documented() -> i32 { + documented::run() +} +`, + 'crates/core/src/platform/unix_impl.rs': 'pub fn open_impl() -> i32 {\n 1\n}\n', + 'crates/core/src/platform/windows_impl.rs': 'pub fn open_impl() -> i32 {\n 2\n}\n', + // In a non-mod-rs file's inline module, the path starts at that module's own directory. + 'crates/core/src/platform/inline/other.rs': 'pub fn from_inner() -> i32 {\n super::super::open()\n}\n', + 'crates/core/src/platform/other.rs': 'pub fn decoy() -> i32 {\n 4\n}\n', + 'crates/core/src/documented.rs': 'pub fn run() -> i32 {\n 5\n}\n', + 'crates/core/src/platform/docs_impl.rs': 'pub fn run() -> i32 {\n 6\n}\n', + 'crates/py/Cargo.toml': '[package]\nname = "py-crate"\nversion = "0.1.0"\nedition = "2021"\n', + 'crates/py/src/lib.rs': 'pub fn predict_rating_multi(x: i32) -> i32 {\n x + 99\n}\n\npub fn private_helper() -> i32 {\n 98\n}\n\npub fn root_helper() -> i32 {\n 97\n}\n\npub fn open() -> i32 {\n 96\n}\n\npub fn make() -> i32 {\n 95\n}\n', + 'tests/unit/scaling_tests.rs': `use super::*; + +#[test] +fn uses_private() { + let _ = private_helper(); +} + +#[test] +fn uses_crate() { + let _ = crate::root_helper(); +} +`, + }; + 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 = (file: string, name: string): string[] => { + const fn = cg.getNodesInFile(file).find((n) => n.name === name && n.kind === 'function'); + expect(fn, `${file}:${name}`).toBeDefined(); + return cg.getOutgoingEdgesFrom([fn!.id]).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.qualifiedName} ${t.filePath}:${t.startLine}`).sort(); +}; + +describe('Rust modules a #[path] attribute loads', () => { + it('see the declaring module\'s private items through `use super::*`', () => { + expect(calls('tests/unit/scaling_tests.rs', 'uses_private')).toEqual(['private_helper crates/core/src/scaling.rs:5']); + }); + + it('start `crate::` from the declaring file\'s crate', () => { + expect(calls('tests/unit/scaling_tests.rs', 'uses_crate')).toEqual(['root_helper crates/core/src/lib.rs:5']); + }); + + it('read the path from an inline module\'s own directory', () => { + expect(calls('crates/core/src/platform/inline/other.rs', 'from_inner')).toEqual(['open crates/core/src/platform.rs:8']); + }); + + it('follow each declaring file into a helper several tests load', () => { + expect(calls('crates/core/tests/a.rs', 'uses_support')).toEqual(['make crates/core/tests/support/helpers.rs:1']); + expect(calls('crates/core/tests/b.rs', 'uses_support_too')).toEqual(['make crates/core/tests/support/helpers.rs:1']); + }); + + it('read a path into a folder whose name only starts with two dots', () => { + expect(calls('crates/core/src/gen_user.rs', 'via_gen')).toEqual(['make_gen crates/core/src/..generated/gen.rs:1']); + }); + + it('start an example the manifest places at a path of its own from that example', () => { + expect(calls('crates/core/examples/nested/demo.rs', 'main')).toEqual(['helper crates/core/examples/nested/util.rs:1']); + }); + + it('leave a module whose cfg alternatives name a file each, or whose path a cfg_attr sets, unread', () => { + expect(calls('crates/core/src/platform.rs', 'via_imp')).toEqual([]); + expect(calls('crates/core/src/platform.rs', 'via_documented')).toEqual([]); + }); +}); diff --git a/__tests__/rust-path-super-glob.test.ts b/__tests__/rust-path-super-glob.test.ts new file mode 100644 index 000000000..c24899486 --- /dev/null +++ b/__tests__/rust-path-super-glob.test.ts @@ -0,0 +1,86 @@ +/** + * Regression: #[path]-included test modules with `use super::*` must resolve + * bare callees through the declaring parent module, not a same-named symbol + * elsewhere in the workspace (ContextualWisdomLab/fast-mlsirm#1837). Case + * from PR #1892 by @seonghobae. + */ +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-path-super-')); +}); + +afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexWorkspace(layout: Record) { + fs.writeFileSync( + path.join(root, 'Cargo.toml'), + '[workspace]\nmembers = ["crates/core", "crates/py"]\nresolver = "2"\n' + ); + fs.mkdirSync(path.join(root, 'crates/core/src'), { recursive: true }); + fs.mkdirSync(path.join(root, 'crates/py/src'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tests/unit'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'crates/core/Cargo.toml'), + '[package]\nname = "core-crate"\nversion = "0.1.0"\nedition = "2021"\n' + ); + fs.writeFileSync( + path.join(root, 'crates/py/Cargo.toml'), + '[package]\nname = "py-crate"\nversion = "0.1.0"\nedition = "2021"\n' + ); + for (const [file, text] of Object.entries(layout)) { + const dest = path.join(root, file); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, text); + } + cg = await CodeGraph.init(root, { index: true }); +} + +function callTargets(testFn: string): string[] { + const caller = cg! + .getNodesByKind('function') + .find((n) => n.filePath === 'tests/unit/scaling_tests.rs' && n.name === testFn); + expect(caller).toBeDefined(); + return cg! + .getOutgoingEdges(caller!.id) + .filter((e) => e.kind === 'calls') + .map((e) => { + const target = cg!.getNode(e.target)!; + return `${target.filePath}:${target.qualifiedName ?? target.name}`; + }); +} + +it('resolves use super::* callee through #[path] parent, not a same-named decoy (#1837)', async () => { + await indexWorkspace({ + 'crates/core/src/lib.rs': 'pub mod scaling;', + 'crates/core/src/scaling.rs': `pub fn predict_rating_multi(x: i32) -> i32 { x + 1 } + +#[cfg(test)] +#[path = "../../../tests/unit/scaling_tests.rs"] +mod tests; +`, + 'crates/py/src/lib.rs': 'pub fn predict_rating_multi(x: i32) -> i32 { x + 99 }', + 'tests/unit/scaling_tests.rs': `use super::*; + +#[test] +fn pr_elom_rowmean() { + let _ = predict_rating_multi(1); +} +`, + }); + + expect(callTargets('pr_elom_rowmean')).toEqual([ + 'crates/core/src/scaling.rs:predict_rating_multi', + ]); +}); diff --git a/src/resolution/frameworks/cargo-workspace.ts b/src/resolution/frameworks/cargo-workspace.ts index 9a161c913..88567bae3 100644 --- a/src/resolution/frameworks/cargo-workspace.ts +++ b/src/resolution/frameworks/cargo-workspace.ts @@ -159,22 +159,25 @@ export function parsePackageName(cargoToml: string): string | null { /** * The target roots a manifest names with a `path` of their own: `[lib] path = - * "…"` and each `[[bin]] path = "…"` (ripgrep's `crates/core/main.rs`), as - * written — relative to the manifest's directory. + * "…"`, each `[[bin]] path = "…"` (ripgrep's `crates/core/main.rs`), and each + * `[[example]]`, `[[test]]` and `[[bench]]` one (dioxus's + * `examples/07-fullstack/handling_errors.rs`), as written — relative to the + * manifest's directory. */ -export function parseTargetPaths(cargoToml: string): { lib?: string; bins: string[] } { - const out: { lib?: string; bins: string[] } = { bins: [] }; - let table: 'lib' | 'bin' | null = null; +export function parseTargetPaths(cargoToml: string): { lib?: string; bins: string[]; others: string[] } { + const out: { lib?: string; bins: string[]; others: string[] } = { bins: [], others: [] }; + let table: 'lib' | 'bin' | 'other' | null = null; for (const raw of cargoToml.split(/\r?\n/)) { const line = raw.replace(/#.*$/, '').trim(); if (line.startsWith('[')) { - table = line === '[lib]' ? 'lib' : line === '[[bin]]' ? 'bin' : null; + table = line === '[lib]' ? 'lib' : line === '[[bin]]' ? 'bin' : /^\[\[(?:example|test|bench)\]\]$/.test(line) ? 'other' : null; continue; } const at = table && /^path\s*=\s*["']([^"'\n]+)["']/.exec(line)?.[1]?.replace(/\\/g, '/'); if (!at) continue; if (table === 'lib') out.lib = at; - else out.bins.push(at); + else if (table === 'bin') out.bins.push(at); + else out.others.push(at); } return out; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 041ee2d63..17a8abda0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2861,6 +2861,24 @@ function rustModuleDir(filePath: string): string { return path.posix.join(dir, base.replace(/\.rs$/, '')); } +/** + * Whether a Rust file is inside the module whose file is `ownerFile` and whose + * submodules live under `ownerDir`: below that directory, or loaded by a + * `#[path]` declaration written inside it (fast-mlsirm's + * `tests/unit/scaling_tests.rs` is `scaling.rs`'s `mod tests`, and sees its + * private items). + */ +function rustFileUnder(file: string, ownerDir: string, ownerFile: string, context: ResolutionContext): boolean { + let f = file; + for (let i = 0; i < 8; i++) { + if (f.startsWith(`${ownerDir}/`) || (i > 0 && f === ownerFile)) return true; + const claim = rustPathClaim(f, context); + if (!claim) return false; + f = claim.file; + } + return false; +} + /** * Standard-library package names (and kubernetes' aliases of a few of its own * util packages). A dotted Go reference through one is taken for a call into @@ -3957,8 +3975,7 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex // trait's, reached through a value whose type the call's receiver // declares (matchRustTypedReceiverCall). A guess by its name alone stays out. if (candidate.kind === 'method' && RUST_TYPED_REFS.get(context)?.has(ref) && rustTraitOf(candidate, context)?.visibility !== 'private') return true; - const owner = rustModuleDir(candidate.filePath); - return ref.filePath.startsWith(owner + '/'); + return rustFileUnder(ref.filePath, rustModuleDir(candidate.filePath), candidate.filePath, context); } // A Dart `_name` is its library's, which spans the library file and its // parts: riverpod_generator's `class Counter extends _$Counter` names the @@ -8732,6 +8749,10 @@ interface RustUses { blocks: RustModBlock[]; /** Modules it declares with a `#[path = "…"]` of their own (often one per cfg), whose file can't be told from their name. */ pathMods: Set; + /** Modules it declares out of line with no `#[path]` (`mod name;`): the files the regular rules name. */ + mods: Set; + /** Whether it starts a binary of its own: a top-level `fn main` or `#![no_main]` (a trybuild case, a fuzz target). */ + main: boolean; /** Whether every brace closes, so `blocks` can be trusted to say what is inside them. */ balanced: boolean; /** The offset each line starts at. */ @@ -8852,6 +8873,8 @@ interface RustCrate { lib?: string; /** The binaries' roots its manifest gives a `path` (ripgrep's `crates/core/main.rs`), as project files. */ bins: string[]; + /** The examples', tests' and benches' roots its manifest gives a `path`, as project files: each its own crate. */ + others: string[]; } interface RustCrateGraph { @@ -8892,8 +8915,8 @@ function rustCrateGraph(context: ResolutionContext): RustCrateGraph { if (!name) return false; const targets = parseTargetPaths(text!); 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 }); + const files = (rels: string[]): string[] => rels.map((rel) => join(dir, rel)).filter((file): file is string => file !== null); + crates.set(dir, { name, keys: new Map(), deps: new Set(), lib: lib ?? undefined, bins: files(targets.bins), others: files(targets.others) }); manifests.set(dir, text!); if (!byName.has(name)) byName.set(name, dir); return true; @@ -9000,7 +9023,7 @@ function rustUsesOf(filePath: string, context: ResolutionContext): RustUses { if (hit) return hit; const uses: RustUses = { names: new Set(), globs: new Set(), external: new Set(), bound: new Set(), boundFrom: new Map(), - paths: new Map(), stdPaths: new Map(), globPaths: [], blocks: [], pathMods: new Set(), balanced: true, lineStarts: [], + paths: new Map(), stdPaths: new Map(), globPaths: [], blocks: [], pathMods: new Set(), mods: new Set(), main: false, balanced: true, lineStarts: [], }; const leaves = (tree: string): string[] => [ ...[...tree.matchAll(/([A-Za-z_]\w*)\s*(?=[,}]|$|\s+as\b)|\bas\s+([A-Za-z_]\w*)/g)] @@ -9067,9 +9090,16 @@ function rustUsesOf(filePath: string, context: ResolutionContext): RustUses { for (const g of tree.matchAll(/(\w+)\s*::\s*(?:\{[^}]*)?\*/g)) uses.globs.add(g[1]!); }); // `#[path = "atomic_u64_native.rs"] mod imp;`, `#[cfg_attr(docsrs, path = "…")] pub mod de;` + const pathModAt = new Set(); for (const m of text.matchAll(/#\[\s*(?:cfg_attr\s*\([^\]]*\bpath|path)\s*=[^\]]*\]\s*(?:#!?\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?mod\s+(?:r#)?([A-Za-z_]\w*)/g)) { uses.pathMods.add(m[1]!); + pathModAt.add(m.index! + m[0].search(/\bmod\s+(?:r#)?[A-Za-z_]\w*$/)); + } + // `mod name;` with no `#[path]` of its own. + for (const m of text.matchAll(/\bmod\s+(?:r#)?([A-Za-z_]\w*)\s*;/g)) { + if (!pathModAt.has(m.index!)) uses.mods.add(m[1]!); } + uses.main = /^(?:pub\s+)?(?:async\s+)?fn\s+main\s*\(|^[ \t]*#!\[\s*no_main\s*\]/m.test(text); for (let at = 0; at !== -1; at = text.indexOf('\n', at) + 1 || -1) uses.lineStarts.push(at); memo.set(filePath, uses); return uses; @@ -9283,13 +9313,239 @@ function isRustTargetRoot(filePath: string, context: ResolutionContext): boolean const dir = rustCrateDirOf(filePath, graph); if (dir === null) return /(?:^|\/)(?:lib|main)\.rs$/.test(filePath); const crate = graph.crates.get(dir); - if (crate && (crate.lib === filePath || crate.bins.includes(filePath))) return true; + if (crate && (crate.lib === filePath || crate.bins.includes(filePath) || crate.others.includes(filePath))) return true; const rel = dir === '' ? filePath : filePath.slice(dir.length + 1); return /^(?:src\/(?:lib|main)\.rs|src\/bin\/[^/]+(?:\.rs|\/main\.rs)|(?:tests|examples|benches)\/[^/]+(?:\.rs|\/main\.rs)|build\.rs)$/.test(rel); } +/** A `#[path = "…"] mod name;` declaration. */ +interface RustPathDecl { + /** The file it is written in, and the offset of its `mod`: the module it is declared in. */ + file: string; + at: number; + name: string; + /** + * The file it loads, from the project's root, read as the Rust Reference + * reads the attribute; null for one a `cfg_attr` sets, which the build's + * cfg decides, or one that leaves the project. + */ + target: string | null; +} + +const RUST_PATH_DECLS = new WeakMap>(); +const RUST_PATH_LOADS = new WeakMap>(); +const RUST_PATH_LOADERS = new WeakMap>(); +const RUST_FILES_BY_DIR = new WeakMap>(); +const NO_RUST_PATH_DECLS: readonly RustPathDecl[] = []; +/** An out-of-line module declaration, `pub(crate) mod name;`, from its visibility. */ +const RUST_MOD_DECL = /(?:\bpub(?:\s*\([^()]*\))?\s+)?\bmod\s+(?:r#)?([A-Za-z_]\w*)\s*;/g; + +/** + * A file's own `#[path = "…"] mod name;` declarations. The attribute's path + * is relative to the directory of the file it is written in — or, inside an + * inline `mod`, to that module's own directory: the file's for a `mod.rs` or + * a crate root, else the directory named after the file, then the inline + * modules' names (the Rust Reference, "The path attribute"). + */ +function rustPathDeclsIn(file: string, context: ResolutionContext): readonly RustPathDecl[] { + let memo = RUST_PATH_DECLS.get(context); + if (!memo) RUST_PATH_DECLS.set(context, (memo = new Map())); + let decls = memo.get(file); + if (!decls) memo.set(file, (decls = rustReadPathDecls(file, context))); + return decls; +} + +function rustReadPathDecls(file: string, context: ResolutionContext): readonly RustPathDecl[] { + let decls: RustPathDecl[] | undefined; + // Both forms write `path =`: `#[path = "…"]`, `#[cfg_attr(…, path = "…")]`. + // The bytes are checked first, so a file without the word is never decoded. + if (!file.endsWith('.rs') || (context.fileContains && !context.fileContains(file, 'path'))) return NO_RUST_PATH_DECLS; + const src = context.readFile(file); + if (!src || !/\bpath\s*=/.test(src)) return NO_RUST_PATH_DECLS; + const code = blankRustCode(src); + let blocks: RustModBlock[] | undefined; + for (const m of code.matchAll(RUST_MOD_DECL)) { + let target: string | null | undefined; + for (const [from, to] of rustAttributesBefore(code, m.index!)) { + const raw = src.slice(from, to); + const plain = /^#\s*\[\s*path\s*=\s*"([^"]*)"\s*\]$/.exec(raw); + if (plain) target = plain[1]!.replace(/\\/g, '/'); + else if (target === undefined && /^#\s*\[\s*cfg_attr\b[\s\S]*\bpath\s*=/.test(raw)) target = null; + } + if (target === undefined) continue; + // The `mod` keyword's offset (`mod modules;` holds a second `mod`). + const at = m.index! + /\bmod\s/.exec(m[0])!.index; + // At the file's top level it is the file's own; only one inside braces + // (an inline module's, or a macro's) needs the file's block layout. + const block = rustBraceDepth(code, at) === 0 ? null : rustBlockAt((blocks ??= rustUsesOf(file, context).blocks), at); + // From the file's directory; in an inline module, from that module's. + let base = rustParentDir(file); + if (block) { + base = isRustTargetRoot(file, context) || /(?:^|\/)mod\.rs$/.test(file) ? base : file.replace(/\.rs$/, ''); + const names: string[] = []; + for (let b: RustModBlock | null = block; b; b = b.parent) names.unshift(b.name); + for (const name of names) base = rustJoin(base, name); + } + let loads: string | null = null; + if (target !== null && !/^(?:\/|[A-Za-z]:)/.test(target)) { + const joined = path.posix.normalize(rustJoin(base, target)); + loads = escapesRoot(joined) ? null : joined; + } + (decls ??= []).push({ file, at, name: m[1]!, target: loads }); + } + return decls ?? NO_RUST_PATH_DECLS; +} + +/** How many braces are open at `at` in blanked code. */ +function rustBraceDepth(code: string, at: number): number { + let depth = 0; + for (let i = 0; i < at; i++) { + const c = code.charCodeAt(i); + if (c === 123) depth++; + else if (c === 125 && depth > 0) depth--; + } + return depth; +} + +/** Each file a `#[path]` of the project loads → the declarations that load it: every Rust file's, read once. */ +function rustPathLoads(context: ResolutionContext): Map { + const hit = RUST_PATH_LOADS.get(context); + if (hit) return hit; + const out = new Map(); + RUST_PATH_LOADS.set(context, out); + for (const file of context.getAllFiles()) { + for (const decl of rustPathDeclsIn(file, context)) { + if (decl.target === null) continue; + const list = out.get(decl.target); + if (list) list.push(decl); + else out.set(decl.target, [decl]); + } + } + return out; +} + +/** The project's Rust files directly in `dir`. */ +function rustFilesIn(dir: string, context: ResolutionContext): readonly string[] { + let index = RUST_FILES_BY_DIR.get(context); + if (!index) { + index = new Map(); + for (const file of context.getAllFiles()) { + if (!file.endsWith('.rs')) continue; + const parent = rustParentDir(file); + const list = index.get(parent); + if (list) list.push(file); + else index.set(parent, [file]); + } + RUST_FILES_BY_DIR.set(context, index); + } + return index.get(dir) ?? []; +} + +/** + * Whether the regular rules place a Rust file: a crate root (one with a + * `fn main` of its own too, as a trybuild case is), or `name.rs` / + * `name/mod.rs` that a plain `mod name;` declares in the module owning its + * directory (that directory's `mod.rs`, the file named after it, or a crate + * root in it) — or in an inline module of the module owning a directory + * above it, whose names make up the rest of the path (dioxus's + * ecommerce-site: `mod components { pub mod home; }` in `src/main.rs` places + * `src/components/home.rs`). Such a file is that module, whatever else loads it. + */ +function rustPlacedByName(file: string, context: ResolutionContext): boolean { + if (isRustTargetRoot(file, context) || rustUsesOf(file, context).main) return true; + const dir = rustParentDir(file); + const isMod = file === 'mod.rs' || file.endsWith('/mod.rs'); + if (isMod && dir === '') return false; + const name = path.posix.basename(isMod ? dir : file, '.rs'); + let parent = isMod ? rustParentDir(dir) : dir; + // The inline modules, outermost first, between the owner's directory and the file's. + const inline: string[] = []; + for (let depth = 0; depth < 4; depth++) { + const owners = [rustJoin(parent, 'mod.rs'), ...(parent === '' ? [] : [`${parent}.rs`])]; + for (const f of rustFilesIn(parent, context)) if (isRustTargetRoot(f, context)) owners.push(f); + for (const owner of owners) { + if (owner === file || !context.fileExists(owner)) continue; + const uses = rustUsesOf(owner, context); + if (uses.mods.has(name) && (inline.length === 0 || rustHasInlineModules(uses.blocks, inline))) return true; + } + if (parent === '') break; + inline.unshift(path.posix.basename(parent)); + parent = rustParentDir(parent); + } + return false; +} + +/** Whether a file declares the inline modules `names` (outermost first) nested in one another from its top level. */ +function rustHasInlineModules(blocks: readonly RustModBlock[], names: readonly string[]): boolean { + return blocks.some((block) => { + let b: RustModBlock | null = block; + for (let i = names.length - 1; i >= 0; i--) { + if (!b || b.name !== names[i]) return false; + b = b.parent; + } + return b === null; + }); +} + +/** + * The `#[path]` declarations that load `file`, for a file the regular rules + * don't place (rustPlacedByName); none for one they do. Only then is every + * Rust file of the project read for its declarations. + */ +function rustPathLoaders(file: string, context: ResolutionContext): readonly RustPathDecl[] { + let memo = RUST_PATH_LOADERS.get(context); + if (!memo) RUST_PATH_LOADERS.set(context, (memo = new Map())); + const hit = memo.get(file); + if (hit) return hit; + const loaders = !file.endsWith('.rs') || rustPlacedByName(file, context) + ? NO_RUST_PATH_DECLS + : rustPathLoads(context).get(file) ?? NO_RUST_PATH_DECLS; + memo.set(file, loaders); + return loaders; +} + +/** The spans of the outer attributes (`#[…]`) written right before `at` in blanked code, nearest first. */ +function rustAttributesBefore(code: string, at: number): Array<[number, number]> { + const spans: Array<[number, number]> = []; + let p = at; + for (;;) { + let q = p - 1; + while (q >= 0 && /\s/.test(code[q]!)) q--; + if (code[q] !== ']') break; + let depth = 0; + let open = -1; + for (let i = q; i >= 0; i--) { + if (code[i] === ']') depth++; + else if (code[i] === '[' && --depth === 0) { + open = i; + break; + } + } + let hash = open - 1; + while (hash >= 0 && /\s/.test(code[hash]!)) hash--; + if (open < 0 || code[hash] !== '#') break; + spans.push([hash, q + 1]); + p = hash; + } + return spans; +} + +/** + * The one `#[path]` declaration that loads `file` as a module, or null: a file + * the regular rules place, one no such declaration loads, or one several do + * (tests that each load a shared helper; then which module it is depends on + * the crate being built). + */ +function rustPathClaim(file: string, context: ResolutionContext): RustPathDecl | null { + const loaders = rustPathLoaders(file, context); + return loaders.length === 1 ? loaders[0]! : null; +} + /** The module a Rust file is, at its own top level. */ function rustFileModule(filePath: string, context: ResolutionContext): RustModule { + // A file a `#[path]` loads owns its directory, as a `mod.rs` does: rustc + // reads every `#[path]` file so. + if (rustPathClaim(filePath, context)) return { file: filePath, dir: rustParentDir(filePath), block: null, root: false }; const root = isRustTargetRoot(filePath, context); const dir = root || filePath.endsWith('/mod.rs') || filePath === 'mod.rs' ? rustParentDir(filePath) : filePath.replace(/\.rs$/, ''); return { file: filePath, dir, block: null, root }; @@ -9314,10 +9570,15 @@ function rustModuleAt(filePath: string, offset: number, context: ResolutionConte * `main.rs` above it. null when no single root is the file's: a * `tests/support/` module serves every test that declares it. */ -function rustCrateRootModule(filePath: string, context: ResolutionContext): RustModule | null { +function rustCrateRootModule(filePath: string, context: ResolutionContext, depth = 0): RustModule | null { + // A file a `#[path]` loads is in the crate of the file that declares it. + const claim = depth < 8 ? rustPathClaim(filePath, context) : null; + if (claim) return rustCrateRootModule(claim.file, context, depth + 1); const graph = rustCrateGraph(context); const dir = rustCrateDirOf(filePath, graph); if (dir === null) return rustNearestRootModule(filePath, context); + // An example, test or bench the manifest places with a path of its own is its own crate's root. + if (graph.crates.get(dir)?.others.includes(filePath)) return { file: filePath, dir: rustParentDir(filePath), block: null, root: true }; const base = dir === '' ? '' : `${dir}/`; const rel = filePath.slice(base.length); const target = /^(src\/bin|tests|examples|benches)\/([^/]+?)(\.rs$|\/)/.exec(rel); @@ -9370,14 +9631,21 @@ function rustLibModule(crateDir: string, context: ResolutionContext): RustModule /** * The module `name` that `m` declares: inline (`mod name { … }`), else * `.rs` or `/mod.rs` in its directory, else one it passes on under - * that name — dioxus's `pub use dioxus_hooks as hooks;`. null for one a - * `#[path]` puts elsewhere — tokio's `process::imp` is `unix/mod.rs` or - * `windows.rs` by cfg — as for one with no file. + * that name — dioxus's `pub use dioxus_hooks as hooks;`. One a `#[path]` + * puts elsewhere is the file it names when `m` declares it once, whatever + * else loads that file too (a helper several tests load is each test's); + * null when cfg alternatives name a file each (tokio's `process::imp` is + * `unix/mod.rs` or `windows.rs`) or a `cfg_attr` sets the path, as for one + * with no file. */ function rustSubmodule(m: RustModule, name: string, context: ResolutionContext, depth = 0): RustModule | null { const dir = rustJoin(m.dir, name); const layout = rustUsesOf(m.file, context); - if (layout.pathMods.has(name)) return null; + if (layout.pathMods.has(name)) { + const decls = rustPathDeclsIn(m.file, context).filter((d) => d.name === name && rustBlockAt(layout.blocks, d.at) === m.block); + const target = decls.length === 1 ? decls[0]!.target : null; + return target !== null && context.fileExists(target) ? { file: target, dir: rustParentDir(target), block: null, root: false } : null; + } const block = layout.blocks.find((b) => b.name === name && b.parent === m.block); if (block) return { file: m.file, dir, block, root: false }; for (const file of [`${dir}.rs`, `${dir}/mod.rs`]) { @@ -9392,11 +9660,18 @@ function rustSubmodule(m: RustModule, name: string, context: ResolutionContext, return null; } -/** The module `m` is declared in — what `super::` names — or null above a crate's root. */ +/** + * The module `m` is declared in — what `super::` names — or null above a + * crate's root. A file a `#[path]` loads is declared where that attribute is, + * wherever the file lies (fast-mlsirm's `tests/unit/scaling_tests.rs` is + * `crates/core/src/scaling.rs`'s `mod tests`). + */ function rustSuperModule(m: RustModule, context: ResolutionContext): RustModule | null { if (m.block) { return m.block.parent ? { file: m.file, dir: rustParentDir(m.dir), block: m.block.parent, root: false } : rustFileModule(m.file, context); } + const claim = rustPathClaim(m.file, context); + if (claim) return rustModuleAt(claim.file, claim.at, context); if (m.root) return null; const dir = rustParentDir(m.dir); for (const file of [rustJoin(dir, 'mod.rs'), ...(dir === '' ? [] : [`${dir}.rs`]), rustJoin(dir, 'lib.rs'), rustJoin(dir, 'main.rs')]) { @@ -9523,7 +9798,7 @@ function rustGlobbedItems( const from = fromOf(g); const mod = rustWalkModules(g.segs, from, context); if (mod === 'outside') continue; - const items = mod === null ? null : rustModuleItems(mod, name, context, seen, !rustModuleWithin(from, mod)); + const items = mod === null ? null : rustModuleItems(mod, name, context, seen, !rustModuleWithin(from, mod, context)); if (items === null) unknown = true; else found.push(...items); } @@ -9531,14 +9806,17 @@ function rustGlobbedItems( } /** Whether module `inner` is `outer` or one declared inside it, inline or in a file below its directory. */ -function rustModuleWithin(inner: RustModule, outer: RustModule): boolean { +function rustModuleWithin(inner: RustModule, outer: RustModule, context?: ResolutionContext, depth = 0): boolean { if (inner.file === outer.file) { for (let b: RustModBlock | null = inner.block; ; b = b.parent) { if (b === outer.block) return true; if (b === null) return false; } } - return inner.file.startsWith(outer.dir === '' ? '' : `${outer.dir}/`) && !(outer.dir === '' && outer.block !== null); + if (inner.file.startsWith(outer.dir === '' ? '' : `${outer.dir}/`) && !(outer.dir === '' && outer.block !== null)) return true; + // A file a `#[path]` loads is inside the module that declares it. + const claim = context && depth < 8 && inner.block === null ? rustPathClaim(inner.file, context) : null; + return claim !== null && rustModuleWithin(rustModuleAt(claim.file, claim.at, context!), outer, context, depth + 1); } /** @@ -9716,9 +9994,9 @@ const RUST_MODULE_ITEM_KINDS = new Set(['function', 'struct', 'enum', 'union', ' * same rule isVisibleAcrossFiles applies to the pipeline's pick, here ahead of * the ranking, so the crate's own `pub(crate) type Result` can win instead. */ -function isRustItemVisibleFrom(candidate: Node, ref: UnresolvedRef): boolean { +function isRustItemVisibleFrom(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { if (candidate.language !== 'rust' || candidate.visibility !== 'private' || !RUST_MODULE_ITEM_KINDS.has(candidate.kind)) return true; - return candidate.filePath === ref.filePath || ref.filePath.startsWith(`${rustModuleDir(candidate.filePath)}/`); + return candidate.filePath === ref.filePath || rustFileUnder(ref.filePath, rustModuleDir(candidate.filePath), candidate.filePath, context); } /** What a Rust `impl` declares besides its functions: `type Err = …;`, `const MAX: usize = …;`. */ @@ -9761,7 +10039,7 @@ function rustGlobCovers(uses: RustUses, candidate: Node, ref: UnresolvedRef): bo */ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { if (RUST_SCOPED_REFS.get(context)?.has(ref)) return true; - if (!isRustItemVisibleFrom(candidate, ref)) return false; + if (!isRustItemVisibleFrom(candidate, ref, context)) return false; const name = ref.referenceName; // Bare in the SOURCE: the index keeps `crate::error::Result` by its last // segment, and a path is not a prelude lookup. @@ -10088,7 +10366,10 @@ export function rustPathTarget(ref: UnresolvedRef, context: ResolutionContext): 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) { + // As a 2015 path, from the crate's root — unless the module declares the + // head itself, where a `#[path]` puts it (a cfg's choice): another module + // of that name at the root is not it. + if (items === null && bare && !rustUsesOf(from.file, context).pathMods.has(head)) { const root = rustCrateRootModule(ref.filePath, context); if (root) items = rustUsePathItems(path, root, context, new Set()); } @@ -11127,7 +11408,7 @@ function rustTypeLookup(name: string, writtenPath: string[], file: string, conte if (segments.length === 0 && (RUST_STD_TYPES.has(real) || RUST_PRELUDE.has(real))) return outside; return { nodes: [], outside: false, undeclared: true, name: real }; } - let nodes = all.filter((n) => isRustItemVisibleFrom(n, fileRef)); + let nodes = all.filter((n) => isRustItemVisibleFrom(n, fileRef, context)); // A path the project's modules can be walked along, written or the one a // `use` binds, names the item of the module it walks to, as a `use` does in // isRustNameInScope: tokio's `crate::runtime::Handle` is the one @@ -20643,6 +20924,10 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUST_VISIBLE_ITEMS.delete(context); RUST_PATH_TARGETS.delete(context); RUST_TYPE_MEMBERS.delete(context); + RUST_PATH_DECLS.delete(context); + RUST_PATH_LOADS.delete(context); + RUST_PATH_LOADERS.delete(context); + RUST_FILES_BY_DIR.delete(context); RUST_SCOPED_REFS.delete(context); RUST_GLOB_ITEMS.delete(context); RUST_CRATES.delete(context);