diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1ee60c3..0374fc204 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 +- Framework name conventions for Django, FastAPI, Flask, ASP.NET, Gin, SwiftUI and Vapor now only pick a class the reference can see. Examples of these conventions are "a `…View` is a view", "a `…Service` is a service" and "a capitalized name is a model". The pick starts from the reference's own file and then its package, and never takes a class declared inside some function or nested in another file's class. Before, Django REST framework's tests linked each file's own `MockView` and `Serializer` to another file's, and netbox's tests linked `TestForm()` to a form declared inside a different test function. - 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. diff --git a/__tests__/framework-name-heuristic-scope.test.ts b/__tests__/framework-name-heuristic-scope.test.ts new file mode 100644 index 000000000..7457c2ea7 --- /dev/null +++ b/__tests__/framework-name-heuristic-scope.test.ts @@ -0,0 +1,68 @@ +/** + * The framework resolvers' name heuristics ("a `…View` is a view under + * `/views/`", "a `…Form` is a form") pick only what the reference can see, + * starting from its own scope: never a class a test declares inside one of + * its functions (netbox's `TestForm`, DRF's `MockView`), its own file's + * first, then its package's, then a conventional folder's. + */ +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-fw-name-')); + const files: Record = { + 'manage.py': '', + 'utilities/tests/test_forms.py': `class GetFieldValueTestCase: + def setUpTestData(self): + class TestForm: + pass + return TestForm() +`, + 'utilities/tests/test_templatetags.py': `def test_any_required(): + return TestForm() +`, + 'tests/browsable_api/views.py': `class MockView: + pass +`, + 'tests/authentication/test_authentication.py': `class MockView: + pass + + +def test_auth(): + return MockView() +`, + }; + 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 === 'instantiates' || e.kind === 'calls' || e.kind === 'references') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.filePath}:${t.qualifiedName}`); +}; + +describe('framework name heuristics', () => { + it('never pick a class declared inside another file’s function', () => { + expect(targetsFrom('utilities/tests/test_templatetags.py').filter((t) => t.includes('TestForm'))).toEqual([]); + }); + + it('take the reference’s own file first', () => { + expect(targetsFrom('tests/authentication/test_authentication.py').filter((t) => t.includes('MockView'))) + .toEqual(['tests/authentication/test_authentication.py:MockView']); + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index cc03c7702..940c5a006 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -998,11 +998,11 @@ describe('springResolver.resolve — DI heuristics are gated to Java/Kotlin non- // hijack a Scala `extends X` to a same-named class found via directory // heuristics — inheritance must resolve through imports/name matching. const decoyNode: Node = { - id: 'class:src/test/model/ExtCustomer.java:ExtCustomer:3', + id: 'class:src/main/model/ExtCustomer.java:ExtCustomer:3', kind: 'class', name: 'ExtCustomer', - qualifiedName: 'src/test/model/ExtCustomer.java::ExtCustomer', - filePath: 'src/test/model/ExtCustomer.java', + qualifiedName: 'src/main/model/ExtCustomer.java::ExtCustomer', + filePath: 'src/main/model/ExtCustomer.java', language: 'java', startLine: 3, endLine: 10, diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 51040c20b..a70daa4a9 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import { pickByNameAndKind } from './name-heuristic'; export const aspnetResolver: FrameworkResolver = { name: 'aspnet', @@ -67,7 +68,7 @@ export const aspnetResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, CONTROLLER_DIRS, context); if (result) { return { original: ref, @@ -80,7 +81,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 2: Service references (dependency injection) if (ref.referenceName.endsWith('Service') || ref.referenceName.startsWith('I') && ref.referenceName.length > 1) { - const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context); + const result = resolveByNameAndKind(ref, SERVICE_KINDS, SERVICE_DIRS, context); if (result) { return { original: ref, @@ -93,7 +94,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 3: Repository references if (ref.referenceName.endsWith('Repository')) { - const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context); + const result = resolveByNameAndKind(ref, SERVICE_KINDS, REPO_DIRS, context); if (result) { return { original: ref, @@ -106,7 +107,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 4: Model/Entity references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -119,7 +120,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 5: ViewModel references if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Dto')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, VIEWMODEL_DIRS, context); if (result) { return { original: ref, @@ -410,28 +411,12 @@ const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/DTOs/', '/Dto/']; const CLASS_KINDS = new Set(['class']); const SERVICE_KINDS = new Set(['class', 'interface']); -/** - * Resolve a symbol by name using indexed queries instead of scanning all files. - */ +/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ function resolveByNameAndKind( - name: string, + ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(name); - if (candidates.length === 0) return null; - - const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); - if (kindFiltered.length === 0) return null; - - // Prefer candidates in framework-conventional directories - const preferred = kindFiltered.filter((n) => - preferredDirPatterns.some((d) => n.filePath.includes(d)) - ); - - if (preferred.length > 0) return preferred[0]!.id; - - // Fall back to any match - return kindFiltered[0]!.id; + return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context); } diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index 168d4b22c..5e819060e 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import { pickByNameAndKind } from './name-heuristic'; export const goResolver: FrameworkResolver = { name: 'go', @@ -27,7 +28,7 @@ export const goResolver: 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', HANDLER_DIRS, context); + const result = resolveByNameAndKind(ref, 'function', HANDLER_DIRS, context); if (result) { return { original: ref, @@ -40,7 +41,7 @@ export const goResolver: FrameworkResolver = { // Pattern 2: Service/Repository references if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository') || ref.referenceName.endsWith('Store')) { - const result = resolveByNameAndKind(ref.referenceName, null, SERVICE_DIRS, context, SERVICE_KINDS); + const result = resolveByNameAndKind(ref, null, SERVICE_DIRS, context, SERVICE_KINDS); if (result) { return { original: ref, @@ -53,7 +54,7 @@ export const goResolver: FrameworkResolver = { // Pattern 3: Middleware references if (ref.referenceName.endsWith('Middleware') || ref.referenceName.startsWith('Auth') || ref.referenceName.startsWith('Log')) { - const result = resolveByNameAndKind(ref.referenceName, 'function', MIDDLEWARE_DIRS, context); + const result = resolveByNameAndKind(ref, 'function', MIDDLEWARE_DIRS, context); if (result) { return { original: ref, @@ -66,7 +67,7 @@ export const goResolver: FrameworkResolver = { // Pattern 4: Model/Entity references (typically PascalCase structs) if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, 'struct', MODEL_DIRS, context); + const result = resolveByNameAndKind(ref, 'struct', MODEL_DIRS, context); if (result) { return { original: ref, @@ -172,36 +173,17 @@ const MIDDLEWARE_DIRS = ['middleware', 'middlewares']; const MODEL_DIRS = ['model', 'models', 'entity', 'entities', 'domain', 'pkg']; const SERVICE_KINDS = new Set(['struct', 'interface']); -/** - * Resolve a symbol by name using indexed queries instead of scanning all files. - * Uses getNodesByName (O(log n) indexed lookup) instead of iterating every file. - */ +/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ function resolveByNameAndKind( - name: string, + ref: UnresolvedRef, kind: string | null, preferredDirs: string[], context: ResolutionContext, kinds?: Set ): string | null { - const candidates = context.getNodesByName(name); - if (candidates.length === 0) return null; - - // Filter by kind - const kindFiltered = candidates.filter((n) => { - if (kinds) return kinds.has(n.kind); - if (kind) return n.kind === kind; - return true; - }); - - if (kindFiltered.length === 0) return null; - - // Prefer candidates in framework-conventional directories - const preferred = kindFiltered.filter((n) => - preferredDirs.some((d) => n.filePath.includes(`/${d}/`)) - ); - - if (preferred.length > 0) return preferred[0]!.id; - - // Fall back to any match - return kindFiltered[0]!.id; + const allowed: ReadonlySet = kinds ?? (kind ? new Set([kind]) : GO_NAMED_KINDS); + return pickByNameAndKind(ref, allowed, (f) => preferredDirs.some((d) => f.includes(`/${d}/`)), context); } + +/** Any declaration a name can be (the heuristic with no kind). */ +const GO_NAMED_KINDS: ReadonlySet = new Set(['function', 'method', 'struct', 'interface', 'type_alias', 'variable', 'constant']); diff --git a/src/resolution/frameworks/java.ts b/src/resolution/frameworks/java.ts index 3e1b7d605..1759b4ea5 100644 --- a/src/resolution/frameworks/java.ts +++ b/src/resolution/frameworks/java.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import { pickByNameAndKind } from './name-heuristic'; export const springResolver: FrameworkResolver = { name: 'spring', @@ -553,48 +554,13 @@ function joinPath(prefix: string, sub: string): string { return '/' + parts.join('/'); } -/** - * Resolve a symbol by name using indexed queries instead of scanning all files. - * What the reference's own scope declares comes first — its file, then its - * package (directory) — and a class nested in another file's class is out of - * reach by its bare name: every MyBatis `XExample` declares its own nested - * `Criteria`, and mall's 8,747 `new Criteria()` went to the first one. - */ +/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ function resolveByNameAndKind( ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(ref.referenceName); - if (candidates.length === 0) return null; - - const kindFiltered = candidates.filter((n) => kinds.has(n.kind) && - (n.filePath === ref.filePath || !isNestedType(n, context))); - if (kindFiltered.length === 0) return null; - - const sameFile = kindFiltered.find((n) => n.filePath === ref.filePath); - if (sameFile) return sameFile.id; - const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1); - const samePackage = kindFiltered.find((n) => n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/')); - if (samePackage) return samePackage.id; - - // Prefer candidates in framework-conventional directories - const preferred = kindFiltered.filter((n) => - preferredDirPatterns.some((d) => n.filePath.includes(d)) - ); - - if (preferred.length > 0) return preferred[0]!.id; - - // Fall back to any match - return kindFiltered[0]!.id; + return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context); } -/** Whether a JVM type is declared inside another type of its file. */ -function isNestedType(n: Node, context: ResolutionContext): boolean { - const cut = n.qualifiedName.lastIndexOf('::'); - if (cut < 0) return false; - const owner = n.qualifiedName.slice(0, cut); - return context.getNodesInFile(n.filePath).some((p) => - p.qualifiedName === owner && (p.kind === 'class' || p.kind === 'interface' || p.kind === 'enum' || p.kind === 'struct' || p.kind === 'trait')); -} diff --git a/src/resolution/frameworks/name-heuristic.ts b/src/resolution/frameworks/name-heuristic.ts new file mode 100644 index 000000000..9b06843a2 --- /dev/null +++ b/src/resolution/frameworks/name-heuristic.ts @@ -0,0 +1,70 @@ +/** + * The shared pick behind the framework resolvers' name heuristics — "a + * `…Service` is a class under `/services/`", "a PascalCase name is a model + * under `/models/`", "`router` is a FastAPI router under `/routers/`". + * + * Each resolver used to take the FIRST same-named node of the right kind, + * preferring a conventional folder, and that heuristic outranks name matching + * on a tie. So every MyBatis `XExample`'s `new Criteria()` in mall went to the + * first `Example`'s nested `Criteria`, and axum's examples' `Uri` to a struct a + * routing test declares inside itself. The pick now considers only what the + * reference can see, and starts from its own scope. + */ +import { Node } from '../../types'; +import { UnresolvedRef, ResolutionContext } from '../types'; +import { isLexicallyReachable, isVisibleAcrossFiles } from '../name-matcher'; + +/** Node kinds a nested type can be declared in. */ +const TYPE_OWNER_KINDS: ReadonlySet = new Set(['class', 'struct', 'interface', 'enum', 'trait', 'protocol', 'record']); + +export interface NamePickOptions { + /** + * Whether a file in the reference's directory comes before the conventional + * folders — its package (JVM, Go, C#, Python). Not for Rust, where a sibling + * file is another module, reached only through a `use`. + */ + sameDirectory?: boolean; + /** A language's own scope rule over the candidates (Rust's `use` / module paths). */ + accept?: (n: Node) => boolean; +} + +/** + * The id of the node named `ref.referenceName`, of one of `kinds`, that the + * reference can see: never a declaration inside some function body, never a + * type nested in another file's type (a nested type is reached by its bare + * name only from inside its owner), never one the language keeps out of the + * reference's file (a `private` member, an unexported Go name, a C# namespace + * the file does not use, a test suite from production code). Its own file's + * first, then (by default) its directory's, then a conventional folder's. + */ +export function pickByNameAndKind( + ref: UnresolvedRef, + kinds: ReadonlySet, + inPreferredDir: (filePath: string) => boolean, + context: ResolutionContext, + options: NamePickOptions = {}, +): string | null { + const candidates = context.getNodesByName(ref.referenceName).filter((n) => + kinds.has(n.kind) && + isLexicallyReachable(n, ref, context) && + (n.filePath === ref.filePath || (!isNestedType(n, context) && isVisibleAcrossFiles(n, ref, context))) && + (!options.accept || options.accept(n))); + if (candidates.length === 0) return null; + + const sameFile = candidates.find((n) => n.filePath === ref.filePath); + if (sameFile) return sameFile.id; + if (options.sameDirectory !== false) { + const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1); + const sameDir = candidates.find((n) => n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/')); + if (sameDir) return sameDir.id; + } + return (candidates.find((n) => inPreferredDir(n.filePath)) ?? candidates[0]!).id; +} + +/** Whether a node is declared inside a type of its own file. */ +function isNestedType(n: Node, context: ResolutionContext): boolean { + const cut = n.qualifiedName.lastIndexOf('::'); + if (cut < 0) return false; + const owner = n.qualifiedName.slice(0, cut); + return context.getNodesInFile(n.filePath).some((p) => p.qualifiedName === owner && TYPE_OWNER_KINDS.has(p.kind)); +} diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts index 7ef69f0df..777016f2a 100644 --- a/src/resolution/frameworks/python.ts +++ b/src/resolution/frameworks/python.ts @@ -8,6 +8,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolutionContext, FrameworkExtractionResult } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; import { resolveImportPath } from '../import-resolver'; +import { pickByNameAndKind } from './name-heuristic'; export const djangoResolver: FrameworkResolver = { name: 'django', @@ -25,15 +26,15 @@ export const djangoResolver: FrameworkResolver = { resolve(ref, context) { if (ref.referenceName.endsWith('Model') || /^[A-Z][a-z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, MODEL_DIRS, context); if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; } if (ref.referenceName.endsWith('View') || ref.referenceName.endsWith('ViewSet')) { - const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context); + const result = resolveByNameAndKind(ref, VIEW_KINDS, VIEW_DIRS, context); if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; } if (ref.referenceName.endsWith('Form')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, FORM_DIRS, context); if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; } // ORM dynamic dispatch: QuerySet._fetch_all (and siblings) call @@ -202,7 +203,7 @@ export const flaskResolver: FrameworkResolver = { resolve(ref, context) { if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) { - const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context); + const result = resolveByNameAndKind(ref, VARIABLE_KINDS, [], context); if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; } return null; @@ -265,11 +266,11 @@ export const fastapiResolver: FrameworkResolver = { resolve(ref, context) { if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') { - const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, ROUTER_DIRS, context); + const result = resolveByNameAndKind(ref, VARIABLE_KINDS, ROUTER_DIRS, context); if (result) return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework' }; } if (ref.referenceName.startsWith('get_') || ref.referenceName.startsWith('Depends')) { - const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, DEP_DIRS, context); + const result = resolveByNameAndKind(ref, FUNCTION_KINDS, DEP_DIRS, context); if (result) return { original: ref, targetNodeId: result, confidence: 0.75, resolvedBy: 'framework' }; } return null; @@ -640,29 +641,12 @@ const VIEW_KINDS = new Set(['class', 'function']); const VARIABLE_KINDS = new Set(['variable']); const FUNCTION_KINDS = new Set(['function']); -/** - * Resolve a symbol by name using indexed queries instead of scanning all files. - */ +/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ function resolveByNameAndKind( - name: string, + ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(name); - if (candidates.length === 0) return null; - - const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); - if (kindFiltered.length === 0) return null; - - // Prefer candidates in framework-conventional directories - if (preferredDirPatterns.length > 0) { - const preferred = kindFiltered.filter((n) => - preferredDirPatterns.some((d) => n.filePath.includes(d)) - ); - if (preferred.length > 0) return preferred[0]!.id; - } - - // Fall back to any match - return kindFiltered[0]!.id; + return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context); } diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index f7e79927a..1fa4f79d1 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -8,7 +8,8 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; import { getCargoWorkspaceCrateMap } from './cargo-workspace'; -import { isRustNameInScope, isLexicallyReachable } from '../name-matcher'; +import { isRustNameInScope } from '../name-matcher'; +import { pickByNameAndKind } from './name-heuristic'; /** * Whether the item a name heuristic found is one the reference can name: @@ -288,43 +289,18 @@ function findMatchingParen(s: string, openIdx: number): number { return -1; } -/** - * 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. - */ +/** A framework name heuristic's pick (see name-heuristic.ts): in scope by its `use`s and module paths. */ function resolveByNameAndKind( ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(ref.referenceName); - if (candidates.length === 0) return null; - - 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)) - ); - - if (preferred.length > 0) return preferred[0]!.id; - - // Fall back to any match - return kindFiltered[0]!.id; + return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context, { + // The file is a module: a sibling file is another one, reached only through a `use`. + sameDirectory: false, + accept: (n) => isRustNameInScope(n, ref, context), + }); } interface ModuleResolution { diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 8c91bf95d..5702aef4a 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import { pickByNameAndKind } from './name-heuristic'; // No extract(): a SwiftUI view is its own struct node, and a UIKit controller // its class. A one-line `component`/`class` twin per `struct X: View` (and per @@ -41,7 +42,7 @@ export const swiftUIResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: View references (SwiftUI views are PascalCase ending in View) if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context); + const result = resolveByNameAndKind(ref, VIEW_KINDS, VIEW_DIRS, context); if (result) { return { original: ref, @@ -54,7 +55,7 @@ export const swiftUIResolver: FrameworkResolver = { // Pattern 2: ViewModel/ObservableObject references if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Store') || ref.referenceName.endsWith('Manager')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, VIEWMODEL_DIRS, context); if (result) { return { original: ref, @@ -67,7 +68,7 @@ export const swiftUIResolver: FrameworkResolver = { // Pattern 3: Model references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, MODEL_KINDS, MODEL_DIRS, context); + const result = resolveByNameAndKind(ref, MODEL_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -107,7 +108,7 @@ export const uikitResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: ViewController references if (ref.referenceName.endsWith('ViewController')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, VC_DIRS, context); if (result) { return { original: ref, @@ -120,7 +121,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 2: UIView subclass references if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, UIVIEW_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, UIVIEW_DIRS, context); if (result) { return { original: ref, @@ -133,7 +134,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 3: Cell references if (ref.referenceName.endsWith('Cell')) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, CELL_DIRS, context); if (result) { return { original: ref, @@ -146,7 +147,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 4: Delegate/DataSource references if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) { - const result = resolveByNameAndKind(ref.referenceName, PROTOCOL_KINDS, [], context); + const result = resolveByNameAndKind(ref, PROTOCOL_KINDS, [], context); if (result) { return { original: ref, @@ -206,7 +207,7 @@ export const vaporResolver: FrameworkResolver = { // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { - const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context); + const result = resolveByNameAndKind(ref, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context); if (result) { return { original: ref, @@ -219,7 +220,7 @@ export const vaporResolver: FrameworkResolver = { // Pattern 2: Model references (Fluent) if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FLUENT_MODEL_DIRS, context); + const result = resolveByNameAndKind(ref, CLASS_KINDS, FLUENT_MODEL_DIRS, context); if (result) { return { original: ref, @@ -232,7 +233,7 @@ export const vaporResolver: FrameworkResolver = { // Pattern 3: Middleware references if (ref.referenceName.endsWith('Middleware')) { - const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context); + const result = resolveByNameAndKind(ref, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context); if (result) { return { original: ref, @@ -495,29 +496,12 @@ const MODEL_KINDS = new Set(['struct', 'class']); const PROTOCOL_KINDS = new Set(['protocol']); const VAPOR_CONTROLLER_KINDS = new Set(['class', 'struct']); -/** - * Resolve a symbol by name using indexed queries instead of scanning all files. - */ +/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ function resolveByNameAndKind( - name: string, + ref: UnresolvedRef, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { - const candidates = context.getNodesByName(name); - if (candidates.length === 0) return null; - - const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); - if (kindFiltered.length === 0) return null; - - // Prefer candidates in framework-conventional directories - if (preferredDirPatterns.length > 0) { - const preferred = kindFiltered.filter((n) => - preferredDirPatterns.some((d) => n.filePath.includes(d)) - ); - if (preferred.length > 0) return preferred[0]!.id; - } - - // Fall back to any match - return kindFiltered[0]!.id; + return pickByNameAndKind(ref, kinds, (f) => preferredDirPatterns.some((d) => f.includes(d)), context); }