mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 17:48:44 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user