fix(rust): a bare name is in scope only through its module or a use (#2214)

The Rust scope rule (#2124) only checked prelude names; every other bare
name could mean any same-named project item, and a name after any path
(`io::Result`) could mean any at all. The Rust framework resolver's
PascalCase / handler / service patterns bound names with no scope check at
0.7-0.8. tokio's 1,221 `io::Result<...>` went to a private
`runtime::task::Result` alias, 642 `Context<'_>` (from `use
std::task::{Context, Poll}`) to `runtime::context::Context`.

- A type or function from another file is in scope only through a `use`
  that binds it (its leaf, `as` alias, or `{self}`) or a glob over its
  module; methods and fields (reached through a value), import refs and
  module / file targets are unaffected.
- A name a file imports from outside the project - `std` / `core` /
  `alloc`, or a crate its Cargo manifests declare as a dependency - is that
  outside item. (An inline `mod support { pub mod panic; }` is the
  project's.)
- A path names its module: `seg::Name` reaches an item of module `seg`;
  a path rooted at std (`std::io::Error`) or at an outside import (`io::`
  under `use std::io`) reaches none; `crate::` / `self::` / `super::` and a
  project crate's name (`clap::Command`, re-exported) look it up as before.
  The path is read anywhere on the line, not only at the reference's column.
- The framework resolver's patterns apply the same check.

tokio -3008 +610, serde -792 +650, ripgrep -602 +247, axum -453 +186, clap
-342 +32, bat -38 +9. Sampled losses: std / core / futures / syn / http /
anyhow imports and paths, associated types (`S::Ok`, `S::Error`, `V::Value`,
`Fut::Output`); gains are same-module items the old first match hid.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 22:54:08 +00:00
committed by GitHub
parent 5f1a635714
commit fa8d7284c5
4 changed files with 217 additions and 7 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Rust type and function names now follow Rust's scoping. A name from another file counts only when a `use` brings it in (or a `*` glob over its module). One imported from outside the project, like `use std::task::{Context, Poll}` or `use futures::Stream`, means that outside item. A path names its module: `io::Result` isn't an unrelated `Result` alias elsewhere, and `std::io::Error` is std's. On tokio, over 1,200 `io::Result` and 600 `Context` references had been landing on private project types.
- In Java, C#, Kotlin, Swift, Scala, Dart and VB.NET, a call now reaches the overload whose parameters fit its arguments, not whichever same-named overload was indexed first. For example, `HashCodeBuilder.reflectionHashCode(this)` now reaches the one-argument overload with varargs, not a three-parameter one. Each overload's callers are now its own.
- A method that calls another overload of itself now links to that overload rather than to itself. An example is `toInstant(instant)` returning `toInstant(instant, Instant.EPOCH)`. The fuller overload now lists its convenience overloads among its callers, so its impact includes them. This applies to Java, C#, Kotlin, Swift, C++, Scala, Dart and VB.NET. Real recursion is unchanged.
- In a project with a React front end, the React rules for hooks (`use…`) and contexts (`…Context`, `…Provider`) no longer apply to backend code. In halo, a Spring app with a React UI, Java imports such as `SecurityContext` and `ObjectProvider` had been handled as React contexts.
+90
View File
@@ -0,0 +1,90 @@
/**
* A bare Rust type or function from another file is in scope only through a
* `use` that binds it (or a glob over its module), and one the file imports
* from outside the project — `use std::task::{Context, Poll}` — is that
* outside item: tokio's 1,221 `io::Result<…>` bound to a private
* `runtime::task::Result` alias, its 642 `Context<'_>` to
* `runtime::context::Context`. A path names its module (`io::Result` is no
* `task` alias; `std::io::Error` is std's), a project crate's name
* (`clap::Command`) re-exports as `crate::` does, and an inline test module
* (`mod support { pub mod panic; }`) is the project's own.
*/
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';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-scope-'));
const files: Record<string, string> = {
'Cargo.toml': `[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nfutures = "0.3"\n`,
'src/lib.rs': `pub mod runtime;\npub mod io;\npub mod net;\n`,
'src/runtime/mod.rs': `pub mod context;\npub mod task;\npub mod driver;\n`,
'src/runtime/context.rs': `pub struct Context {\n pub depth: u8,\n}\n`,
'src/runtime/task/mod.rs': `pub type Result<T> = std::result::Result<T, ()>;\n`,
'src/io/mod.rs': `pub mod copy;\n`,
'src/io/copy.rs': `pub struct Error;\n`,
'src/net.rs': `use std::io;
use std::task::{Context, Poll};
pub fn poll_ready(cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let _ = cx;
Poll::Ready(Ok(()))
}
pub fn fail() -> std::io::Error {
std::io::Error::other("x")
}
`,
'src/runtime/driver.rs': `use crate::runtime::context::Context;
pub fn enter(ctx: &Context) -> u8 {
ctx.depth
}
`,
'tests/support/panic.rs': `pub fn test_panic() {}\n`,
'tests/io_panic.rs': `mod support {
pub mod panic;
}
use support::panic::test_panic;
#[test]
fn panics() {
test_panic();
}
`,
};
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 });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const targetsFrom = (file: string) => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!.filePath);
};
describe('Rust names in scope', () => {
it('an outside import, or a path through one, is never a project item', () => {
const targets = targetsFrom('src/net.rs');
expect(targets).not.toContain('src/runtime/context.rs');
expect(targets).not.toContain('src/runtime/task/mod.rs');
expect(targets).not.toContain('src/io/copy.rs');
});
it('a crate-local use, and an inline test module, still reach the project item', () => {
expect(targetsFrom('src/runtime/driver.rs')).toContain('src/runtime/context.rs');
expect(targetsFrom('tests/io_panic.rs')).toContain('tests/support/panic.rs');
});
});
+14 -3
View File
@@ -8,6 +8,17 @@ import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
import { getCargoWorkspaceCrateMap } from './cargo-workspace';
import { isRustNameInScope } from '../name-matcher';
/**
* Whether the item a name heuristic found is one the reference can name:
* `Context<'_>` under `use std::task::{Context, Poll}` is std's, not tokio's
* `runtime::context::Context` (642 of them).
*/
function inRustScope(id: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const node = context.getNodeById?.(id);
return !node || isRustNameInScope(node, ref, context);
}
const cargoWorkspaceMapCache = new WeakMap<ResolutionContext, Map<string, string>>();
@@ -32,7 +43,7 @@ export const rustResolver: FrameworkResolver = {
// Pattern 1: Handler references
if (ref.referenceName.endsWith('_handler') || ref.referenceName.startsWith('handle_')) {
const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, HANDLER_DIRS, context);
if (result) {
if (result && inRustScope(result, ref, context)) {
return {
original: ref,
targetNodeId: result,
@@ -45,7 +56,7 @@ export const rustResolver: FrameworkResolver = {
// Pattern 2: Service/Repository trait implementations
if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
if (result) {
if (result && inRustScope(result, ref, context)) {
return {
original: ref,
targetNodeId: result,
@@ -58,7 +69,7 @@ export const rustResolver: FrameworkResolver = {
// Pattern 3: Struct references (PascalCase)
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
const result = resolveByNameAndKind(ref.referenceName, STRUCT_KINDS, MODEL_DIRS, context);
if (result) {
if (result && inRustScope(result, ref, context)) {
return {
original: ref,
targetNodeId: result,
+112 -4
View File
@@ -13,6 +13,7 @@ import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-built
import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility';
import { isTestPath } from '../search/query-utils';
import { isMinifiedContent } from '../extraction/generated-detection';
import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace';
/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
* score. A name defined more times than this is "ubiquitous" — a method/symbol
@@ -3775,6 +3776,59 @@ interface RustUses {
names: Set<string>;
/** `X` of each `use …::X::*` (`super` for `use super::*`). */
globs: Set<string>;
/** Names the file imports from outside the project — `use std::task::{Context, Poll}`, `use futures::Stream`. */
external: Set<string>;
/** The items the file's project `use`s bind — their leaves, not the paths they walk. */
bound: Set<string>;
}
const RUST_CRATES = new WeakMap<ResolutionContext, Set<string>>();
const RUST_DEPENDENCIES = new WeakMap<ResolutionContext, Set<string>>();
/**
* The crates the project's manifests depend on (`[dependencies]`,
* `[dev-dependencies]`, `[build-dependencies]`, per-target ones), by the name
* code writes them (`futures_util`), less the project's own.
*/
function rustDependencyCrates(context: ResolutionContext): Set<string> {
const hit = RUST_DEPENDENCIES.get(context);
if (hit) return hit;
const deps = new Set<string>();
const manifests = ['Cargo.toml', ...[...getCargoWorkspaceCrateMap(context).values()].map((dir) => `${dir}/Cargo.toml`)];
for (const manifest of new Set(manifests)) {
const text = context.readFile(manifest) ?? '';
let inDeps = false;
for (const raw of text.split(/\r?\n/)) {
const line = raw.replace(/#.*$/, '').trim();
const header = /^\[([^\]]+)\]$/.exec(line);
if (header) {
const table = header[1]!.trim();
// `[dependencies.tokio]` names one dependency in its header.
const named = /(?:^|\.)(?:dev-|build-)?dependencies\.([A-Za-z0-9_-]+)$/.exec(table);
if (named) deps.add(named[1]!.replace(/-/g, '_'));
inDeps = /(?:^|\.)(?:dev-|build-)?dependencies$/.test(table);
continue;
}
const key = inDeps ? /^([A-Za-z0-9_-]+)\s*=/.exec(line)?.[1] : undefined;
if (key) deps.add(key.replace(/-/g, '_'));
}
}
for (const own of rustProjectCrates(context)) deps.delete(own);
RUST_DEPENDENCIES.set(context, deps);
return deps;
}
/** The project's own crate names (`tokio`, `tokio_util`), from its Cargo.toml files. */
function rustProjectCrates(context: ResolutionContext): Set<string> {
const hit = RUST_CRATES.get(context);
if (hit) return hit;
const crates = new Set<string>();
// The manifests are not indexed files: the root's package, and the workspace's members.
const root = /\[package\][^[]*?\bname\s*=\s*"([^"]+)"/.exec(context.readFile('Cargo.toml') ?? '')?.[1];
if (root) crates.add(root.replace(/-/g, '_'));
for (const name of getCargoWorkspaceCrateMap(context).keys()) crates.add(name.replace(/-/g, '_'));
RUST_CRATES.set(context, crates);
return crates;
}
const RUST_USES = new WeakMap<ResolutionContext, Map<string, RustUses>>();
@@ -3786,12 +3840,28 @@ function rustUsesOf(filePath: string, context: ResolutionContext): RustUses {
}
const hit = memo.get(filePath);
if (hit) return hit;
const uses: RustUses = { names: new Set(), globs: new Set() };
const uses: RustUses = { names: new Set(), globs: new Set(), external: new Set(), bound: new Set() };
const leaves = (tree: string): string[] => [
...[...tree.matchAll(/([A-Za-z_]\w*)\s*(?=[,}]|$|\s+as\b)|\bas\s+([A-Za-z_]\w*)/g)]
.map((leaf) => leaf[2] ?? leaf[1]!).filter((id) => id !== 'self' && id !== 'as'),
// `use std::io::{self, Read}` binds `io` too.
...[...tree.matchAll(/([A-Za-z_]\w*)\s*::\s*\{[^{}]*\bself\b/g)].map((m) => m[1]!),
];
// Comments first: a doc comment's prose ("…use the Option…") is not a `use`.
const text = stripCommentsForRegex(context.readFile(filePath) ?? '', 'rust');
const dependencies = rustDependencyCrates(context);
for (const m of text.matchAll(/(?:^|[;{}\s])use\s+([^;]{1,2000});/g)) {
const tree = m[1]!;
if (/^\s*(?:::)?(?:std|core|alloc)\b/.test(tree)) continue;
const root = /^\s*(?:::)?([A-Za-z_]\w*)/.exec(tree)?.[1] ?? '';
// Outside: the standard library or a crate the manifests depend on — not a
// module of the project's (`mod support { … }` inline in a test).
const outside = root === 'std' || root === 'core' || root === 'alloc' || (root !== '' && dependencies.has(root));
// The items it binds: each leaf (`as` aliases by their alias), never the path it walks.
if (outside) {
for (const id of leaves(tree)) uses.external.add(id);
continue;
}
for (const id of leaves(tree)) uses.bound.add(id);
for (const id of tree.matchAll(/[A-Za-z_]\w*/g)) uses.names.add(id[0]);
for (const g of tree.matchAll(/(\w+)\s*::\s*(?:\{[^}]*)?\*/g)) uses.globs.add(g[1]!);
}
@@ -3829,7 +3899,27 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context:
// Bare in the SOURCE: the index keeps `crate::error::Result` by its last
// segment, and a path is not a prelude lookup.
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
if (line !== undefined && line.startsWith(name, ref.column) && /::\s*$/.test(line.slice(0, ref.column))) return true;
// Written through a path on its line (`jsont::SubMatch { … }`, `io::Result<…>`),
// wherever the reference's column points.
const pathed = line === undefined ? null
: (line.startsWith(name, ref.column) && /::\s*$/.test(line.slice(0, ref.column)) ? /((?:[A-Za-z_]\w*\s*::\s*)*)([A-Za-z_]\w*)?\s*::\s*$/.exec(line.slice(0, ref.column))
: !new RegExp(`(?<![\\w$:])${name}\\b`).test(line) ? new RegExp(`((?:[A-Za-z_]\\w*\\s*::\\s*)*)([A-Za-z_]\\w*)\\s*::\\s*${name}\\b`).exec(line) : null);
if (pathed) {
// Through a path: `crate::` / `self::` / `super::` look it up relatively;
// `io::Result` is the `io` module's — tokio's `runtime/task` alias is not —
// and a path from std (`std::io::Error`) is std's.
const seg = pathed[2] ?? '';
const root = /^([A-Za-z_]\w*)/.exec(pathed[1] ?? '')?.[1] ?? seg;
if ((root === 'std' || root === 'core' || root === 'alloc') && candidate.filePath !== ref.filePath) 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;
// `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;
return rustModuleName(candidate.filePath) === seg || candidate.filePath.includes(`/${seg}/`) ||
candidate.qualifiedName.split('::').includes(seg);
}
if (candidate.kind === 'enum_member') {
if (ref.referenceKind === 'references') return false;
const uses = rustUsesOf(ref.filePath, context);
@@ -3837,8 +3927,24 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context:
const owner = cut >= 0 ? candidate.qualifiedName.slice(0, cut).split('::').pop()! : '';
return (owner !== '' && uses.globs.has(owner)) || (uses.names.has(name) && uses.names.has(owner));
}
if (!RUST_PRELUDE.has(name) || candidate.filePath === ref.filePath) return true;
if (candidate.filePath === ref.filePath) return true;
const uses = rustUsesOf(ref.filePath, context);
// `use std::task::{Context, Poll}`: the file's `Context` is std's, not tokio's
// `runtime::context::Context`. (A method call `.env(…)` is no imported name.)
if (uses.external.has(name) && !uses.bound.has(name) && line !== undefined) {
// Not on its line at all: a later link of a chain written across lines (`Arg::new(…)\n.env(…)`).
const at = new RegExp(`(?<![\\w$])${name}\\b`).exec(line.slice(Math.max(0, ref.column)));
if (at && !/\.\s*$/.test(line.slice(0, Math.max(0, ref.column) + at.index))) return false;
}
if (!RUST_PRELUDE.has(name)) {
// Another file's item — a type, a function — is in scope only through a
// `use` that binds it or a glob over its module: tokio's `Context<'_>` is
// not `runtime::task::trace`'s `Context` unless the file brings that one
// in. A method is reached through a value, never a `use`.
if (TYPE_MEMBER_KINDS.has(candidate.kind) || ref.referenceKind === 'imports' ||
candidate.kind === 'file' || candidate.kind === 'module' || candidate.kind === 'namespace') return true;
return uses.bound.has(name) || rustGlobCovers(uses, candidate, ref);
}
return uses.names.has(name) || rustGlobCovers(uses, candidate, ref);
}
@@ -5217,6 +5323,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
C_STATIC_MEMO.delete(context);
RUST_TRAIT_IMPL_MEMO.delete(context);
RUST_USES.delete(context);
RUST_CRATES.delete(context);
RUST_DEPENDENCIES.delete(context);
LEXICAL_SCOPE_MEMO.delete(context);
KOTLIN_LAMBDA_RECEIVERS.delete(context);
SCALA_IMPORTED_SUPERS.delete(context);