From 7a3afc912401e7327fa8404a1a21ae5a60bfbb17 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 6 Apr 2026 11:16:56 -0500 Subject: [PATCH] feat: Enhance symbol search with co-location boosting and receiver type support Improves search accuracy by boosting results when multiple query symbols appear in the same file, addressing cases where common names like "run" return too many results. Adds Go method receiver type extraction to qualified names for better searchability (e.g., "scrapeLoop.run"). Optimizes database queries with two-pass approach to handle distinctive vs common symbol names efficiently. --- src/context/index.ts | 28 ++- src/db/queries.ts | 93 +++++++--- src/extraction/languages/go.ts | 12 ++ src/extraction/tree-sitter-types.ts | 7 + src/extraction/tree-sitter.ts | 12 +- src/resolution/frameworks/csharp.ts | 145 ++++----------- src/resolution/frameworks/express.ts | 108 +++++------ src/resolution/frameworks/java.ts | 150 ++++------------ src/resolution/frameworks/laravel.ts | 12 +- src/resolution/frameworks/python.ts | 163 ++++------------- src/resolution/frameworks/react.ts | 135 ++++++-------- src/resolution/frameworks/ruby.ts | 42 ++--- src/resolution/frameworks/rust.ts | 112 ++++-------- src/resolution/frameworks/svelte.ts | 40 +---- src/resolution/frameworks/swift.ts | 260 +++++---------------------- src/search/query-utils.ts | 24 ++- 16 files changed, 466 insertions(+), 877 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 3d4da09..cf5aedc 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -291,10 +291,36 @@ export class ContextBuilder { let exactMatches: SearchResult[] = []; if (symbolsFromQuery.length > 0) { try { + // Get more results so we can apply co-location boosting before trimming exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { - limit: Math.ceil(opts.searchLimit * 2), // Get more since we'll merge + limit: Math.ceil(opts.searchLimit * 5), kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, }); + + // Co-location boost: when multiple extracted symbols appear in the same file, + // those results are much more likely to be what the user is looking for. + // E.g., "scrapeLoop" + "run" both in scrape/scrape.go → boost both. + if (exactMatches.length > 1) { + // Build a map of files → how many distinct symbol names matched in that file + const fileSymbolCounts = new Map>(); + for (const r of exactMatches) { + const names = fileSymbolCounts.get(r.node.filePath) || new Set(); + names.add(r.node.name.toLowerCase()); + fileSymbolCounts.set(r.node.filePath, names); + } + // Boost results in files where multiple query symbols co-occur + exactMatches = exactMatches.map(r => { + const symbolCount = fileSymbolCounts.get(r.node.filePath)?.size || 1; + return { + ...r, + score: symbolCount > 1 ? r.score + (symbolCount - 1) * 20 : r.score, + }; + }); + exactMatches.sort((a, b) => b.score - a.score); + } + + // Trim back to reasonable size + exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 2)); logDebug('Exact symbol matches', { count: exactMatches.length }); } catch (error) { logDebug('Exact symbol lookup failed', { error: String(error) }); diff --git a/src/db/queries.ts b/src/db/queries.ts index b7caf4e..e82f1a4 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -630,39 +630,80 @@ export class QueryBuilder { const { kinds, languages, limit = 50 } = options; - // Build query with exact matches (case-insensitive) - let sql = ` - SELECT nodes.*, - CASE - WHEN name COLLATE NOCASE IN (${names.map(() => '?').join(',')}) THEN 1.0 - ELSE 0.9 - END as score - FROM nodes - WHERE name COLLATE NOCASE IN (${names.map(() => '?').join(',')}) - `; + // Two-pass approach to handle common names (e.g., "run" has 40+ matches): + // Pass 1: Find which files contain distinctive (rare) symbols from the query. + // Pass 2: Query each name, boosting results that co-locate with distinctive symbols. - // Duplicate names for both SELECT and WHERE clauses - const params: (string | number)[] = [...names, ...names]; - - if (kinds && kinds.length > 0) { - sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; - params.push(...kinds); + // Pass 1: Find files containing each queried name, identify distinctive names + const nameToFiles = new Map>(); + for (const name of names) { + let sql = 'SELECT DISTINCT file_path FROM nodes WHERE name COLLATE NOCASE = ?'; + const params: (string | number)[] = [name]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + sql += ' LIMIT 100'; + const rows = this.db.prepare(sql).all(...params) as { file_path: string }[]; + nameToFiles.set(name.toLowerCase(), new Set(rows.map(r => r.file_path))); } - if (languages && languages.length > 0) { - sql += ` AND language IN (${languages.map(() => '?').join(',')})`; - params.push(...languages); + // Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file) + const distinctiveFiles = new Set(); + for (const [, files] of nameToFiles) { + if (files.size > 0 && files.size < 10) { + for (const f of files) distinctiveFiles.add(f); + } } - sql += ' ORDER BY score DESC, length(name) ASC LIMIT ?'; - params.push(limit); + // Pass 2: Query each name with per-name limit, scoring by co-location + const perNameLimit = Math.max(8, Math.ceil(limit / names.length)); + const allResults: SearchResult[] = []; + const seenIds = new Set(); - const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + for (const name of names) { + let sql = ` + SELECT nodes.*, 1.0 as score + FROM nodes + WHERE name COLLATE NOCASE = ? + `; + const params: (string | number)[] = [name]; - return rows.map((row) => ({ - node: rowToNode(row), - score: row.score, - })); + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + + // Fetch enough to find co-located results among common names + sql += ' LIMIT ?'; + params.push(Math.max(perNameLimit * 3, 50)); + + const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + const nameResults: SearchResult[] = []; + for (const row of rows) { + const node = rowToNode(row); + if (seenIds.has(node.id)) continue; + // Boost results in files that also contain distinctive symbols + const coLocationBoost = distinctiveFiles.has(node.filePath) ? 20 : 0; + nameResults.push({ node, score: row.score + coLocationBoost }); + } + + // Sort by score (co-located first), take per-name limit + nameResults.sort((a, b) => b.score - a.score); + for (const r of nameResults.slice(0, perNameLimit)) { + seenIds.add(r.node.id); + allResults.push(r); + } + } + + // Sort all results by score so co-located results bubble up + allResults.sort((a, b) => b.score - a.score); + return allResults.slice(0, limit); } // =========================================================================== diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index de9578a..4568a38 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -27,4 +27,16 @@ export const goExtractor: LanguageExtractor = { } return sig; }, + getReceiverType: (node, source) => { + // Go method_declaration has a "receiver" field: func (sl *scrapeLoop) run(...) + // The receiver is a parameter_list containing a parameter_declaration + // with a type that may be a pointer_type (*scrapeLoop) or plain type (scrapeLoop) + const receiver = getChildByField(node, 'receiver'); + if (!receiver) return undefined; + // Find the type identifier inside the receiver + const text = getNodeText(receiver, source); + // Extract type name from patterns like "(sl *Type)", "(sl Type)", "(*Type)", "(Type)" + const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/); + return match?.[1]; + }, }; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 0afef37..a585a89 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -163,4 +163,11 @@ export interface LanguageExtractor { * Returns info about each declared variable, allowing the core to create nodes. */ extractVariables?: (node: SyntaxNode, source: string) => VariableInfo[]; + + /** + * Extract receiver/owner type name from a method declaration. + * Used by Go to get the struct receiver (e.g., "scrapeLoop" from "func (sl *scrapeLoop) run()"). + * When present, the receiver type is included in the qualified name for better searchability. + */ + getReceiverType?: (node: SyntaxNode, source: string) => string | undefined; } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9c7cf38..722df1a 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -505,13 +505,21 @@ export class TreeSitterExtractor { const isAsync = this.extractor.isAsync?.(node); const isStatic = this.extractor.isStatic?.(node); - const methodNode = this.createNode('method', name, node, { + // For languages with receiver types (Go), include receiver in qualified name + // so FTS can match "scrapeLoop.run" → qualified_name "...::scrapeLoop::run" + const receiverType = this.extractor.getReceiverType?.(node, this.source); + const extraProps: Partial = { docstring, signature, visibility, isAsync, isStatic, - }); + }; + if (receiverType) { + extraProps.qualifiedName = `${this.filePath}::${receiverType}::${name}`; + } + + const methodNode = this.createNode('method', name, node, extraProps); if (!methodNode) return; // Extract type annotations (parameter types and return type) diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 5f278f0..1e170be 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -48,7 +48,7 @@ export const aspnetResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { - const result = resolveController(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context); if (result) { return { original: ref, @@ -61,7 +61,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 = resolveService(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context); if (result) { return { original: ref, @@ -74,7 +74,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 3: Repository references if (ref.referenceName.endsWith('Repository')) { - const result = resolveRepository(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context); if (result) { return { original: ref, @@ -87,7 +87,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 4: Model/Entity references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -100,7 +100,7 @@ export const aspnetResolver: FrameworkResolver = { // Pattern 5: ViewModel references if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Dto')) { - const result = resolveViewModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context); if (result) { return { original: ref, @@ -213,111 +213,38 @@ export const aspnetResolver: FrameworkResolver = { }, }; -// Helper functions +// Directory patterns +const CONTROLLER_DIRS = ['/Controllers/']; +const SERVICE_DIRS = ['/Services/', '/Service/', '/Application/']; +const REPO_DIRS = ['/Repositories/', '/Repository/', '/Data/', '/Infrastructure/']; +const MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Entity/', '/Domain/']; +const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/DTOs/', '/Dto/']; -function resolveController(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); +const CLASS_KINDS = new Set(['class']); +const SERVICE_KINDS = new Set(['class', 'interface']); - for (const file of allFiles) { - if (file.endsWith('.cs') && file.includes('/Controllers/')) { - const nodes = context.getNodesInFile(file); - const controllerNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (controllerNode) { - return controllerNode.id; - } - } - } +/** + * Resolve a symbol by name using indexed queries instead of scanning all files. + */ +function resolveByNameAndKind( + name: string, + kinds: Set, + preferredDirPatterns: string[], + context: ResolutionContext, +): string | null { + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; - return null; -} - -function resolveService(name: string, context: ResolutionContext): string | null { - const serviceDirs = ['Services', 'Service', 'Application']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.cs') && serviceDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const serviceNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'interface') && n.name === name - ); - if (serviceNode) { - return serviceNode.id; - } - } - } - - // Search all C# files for interfaces (often services are injected via interface) - for (const file of allFiles) { - if (file.endsWith('.cs')) { - const nodes = context.getNodesInFile(file); - const serviceNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'interface') && n.name === name - ); - if (serviceNode) { - return serviceNode.id; - } - } - } - - return null; -} - -function resolveRepository(name: string, context: ResolutionContext): string | null { - const repoDirs = ['Repositories', 'Repository', 'Data', 'Infrastructure']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.cs') && repoDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const repoNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'interface') && n.name === name - ); - if (repoNode) { - return repoNode.id; - } - } - } - - return null; -} - -function resolveModel(name: string, context: ResolutionContext): string | null { - const modelDirs = ['Models', 'Model', 'Entities', 'Entity', 'Domain']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.cs') && modelDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const modelNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (modelNode) { - return modelNode.id; - } - } - } - - return null; -} - -function resolveViewModel(name: string, context: ResolutionContext): string | null { - const viewModelDirs = ['ViewModels', 'ViewModel', 'DTOs', 'Dto']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.cs') && viewModelDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const vmNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (vmNode) { - return vmNode.id; - } - } - } - - 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; } diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index f98dac6..0afa7e0 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -61,7 +61,7 @@ export const expressResolver: FrameworkResolver = { const controllerMatch = ref.referenceName.match(/^(\w+)Controller\.(\w+)$/); if (controllerMatch) { const [, controller, method] = controllerMatch; - const result = resolveController(controller!, method!, context); + const result = resolveControllerMethod(controller!, method!, context); if (result) { return { original: ref, @@ -76,7 +76,7 @@ export const expressResolver: FrameworkResolver = { const serviceMatch = ref.referenceName.match(/^(\w+)(Service|Helper|Utils?)\.(\w+)$/); if (serviceMatch) { const [, name, suffix, method] = serviceMatch; - const result = resolveService(name! + suffix!, method!, context); + const result = resolveServiceMethod(name! + suffix!, method!, context); if (result) { return { original: ref, @@ -154,93 +154,83 @@ function isMiddlewareName(name: string): boolean { } /** - * Resolve middleware reference + * Resolve middleware reference using name-based lookup */ function resolveMiddleware( name: string, context: ResolutionContext ): string | null { - // Look in middleware directories - const middlewareDirs = ['middleware', 'middlewares', 'src/middleware', 'src/middlewares']; + // Try exact name first + const candidates = context.getNodesByName(name); + const match = candidates.find((n) => + n.name.toLowerCase() === name.toLowerCase() || + n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase() + ); + if (match) return match.id; - for (const dir of middlewareDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) || file.includes('/middleware/')) { - const nodes = context.getNodesInFile(file); - const match = nodes.find( - (n) => - n.name.toLowerCase() === name.toLowerCase() || - n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase() - ); - if (match) { - return match.id; - } - } - } + // Try without Middleware suffix + const baseName = name.replace(/Middleware$/i, ''); + if (baseName !== name) { + const baseCandidates = context.getNodesByName(baseName); + const MIDDLEWARE_DIRS = ['/middleware/', '/middlewares/']; + const preferred = baseCandidates.filter((n) => + MIDDLEWARE_DIRS.some((d) => n.filePath.includes(d)) + ); + if (preferred.length > 0) return preferred[0]!.id; + if (baseCandidates.length > 0) return baseCandidates[0]!.id; } return null; } /** - * Resolve controller method + * Resolve controller method using name-based lookup */ -function resolveController( +function resolveControllerMethod( controller: string, method: string, context: ResolutionContext ): string | null { - const controllerDirs = ['controllers', 'src/controllers', 'app/controllers']; + // Look for the method name directly + const methodCandidates = context.getNodesByName(method); + const methodNodes = methodCandidates.filter( + (n) => (n.kind === 'method' || n.kind === 'function') && + n.filePath.toLowerCase().includes(controller.toLowerCase()) + ); - for (const dir of controllerDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if ( - (file.startsWith(dir) || file.includes('/controllers/')) && - file.toLowerCase().includes(controller.toLowerCase()) - ) { - const nodes = context.getNodesInFile(file); - const methodNode = nodes.find( - (n) => (n.kind === 'method' || n.kind === 'function') && n.name === method - ); - if (methodNode) { - return methodNode.id; - } - } - } + if (methodNodes.length > 0) return methodNodes[0]!.id; + + // Fall back: look for controller class, then find the method in its file + const controllerName = controller + 'Controller'; + const controllerCandidates = context.getNodesByName(controllerName); + for (const ctrl of controllerCandidates) { + const nodesInFile = context.getNodesInFile(ctrl.filePath); + const methodNode = nodesInFile.find( + (n) => (n.kind === 'method' || n.kind === 'function') && n.name === method + ); + if (methodNode) return methodNode.id; } return null; } /** - * Resolve service/helper + * Resolve service/helper method using name-based lookup */ -function resolveService( +function resolveServiceMethod( serviceName: string, method: string, context: ResolutionContext ): string | null { - const serviceDirs = ['services', 'src/services', 'helpers', 'src/helpers', 'utils', 'src/utils']; + // Look for the method in files matching the service name + const methodCandidates = context.getNodesByName(method); + const stripped = serviceName.replace(/(Service|Helper|Utils?)$/i, '').toLowerCase(); + const methodNodes = methodCandidates.filter( + (n) => (n.kind === 'method' || n.kind === 'function') && + n.filePath.toLowerCase().includes(stripped) + ); - for (const dir of serviceDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if ( - (file.startsWith(dir) || file.includes('/services/') || file.includes('/helpers/') || file.includes('/utils/')) && - file.toLowerCase().includes(serviceName.toLowerCase().replace(/(service|helper|utils?)$/i, '')) - ) { - const nodes = context.getNodesInFile(file); - const methodNode = nodes.find( - (n) => (n.kind === 'method' || n.kind === 'function') && n.name === method - ); - if (methodNode) { - return methodNode.id; - } - } - } - } + if (methodNodes.length > 0) return methodNodes[0]!.id; return null; } diff --git a/src/resolution/frameworks/java.ts b/src/resolution/frameworks/java.ts index 6bb3ae7..2acb2b8 100644 --- a/src/resolution/frameworks/java.ts +++ b/src/resolution/frameworks/java.ts @@ -50,7 +50,7 @@ export const springResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Service references (dependency injection) if (ref.referenceName.endsWith('Service')) { - const result = resolveService(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context); if (result) { return { original: ref, @@ -63,7 +63,7 @@ export const springResolver: FrameworkResolver = { // Pattern 2: Repository references if (ref.referenceName.endsWith('Repository')) { - const result = resolveRepository(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context); if (result) { return { original: ref, @@ -76,7 +76,7 @@ export const springResolver: FrameworkResolver = { // Pattern 3: Controller references if (ref.referenceName.endsWith('Controller')) { - const result = resolveController(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context); if (result) { return { original: ref, @@ -89,7 +89,7 @@ export const springResolver: FrameworkResolver = { // Pattern 4: Entity/Model references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveEntity(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, ENTITY_DIRS, context); if (result) { return { original: ref, @@ -102,7 +102,7 @@ export const springResolver: FrameworkResolver = { // Pattern 5: Component references if (ref.referenceName.endsWith('Component') || ref.referenceName.endsWith('Config')) { - const result = resolveComponent(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, COMPONENT_DIRS, context); if (result) { return { original: ref, @@ -176,118 +176,38 @@ export const springResolver: FrameworkResolver = { }, }; -// Helper functions +// Directory patterns +const SERVICE_DIRS = ['/service/', '/services/']; +const REPO_DIRS = ['/repository/', '/repositories/']; +const CONTROLLER_DIRS = ['/controller/', '/controllers/']; +const ENTITY_DIRS = ['/entity/', '/entities/', '/model/', '/models/', '/domain/']; +const COMPONENT_DIRS = ['/component/', '/components/', '/config/']; -function resolveService(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); +const CLASS_KINDS = new Set(['class']); +const SERVICE_KINDS = new Set(['class', 'interface']); - for (const file of allFiles) { - if (file.endsWith('.java') && (file.includes('/service/') || file.includes('/services/'))) { - const nodes = context.getNodesInFile(file); - const serviceNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (serviceNode) { - return serviceNode.id; - } - } - } +/** + * Resolve a symbol by name using indexed queries instead of scanning all files. + */ +function resolveByNameAndKind( + name: string, + kinds: Set, + preferredDirPatterns: string[], + context: ResolutionContext, +): string | null { + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; - // Also check interface definitions - for (const file of allFiles) { - if (file.endsWith('.java')) { - const nodes = context.getNodesInFile(file); - const serviceNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'interface') && n.name === name - ); - if (serviceNode) { - return serviceNode.id; - } - } - } + const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); + if (kindFiltered.length === 0) return null; - return null; -} - -function resolveRepository(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - - for (const file of allFiles) { - if (file.endsWith('.java') && (file.includes('/repository/') || file.includes('/repositories/'))) { - const nodes = context.getNodesInFile(file); - const repoNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'interface') && n.name === name - ); - if (repoNode) { - return repoNode.id; - } - } - } - - return null; -} - -function resolveController(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - - for (const file of allFiles) { - if (file.endsWith('.java') && (file.includes('/controller/') || file.includes('/controllers/'))) { - const nodes = context.getNodesInFile(file); - const controllerNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (controllerNode) { - return controllerNode.id; - } - } - } - - return null; -} - -function resolveEntity(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - - // Check entity/model directories first - for (const file of allFiles) { - if (file.endsWith('.java') && ( - file.includes('/entity/') || - file.includes('/entities/') || - file.includes('/model/') || - file.includes('/models/') || - file.includes('/domain/') - )) { - const nodes = context.getNodesInFile(file); - const entityNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (entityNode) { - return entityNode.id; - } - } - } - - return null; -} - -function resolveComponent(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - - for (const file of allFiles) { - if (file.endsWith('.java') && ( - file.includes('/component/') || - file.includes('/components/') || - file.includes('/config/') - )) { - const nodes = context.getNodesInFile(file); - const componentNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (componentNode) { - return componentNode.id; - } - } - } - - 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; } diff --git a/src/resolution/frameworks/laravel.ts b/src/resolution/frameworks/laravel.ts index 05d0559..d6a7988 100644 --- a/src/resolution/frameworks/laravel.ts +++ b/src/resolution/frameworks/laravel.ts @@ -216,12 +216,12 @@ function resolveControllerMethod( } } - // Try subdirectories (namespaced controllers) - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith(`${controller}.php`) && file.includes('Controllers')) { - const nodes = context.getNodesInFile(file); - const methodNode = nodes.find( + // Try name-based lookup for namespaced controllers + const controllerCandidates = context.getNodesByName(controller); + for (const ctrl of controllerCandidates) { + if (ctrl.kind === 'class' && ctrl.filePath.includes('Controllers')) { + const nodesInFile = context.getNodesInFile(ctrl.filePath); + const methodNode = nodesInFile.find( (n) => n.kind === 'method' && n.name === method ); if (methodNode) { diff --git a/src/resolution/frameworks/python.ts b/src/resolution/frameworks/python.ts index b91623f..88f5034 100644 --- a/src/resolution/frameworks/python.ts +++ b/src/resolution/frameworks/python.ts @@ -34,7 +34,7 @@ export const djangoResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Model references if (ref.referenceName.endsWith('Model') || /^[A-Z][a-z]+$/.test(ref.referenceName)) { - const result = resolveModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -47,7 +47,7 @@ export const djangoResolver: FrameworkResolver = { // Pattern 2: View references if (ref.referenceName.endsWith('View') || ref.referenceName.endsWith('ViewSet')) { - const result = resolveView(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context); if (result) { return { original: ref, @@ -60,7 +60,7 @@ export const djangoResolver: FrameworkResolver = { // Pattern 3: Form references if (ref.referenceName.endsWith('Form')) { - const result = resolveForm(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context); if (result) { return { original: ref, @@ -140,7 +140,7 @@ export const flaskResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Blueprint references if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) { - const result = resolveBlueprint(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, [], context); if (result) { return { original: ref, @@ -215,7 +215,7 @@ export const fastapiResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Router references if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') { - const result = resolveRouter(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VARIABLE_KINDS, ROUTER_DIRS, context); if (result) { return { original: ref, @@ -228,7 +228,7 @@ export const fastapiResolver: FrameworkResolver = { // Pattern 2: Dependency references if (ref.referenceName.startsWith('get_') || ref.referenceName.startsWith('Depends')) { - const result = resolveDependency(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, DEP_DIRS, context); if (result) { return { original: ref, @@ -274,126 +274,41 @@ export const fastapiResolver: FrameworkResolver = { }, }; -// Helper functions +// Directory patterns +const MODEL_DIRS = ['models', 'app/models', 'src/models']; +const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views']; +const FORM_DIRS = ['forms', 'app/forms', 'src/forms']; +const ROUTER_DIRS = ['/routers/', '/api/', '/routes/', '/endpoints/']; +const DEP_DIRS = ['/dependencies/', '/deps/', '/core/']; -function resolveModel(name: string, context: ResolutionContext): string | null { - const modelDirs = ['models', 'app/models', 'src/models']; +const CLASS_KINDS = new Set(['class']); +const VIEW_KINDS = new Set(['class', 'function']); +const VARIABLE_KINDS = new Set(['variable']); +const FUNCTION_KINDS = new Set(['function']); - for (const dir of modelDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) && file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const modelNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (modelNode) { - return modelNode.id; - } - } - } +/** + * Resolve a symbol by name using indexed queries instead of scanning all files. + */ +function resolveByNameAndKind( + name: string, + 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; } - return null; -} - -function resolveView(name: string, context: ResolutionContext): string | null { - const viewDirs = ['views', 'app/views', 'src/views', 'api/views']; - - for (const dir of viewDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) && file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const viewNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'function') && n.name === name - ); - if (viewNode) { - return viewNode.id; - } - } - } - } - - return null; -} - -function resolveForm(name: string, context: ResolutionContext): string | null { - const formDirs = ['forms', 'app/forms', 'src/forms']; - - for (const dir of formDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) && file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const formNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (formNode) { - return formNode.id; - } - } - } - } - - return null; -} - -function resolveBlueprint(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const bpNode = nodes.find( - (n) => n.kind === 'variable' && n.name === name - ); - if (bpNode) { - return bpNode.id; - } - } - } - - return null; -} - -function resolveRouter(name: string, context: ResolutionContext): string | null { - const routerDirs = ['routers', 'api', 'routes', 'endpoints']; - - for (const dir of routerDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if ((file.startsWith(dir) || file.includes('/routers/')) && file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const routerNode = nodes.find( - (n) => n.kind === 'variable' && n.name === name - ); - if (routerNode) { - return routerNode.id; - } - } - } - } - - return null; -} - -function resolveDependency(name: string, context: ResolutionContext): string | null { - const depDirs = ['dependencies', 'deps', 'core']; - - for (const dir of depDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if ((file.startsWith(dir) || file.includes('/dependencies/')) && file.endsWith('.py')) { - const nodes = context.getNodesInFile(file); - const depNode = nodes.find( - (n) => n.kind === 'function' && n.name === name - ); - if (depNode) { - return depNode.id; - } - } - } - } - - return null; + // Fall back to any match + return kindFiltered[0]!.id; } diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index f1ec697..74395df 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -183,116 +183,89 @@ function isPascalCase(str: string): boolean { * Check if name is a built-in type */ function isBuiltInType(name: string): boolean { - const builtIns = [ - 'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Math', 'Number', - 'Object', 'Promise', 'RegExp', 'String', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', - 'React', 'Component', 'Fragment', 'Suspense', 'StrictMode', - ]; - return builtIns.includes(name); + return BUILT_IN_TYPES.has(name); } +const BUILT_IN_TYPES = new Set([ + 'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Math', 'Number', + 'Object', 'Promise', 'RegExp', 'String', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', + 'React', 'Component', 'Fragment', 'Suspense', 'StrictMode', +]); + +const COMPONENT_KINDS = new Set(['component', 'function', 'class']); + /** - * Resolve a component reference + * Resolve a component reference using name-based lookup */ function resolveComponent( name: string, fromFile: string, context: ResolutionContext ): string | null { - // Look for component in common locations - const componentDirs = [ - 'components', - 'src/components', - 'app/components', - 'pages', - 'src/pages', - 'views', - 'src/views', - ]; + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; - // First, check same directory + const components = candidates.filter((n) => COMPONENT_KINDS.has(n.kind)); + if (components.length === 0) return null; + + // Prefer same directory const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); - const sameDir = context.getAllFiles().filter((f) => f.startsWith(fromDir)); - for (const file of sameDir) { - if (file.toLowerCase().includes(name.toLowerCase())) { - const nodes = context.getNodesInFile(file); - const component = nodes.find( - (n) => (n.kind === 'component' || n.kind === 'function' || n.kind === 'class') && n.name === name - ); - if (component) { - return component.id; - } - } - } + const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); + if (sameDir.length > 0) return sameDir[0]!.id; - // Then check component directories - for (const dir of componentDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) && file.toLowerCase().includes(name.toLowerCase())) { - const nodes = context.getNodesInFile(file); - const component = nodes.find( - (n) => (n.kind === 'component' || n.kind === 'function' || n.kind === 'class') && n.name === name - ); - if (component) { - return component.id; - } - } - } - } + // Prefer component directories + const COMPONENT_DIRS = ['/components/', '/src/components/', '/app/components/', '/pages/', '/src/pages/', '/views/', '/src/views/']; + const preferred = components.filter((n) => + COMPONENT_DIRS.some((d) => n.filePath.includes(d)) + ); + if (preferred.length > 0) return preferred[0]!.id; - return null; + return components[0]!.id; } /** - * Resolve a custom hook reference + * Resolve a custom hook reference using name-based lookup */ function resolveHook(name: string, context: ResolutionContext): string | null { - const hookDirs = ['hooks', 'src/hooks', 'lib/hooks', 'utils/hooks']; + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; - for (const dir of hookDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) || file.includes('/hooks/')) { - const nodes = context.getNodesInFile(file); - const hook = nodes.find((n) => n.kind === 'function' && n.name === name); - if (hook) { - return hook.id; - } - } - } - } + const hooks = candidates.filter((n) => n.kind === 'function' && n.name.startsWith('use')); + if (hooks.length === 0) return null; - // Also check all files for the hook - const allNodes = context.getNodesByName(name); - const hookNode = allNodes.find((n) => n.kind === 'function' && n.name.startsWith('use')); - if (hookNode) { - return hookNode.id; - } + // Prefer hooks directories + const HOOK_DIRS = ['/hooks/', '/src/hooks/', '/lib/hooks/', '/utils/hooks/']; + const preferred = hooks.filter((n) => + HOOK_DIRS.some((d) => n.filePath.includes(d)) + ); + if (preferred.length > 0) return preferred[0]!.id; - return null; + return hooks[0]!.id; } /** - * Resolve a context reference + * Resolve a context reference using name-based lookup */ function resolveContext(name: string, context: ResolutionContext): string | null { - const contextDirs = ['context', 'contexts', 'src/context', 'src/contexts', 'providers', 'src/providers']; - - for (const dir of contextDirs) { - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.startsWith(dir) || file.includes('/context/') || file.includes('/contexts/')) { - const nodes = context.getNodesInFile(file); - const contextNode = nodes.find((n) => n.name === name || n.name === name.replace(/Context$|Provider$/, '')); - if (contextNode) { - return contextNode.id; - } - } + const candidates = context.getNodesByName(name); + if (candidates.length === 0) { + // Try without Context/Provider suffix + const baseName = name.replace(/Context$|Provider$/, ''); + if (baseName !== name) { + const baseCandidates = context.getNodesByName(baseName); + if (baseCandidates.length > 0) return baseCandidates[0]!.id; } + return null; } - return null; + // Prefer context directories + const CONTEXT_DIRS = ['/context/', '/contexts/', '/src/context/', '/src/contexts/', '/providers/', '/src/providers/']; + const preferred = candidates.filter((n) => + CONTEXT_DIRS.some((d) => n.filePath.includes(d)) + ); + if (preferred.length > 0) return preferred[0]!.id; + + return candidates[0]!.id; } /** diff --git a/src/resolution/frameworks/ruby.ts b/src/resolution/frameworks/ruby.ts index c2c6f40..49c9a2e 100644 --- a/src/resolution/frameworks/ruby.ts +++ b/src/resolution/frameworks/ruby.ts @@ -189,7 +189,7 @@ export const railsResolver: FrameworkResolver = { // Helper functions function resolveModel(name: string, context: ResolutionContext): string | null { - // Convert CamelCase to snake_case for file lookup + // Try direct file path lookup first (Rails convention: CamelCase -> snake_case.rb) const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1); const possiblePaths = [ `app/models/${snakeName}.rb`, @@ -208,25 +208,18 @@ function resolveModel(name: string, context: ResolutionContext): string | null { } } - // Search all model files - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.includes('app/models/') && file.endsWith('.rb')) { - const nodes = context.getNodesInFile(file); - const modelNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (modelNode) { - return modelNode.id; - } - } - } + // Fall back to name-based lookup + const candidates = context.getNodesByName(name); + const modelNode = candidates.find( + (n) => n.kind === 'class' && n.filePath.includes('app/models/') + ); + if (modelNode) return modelNode.id; return null; } function resolveController(name: string, context: ResolutionContext): string | null { - // Convert CamelCase to snake_case + // Try direct file path lookup first const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1); const possiblePaths = [ `app/controllers/${snakeName}.rb`, @@ -246,19 +239,12 @@ function resolveController(name: string, context: ResolutionContext): string | n } } - // Search all controller files - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.includes('controllers/') && file.endsWith('.rb')) { - const nodes = context.getNodesInFile(file); - const controllerNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (controllerNode) { - return controllerNode.id; - } - } - } + // Fall back to name-based lookup + const candidates = context.getNodesByName(name); + const controllerNode = candidates.find( + (n) => n.kind === 'class' && n.filePath.includes('controllers/') + ); + if (controllerNode) return controllerNode.id; return null; } diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index 68b6b2d..5ab10bc 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -18,7 +18,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 = resolveHandler(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, HANDLER_DIRS, context); if (result) { return { original: ref, @@ -31,7 +31,7 @@ export const rustResolver: FrameworkResolver = { // Pattern 2: Service/Repository trait implementations if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository')) { - const result = resolveService(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context); if (result) { return { original: ref, @@ -44,7 +44,7 @@ export const rustResolver: FrameworkResolver = { // Pattern 3: Struct references (PascalCase) if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveStruct(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, STRUCT_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -153,91 +153,39 @@ export const rustResolver: FrameworkResolver = { }, }; -// Helper functions +// Directory patterns +const HANDLER_DIRS = ['/handlers/', '/handler/', '/api/', '/routes/', '/controllers/']; +const SERVICE_DIRS = ['/services/', '/service/', '/repository/', '/domain/']; +const MODEL_DIRS = ['/models/', '/model/', '/entities/', '/entity/', '/domain/', '/types/']; -function resolveHandler(name: string, context: ResolutionContext): string | null { - const handlerDirs = ['handlers', 'handler', 'api', 'routes', 'controllers']; +const FUNCTION_KINDS = new Set(['function']); +const SERVICE_KINDS = new Set(['struct', 'trait']); +const STRUCT_KINDS = new Set(['struct']); - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.rs') && handlerDirs.some((d) => file.includes(`/${d}/`) || file.includes(`/${d}.rs`))) { - const nodes = context.getNodesInFile(file); - const handlerNode = nodes.find( - (n) => n.kind === 'function' && n.name === name - ); - if (handlerNode) { - return handlerNode.id; - } - } - } +/** + * Resolve a symbol by name using indexed queries instead of scanning all files. + */ +function resolveByNameAndKind( + name: string, + kinds: Set, + preferredDirPatterns: string[], + context: ResolutionContext, +): string | null { + const candidates = context.getNodesByName(name); + if (candidates.length === 0) return null; - // Search all Rust files - for (const file of allFiles) { - if (file.endsWith('.rs')) { - const nodes = context.getNodesInFile(file); - const handlerNode = nodes.find( - (n) => n.kind === 'function' && n.name === name - ); - if (handlerNode) { - return handlerNode.id; - } - } - } + const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); + if (kindFiltered.length === 0) return null; - return null; -} + // Prefer candidates in framework-conventional directories + const preferred = kindFiltered.filter((n) => + preferredDirPatterns.some((d) => n.filePath.includes(d)) + ); -function resolveService(name: string, context: ResolutionContext): string | null { - const serviceDirs = ['services', 'service', 'repository', 'domain']; + if (preferred.length > 0) return preferred[0]!.id; - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.rs') && serviceDirs.some((d) => file.includes(`/${d}/`) || file.includes(`/${d}.rs`))) { - const nodes = context.getNodesInFile(file); - const serviceNode = nodes.find( - (n) => (n.kind === 'struct' || n.kind === 'trait') && n.name === name - ); - if (serviceNode) { - return serviceNode.id; - } - } - } - - return null; -} - -function resolveStruct(name: string, context: ResolutionContext): string | null { - const modelDirs = ['models', 'model', 'entities', 'entity', 'domain', 'types']; - - const allFiles = context.getAllFiles(); - - // Check model directories first - for (const file of allFiles) { - if (file.endsWith('.rs') && modelDirs.some((d) => file.includes(`/${d}/`) || file.includes(`/${d}.rs`))) { - const nodes = context.getNodesInFile(file); - const structNode = nodes.find( - (n) => n.kind === 'struct' && n.name === name - ); - if (structNode) { - return structNode.id; - } - } - } - - // Search all Rust files - for (const file of allFiles) { - if (file.endsWith('.rs')) { - const nodes = context.getNodesInFile(file); - const structNode = nodes.find( - (n) => n.kind === 'struct' && n.name === name - ); - if (structNode) { - return structNode.id; - } - } - } - - return null; + // Fall back to any match + return kindFiltered[0]!.id; } function resolveModule(name: string, context: ResolutionContext): string | null { diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts index 5e0fd9a..7575ba0 100644 --- a/src/resolution/frameworks/svelte.ts +++ b/src/resolution/frameworks/svelte.ts @@ -201,47 +201,25 @@ function isPascalCase(str: string): boolean { } /** - * Resolve a Svelte component reference to its .svelte file + * Resolve a Svelte component reference using name-based lookup */ function resolveComponent( name: string, fromFile: string, context: ResolutionContext ): string | null { - // Look for matching .svelte files - const allFiles = context.getAllFiles(); - const svelteFiles = allFiles.filter((f) => f.endsWith('.svelte')); + // Look for component nodes by name + const candidates = context.getNodesByName(name); + const components = candidates.filter((n) => n.kind === 'component'); - // Check for exact name match (Button -> Button.svelte) - for (const file of svelteFiles) { - const fileName = file.split(/[/\\]/).pop() || ''; - const componentName = fileName.replace(/\.svelte$/, ''); - if (componentName === name) { - const nodes = context.getNodesInFile(file); - const component = nodes.find((n) => n.kind === 'component' && n.name === name); - if (component) { - return component.id; - } - } - } + if (components.length === 0) return null; - // Check same directory first for better specificity + // Prefer same directory const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); - for (const file of svelteFiles) { - if (file.startsWith(fromDir)) { - const fileName = file.split(/[/\\]/).pop() || ''; - const componentName = fileName.replace(/\.svelte$/, ''); - if (componentName === name) { - const nodes = context.getNodesInFile(file); - const component = nodes.find((n) => n.kind === 'component'); - if (component) { - return component.id; - } - } - } - } + const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); + if (sameDir.length > 0) return sameDir[0]!.id; - return null; + return components[0]!.id; } /** diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 6a7f8a2..25f6201 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -35,7 +35,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 = resolveView(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context); if (result) { return { original: ref, @@ -48,7 +48,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 = resolveViewModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context); if (result) { return { original: ref, @@ -61,7 +61,7 @@ export const swiftUIResolver: FrameworkResolver = { // Pattern 3: Model references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, MODEL_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, @@ -153,7 +153,7 @@ export const uikitResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: ViewController references if (ref.referenceName.endsWith('ViewController')) { - const result = resolveViewController(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context); if (result) { return { original: ref, @@ -166,7 +166,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 2: UIView subclass references if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) { - const result = resolveUIView(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, UIVIEW_DIRS, context); if (result) { return { original: ref, @@ -179,7 +179,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 3: Cell references if (ref.referenceName.endsWith('Cell')) { - const result = resolveCell(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context); if (result) { return { original: ref, @@ -192,7 +192,7 @@ export const uikitResolver: FrameworkResolver = { // Pattern 4: Delegate/DataSource references if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) { - const result = resolveProtocol(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, PROTOCOL_KINDS, [], context); if (result) { return { original: ref, @@ -286,7 +286,7 @@ export const vaporResolver: FrameworkResolver = { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { - const result = resolveVaporController(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context); if (result) { return { original: ref, @@ -299,7 +299,7 @@ export const vaporResolver: FrameworkResolver = { // Pattern 2: Model references (Fluent) if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveFluentModel(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FLUENT_MODEL_DIRS, context); if (result) { return { original: ref, @@ -312,7 +312,7 @@ export const vaporResolver: FrameworkResolver = { // Pattern 3: Middleware references if (ref.referenceName.endsWith('Middleware')) { - const result = resolveVaporMiddleware(ref.referenceName, context); + const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context); if (result) { return { original: ref, @@ -382,210 +382,46 @@ export const vaporResolver: FrameworkResolver = { }, }; -// Helper functions for SwiftUI +// Directory patterns +const VIEW_DIRS = ['/Views/', '/View/', '/Screens/', '/Components/', '/UI/']; +const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/Stores/', '/Managers/', '/Services/']; +const MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Domain/']; +const VC_DIRS = ['/ViewControllers/', '/ViewController/', '/Controllers/', '/Screens/']; +const UIVIEW_DIRS = ['/Views/', '/View/', '/UI/', '/Components/']; +const CELL_DIRS = ['/Cells/', '/Cell/', '/Views/', '/TableViewCells/', '/CollectionViewCells/']; +const VAPOR_CONTROLLER_DIRS = ['/Controllers/', '/Controller/', '/Routes/']; +const FLUENT_MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Database/']; +const VAPOR_MIDDLEWARE_DIRS = ['/Middleware/', '/Middlewares/']; -function resolveView(name: string, context: ResolutionContext): string | null { - const viewDirs = ['Views', 'View', 'Screens', 'Components', 'UI']; +const VIEW_KINDS = new Set(['struct', 'component']); +const CLASS_KINDS = new Set(['class']); +const MODEL_KINDS = new Set(['struct', 'class']); +const PROTOCOL_KINDS = new Set(['protocol']); +const VAPOR_CONTROLLER_KINDS = new Set(['class', 'struct']); - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && viewDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const viewNode = nodes.find( - (n) => (n.kind === 'struct' || n.kind === 'component') && n.name === name - ); - if (viewNode) { - return viewNode.id; - } - } +/** + * Resolve a symbol by name using indexed queries instead of scanning all files. + */ +function resolveByNameAndKind( + name: string, + 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; } - // Search all Swift files - for (const file of allFiles) { - if (file.endsWith('.swift')) { - const nodes = context.getNodesInFile(file); - const viewNode = nodes.find( - (n) => (n.kind === 'struct' || n.kind === 'component') && n.name === name - ); - if (viewNode) { - return viewNode.id; - } - } - } - - return null; -} - -function resolveViewModel(name: string, context: ResolutionContext): string | null { - const vmDirs = ['ViewModels', 'ViewModel', 'Stores', 'Managers', 'Services']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && vmDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const vmNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (vmNode) { - return vmNode.id; - } - } - } - - return null; -} - -function resolveModel(name: string, context: ResolutionContext): string | null { - const modelDirs = ['Models', 'Model', 'Entities', 'Domain']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && modelDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const modelNode = nodes.find( - (n) => (n.kind === 'struct' || n.kind === 'class') && n.name === name - ); - if (modelNode) { - return modelNode.id; - } - } - } - - return null; -} - -// Helper functions for UIKit - -function resolveViewController(name: string, context: ResolutionContext): string | null { - const vcDirs = ['ViewControllers', 'ViewController', 'Controllers', 'Screens']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && (vcDirs.some((d) => file.includes(`/${d}/`)) || file.includes(name))) { - const nodes = context.getNodesInFile(file); - const vcNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (vcNode) { - return vcNode.id; - } - } - } - - return null; -} - -function resolveUIView(name: string, context: ResolutionContext): string | null { - const viewDirs = ['Views', 'View', 'UI', 'Components']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && viewDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const viewNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (viewNode) { - return viewNode.id; - } - } - } - - return null; -} - -function resolveCell(name: string, context: ResolutionContext): string | null { - const cellDirs = ['Cells', 'Cell', 'Views', 'TableViewCells', 'CollectionViewCells']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && cellDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const cellNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (cellNode) { - return cellNode.id; - } - } - } - - return null; -} - -function resolveProtocol(name: string, context: ResolutionContext): string | null { - const allFiles = context.getAllFiles(); - - for (const file of allFiles) { - if (file.endsWith('.swift')) { - const nodes = context.getNodesInFile(file); - const protocolNode = nodes.find( - (n) => n.kind === 'protocol' && n.name === name - ); - if (protocolNode) { - return protocolNode.id; - } - } - } - - return null; -} - -// Helper functions for Vapor - -function resolveVaporController(name: string, context: ResolutionContext): string | null { - const controllerDirs = ['Controllers', 'Controller', 'Routes']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && controllerDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const controllerNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'struct') && n.name === name - ); - if (controllerNode) { - return controllerNode.id; - } - } - } - - return null; -} - -function resolveFluentModel(name: string, context: ResolutionContext): string | null { - const modelDirs = ['Models', 'Model', 'Entities', 'Database']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && modelDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const modelNode = nodes.find( - (n) => n.kind === 'class' && n.name === name - ); - if (modelNode) { - return modelNode.id; - } - } - } - - return null; -} - -function resolveVaporMiddleware(name: string, context: ResolutionContext): string | null { - const middlewareDirs = ['Middleware', 'Middlewares']; - - const allFiles = context.getAllFiles(); - for (const file of allFiles) { - if (file.endsWith('.swift') && middlewareDirs.some((d) => file.includes(`/${d}/`))) { - const nodes = context.getNodesInFile(file); - const mwNode = nodes.find( - (n) => (n.kind === 'class' || n.kind === 'struct') && n.name === name - ); - if (mwNode) { - return mwNode.id; - } - } - } - - return null; + // Fall back to any match + return kindFiltered[0]!.id; } diff --git a/src/search/query-utils.ts b/src/search/query-utils.ts index 91ece3e..57f0b12 100644 --- a/src/search/query-utils.ts +++ b/src/search/query-utils.ts @@ -25,17 +25,39 @@ export const STOP_WORDS = new Set([ 'over', 'only', 'new', 'out', 'its', 'so', 'up', 'as', 'if', // Code-specific noise 'code', 'file', 'files', 'function', 'method', 'class', 'type', - 'build', 'run', 'test', 'fix', 'bug', 'call', 'called', 'set', 'add', + 'build', 'fix', 'bug', 'called', 'set', 'add', ]); /** * Extract meaningful search terms from a natural language query. * Splits camelCase, PascalCase, snake_case, SCREAMING_SNAKE, and dot.notation * into individual tokens before filtering. + * + * Preserves original compound identifiers (e.g., "scrapeLoop") alongside + * their split parts so that FTS can match both the full symbol name and + * individual words within it. */ export function extractSearchTerms(query: string): string[] { const tokens = new Set(); + // First, extract and preserve compound identifiers before splitting + // CamelCase: scrapeLoop, UserService, getCallGraph + const compoundPattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:[A-Z][a-z]+)+|[A-Z][a-z]+(?:[A-Z][a-z]*)+)\b/g; + let match; + while ((match = compoundPattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 3) { + tokens.add(match[1].toLowerCase()); // preserve full compound: "scrapeloop" + } + } + + // snake_case: scrape_loop, user_service + const snakePattern = /\b([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)\b/g; + while ((match = snakePattern.exec(query)) !== null) { + if (match[1] && match[1].length >= 3) { + tokens.add(match[1].toLowerCase()); + } + } + // Split camelCase / PascalCase: "getUserName" → "get User Name" const camelSplit = query .replace(/([a-z])([A-Z])/g, '$1 $2')