diff --git a/CHANGELOG.md b/CHANGELOG.md index fd864a166..b68d3769f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, a type alias, struct, enum or function declared without `pub` now links only from its own module and the modules inside it, and a name a `use` brings in from one of your workspace's crates, like `use tokio_test::task::spawn` or `use crate::error::Error`, links only to an item of that crate or of a crate it depends on. Before, a file's `use crate::Result` could link its `Result<…>` types to another module's private `type Result`, as in dioxus's CLI, and a call to an imported function could link to another crate's function of the same name, such as tokio's test `spawn(…)` calls to one in its examples, or to nothing because a private function of that name was in the way. Re-index Rust projects after upgrading. - In Rust, a name a `use` brings in, like `use crate::sync::watch::Receiver` or `use tokio::task::yield_now`, now links to the item of that name in the module its path leads to, found the way Rust finds it: declared there, in a module declared inline with `mod name { … }`, or passed on by that module's own `pub use`, including one written inside a macro like tokio's `cfg_sync! { … }`. Before, any item of that name in the crate could take it, so tokio's `Receiver<()>` linked to the `Receiver` of `net/unix/pipe.rs`, `crate::runtime::Handle` to the runtime driver's `Handle`, and `crate::runtime::task::JoinHandle` to a test mock. A name whose item is declared by a macro, which the index doesn't see, now links to nothing rather than to a same-named item elsewhere. Re-index Rust projects after upgrading. - In Rust, a name written on its own, like an `Err(…)` call or a `Response` return type, or through a module, like `io::Error` or `use crate::de::Error`, no longer links to a `type` or `const` declared inside an `impl`, such as a `FromStr` impl's `type Err` or a `Service` impl's `type Response`: only a path like `Self::Err` names those, and they are now listed under their type, as `ValueHint::Err`. Before, serde's and ripgrep's `Ok(…)` and `Err(…)` calls linked to a serializer's or parser's `type Ok` or `type Err`, axum's handlers' `Response` to a `Service` impl's, and ripgrep's `impl Captures for RegexCaptures` to another impl's `type Captures` instead of the `Captures` trait. Re-index Rust projects after upgrading. +- In Rust, a name written through a path, like `io::Error`, `std::ffi::OsStr`, `mio::Interest`, `ast::Pattern` or `Source::Record { … }`, now links to what that path names, or to nothing when the path leads into the standard library or another crate, and no longer to an item of the same name that merely sits in the same file. Before, clap's `std::ffi::OsStr` and `io::Error` linked to its own `OsStr` and `Error`, serde's tests' `Token::Struct { … }` to their own `Struct`, and gleam's `ast::Pattern` to the `Pattern` enum beside it. Re-index Rust projects after upgrading. - In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. - In C++, a call on a parameter, a local lambda or functor, a lambda's parameter or a range-`for` variable no longer links to a function or method elsewhere that only shares its name, like protocolbuffers/protobuf's code generators calling `format(…)` on their `Formatter& format` parameter, which linked to the conformance suite's `TestResult::format`, or facebook/rocksdb's tests calling a local `auto key = [](int i) {…};`. C++ finds the local first, so that is what the call calls. diff --git a/__tests__/rust-path-same-file.test.ts b/__tests__/rust-path-same-file.test.ts new file mode 100644 index 000000000..bbd2794ad --- /dev/null +++ b/__tests__/rust-path-same-file.test.ts @@ -0,0 +1,183 @@ +/** + * A Rust name written through a path is an item of what the path names: a + * module (`ast::Pattern`, `io::Error`), a type (`Position::Record { … }`) or a + * crate (`mio::Interest`, `std::ffi::OsStr`). An item of the file the path is + * written in is that only when the path leads there: an inline `mod` of the + * file, the item's own type, or the file's own module. A path's file was + * enough before: clap's `std::ffi::OsStr` linked to its own `struct OsStr`, + * its `io::Error` and `fmt::Error` to `error/mod.rs`'s `struct Error`, tokio's + * `mio::Interest` to its own `Interest`, gleam's `ast::Pattern` to the + * `enum Pattern` beside it, and a `Source::Record { … }` to another enum's + * `Record` variant. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +/** What `file` links `name` to on `line`, as `kind qualifiedName file@line`, sorted. */ +const targetsOn = (cg: CodeGraph, file: string, line: number, name: string): string[] => { + const out: string[] = []; + for (const node of cg.getNodesInFile(file)) { + for (const edge of cg.getOutgoingEdges(node.id)) { + if (edge.line !== line) continue; + const target = cg.getNode(edge.target)!; + if (target.name === name) out.push(`${target.kind} ${target.qualifiedName} ${target.filePath}@${target.startLine}`); + } + } + return [...new Set(out)].sort(); +}; + +describe('a Rust path into another module, type or crate', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-path-file-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "rt"\nversion = "0.1.0"\n\n[dependencies]\nmio = "1"\n', + 'src/lib.rs': 'pub mod ast;\npub mod error;\npub mod exhaustiveness;\npub mod interest;\npub mod os_str;\npub mod position;\n', + 'src/ast.rs': 'pub enum Pattern {\n Int,\n Discard,\n}\n', + // gleam: `ast::Pattern` is the ast module's, not this file's own `Pattern`. + 'src/exhaustiveness.rs': [ + 'use crate::ast;', + '', + 'pub enum Pattern {', + ' Variable,', + '}', + '', + 'pub fn check(pattern: &ast::Pattern) -> bool {', + ' true', + '}', + '', + 'pub fn own() -> Pattern {', + ' Pattern::Variable', + '}', + '', + ].join('\n'), + // clap: `io::Error`, `fmt::Error` and `fmt::Result` are std's, though this file is `error`. + 'src/error/mod.rs': [ + 'use std::{error, fmt, io};', + '', + 'pub type Result = std::result::Result;', + '', + 'pub struct Error;', + '', + 'impl From for Error {', + ' fn from(e: io::Error) -> Self {', + ' Error', + ' }', + '}', + '', + 'impl fmt::Display for Error {', + ' fn fmt(&self, f: &mut fmt::Formatter<\'_>) -> fmt::Result {', + ' Ok(())', + ' }', + '}', + '', + 'impl error::Error for Error {}', + '', + ].join('\n'), + // tokio: `mio::Interest` is the mio crate's. + 'src/interest.rs': 'pub struct Interest(usize);\n\nimpl Interest {\n pub fn to_mio(self) -> mio::Interest {\n todo!()\n }\n}\n', + // clap: `std::ffi::OsStr` is std's. + 'src/os_str.rs': 'pub struct OsStr;\n\nimpl OsStr {\n pub fn as_os_str(&self) -> &std::ffi::OsStr {\n todo!()\n }\n}\n', + // gleam: two enums share a variant name; a path names its own enum's. + 'src/position.rs': [ + 'pub enum Source {', + ' Record { first: bool },', + '}', + '', + 'pub enum Builder {', + ' Record { fields: usize },', + '}', + '', + 'pub fn source() -> Source {', + ' Source::Record { first: true }', + '}', + '', + 'mod inner {', + ' pub struct Cell;', + '}', + '', + 'pub fn cell() -> inner::Cell {', + ' inner::Cell', + '}', + '', + ].join('\n'), + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('names the module a path writes, not an item of the file of that name', () => { + expect(targetsOn(cg, 'src/exhaustiveness.rs', 7, 'Pattern')).toEqual(['enum Pattern src/ast.rs@1']); + expect(targetsOn(cg, 'src/exhaustiveness.rs', 11, 'Pattern')).toEqual(['enum Pattern src/exhaustiveness.rs@3']); + }); + + it('names std through `io::`, `fmt::` and `error::`, whatever the file is called', () => { + expect(targetsOn(cg, 'src/error/mod.rs', 8, 'Error')).toEqual([]); + expect(targetsOn(cg, 'src/error/mod.rs', 14, 'Result')).toEqual([]); + expect(targetsOn(cg, 'src/error/mod.rs', 19, 'Error')).toEqual([]); + }); + + it('names an outside crate through its own name or `std::`', () => { + expect(targetsOn(cg, 'src/interest.rs', 4, 'Interest')).toEqual([]); + expect(targetsOn(cg, 'src/os_str.rs', 4, 'OsStr')).toEqual([]); + }); + + it('names the variant of the enum the path writes', () => { + expect(targetsOn(cg, 'src/position.rs', 10, 'Record')).toEqual(['enum_member Source::Record src/position.rs@2']); + }); + + it('still names an inline module\'s item', () => { + expect(targetsOn(cg, 'src/position.rs', 17, 'Cell')).toEqual(['struct Cell src/position.rs@14']); + }); +}); + +describe('a Rust path through a module a `use` binds', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-path-use-')); + const files: Record = { + 'Cargo.toml': '[workspace]\nmembers = ["rt", "rt-test"]\n', + 'rt/Cargo.toml': '[package]\nname = "rt"\nversion = "0.1.0"\n\n[dev-dependencies]\nrt-test = { path = "../rt-test" }\n', + 'rt/src/lib.rs': 'pub mod runtime;\n', + 'rt/src/runtime/mod.rs': 'pub mod io;\npub mod signal;\n', + 'rt/src/runtime/io/mod.rs': 'pub struct Handle;\n', + // tokio: `io::Handle` under `use crate::runtime::io` is tokio's own, not tokio-test's. + 'rt/src/runtime/signal.rs': 'use crate::runtime::io;\n\npub struct Handle;\n\npub fn new(io_handle: &io::Handle) -> Handle {\n Handle\n}\n', + 'rt-test/Cargo.toml': '[package]\nname = "rt-test"\nversion = "0.1.0"\n\n[dependencies]\nrt = { path = "../rt" }\n', + 'rt-test/src/lib.rs': 'pub mod io;\n', + 'rt-test/src/io.rs': 'pub struct Handle;\n', + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('names an item of a crate that `use`\'s path can reach', () => { + expect(targetsOn(cg, 'rt/src/runtime/signal.rs', 5, 'Handle')).toEqual([ + 'struct Handle rt/src/runtime/io/mod.rs@1', + 'struct Handle rt/src/runtime/signal.rs@3', + ]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5aac6368f..ccf2f4bc1 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8656,15 +8656,29 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: // `::Err`. No module path names one: axum-core's // `std::error::Error` is std's, not its file's `impl IntoResponseParts`'s `type Error`. if (isRustAssociatedItem(candidate)) return seg === '' || (seg === 'Self' && isInEnclosingRustImpl(candidate, ref, context)); - if ((root === 'std' || root === 'core' || root === 'alloc') && candidate.filePath !== ref.filePath) return false; + const projectCrates = rustProjectCrates(context); + const pathUses = rustUsesOf(ref.filePath, context); + // A path from std or a crate the manifests depend on is that crate's, in + // the file of a namesake too: clap's `std::ffi::OsStr` is not its own + // `struct OsStr`, tokio's `mio::Interest` not its own `Interest`. + if ((root === 'std' || root === 'core' || root === 'alloc' || rustDependencyCrates(context).has(root)) && + !projectCrates.has(root) && !pathUses.bound.has(root)) return false; // A project crate's name re-exports as `crate::` does: `clap::Command` is clap_builder's. - if (seg === '' || seg === 'crate' || seg === 'self' || seg === 'super' || seg === 'Self' || candidate.filePath === ref.filePath || - rustProjectCrates(context).has(seg)) return true; + if (seg === '' || seg === 'crate' || seg === 'self' || seg === 'super' || seg === 'Self' || projectCrates.has(seg)) return true; // `io::Error` under `use std::io;` is std's, whatever `io/` directory the project has. - const pathUses = rustUsesOf(ref.filePath, context); if (pathUses.external.has(seg) && !pathUses.bound.has(seg)) return false; + // Through a module a `use` binds, an item of a crate that `use`'s path can + // reach: tokio's `io::Handle` under `use crate::runtime::io` is tokio's, + // not tokio-test's `io.rs` `Handle`. + const from = pathUses.boundFrom.get(root); + if (from && !rustUseCanReach(from, candidate, ref, context)) return false; + // Otherwise the module or type the path names, wherever it is written: + // gleam's `ast::Pattern` is `ast.rs`'s, not the `enum Pattern` beside it, + // and `Source::Record { … }` `Source`'s variant, not another enum's. The + // file's own items are reached so only from an inline `mod` of the file. return rustModuleName(candidate.filePath) === seg || candidate.filePath.includes(`/${seg}/`) || - candidate.qualifiedName.split('::').includes(seg); + candidate.qualifiedName.split('::').includes(seg) || + (candidate.filePath === ref.filePath && isInRustInlineModule(candidate, seg, context)); } // Nor does its name alone: clap's `Err(…)` calls went to the `type Err` of // a `FromStr` impl in the same file, axum's routing tests' `Response`s to @@ -9091,6 +9105,29 @@ function matchRustSelfPath(name: string, ref: UnresolvedRef, context: Resolution return { original: ref, targetNodeId: target.id, confidence: target.filePath === ref.filePath ? 0.9 : 0.8, resolvedBy: 'qualified-name' }; } +/** + * Whether `candidate` sits in an inline `mod { … }` of its own file — + * the only way a path through `name` reaches it, since an inline module adds + * nothing to its items' qualified names. rustfmt closes the block at the + * indentation it opens at. + */ +function isInRustInlineModule(candidate: Node, name: string, context: ResolutionContext): boolean { + const lines = context.getFileLines?.(candidate.filePath) ?? context.readFile(candidate.filePath)?.split(/\r?\n/) ?? []; + const head = new RegExp(`^(\\s*)(?:pub(?:\\([^)]*\\))?\\s+)?mod\\s+${name}\\s*\\{`); + for (let i = candidate.startLine - 2; i >= 0; i--) { + const open = head.exec(lines[i] ?? ''); + if (!open) continue; + const indent = open[1]!.length; + if (candidate.startColumn <= indent) return false; + for (let j = i + 1; j < candidate.startLine - 1; j++) { + const closer = /^(\s*)\}/.exec(lines[j] ?? ''); + if (closer && closer[1]!.length <= indent) return false; + } + return true; + } + return false; +} + /** Whether `candidate` is declared in the same `impl` / `trait` block as `ref`, above it. */ function isInEnclosingRustImpl(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { const header = rustEnclosingImplLine(ref, context);