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.
This commit is contained in:
Colby McHenry
2026-04-06 11:16:56 -05:00
parent d256af3a23
commit 7a3afc9124
16 changed files with 466 additions and 877 deletions
+27 -1
View File
@@ -291,10 +291,36 @@ export class ContextBuilder {
let exactMatches: SearchResult[] = []; let exactMatches: SearchResult[] = [];
if (symbolsFromQuery.length > 0) { if (symbolsFromQuery.length > 0) {
try { try {
// Get more results so we can apply co-location boosting before trimming
exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { 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, 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<string, Set<string>>();
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 }); logDebug('Exact symbol matches', { count: exactMatches.length });
} catch (error) { } catch (error) {
logDebug('Exact symbol lookup failed', { error: String(error) }); logDebug('Exact symbol lookup failed', { error: String(error) });
+59 -18
View File
@@ -630,19 +630,44 @@ export class QueryBuilder {
const { kinds, languages, limit = 50 } = options; const { kinds, languages, limit = 50 } = options;
// Build query with exact matches (case-insensitive) // Two-pass approach to handle common names (e.g., "run" has 40+ matches):
let sql = ` // Pass 1: Find which files contain distinctive (rare) symbols from the query.
SELECT nodes.*, // Pass 2: Query each name, boosting results that co-locate with distinctive symbols.
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(',')})
`;
// Duplicate names for both SELECT and WHERE clauses // Pass 1: Find files containing each queried name, identify distinctive names
const params: (string | number)[] = [...names, ...names]; const nameToFiles = new Map<string, Set<string>>();
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)));
}
// Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file)
const distinctiveFiles = new Set<string>();
for (const [, files] of nameToFiles) {
if (files.size > 0 && files.size < 10) {
for (const f of files) distinctiveFiles.add(f);
}
}
// 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<string>();
for (const name of names) {
let sql = `
SELECT nodes.*, 1.0 as score
FROM nodes
WHERE name COLLATE NOCASE = ?
`;
const params: (string | number)[] = [name];
if (kinds && kinds.length > 0) { if (kinds && kinds.length > 0) {
sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
@@ -654,15 +679,31 @@ export class QueryBuilder {
params.push(...languages); params.push(...languages);
} }
sql += ' ORDER BY score DESC, length(name) ASC LIMIT ?'; // Fetch enough to find co-located results among common names
params.push(limit); sql += ' LIMIT ?';
params.push(Math.max(perNameLimit * 3, 50));
const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; 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 });
}
return rows.map((row) => ({ // Sort by score (co-located first), take per-name limit
node: rowToNode(row), nameResults.sort((a, b) => b.score - a.score);
score: row.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);
} }
// =========================================================================== // ===========================================================================
+12
View File
@@ -27,4 +27,16 @@ export const goExtractor: LanguageExtractor = {
} }
return sig; 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];
},
}; };
+7
View File
@@ -163,4 +163,11 @@ export interface LanguageExtractor {
* Returns info about each declared variable, allowing the core to create nodes. * Returns info about each declared variable, allowing the core to create nodes.
*/ */
extractVariables?: (node: SyntaxNode, source: string) => VariableInfo[]; 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;
} }
+10 -2
View File
@@ -505,13 +505,21 @@ export class TreeSitterExtractor {
const isAsync = this.extractor.isAsync?.(node); const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(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<Node> = {
docstring, docstring,
signature, signature,
visibility, visibility,
isAsync, isAsync,
isStatic, isStatic,
}); };
if (receiverType) {
extraProps.qualifiedName = `${this.filePath}::${receiverType}::${name}`;
}
const methodNode = this.createNode('method', name, node, extraProps);
if (!methodNode) return; if (!methodNode) return;
// Extract type annotations (parameter types and return type) // Extract type annotations (parameter types and return type)
+35 -108
View File
@@ -48,7 +48,7 @@ export const aspnetResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Controller references // Pattern 1: Controller references
if (ref.referenceName.endsWith('Controller')) { if (ref.referenceName.endsWith('Controller')) {
const result = resolveController(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -61,7 +61,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 2: Service references (dependency injection) // Pattern 2: Service references (dependency injection)
if (ref.referenceName.endsWith('Service') || ref.referenceName.startsWith('I') && ref.referenceName.length > 1) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -74,7 +74,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 3: Repository references // Pattern 3: Repository references
if (ref.referenceName.endsWith('Repository')) { if (ref.referenceName.endsWith('Repository')) {
const result = resolveRepository(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -87,7 +87,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 4: Model/Entity references // Pattern 4: Model/Entity references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -100,7 +100,7 @@ export const aspnetResolver: FrameworkResolver = {
// Pattern 5: ViewModel references // Pattern 5: ViewModel references
if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Dto')) { 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) { if (result) {
return { return {
original: ref, 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 CLASS_KINDS = new Set(['class']);
const allFiles = context.getAllFiles(); const SERVICE_KINDS = new Set(['class', 'interface']);
for (const file of allFiles) { /**
if (file.endsWith('.cs') && file.includes('/Controllers/')) { * Resolve a symbol by name using indexed queries instead of scanning all files.
const nodes = context.getNodesInFile(file); */
const controllerNode = nodes.find( function resolveByNameAndKind(
(n) => n.kind === 'class' && n.name === name name: string,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
); );
if (controllerNode) {
return controllerNode.id;
}
}
}
return null; if (preferred.length > 0) return preferred[0]!.id;
}
// Fall back to any match
function resolveService(name: string, context: ResolutionContext): string | null { return kindFiltered[0]!.id;
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;
} }
+45 -55
View File
@@ -61,7 +61,7 @@ export const expressResolver: FrameworkResolver = {
const controllerMatch = ref.referenceName.match(/^(\w+)Controller\.(\w+)$/); const controllerMatch = ref.referenceName.match(/^(\w+)Controller\.(\w+)$/);
if (controllerMatch) { if (controllerMatch) {
const [, controller, method] = controllerMatch; const [, controller, method] = controllerMatch;
const result = resolveController(controller!, method!, context); const result = resolveControllerMethod(controller!, method!, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -76,7 +76,7 @@ export const expressResolver: FrameworkResolver = {
const serviceMatch = ref.referenceName.match(/^(\w+)(Service|Helper|Utils?)\.(\w+)$/); const serviceMatch = ref.referenceName.match(/^(\w+)(Service|Helper|Utils?)\.(\w+)$/);
if (serviceMatch) { if (serviceMatch) {
const [, name, suffix, method] = serviceMatch; const [, name, suffix, method] = serviceMatch;
const result = resolveService(name! + suffix!, method!, context); const result = resolveServiceMethod(name! + suffix!, method!, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -154,93 +154,83 @@ function isMiddlewareName(name: string): boolean {
} }
/** /**
* Resolve middleware reference * Resolve middleware reference using name-based lookup
*/ */
function resolveMiddleware( function resolveMiddleware(
name: string, name: string,
context: ResolutionContext context: ResolutionContext
): string | null { ): string | null {
// Look in middleware directories // Try exact name first
const middlewareDirs = ['middleware', 'middlewares', 'src/middleware', 'src/middlewares']; const candidates = context.getNodesByName(name);
const match = candidates.find((n) =>
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.toLowerCase() ||
n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase() n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase()
); );
if (match) { if (match) return match.id;
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; return null;
} }
/** /**
* Resolve controller method * Resolve controller method using name-based lookup
*/ */
function resolveController( function resolveControllerMethod(
controller: string, controller: string,
method: string, method: string,
context: ResolutionContext context: ResolutionContext
): string | null { ): 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) { if (methodNodes.length > 0) return methodNodes[0]!.id;
const allFiles = context.getAllFiles();
for (const file of allFiles) { // Fall back: look for controller class, then find the method in its file
if ( const controllerName = controller + 'Controller';
(file.startsWith(dir) || file.includes('/controllers/')) && const controllerCandidates = context.getNodesByName(controllerName);
file.toLowerCase().includes(controller.toLowerCase()) for (const ctrl of controllerCandidates) {
) { const nodesInFile = context.getNodesInFile(ctrl.filePath);
const nodes = context.getNodesInFile(file); const methodNode = nodesInFile.find(
const methodNode = nodes.find(
(n) => (n.kind === 'method' || n.kind === 'function') && n.name === method (n) => (n.kind === 'method' || n.kind === 'function') && n.name === method
); );
if (methodNode) { if (methodNode) return methodNode.id;
return methodNode.id;
}
}
}
} }
return null; return null;
} }
/** /**
* Resolve service/helper * Resolve service/helper method using name-based lookup
*/ */
function resolveService( function resolveServiceMethod(
serviceName: string, serviceName: string,
method: string, method: string,
context: ResolutionContext context: ResolutionContext
): string | null { ): 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);
for (const dir of serviceDirs) { const stripped = serviceName.replace(/(Service|Helper|Utils?)$/i, '').toLowerCase();
const allFiles = context.getAllFiles(); const methodNodes = methodCandidates.filter(
for (const file of allFiles) { (n) => (n.kind === 'method' || n.kind === 'function') &&
if ( n.filePath.toLowerCase().includes(stripped)
(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; return null;
} }
+34 -114
View File
@@ -50,7 +50,7 @@ export const springResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Service references (dependency injection) // Pattern 1: Service references (dependency injection)
if (ref.referenceName.endsWith('Service')) { if (ref.referenceName.endsWith('Service')) {
const result = resolveService(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -63,7 +63,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 2: Repository references // Pattern 2: Repository references
if (ref.referenceName.endsWith('Repository')) { if (ref.referenceName.endsWith('Repository')) {
const result = resolveRepository(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, REPO_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -76,7 +76,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 3: Controller references // Pattern 3: Controller references
if (ref.referenceName.endsWith('Controller')) { if (ref.referenceName.endsWith('Controller')) {
const result = resolveController(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CONTROLLER_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -89,7 +89,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 4: Entity/Model references // Pattern 4: Entity/Model references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -102,7 +102,7 @@ export const springResolver: FrameworkResolver = {
// Pattern 5: Component references // Pattern 5: Component references
if (ref.referenceName.endsWith('Component') || ref.referenceName.endsWith('Config')) { 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) { if (result) {
return { return {
original: ref, 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 CLASS_KINDS = new Set(['class']);
const allFiles = context.getAllFiles(); const SERVICE_KINDS = new Set(['class', 'interface']);
for (const file of allFiles) { /**
if (file.endsWith('.java') && (file.includes('/service/') || file.includes('/services/'))) { * Resolve a symbol by name using indexed queries instead of scanning all files.
const nodes = context.getNodesInFile(file); */
const serviceNode = nodes.find( function resolveByNameAndKind(
(n) => n.kind === 'class' && n.name === name name: string,
kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
); );
if (serviceNode) {
return serviceNode.id;
}
}
}
// Also check interface definitions if (preferred.length > 0) return preferred[0]!.id;
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;
}
}
}
return null; // Fall back to any match
} return kindFiltered[0]!.id;
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;
} }
+6 -6
View File
@@ -216,12 +216,12 @@ function resolveControllerMethod(
} }
} }
// Try subdirectories (namespaced controllers) // Try name-based lookup for namespaced controllers
const allFiles = context.getAllFiles(); const controllerCandidates = context.getNodesByName(controller);
for (const file of allFiles) { for (const ctrl of controllerCandidates) {
if (file.endsWith(`${controller}.php`) && file.includes('Controllers')) { if (ctrl.kind === 'class' && ctrl.filePath.includes('Controllers')) {
const nodes = context.getNodesInFile(file); const nodesInFile = context.getNodesInFile(ctrl.filePath);
const methodNode = nodes.find( const methodNode = nodesInFile.find(
(n) => n.kind === 'method' && n.name === method (n) => n.kind === 'method' && n.name === method
); );
if (methodNode) { if (methodNode) {
+38 -123
View File
@@ -34,7 +34,7 @@ export const djangoResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Model references // Pattern 1: Model references
if (ref.referenceName.endsWith('Model') || /^[A-Z][a-z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -47,7 +47,7 @@ export const djangoResolver: FrameworkResolver = {
// Pattern 2: View references // Pattern 2: View references
if (ref.referenceName.endsWith('View') || ref.referenceName.endsWith('ViewSet')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -60,7 +60,7 @@ export const djangoResolver: FrameworkResolver = {
// Pattern 3: Form references // Pattern 3: Form references
if (ref.referenceName.endsWith('Form')) { if (ref.referenceName.endsWith('Form')) {
const result = resolveForm(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FORM_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -140,7 +140,7 @@ export const flaskResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Blueprint references // Pattern 1: Blueprint references
if (ref.referenceName.endsWith('_bp') || ref.referenceName.endsWith('_blueprint')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -215,7 +215,7 @@ export const fastapiResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Router references // Pattern 1: Router references
if (ref.referenceName.endsWith('_router') || ref.referenceName === 'router') { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -228,7 +228,7 @@ export const fastapiResolver: FrameworkResolver = {
// Pattern 2: Dependency references // Pattern 2: Dependency references
if (ref.referenceName.startsWith('get_') || ref.referenceName.startsWith('Depends')) { 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) { if (result) {
return { return {
original: ref, 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 CLASS_KINDS = new Set(['class']);
const modelDirs = ['models', 'app/models', 'src/models']; 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(); * Resolve a symbol by name using indexed queries instead of scanning all files.
for (const file of allFiles) { */
if (file.startsWith(dir) && file.endsWith('.py')) { function resolveByNameAndKind(
const nodes = context.getNodesInFile(file); name: string,
const modelNode = nodes.find( kinds: Set<string>,
(n) => n.kind === 'class' && n.name === name 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 (modelNode) { if (preferred.length > 0) return preferred[0]!.id;
return modelNode.id;
}
}
}
} }
return null; // Fall back to any match
} return kindFiltered[0]!.id;
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;
} }
+52 -79
View File
@@ -183,116 +183,89 @@ function isPascalCase(str: string): boolean {
* Check if name is a built-in type * Check if name is a built-in type
*/ */
function isBuiltInType(name: string): boolean { function isBuiltInType(name: string): boolean {
const builtIns = [ return BUILT_IN_TYPES.has(name);
}
const BUILT_IN_TYPES = new Set([
'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Math', 'Number', 'Array', 'Boolean', 'Date', 'Error', 'Function', 'JSON', 'Math', 'Number',
'Object', 'Promise', 'RegExp', 'String', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Object', 'Promise', 'RegExp', 'String', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet',
'React', 'Component', 'Fragment', 'Suspense', 'StrictMode', 'React', 'Component', 'Fragment', 'Suspense', 'StrictMode',
]; ]);
return builtIns.includes(name);
} const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
/** /**
* Resolve a component reference * Resolve a component reference using name-based lookup
*/ */
function resolveComponent( function resolveComponent(
name: string, name: string,
fromFile: string, fromFile: string,
context: ResolutionContext context: ResolutionContext
): string | null { ): string | null {
// Look for component in common locations const candidates = context.getNodesByName(name);
const componentDirs = [ if (candidates.length === 0) return null;
'components',
'src/components',
'app/components',
'pages',
'src/pages',
'views',
'src/views',
];
// 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 fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
const sameDir = context.getAllFiles().filter((f) => f.startsWith(fromDir)); const sameDir = components.filter((n) => n.filePath.startsWith(fromDir));
for (const file of sameDir) { if (sameDir.length > 0) return sameDir[0]!.id;
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;
}
}
}
// Then check component directories // Prefer component directories
for (const dir of componentDirs) { const COMPONENT_DIRS = ['/components/', '/src/components/', '/app/components/', '/pages/', '/src/pages/', '/views/', '/src/views/'];
const allFiles = context.getAllFiles(); const preferred = components.filter((n) =>
for (const file of allFiles) { COMPONENT_DIRS.some((d) => n.filePath.includes(d))
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) { if (preferred.length > 0) return preferred[0]!.id;
return component.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 { 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 hooks = candidates.filter((n) => n.kind === 'function' && n.name.startsWith('use'));
const allFiles = context.getAllFiles(); if (hooks.length === 0) return null;
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;
}
}
}
}
// Also check all files for the hook // Prefer hooks directories
const allNodes = context.getNodesByName(name); const HOOK_DIRS = ['/hooks/', '/src/hooks/', '/lib/hooks/', '/utils/hooks/'];
const hookNode = allNodes.find((n) => n.kind === 'function' && n.name.startsWith('use')); const preferred = hooks.filter((n) =>
if (hookNode) { HOOK_DIRS.some((d) => n.filePath.includes(d))
return hookNode.id; );
} 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 { function resolveContext(name: string, context: ResolutionContext): string | null {
const contextDirs = ['context', 'contexts', 'src/context', 'src/contexts', 'providers', 'src/providers']; const candidates = context.getNodesByName(name);
if (candidates.length === 0) {
for (const dir of contextDirs) { // Try without Context/Provider suffix
const allFiles = context.getAllFiles(); const baseName = name.replace(/Context$|Provider$/, '');
for (const file of allFiles) { if (baseName !== name) {
if (file.startsWith(dir) || file.includes('/context/') || file.includes('/contexts/')) { const baseCandidates = context.getNodesByName(baseName);
const nodes = context.getNodesInFile(file); if (baseCandidates.length > 0) return baseCandidates[0]!.id;
const contextNode = nodes.find((n) => n.name === name || n.name === name.replace(/Context$|Provider$/, ''));
if (contextNode) {
return contextNode.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;
} }
/** /**
+12 -26
View File
@@ -189,7 +189,7 @@ export const railsResolver: FrameworkResolver = {
// Helper functions // Helper functions
function resolveModel(name: string, context: ResolutionContext): string | null { 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 snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
const possiblePaths = [ const possiblePaths = [
`app/models/${snakeName}.rb`, `app/models/${snakeName}.rb`,
@@ -208,25 +208,18 @@ function resolveModel(name: string, context: ResolutionContext): string | null {
} }
} }
// Search all model files // Fall back to name-based lookup
const allFiles = context.getAllFiles(); const candidates = context.getNodesByName(name);
for (const file of allFiles) { const modelNode = candidates.find(
if (file.includes('app/models/') && file.endsWith('.rb')) { (n) => n.kind === 'class' && n.filePath.includes('app/models/')
const nodes = context.getNodesInFile(file);
const modelNode = nodes.find(
(n) => n.kind === 'class' && n.name === name
); );
if (modelNode) { if (modelNode) return modelNode.id;
return modelNode.id;
}
}
}
return null; return null;
} }
function resolveController(name: string, context: ResolutionContext): string | 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 snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
const possiblePaths = [ const possiblePaths = [
`app/controllers/${snakeName}.rb`, `app/controllers/${snakeName}.rb`,
@@ -246,19 +239,12 @@ function resolveController(name: string, context: ResolutionContext): string | n
} }
} }
// Search all controller files // Fall back to name-based lookup
const allFiles = context.getAllFiles(); const candidates = context.getNodesByName(name);
for (const file of allFiles) { const controllerNode = candidates.find(
if (file.includes('controllers/') && file.endsWith('.rb')) { (n) => n.kind === 'class' && n.filePath.includes('controllers/')
const nodes = context.getNodesInFile(file);
const controllerNode = nodes.find(
(n) => n.kind === 'class' && n.name === name
); );
if (controllerNode) { if (controllerNode) return controllerNode.id;
return controllerNode.id;
}
}
}
return null; return null;
} }
+31 -83
View File
@@ -18,7 +18,7 @@ export const rustResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Handler references // Pattern 1: Handler references
if (ref.referenceName.endsWith('_handler') || ref.referenceName.startsWith('handle_')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -31,7 +31,7 @@ export const rustResolver: FrameworkResolver = {
// Pattern 2: Service/Repository trait implementations // Pattern 2: Service/Repository trait implementations
if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -44,7 +44,7 @@ export const rustResolver: FrameworkResolver = {
// Pattern 3: Struct references (PascalCase) // Pattern 3: Struct references (PascalCase)
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, 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 FUNCTION_KINDS = new Set(['function']);
const handlerDirs = ['handlers', 'handler', 'api', 'routes', 'controllers']; const SERVICE_KINDS = new Set(['struct', 'trait']);
const STRUCT_KINDS = new Set(['struct']);
const allFiles = context.getAllFiles(); /**
for (const file of allFiles) { * Resolve a symbol by name using indexed queries instead of scanning all files.
if (file.endsWith('.rs') && handlerDirs.some((d) => file.includes(`/${d}/`) || file.includes(`/${d}.rs`))) { */
const nodes = context.getNodesInFile(file); function resolveByNameAndKind(
const handlerNode = nodes.find( name: string,
(n) => n.kind === 'function' && n.name === name kinds: Set<string>,
preferredDirPatterns: string[],
context: ResolutionContext,
): string | null {
const candidates = context.getNodesByName(name);
if (candidates.length === 0) return null;
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
if (kindFiltered.length === 0) return null;
// Prefer candidates in framework-conventional directories
const preferred = kindFiltered.filter((n) =>
preferredDirPatterns.some((d) => n.filePath.includes(d))
); );
if (handlerNode) {
return handlerNode.id;
}
}
}
// Search all Rust files if (preferred.length > 0) return preferred[0]!.id;
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;
}
}
}
return null; // Fall back to any match
} return kindFiltered[0]!.id;
function resolveService(name: string, context: ResolutionContext): string | null {
const serviceDirs = ['services', 'service', 'repository', 'domain'];
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;
} }
function resolveModule(name: string, context: ResolutionContext): string | null { function resolveModule(name: string, context: ResolutionContext): string | null {
+9 -31
View File
@@ -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( function resolveComponent(
name: string, name: string,
fromFile: string, fromFile: string,
context: ResolutionContext context: ResolutionContext
): string | null { ): string | null {
// Look for matching .svelte files // Look for component nodes by name
const allFiles = context.getAllFiles(); const candidates = context.getNodesByName(name);
const svelteFiles = allFiles.filter((f) => f.endsWith('.svelte')); const components = candidates.filter((n) => n.kind === 'component');
// Check for exact name match (Button -> Button.svelte) if (components.length === 0) return null;
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;
}
}
}
// Check same directory first for better specificity // Prefer same directory
const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
for (const file of svelteFiles) { const sameDir = components.filter((n) => n.filePath.startsWith(fromDir));
if (file.startsWith(fromDir)) { if (sameDir.length > 0) return sameDir[0]!.id;
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;
}
}
}
}
return null; return components[0]!.id;
} }
/** /**
+47 -211
View File
@@ -35,7 +35,7 @@ export const swiftUIResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: View references (SwiftUI views are PascalCase ending in View) // Pattern 1: View references (SwiftUI views are PascalCase ending in View)
if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -48,7 +48,7 @@ export const swiftUIResolver: FrameworkResolver = {
// Pattern 2: ViewModel/ObservableObject references // Pattern 2: ViewModel/ObservableObject references
if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Store') || ref.referenceName.endsWith('Manager')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -61,7 +61,7 @@ export const swiftUIResolver: FrameworkResolver = {
// Pattern 3: Model references // Pattern 3: Model references
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -153,7 +153,7 @@ export const uikitResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: ViewController references // Pattern 1: ViewController references
if (ref.referenceName.endsWith('ViewController')) { if (ref.referenceName.endsWith('ViewController')) {
const result = resolveViewController(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -166,7 +166,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 2: UIView subclass references // Pattern 2: UIView subclass references
if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -179,7 +179,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 3: Cell references // Pattern 3: Cell references
if (ref.referenceName.endsWith('Cell')) { if (ref.referenceName.endsWith('Cell')) {
const result = resolveCell(ref.referenceName, context); const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context);
if (result) { if (result) {
return { return {
original: ref, original: ref,
@@ -192,7 +192,7 @@ export const uikitResolver: FrameworkResolver = {
// Pattern 4: Delegate/DataSource references // Pattern 4: Delegate/DataSource references
if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -286,7 +286,7 @@ export const vaporResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 1: Controller references // Pattern 1: Controller references
if (ref.referenceName.endsWith('Controller')) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -299,7 +299,7 @@ export const vaporResolver: FrameworkResolver = {
// Pattern 2: Model references (Fluent) // Pattern 2: Model references (Fluent)
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { 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) { if (result) {
return { return {
original: ref, original: ref,
@@ -312,7 +312,7 @@ export const vaporResolver: FrameworkResolver = {
// Pattern 3: Middleware references // Pattern 3: Middleware references
if (ref.referenceName.endsWith('Middleware')) { 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) { if (result) {
return { return {
original: ref, 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 VIEW_KINDS = new Set(['struct', 'component']);
const viewDirs = ['Views', 'View', 'Screens', 'Components', 'UI']; 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) { * Resolve a symbol by name using indexed queries instead of scanning all files.
if (file.endsWith('.swift') && viewDirs.some((d) => file.includes(`/${d}/`))) { */
const nodes = context.getNodesInFile(file); function resolveByNameAndKind(
const viewNode = nodes.find( name: string,
(n) => (n.kind === 'struct' || n.kind === 'component') && n.name === name kinds: Set<string>,
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 (viewNode) { if (preferred.length > 0) return preferred[0]!.id;
return viewNode.id;
}
}
} }
// Search all Swift files // Fall back to any match
for (const file of allFiles) { return kindFiltered[0]!.id;
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;
} }
+23 -1
View File
@@ -25,17 +25,39 @@ export const STOP_WORDS = new Set([
'over', 'only', 'new', 'out', 'its', 'so', 'up', 'as', 'if', 'over', 'only', 'new', 'out', 'its', 'so', 'up', 'as', 'if',
// Code-specific noise // Code-specific noise
'code', 'file', 'files', 'function', 'method', 'class', 'type', '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. * Extract meaningful search terms from a natural language query.
* Splits camelCase, PascalCase, snake_case, SCREAMING_SNAKE, and dot.notation * Splits camelCase, PascalCase, snake_case, SCREAMING_SNAKE, and dot.notation
* into individual tokens before filtering. * 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[] { export function extractSearchTerms(query: string): string[] {
const tokens = new Set<string>(); const tokens = new Set<string>();
// 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" // Split camelCase / PascalCase: "getUserName" → "get User Name"
const camelSplit = query const camelSplit = query
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')