perf: Cache import mappings and index fuzzy matches for resolution

Two major optimizations for the ref resolution phase:

1. Cache extractImportMappings() results per file path — previously
   re-read and re-parsed the source file for every single ref from
   that file (e.g. 100 refs from one file = 100 identical file reads)

2. Replace linear scan in matchFuzzy() with a lazily-built
   case-insensitive Map index — O(1) lookup instead of iterating
   all function/method/class nodes for every unresolved ref.
   Also drop low-value prefix matching (confidence 0.3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Olaf Monien
2026-02-12 19:29:18 +01:00
co-authored by Claude Opus 4.6
parent 44ea6af043
commit ab9d7188c1
3 changed files with 53 additions and 33 deletions
+19 -6
View File
@@ -425,6 +425,16 @@ function extractPHPImports(content: string): ImportMapping[] {
return mappings; return mappings;
} }
// Cache import mappings per file to avoid re-reading and re-parsing
const importMappingCache = new Map<string, ImportMapping[]>();
/**
* Clear the import mapping cache (call between indexing runs)
*/
export function clearImportMappingCache(): void {
importMappingCache.clear();
}
/** /**
* Resolve a reference using import mappings * Resolve a reference using import mappings
*/ */
@@ -432,14 +442,17 @@ export function resolveViaImport(
ref: UnresolvedRef, ref: UnresolvedRef,
context: ResolutionContext context: ResolutionContext
): ResolvedRef | null { ): ResolvedRef | null {
// Read the source file to extract imports // Use cached import mappings or extract and cache them
const content = context.readFile(ref.filePath); let imports = importMappingCache.get(ref.filePath);
if (!content) { if (!imports) {
return null; const content = context.readFile(ref.filePath);
if (!content) {
return null;
}
imports = extractImportMappings(ref.filePath, content, ref.language);
importMappingCache.set(ref.filePath, imports);
} }
const imports = extractImportMappings(ref.filePath, content, ref.language);
// Check if the reference name matches any import // Check if the reference name matches any import
for (const imp of imports) { for (const imp of imports) {
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) { if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
+4 -2
View File
@@ -16,8 +16,8 @@ import {
ResolutionContext, ResolutionContext,
FrameworkResolver, FrameworkResolver,
} from './types'; } from './types';
import { matchReference } from './name-matcher'; import { matchReference, clearFuzzyIndex } from './name-matcher';
import { resolveViaImport } from './import-resolver'; import { resolveViaImport, clearImportMappingCache } from './import-resolver';
import { detectFrameworks } from './frameworks'; import { detectFrameworks } from './frameworks';
import { logDebug } from '../errors'; import { logDebug } from '../errors';
@@ -106,6 +106,8 @@ export class ReferenceResolver {
this.qualifiedNameCache.clear(); this.qualifiedNameCache.clear();
this.kindCache.clear(); this.kindCache.clear();
this.nodeByIdCache.clear(); this.nodeByIdCache.clear();
clearImportMappingCache();
clearFuzzyIndex();
this.cachesWarmed = false; this.cachesWarmed = false;
} }
+30 -25
View File
@@ -190,6 +190,16 @@ function findBestMatch(
return bestNode; return bestNode;
} }
// Lazily-built case-insensitive index for fuzzy matching
let fuzzyIndex: Map<string, Node[]> | null = null;
/**
* Clear the fuzzy match index (call between indexing runs)
*/
export function clearFuzzyIndex(): void {
fuzzyIndex = null;
}
/** /**
* Fuzzy match - last resort with lower confidence * Fuzzy match - last resort with lower confidence
*/ */
@@ -197,21 +207,29 @@ export function matchFuzzy(
ref: UnresolvedRef, ref: UnresolvedRef,
context: ResolutionContext context: ResolutionContext
): ResolvedRef | null { ): ResolvedRef | null {
// Try case-insensitive match // Build case-insensitive index on first use
const allNodes = [ if (!fuzzyIndex) {
...context.getNodesByKind('function'), fuzzyIndex = new Map();
...context.getNodesByKind('method'), const kinds: Array<Node['kind']> = ['function', 'method', 'class'];
...context.getNodesByKind('class'), for (const kind of kinds) {
]; for (const node of context.getNodesByKind(kind)) {
const lower = node.name.toLowerCase();
const existing = fuzzyIndex.get(lower);
if (existing) {
existing.push(node);
} else {
fuzzyIndex.set(lower, [node]);
}
}
}
}
const lowerName = ref.referenceName.toLowerCase(); const lowerName = ref.referenceName.toLowerCase();
// Exact case-insensitive match // Exact case-insensitive match via index (O(1) lookup)
const caseInsensitive = allNodes.filter( const caseInsensitive = fuzzyIndex.get(lowerName);
(n) => n.name.toLowerCase() === lowerName
);
if (caseInsensitive.length === 1) { if (caseInsensitive && caseInsensitive.length === 1) {
return { return {
original: ref, original: ref,
targetNodeId: caseInsensitive[0]!.id, targetNodeId: caseInsensitive[0]!.id,
@@ -220,20 +238,7 @@ export function matchFuzzy(
}; };
} }
// Try prefix match (e.g., "get" matches "getUser") // Skip prefix matching — too expensive and low value (confidence 0.3)
const prefixMatches = allNodes.filter((n) =>
n.name.toLowerCase().startsWith(lowerName)
);
if (prefixMatches.length === 1) {
return {
original: ref,
targetNodeId: prefixMatches[0]!.id,
confidence: 0.3,
resolvedBy: 'fuzzy',
};
}
return null; return null;
} }