fix(rust): a name written through a path is what the path names, not a same-file namesake (#2711)

A Rust name written through a path is an item of what the path names: a
module (`ast::Pattern`, `io::Error`), a type (`Source::Record { … }`) or a
crate (`mio::Interest`, `std::ffi::OsStr`). isRustNameInScope accepted any
candidate in the file the path is written in, whatever the path said, and
a std path only when the candidate was in another file:
- clap's `std::ffi::OsStr` linked to its own `struct OsStr`, and
  `io::Error`, `fmt::Error` and `fmt::Result` in `error/mod.rs` to its own
  `Error` and `Result`;
- tokio's `mio::Interest` linked to its own `Interest`, and
  `std::time::Instant` to its own `Instant`;
- serde's tests' `Token::Struct { … }` literals linked to their own
  `Struct`s, and serde_derive's `syn::Path { … }` to a `Default::Path`
  variant;
- gleam's `ast::Pattern` linked to the `enum Pattern` beside it, and
  `BinaryBuilderPosition::RecordAttribute { … }` to another enum's
  variant of that name.

Fix, in the path branch of isRustNameInScope:
- A path from std, core, alloc or a crate the manifests depend on names
  nothing of the project's, in any file, unless the project is that crate
  or a `use` in the file binds the root.
- The same-file shortcut is gone. A candidate is the path's when its
  file is the path's module, its qualified name holds the segment (a
  type's variant or method), or it sits in an inline `mod` of that name
  in the file (isInRustInlineModule; rustfmt closes the block at the
  indentation it opens at).
- Through a module a `use` binds, a candidate must be in a crate that
  `use`'s path can reach (rustUseCanReach, as for a bare name): tokio's
  `io::Handle` under `use crate::runtime::io` is tokio's own, not
  tokio-test's `io.rs` `Handle` (a dev-dependency).

Tests: rust-path-same-file.test.ts, 6 tests, on both engines; 5 fail
on main (the sixth is the inline-module guard), and the crate-reach one
also fails with only that rule switched off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-20 17:38:32 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5e72a43b3c
commit 505c355be3
3 changed files with 227 additions and 6 deletions
+1
View File
@@ -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 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 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 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++, 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. - 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.
+183
View File
@@ -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<string, string> = {
'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<T, E = Error> = std::result::Result<T, E>;',
'',
'pub struct Error;',
'',
'impl From<io::Error> 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<string, string> = {
'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',
]);
});
});
+43 -6
View File
@@ -8656,15 +8656,29 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context:
// `<T as FromStr>::Err`. No module path names one: axum-core's // `<T as FromStr>::Err`. No module path names one: axum-core's
// `std::error::Error` is std's, not its file's `impl IntoResponseParts`'s `type Error`. // `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 (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);
// 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;
// `io::Error` under `use std::io;` is std's, whatever `io/` directory the project has.
const pathUses = rustUsesOf(ref.filePath, 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' || projectCrates.has(seg)) return true;
// `io::Error` under `use std::io;` is std's, whatever `io/` directory the project has.
if (pathUses.external.has(seg) && !pathUses.bound.has(seg)) return false; 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}/`) || 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 // 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 // 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' }; 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 <name> { … }` 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. */ /** Whether `candidate` is declared in the same `impl` / `trait` block as `ref`, above it. */
function isInEnclosingRustImpl(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { function isInEnclosingRustImpl(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
const header = rustEnclosingImplLine(ref, context); const header = rustEnclosingImplLine(ref, context);