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')