mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +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:
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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