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[] = [];
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<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 });
} catch (error) {
logDebug('Exact symbol lookup failed', { error: String(error) });
+67 -26
View File
@@ -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<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)));
}
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<string>();
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<string>();
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);
}
// ===========================================================================
+12
View File
@@ -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];
},
};
+7
View File
@@ -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;
}
+10 -2
View File
@@ -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<Node> = {
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)
+36 -109
View File
@@ -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<string>,
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;
}
+49 -59
View File
@@ -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;
}
+35 -115
View File
@@ -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<string>,
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;
}
+6 -6
View File
@@ -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) {
+39 -124
View File
@@ -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<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 (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;
}
+54 -81
View File
@@ -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;
}
/**
+14 -28
View File
@@ -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;
}
+30 -82
View File
@@ -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<string>,
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 {
+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(
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;
}
/**
+48 -212
View File
@@ -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<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 (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;
}
+23 -1
View File
@@ -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<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"
const camelSplit = query
.replace(/([a-z])([A-Z])/g, '$1 $2')