diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd52ba5..b85523722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 max(…)` or `class Foo`, 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`, `new TypeMap()` or `Dictionary`, 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. diff --git a/__tests__/rust-prelude-scope.test.ts b/__tests__/rust-prelude-scope.test.ts new file mode 100644 index 000000000..5e12b0c57 --- /dev/null +++ b/__tests__/rust-prelude-scope.test.ts @@ -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 = { + 'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\n', + 'src/lowargs.rs': `pub enum EncodingMode { + Auto, + Some(u8), + Disabled, +} +pub enum ParseResult { + Ok(T), + Err(String), +} +`, + 'src/hygiene.rs': `struct Ok; +struct Result; +fn local() -> Ok { Ok } +`, + 'src/decompress.rs': `pub fn find(cmd: u8) -> Option { + if cmd > 0 { return Some(cmd); } + let r: std::result::Result = Ok(cmd); + None +} +`, + 'src/error.rs': `pub struct Error; +pub type Result = std::result::Result; +`, + 'src/api.rs': `pub fn load() -> crate::error::Result { 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(); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 168079640..a5a90d329 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b0c669995..fb46cd5e3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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; + /** `X` of each `use …::X::*` (`super` for `use super::*`). */ + globs: Set; +} +const RUST_USES = new WeakMap>(); + +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 = 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));