mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(resolution): a bare Rust name reaches only what is in scope (#2124)
Found by the README-wide sweep: ripgrep bound 469 `Some(x)` calls to its own `EncodingMode::Some` variant and 536 `Ok(x)` to `ParseResult::Ok`; serde bound its `Ok(…)`/`Result<…>` to the `struct Ok` / `struct Result` its macro-hygiene test declares (845 on one); tokio 924 `Ok` onto `TransitionToIdle::Ok`, 290 `drop(x)` onto a `drop` method. For a name that is bare IN THE SOURCE (a `::` path is kept by its last segment and is not a prelude lookup — checked at the reference's column): - an enum variant is in scope only through a `use` of it (and its enum) or of its enum's `*`, and never names a type; - a prelude name (Ok/Err/Some/None/Result/Option/Box/Vec/String/drop/…) reaches a project item in another file only through a project `use` of that name or a glob of that item's own module (`use crate::glob::*`, `use super::*` for the parent) — `use std::…` trees don't count, and the `use` scan runs on comment-stripped text (a doc comment's "…use the Option…" once read as an import); - fuzzy matching is case-exact for Rust (`Bytes` ≠ method `bytes`, `Ok` ≠ fn `ok`). Applied as a candidate filter (so the in-scope item can win) and at resolveOne's seam — a Rust framework resolver's `Ok(x)` → `struct Ok` construction bypassed name matching. A/B vs main (edge diffs, every change classified): ripgrep −1,462/+0, serde −2,072/+40, tokio −1,820/+2, clap −752/+7, axum −397/+4, bat −45/+0 — losses are Ok/Some/Err/Result/Option/Vec/drop and same-named variants pinned on project items; gains are pre-existing same-name ambiguity (serde's two `Content` enums). 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
|
||||
|
||||
- In Rust, a bare `Ok(…)`, `Some(…)`, `Err(…)`, `Result<…>`, `Option<…>`, `Vec<…>` or `drop(…)` now means the standard library's, unless the file defines or imports a project item of that name. These used to link to any same-named enum variant or struct in the project: every `Some(x)` in ripgrep to one enum's `Some` variant, and every `Ok` and `Result` in serde to structs its macro-hygiene tests declare. An enum variant is now reached by a bare name only where the file `use`s it or its enum's `*`, and a variant never stands for a type. Re-index Rust projects after upgrading.
|
||||
- A generic type parameter, like `A` in `def zipWith[A, B](…)`, `T` in `<T> T max(…)` or `class Foo<T>`, no longer links to a project symbol that happens to share its name. On cats, one `implicit def A` had thousands of made-up dependents from every `A` in the library. The same goes for Rust, Dart, TypeScript, Java, Kotlin, Swift, Go, C# and C++. A class or other type declared inside a function is only linked from inside that function. In Scala, a `def`'s own parameter, like `f` in `(f: A => B)`, no longer links to a same-named field elsewhere. A bare name no longer reaches a type nested inside another type unless the code is inside that type, extends it or imports its members; the type in scope is linked instead, like cats' own `FlatMap` in place of `Eval`'s nested `FlatMap`. Re-index projects in these languages after upgrading.
|
||||
- A minified or bundled JavaScript file is now recognized as generated even when it isn't named `.min.js`, for example a docs site's `bundle.js`, a readable webpack build or a vendored `underscore-min.js`. Its one-letter functions now rank below your own code in search and `codegraph_explore` answers.
|
||||
- In C# and VB.NET, a type written in code, like `Type sourceType`, `List<int>`, `new TypeMap()` or `Dictionary<string, Type>`, now links to the type and no longer to a property, method or field that shares its name. `List<…>` used to link to some test class's `List` property, `Type` to an attribute's `Type` property, and a parameter of type `TypeMap` to a `TypeMap` property instead of the `TypeMap` class. Those members showed up with hundreds of made-up dependents. Reading a property or passing a method, like `Builder.Services` or `MapGet("/x", GetItems)`, still links as before. Re-index C# and VB.NET projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* A bare Rust name reaches only what is in scope. `Some(x)` / `Ok(x)` are the
|
||||
* prelude's: ripgrep bound every one to its own `EncodingMode::Some` and
|
||||
* `ParseResult::Ok` variants, serde its `Ok`/`Result` to the structs its
|
||||
* macro-hygiene test declares. A variant needs a `use` of it or its enum's `*`.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const FILES: Record<string, string> = {
|
||||
'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\n',
|
||||
'src/lowargs.rs': `pub enum EncodingMode {
|
||||
Auto,
|
||||
Some(u8),
|
||||
Disabled,
|
||||
}
|
||||
pub enum ParseResult<T> {
|
||||
Ok(T),
|
||||
Err(String),
|
||||
}
|
||||
`,
|
||||
'src/hygiene.rs': `struct Ok;
|
||||
struct Result;
|
||||
fn local() -> Ok { Ok }
|
||||
`,
|
||||
'src/decompress.rs': `pub fn find(cmd: u8) -> Option<u8> {
|
||||
if cmd > 0 { return Some(cmd); }
|
||||
let r: std::result::Result<u8, ()> = Ok(cmd);
|
||||
None
|
||||
}
|
||||
`,
|
||||
'src/error.rs': `pub struct Error;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
`,
|
||||
'src/api.rs': `pub fn load() -> crate::error::Result<u8> { unimplemented!() }
|
||||
`,
|
||||
'src/modes.rs': `use crate::lowargs::EncodingMode::*;
|
||||
pub fn pick() -> crate::lowargs::EncodingMode { Some(1) }
|
||||
`,
|
||||
};
|
||||
|
||||
describe('Rust: a bare name reaches what is in scope', () => {
|
||||
it('the prelude, a used variant, a same-file item', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-prelude-'));
|
||||
roots.push(root);
|
||||
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);
|
||||
}
|
||||
const cg = await CodeGraph.init(root, { index: true });
|
||||
try {
|
||||
const targets = (fn: string): string[] => {
|
||||
const from = cg.getNodesByName(fn).filter((n) => n.kind === 'function');
|
||||
return cg
|
||||
.getOutgoingEdgesFrom(from.map((n) => n.id), ['calls', 'references', 'instantiates'])
|
||||
.map((e) => cg.getNode(e.target)!)
|
||||
.map((n) => `${n.kind}:${n.qualifiedName}`);
|
||||
};
|
||||
// Prelude `Some` / `Ok`, not the project's variants or structs.
|
||||
expect(targets('find').filter((t) => /Some|Ok|Result/.test(t))).toEqual([]);
|
||||
// `use EncodingMode::*` brings the variant in.
|
||||
expect(targets('pick')).toContain('enum_member:EncodingMode::Some');
|
||||
// A path (`crate::error::Result`) is not a prelude lookup, so it still links.
|
||||
expect(targets('load').some((t) => t.endsWith(':Result'))).toBe(true);
|
||||
// A same-file struct still shadows the prelude.
|
||||
expect(targets('local')).toContain('struct:Ok');
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
+16
-4
@@ -21,7 +21,7 @@ import {
|
||||
isImportableKind,
|
||||
CPP_DEFINE_SIGNATURE,
|
||||
} from './types';
|
||||
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
|
||||
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope } from './name-matcher';
|
||||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||||
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
|
||||
@@ -994,9 +994,10 @@ export class ReferenceResolver {
|
||||
ref,
|
||||
this.context,
|
||||
);
|
||||
const resolved = candidate?.resolvedBy === 'framework'
|
||||
? this.gateFrameworkLanguage(candidate, ref)
|
||||
: this.gateLanguage(candidate, ref);
|
||||
const scoped = this.gateRustScope(candidate, ref);
|
||||
const resolved = scoped?.resolvedBy === 'framework'
|
||||
? this.gateFrameworkLanguage(scoped, ref)
|
||||
: this.gateLanguage(scoped, ref);
|
||||
if (!resolved || ref.referenceKind !== 'calls') return resolved;
|
||||
|
||||
const target = this.nodeById(resolved.targetNodeId);
|
||||
@@ -2860,6 +2861,17 @@ export class ReferenceResolver {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare Rust name reaches only what is in scope — every strategy's result,
|
||||
* a framework resolver's `Ok(x)` → `struct Ok` construction included (see
|
||||
* isRustNameInScope).
|
||||
*/
|
||||
private gateRustScope(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result || ref.language !== 'rust' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return result;
|
||||
const target = this.nodeById(result.targetNodeId);
|
||||
return target && !isRustNameInScope(target, ref, this.context) ? null : result;
|
||||
}
|
||||
|
||||
/** The repository's own package name, from its root package.json; null without one. */
|
||||
private ownPackageName: string | null | undefined;
|
||||
|
||||
|
||||
@@ -1039,6 +1039,85 @@ function canNameInTypePosition(n: Node): boolean {
|
||||
return !(n.kind === 'property' || n.kind === 'method' || n.kind === 'enum_member' || n.kind === 'field');
|
||||
}
|
||||
|
||||
/** Names the Rust prelude puts in every module; a project item of the same name needs a `use` to shadow one. */
|
||||
const RUST_PRELUDE = new Set([
|
||||
'Ok', 'Err', 'Some', 'None', 'Result', 'Option', 'Box', 'Vec', 'String', 'Default', 'Drop', 'Iterator',
|
||||
'IntoIterator', 'From', 'Into', 'Clone', 'Copy', 'Send', 'Sync', 'Sized', 'ToString', 'ToOwned', 'PartialEq',
|
||||
'Eq', 'PartialOrd', 'Ord', 'AsRef', 'AsMut', 'Fn', 'FnMut', 'FnOnce', 'Extend', 'drop',
|
||||
]);
|
||||
|
||||
interface RustUses {
|
||||
/** Every identifier in the file's project `use` trees — not `std::` / `core::` / `alloc::` ones. */
|
||||
names: Set<string>;
|
||||
/** `X` of each `use …::X::*` (`super` for `use super::*`). */
|
||||
globs: Set<string>;
|
||||
}
|
||||
const RUST_USES = new WeakMap<ResolutionContext, Map<string, RustUses>>();
|
||||
|
||||
function rustUsesOf(filePath: string, context: ResolutionContext): RustUses {
|
||||
let memo = RUST_USES.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
RUST_USES.set(context, memo);
|
||||
}
|
||||
const hit = memo.get(filePath);
|
||||
if (hit) return hit;
|
||||
const uses: RustUses = { names: new Set(), globs: new Set() };
|
||||
// Comments first: a doc comment's prose ("…use the Option…") is not a `use`.
|
||||
const text = stripCommentsForRegex(context.readFile(filePath) ?? '', 'rust');
|
||||
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;
|
||||
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]!);
|
||||
}
|
||||
memo.set(filePath, uses);
|
||||
return uses;
|
||||
}
|
||||
|
||||
/** The module a Rust file is: `src/glob.rs` → `glob`, `src/walk/mod.rs` → `walk`. */
|
||||
function rustModuleName(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
const base = parts[parts.length - 1]!.replace(/\.rs$/, '');
|
||||
return base === 'mod' || base === 'lib' || base === 'main' ? parts[parts.length - 2] ?? base : base;
|
||||
}
|
||||
|
||||
/** Does one of the file's globs bring in this candidate's module (or, for `use super::*`, its parent's)? */
|
||||
function rustGlobCovers(uses: RustUses, candidate: Node, ref: UnresolvedRef): boolean {
|
||||
if (uses.globs.has(rustModuleName(candidate.filePath))) return true;
|
||||
if (!uses.globs.has('super')) return false;
|
||||
const dir = (p: string): string => p.slice(0, p.lastIndexOf('/'));
|
||||
// `use super::*` in a child module: the parent's file, or a sibling in the parent's directory.
|
||||
return dir(candidate.filePath) === dir(ref.filePath) || dir(candidate.filePath) === dir(dir(ref.filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bare Rust name can mean this candidate. An enum's variant is in
|
||||
* scope bare only through a `use` of it or of its enum's `*`, and never
|
||||
* names a TYPE — ripgrep's every `Some(x)` bound to its `EncodingMode::Some`
|
||||
* variant, every `Ok(x)` to `ParseResult::Ok`. A prelude name (`Ok`,
|
||||
* `Result`, `Box`) is the prelude's unless the file defines it or imports a
|
||||
* project item of that name — serde's macro-hygiene tests declare `struct
|
||||
* Ok`, `struct Result`, and serde bound its own `Ok(…)` and `Result<…>` to them.
|
||||
*/
|
||||
export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const name = ref.referenceName;
|
||||
// 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;
|
||||
if (candidate.kind === 'enum_member') {
|
||||
if (ref.referenceKind === 'references') return false;
|
||||
const uses = rustUsesOf(ref.filePath, context);
|
||||
const cut = candidate.qualifiedName.lastIndexOf('::');
|
||||
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;
|
||||
const uses = rustUsesOf(ref.filePath, context);
|
||||
return uses.names.has(name) || rustGlobCovers(uses, candidate, ref);
|
||||
}
|
||||
|
||||
/** What only exists inside a type, reachable through a receiver alone. */
|
||||
const TYPE_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member']);
|
||||
|
||||
@@ -1248,7 +1327,9 @@ export function matchByExactName(
|
||||
const cMacroCall = ref.referenceKind === 'calls' && (ref.language === 'c' || ref.language === 'cpp') &&
|
||||
sameName.some((n) => n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''));
|
||||
const typeRef = isDotNetTypeRef(ref, context);
|
||||
const rustBare = ref.language === 'rust' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const candidates = sameName.filter((n) =>
|
||||
!(rustBare && !isRustNameInScope(n, ref, context)) &&
|
||||
!(cMacroCall && n.kind !== 'function' && n.kind !== 'method') &&
|
||||
!(typeRef && !canNameInTypePosition(n)) &&
|
||||
// A Scala type position (`Arbitrary[B]`) never names a method: an
|
||||
@@ -2348,6 +2429,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
AWAITED_FILES.delete(context);
|
||||
C_STATIC_MEMO.delete(context);
|
||||
RUST_TRAIT_IMPL_MEMO.delete(context);
|
||||
RUST_USES.delete(context);
|
||||
SEALED_MODULES.delete(context);
|
||||
LOCAL_BINDING_MEMO.delete(context);
|
||||
LOCAL_BINDING_SITES.delete(context);
|
||||
@@ -4252,7 +4334,10 @@ export function matchFuzzy(
|
||||
// Filter to callable kinds only (function, method, class)
|
||||
const callableKinds = new Set(['function', 'method', 'class']);
|
||||
const typeRef = isDotNetTypeRef(ref, context);
|
||||
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)))
|
||||
const rustBare = ref.language === 'rust' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
// Rust names are case-sensitive: `Bytes` is not the method `bytes`, `Ok` not a function `ok`.
|
||||
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) &&
|
||||
!(rustBare && (n.name !== ref.referenceName || !isRustNameInScope(n, ref, context))))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user