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>
507 lines
13 KiB
TypeScript
507 lines
13 KiB
TypeScript
/**
|
|
* Import Resolver
|
|
*
|
|
* Resolves import paths to actual files and symbols.
|
|
*/
|
|
|
|
import * as path from 'path';
|
|
import { Language, Node } from '../types';
|
|
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping } from './types';
|
|
|
|
/**
|
|
* Extension resolution order by language
|
|
*/
|
|
const EXTENSION_RESOLUTION: Record<string, string[]> = {
|
|
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
|
|
javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
|
|
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
|
|
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
|
|
python: ['.py', '/__init__.py'],
|
|
go: ['.go'],
|
|
rust: ['.rs', '/mod.rs'],
|
|
java: ['.java'],
|
|
csharp: ['.cs'],
|
|
php: ['.php'],
|
|
ruby: ['.rb'],
|
|
};
|
|
|
|
/**
|
|
* Resolve an import path to an actual file
|
|
*/
|
|
export function resolveImportPath(
|
|
importPath: string,
|
|
fromFile: string,
|
|
language: Language,
|
|
context: ResolutionContext
|
|
): string | null {
|
|
// Skip external/npm packages
|
|
if (isExternalImport(importPath, language)) {
|
|
return null;
|
|
}
|
|
|
|
const projectRoot = context.getProjectRoot();
|
|
const fromDir = path.dirname(path.join(projectRoot, fromFile));
|
|
|
|
// Handle relative imports
|
|
if (importPath.startsWith('.')) {
|
|
return resolveRelativeImport(importPath, fromDir, language, context);
|
|
}
|
|
|
|
// Handle absolute/aliased imports (like @/ or src/)
|
|
return resolveAliasedImport(importPath, projectRoot, language, context);
|
|
}
|
|
|
|
/**
|
|
* Check if an import is external (npm package, etc.)
|
|
*/
|
|
function isExternalImport(importPath: string, language: Language): boolean {
|
|
// Relative imports are not external
|
|
if (importPath.startsWith('.')) {
|
|
return false;
|
|
}
|
|
|
|
// Common external patterns
|
|
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
|
// Node built-ins
|
|
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
|
|
return true;
|
|
}
|
|
// Scoped packages or bare specifiers that don't start with aliases
|
|
if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
|
|
// Likely an npm package
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (language === 'python') {
|
|
// Standard library modules
|
|
const stdLibs = ['os', 'sys', 'json', 're', 'math', 'datetime', 'collections', 'typing', 'pathlib', 'logging'];
|
|
if (stdLibs.includes(importPath.split('.')[0]!)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (language === 'go') {
|
|
// Standard library or external packages
|
|
if (!importPath.startsWith('.') && !importPath.includes('/internal/')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve a relative import
|
|
*/
|
|
function resolveRelativeImport(
|
|
importPath: string,
|
|
fromDir: string,
|
|
language: Language,
|
|
context: ResolutionContext
|
|
): string | null {
|
|
const projectRoot = context.getProjectRoot();
|
|
const extensions = EXTENSION_RESOLUTION[language] || [];
|
|
|
|
// Try the path as-is first
|
|
const basePath = path.resolve(fromDir, importPath);
|
|
const relativePath = path.relative(projectRoot, basePath);
|
|
|
|
// Try each extension
|
|
for (const ext of extensions) {
|
|
const candidatePath = relativePath + ext;
|
|
if (context.fileExists(candidatePath)) {
|
|
return candidatePath;
|
|
}
|
|
}
|
|
|
|
// Try without extension (might already have one)
|
|
if (context.fileExists(relativePath)) {
|
|
return relativePath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve an aliased/absolute import
|
|
*/
|
|
function resolveAliasedImport(
|
|
importPath: string,
|
|
_projectRoot: string,
|
|
language: Language,
|
|
context: ResolutionContext
|
|
): string | null {
|
|
const extensions = EXTENSION_RESOLUTION[language] || [];
|
|
|
|
// Common aliases
|
|
const aliases: Record<string, string> = {
|
|
'@/': 'src/',
|
|
'~/': 'src/',
|
|
'@src/': 'src/',
|
|
'src/': 'src/',
|
|
'@app/': 'app/',
|
|
'app/': 'app/',
|
|
};
|
|
|
|
// Try each alias
|
|
for (const [alias, replacement] of Object.entries(aliases)) {
|
|
if (importPath.startsWith(alias)) {
|
|
const resolvedPath = importPath.replace(alias, replacement);
|
|
|
|
// Try with extensions
|
|
for (const ext of extensions) {
|
|
const candidatePath = resolvedPath + ext;
|
|
if (context.fileExists(candidatePath)) {
|
|
return candidatePath;
|
|
}
|
|
}
|
|
|
|
// Try as-is
|
|
if (context.fileExists(resolvedPath)) {
|
|
return resolvedPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try direct path
|
|
for (const ext of extensions) {
|
|
const candidatePath = importPath + ext;
|
|
if (context.fileExists(candidatePath)) {
|
|
return candidatePath;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract import mappings from a file
|
|
*/
|
|
export function extractImportMappings(
|
|
_filePath: string,
|
|
content: string,
|
|
language: Language
|
|
): ImportMapping[] {
|
|
const mappings: ImportMapping[] = [];
|
|
|
|
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx') {
|
|
mappings.push(...extractJSImports(content));
|
|
} else if (language === 'python') {
|
|
mappings.push(...extractPythonImports(content));
|
|
} else if (language === 'go') {
|
|
mappings.push(...extractGoImports(content));
|
|
} else if (language === 'php') {
|
|
mappings.push(...extractPHPImports(content));
|
|
}
|
|
|
|
return mappings;
|
|
}
|
|
|
|
/**
|
|
* Extract JS/TS import mappings
|
|
*/
|
|
function extractJSImports(content: string): ImportMapping[] {
|
|
const mappings: ImportMapping[] = [];
|
|
|
|
// ES6 imports
|
|
const importRegex = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]/g;
|
|
|
|
let match;
|
|
while ((match = importRegex.exec(content)) !== null) {
|
|
const [, defaultImport, namedImports, star, namespaceAlias, source] = match;
|
|
|
|
// Default import
|
|
if (defaultImport) {
|
|
mappings.push({
|
|
localName: defaultImport,
|
|
exportedName: 'default',
|
|
source: source!,
|
|
isDefault: true,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
|
|
// Named imports
|
|
if (namedImports) {
|
|
const names = namedImports.split(',').map((s) => s.trim());
|
|
for (const name of names) {
|
|
const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
|
|
if (aliasMatch) {
|
|
mappings.push({
|
|
localName: aliasMatch[2]!,
|
|
exportedName: aliasMatch[1]!,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
} else if (name) {
|
|
mappings.push({
|
|
localName: name,
|
|
exportedName: name,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Namespace import
|
|
if (star && namespaceAlias) {
|
|
mappings.push({
|
|
localName: namespaceAlias,
|
|
exportedName: '*',
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Require statements
|
|
const requireRegex = /(?:const|let|var)\s+(?:(\w+)|{([^}]+)})\s*=\s*require\(['"]([^'"]+)['"]\)/g;
|
|
while ((match = requireRegex.exec(content)) !== null) {
|
|
const [, defaultName, destructured, source] = match;
|
|
|
|
if (defaultName) {
|
|
mappings.push({
|
|
localName: defaultName,
|
|
exportedName: 'default',
|
|
source: source!,
|
|
isDefault: true,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
|
|
if (destructured) {
|
|
const names = destructured.split(',').map((s) => s.trim());
|
|
for (const name of names) {
|
|
const aliasMatch = name.match(/(\w+)\s*:\s*(\w+)/);
|
|
if (aliasMatch) {
|
|
mappings.push({
|
|
localName: aliasMatch[2]!,
|
|
exportedName: aliasMatch[1]!,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
} else if (name) {
|
|
mappings.push({
|
|
localName: name,
|
|
exportedName: name,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mappings;
|
|
}
|
|
|
|
/**
|
|
* Extract Python import mappings
|
|
*/
|
|
function extractPythonImports(content: string): ImportMapping[] {
|
|
const mappings: ImportMapping[] = [];
|
|
|
|
// from X import Y
|
|
const fromImportRegex = /from\s+([\w.]+)\s+import\s+([^#\n]+)/g;
|
|
let match;
|
|
|
|
while ((match = fromImportRegex.exec(content)) !== null) {
|
|
const [, source, imports] = match;
|
|
const names = imports!.split(',').map((s) => s.trim());
|
|
|
|
for (const name of names) {
|
|
const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
|
|
if (aliasMatch) {
|
|
mappings.push({
|
|
localName: aliasMatch[2]!,
|
|
exportedName: aliasMatch[1]!,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
} else if (name && name !== '*') {
|
|
mappings.push({
|
|
localName: name,
|
|
exportedName: name,
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// import X
|
|
const importRegex = /^import\s+([\w.]+)(?:\s+as\s+(\w+))?/gm;
|
|
while ((match = importRegex.exec(content)) !== null) {
|
|
const [, source, alias] = match;
|
|
const localName = alias || source!.split('.').pop()!;
|
|
mappings.push({
|
|
localName,
|
|
exportedName: '*',
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: true,
|
|
});
|
|
}
|
|
|
|
return mappings;
|
|
}
|
|
|
|
/**
|
|
* Extract Go import mappings
|
|
*/
|
|
function extractGoImports(content: string): ImportMapping[] {
|
|
const mappings: ImportMapping[] = [];
|
|
|
|
// import "path" or import alias "path"
|
|
const singleImportRegex = /import\s+(?:(\w+)\s+)?["']([^"']+)["']/g;
|
|
let match;
|
|
|
|
while ((match = singleImportRegex.exec(content)) !== null) {
|
|
const [, alias, source] = match;
|
|
const packageName = source!.split('/').pop()!;
|
|
mappings.push({
|
|
localName: alias || packageName,
|
|
exportedName: '*',
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: true,
|
|
});
|
|
}
|
|
|
|
// import ( ... ) block
|
|
const blockImportRegex = /import\s*\(\s*([^)]+)\s*\)/gs;
|
|
while ((match = blockImportRegex.exec(content)) !== null) {
|
|
const block = match[1]!;
|
|
const lineRegex = /(?:(\w+)\s+)?["']([^"']+)["']/g;
|
|
let lineMatch;
|
|
|
|
while ((lineMatch = lineRegex.exec(block)) !== null) {
|
|
const [, alias, source] = lineMatch;
|
|
const packageName = source!.split('/').pop()!;
|
|
mappings.push({
|
|
localName: alias || packageName,
|
|
exportedName: '*',
|
|
source: source!,
|
|
isDefault: false,
|
|
isNamespace: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return mappings;
|
|
}
|
|
|
|
/**
|
|
* Extract PHP import mappings (use statements)
|
|
*/
|
|
function extractPHPImports(content: string): ImportMapping[] {
|
|
const mappings: ImportMapping[] = [];
|
|
|
|
// use Namespace\Class; or use Namespace\Class as Alias;
|
|
const useRegex = /use\s+([\w\\]+)(?:\s+as\s+(\w+))?;/g;
|
|
let match;
|
|
|
|
while ((match = useRegex.exec(content)) !== null) {
|
|
const [, fullPath, alias] = match;
|
|
const className = fullPath!.split('\\').pop()!;
|
|
mappings.push({
|
|
localName: alias || className,
|
|
exportedName: className,
|
|
source: fullPath!,
|
|
isDefault: false,
|
|
isNamespace: false,
|
|
});
|
|
}
|
|
|
|
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
|
|
*/
|
|
export function resolveViaImport(
|
|
ref: UnresolvedRef,
|
|
context: ResolutionContext
|
|
): ResolvedRef | null {
|
|
// Use cached import mappings or extract and cache them
|
|
let imports = importMappingCache.get(ref.filePath);
|
|
if (!imports) {
|
|
const content = context.readFile(ref.filePath);
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
imports = extractImportMappings(ref.filePath, content, ref.language);
|
|
importMappingCache.set(ref.filePath, imports);
|
|
}
|
|
|
|
// Check if the reference name matches any import
|
|
for (const imp of imports) {
|
|
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
|
// Resolve the import path
|
|
const resolvedPath = resolveImportPath(
|
|
imp.source,
|
|
ref.filePath,
|
|
ref.language,
|
|
context
|
|
);
|
|
|
|
if (resolvedPath) {
|
|
// Find the exported symbol in the resolved file
|
|
const nodesInFile = context.getNodesInFile(resolvedPath);
|
|
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
|
|
|
// Look for the symbol
|
|
let targetNode: Node | undefined;
|
|
|
|
if (imp.isDefault) {
|
|
// Find default export or main class/function
|
|
targetNode = nodesInFile.find(
|
|
(n) => n.isExported && (n.kind === 'function' || n.kind === 'class')
|
|
);
|
|
} else if (imp.isNamespace) {
|
|
// Namespace import - look for the specific member
|
|
const memberName = ref.referenceName.replace(imp.localName + '.', '');
|
|
targetNode = nodesInFile.find(
|
|
(n) => n.name === memberName && n.isExported
|
|
);
|
|
} else {
|
|
// Named import
|
|
targetNode = nodesInFile.find(
|
|
(n) => n.name === exportedName && n.isExported
|
|
);
|
|
}
|
|
|
|
if (targetNode) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: targetNode.id,
|
|
confidence: 0.9,
|
|
resolvedBy: 'import',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|