diff --git a/CHANGELOG.md b/CHANGELOG.md index 13176aaf..219e0257 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 +- 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. diff --git a/__tests__/rust-type-scope.test.ts b/__tests__/rust-type-scope.test.ts new file mode 100644 index 00000000..766252fc --- /dev/null +++ b/__tests__/rust-type-scope.test.ts @@ -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 = { + '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 = std::result::Result;\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> { + 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'); + }); +}); diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index f4828a77..283c4535 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -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>(); @@ -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, diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ac62250c..18473aed 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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; /** `X` of each `use …::X::*` (`super` for `use super::*`). */ globs: Set; + /** Names the file imports from outside the project — `use std::task::{Context, Poll}`, `use futures::Stream`. */ + external: Set; + /** The items the file's project `use`s bind — their leaves, not the paths they walk. */ + bound: Set; +} + +const RUST_CRATES = new WeakMap>(); +const RUST_DEPENDENCIES = new WeakMap>(); + +/** + * 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 { + const hit = RUST_DEPENDENCIES.get(context); + if (hit) return hit; + const deps = new Set(); + 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 { + const hit = RUST_CRATES.get(context); + if (hit) return hit; + const crates = new Set(); + // 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>(); @@ -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(`(?= 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(`(?` 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);