diff --git a/CHANGELOG.md b/CHANGELOG.md index 7108327c..1d1ee60c 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 projects, a struct, handler or service name no longer resolves to an item declared inside some function body, and a file's own item comes before any other crate's. Before, axum's examples' `Uri` linked to a `struct Uri` declared inside a routing test, tokio's runtime `Handle` linked to `tokio-test`'s, and ripgrep's `Glob { … }` in globset linked to the CLI flags' `Glob`. - A method that calls a same-named method on another expression's result is no longer linked to itself in any language. Examples are Scala's `requestToArmeria(request).execute()` inside `execute()`, Rust's `self.0.into_route(state)` inside `into_route()`, and Kotlin's `this@Buffer.write(…)` from an inner object. A value whose initializer chain calls a same-named method, like sttp's `val response = basicRequest.get(…).response(…)`, no longer links to itself either. - In Spring projects, a class name now resolves to the one in the reference's own file or package before any guess by folder convention. A class nested inside another file's class is never reached by its bare name. Before, every MyBatis `Example` class's `new Criteria()` in mall linked to the first `Example`'s nested `Criteria`, and halo's Lombok `@Builder` linked to a nested `Builder` class elsewhere. - A method that hands its call on to another object is no longer linked to itself. In TypeScript and JavaScript, a call through `this.` or `window.` counts as recursion only when the field is declared as the method's own class. A guess from a receiver's name alone never lands on the calling method. Before, BookStack's `toggle()` doing `this.container.classList.toggle('open')`, `listen()` doing `window.$events.listen(…)`, and `FileStorage::delete` doing `$storage->delete($path)` each pointed at themselves. diff --git a/__tests__/rust-framework-name-scope.test.ts b/__tests__/rust-framework-name-scope.test.ts new file mode 100644 index 00000000..614834f2 --- /dev/null +++ b/__tests__/rust-framework-name-scope.test.ts @@ -0,0 +1,83 @@ +/** + * The Rust resolver's struct/handler/service name heuristics never reach an + * item declared inside a function body — axum's examples' `Uri` (imported + * from `http`) went to a `struct Uri` a routing test declares inside itself — + * and a file's own item comes first: ripgrep's `Ok(Glob { … })` in + * globset's glob.rs is its own `Glob`, not the CLI flags' `Glob`. + */ +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-fw-')); + const files: Record = { + 'Cargo.toml': '[workspace]\nmembers = ["crates/core", "crates/globset", "crates/web", "examples/tls"]\n', + 'crates/core/Cargo.toml': '[package]\nname = "rg-core"\n', + 'crates/web/Cargo.toml': '[package]\nname = "web"\n', + 'crates/web/src/lib.rs': 'pub mod http;\n', + 'crates/web/src/http.rs': 'pub use ::http::Uri;\n', + 'crates/web/src/routing/tests.rs': `fn outer_middleware_still_see_whole_url() { + struct Uri; + let _ = Uri; +} +`, + 'examples/tls/Cargo.toml': '[package]\nname = "example-tls"\n\n[dependencies]\nweb = { path = "../../crates/web" }\n', + 'examples/tls/src/main.rs': `use web::http::Uri; + +fn make_https(uri: Uri, port: u16) -> Uri { + uri +} +`, + 'crates/core/src/flags/defs.rs': `pub struct Glob; + +pub fn glob_flag() -> Glob { + Glob +} +`, + 'crates/globset/Cargo.toml': '[package]\nname = "globset"\n', + 'crates/globset/src/glob.rs': `pub struct Glob { + glob: String, +} + +impl Glob { + pub fn new(glob: &str) -> Result { + Ok(Glob { glob: glob.to_string() }) + } +} +`, + }; + 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)!).map((t) => `${t.filePath}:${t.qualifiedName}`); +}; + +describe('Rust framework name heuristics', () => { + it('never reach an item declared inside a function body', () => { + expect(targetsFrom('examples/tls/src/main.rs').filter((t) => t.includes('outer_middleware'))).toEqual([]); + }); + + it('take the file’s own item first', () => { + const globs = targetsFrom('crates/globset/src/glob.rs').filter((t) => t.endsWith(':Glob')); + expect(globs.length).toBeGreaterThan(0); + expect(new Set(globs)).toEqual(new Set(['crates/globset/src/glob.rs:Glob'])); + }); +}); diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index 283c4535..f7e79927 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -8,7 +8,7 @@ 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'; +import { isRustNameInScope, isLexicallyReachable } from '../name-matcher'; /** * Whether the item a name heuristic found is one the reference can name: @@ -42,7 +42,7 @@ export const rustResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Handler references if (ref.referenceName.endsWith('_handler') || ref.referenceName.startsWith('handle_')) { - const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, HANDLER_DIRS, context); + const result = resolveByNameAndKind(ref, FUNCTION_KINDS, HANDLER_DIRS, context); if (result && inRustScope(result, ref, context)) { return { original: ref, @@ -55,7 +55,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); + const result = resolveByNameAndKind(ref, SERVICE_KINDS, SERVICE_DIRS, context); if (result && inRustScope(result, ref, context)) { return { original: ref, @@ -68,7 +68,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); + const result = resolveByNameAndKind(ref, STRUCT_KINDS, MODEL_DIRS, context); if (result && inRustScope(result, ref, context)) { return { original: ref, @@ -291,18 +291,31 @@ function findMatchingParen(s: string, openIdx: number): number { /** * Resolve a symbol by name using indexed queries instead of scanning all files. */ +/** + * The same-named item of these kinds that the reference can see — in scope + * by its `use`s and module paths, and not declared inside some function body + * (axum's examples' `Uri`, imported from `http`, went to a `struct Uri` a + * routing test declares inside itself) — its own file's first, then a + * framework-conventional directory's. + */ function resolveByNameAndKind( - name: string, + ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(name); + const candidates = context.getNodesByName(ref.referenceName); if (candidates.length === 0) return null; - const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); + const kindFiltered = candidates.filter((n) => kinds.has(n.kind) && + isLexicallyReachable(n, ref, context) && isRustNameInScope(n, ref, context)); if (kindFiltered.length === 0) return null; + // The file is a module: its own items are in scope. A sibling file is + // another module, reached only through a `use` — no nearer than any other. + const sameFile = kindFiltered.find((n) => n.filePath === ref.filePath); + if (sameFile) return sameFile.id; + // Prefer candidates in framework-conventional directories const preferred = kindFiltered.filter((n) => preferredDirPatterns.some((d) => n.filePath.includes(d)) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 640a2bee..9cb0459b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -636,7 +636,7 @@ const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface' * unaffected (their parent resolves to a class-like node), as are top-level * symbols and C++ namespace-prefixed names (the prefix has no node). */ -function isLexicallyReachable( +export function isLexicallyReachable( candidate: Node, ref: UnresolvedRef, context: ResolutionContext