mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
fix(rust): framework name heuristics never reach a function-local item (#2224)
The Rust resolver's handler/service/struct heuristics took the first same-named item and then checked scope; they now drop items declared inside a function body and out-of-scope names before choosing, and take the file's own item first. 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 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.<field>` or `window.<x>` 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.
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<Glob, ()> {
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
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))
|
||||
|
||||
@@ -636,7 +636,7 @@ const LOCAL_TYPE_KINDS = new Set<string>(['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
|
||||
|
||||
Reference in New Issue
Block a user