/** * 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, ReExport } from './types'; import { applyAliases } from './path-aliases'; /** * Extension resolution order by language */ const EXTENSION_RESOLUTION: Record = { 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'], objc: ['.h', '.m', '.mm'], }; /** * 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 — but pass the context so the // bare-specifier heuristic can consult the project's tsconfig // alias map first (custom prefixes like `@components/*` would // otherwise be misclassified as npm). if (isExternalImport(importPath, language, context)) { 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.) * * `context` is consulted for project-defined path aliases * (tsconfig/jsconfig `paths`). Without that check, custom prefixes * like `@components/*` would fail the bare-specifier heuristic and * be classified as external before alias resolution can run. */ function isExternalImport( importPath: string, language: Language, context?: ResolutionContext ): 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; } // Project-defined alias prefix? Treat as local. const aliases = context?.getProjectAliases?.(); if (aliases) { for (const pat of aliases.patterns) { if (importPath.startsWith(pat.prefix)) return false; } } // 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') { // Relative imports (rare in idiomatic Go but the grammar allows them). if (importPath.startsWith('.')) { return false; } // In-module imports look like `/sub/pkg` — local to // this project. Without the module-path check we'd flag every // cross-package call in a Go monorepo as external (issue #388). const mod = context?.getGoModule?.(); if (mod && (importPath === mod.modulePath || importPath.startsWith(mod.modulePath + '/'))) { return false; } // `internal/` packages stay local even when go.mod is missing — // preserves the pre-#388 escape hatch for repos without a parsed module path. if (importPath.includes('/internal/')) { return false; } // Anything else is the Go standard library or a third-party module. 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).replace(/\\/g, '/'); // 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. * * Tries, in order: * 1. Project-defined `compilerOptions.paths` (tsconfig/jsconfig). * Each pattern can have multiple replacements; tried in tsconfig * priority order with extension permutations. * 2. The legacy hard-coded fallback list (`@/`, `~/`, `src/`, ...) * for projects that have aliases but no tsconfig paths block. * 3. Direct path lookup (with extensions). */ function resolveAliasedImport( importPath: string, projectRoot: string, language: Language, context: ResolutionContext ): string | null { const extensions = EXTENSION_RESOLUTION[language] || []; const tryWithExt = (basePath: string): string | null => { for (const ext of extensions) { const candidate = basePath + ext; if (context.fileExists(candidate)) return candidate; } if (context.fileExists(basePath)) return basePath; return null; }; // 1. Project tsconfig/jsconfig paths. const aliasMap = context.getProjectAliases?.(); if (aliasMap) { const candidates = applyAliases(importPath, aliasMap, projectRoot); for (const c of candidates) { const hit = tryWithExt(c); if (hit) return hit; } } // 2. Hard-coded fallback list. Kept for projects that use these // conventional aliases without declaring them in tsconfig. const fallbackAliases: Record = { '@/': 'src/', '~/': 'src/', '@src/': 'src/', 'src/': 'src/', '@app/': 'app/', 'app/': 'app/', }; for (const [alias, replacement] of Object.entries(fallbackAliases)) { if (importPath.startsWith(alias)) { const hit = tryWithExt(importPath.replace(alias, replacement)); if (hit) return hit; } } // 3. Direct path. return tryWithExt(importPath); } /** * 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(); /** * Clear the import mapping cache (call between indexing runs) */ export function clearImportMappingCache(): void { importMappingCache.clear(); } /** * Strip JS line + block comments from `content` while preserving * string literals (so `"//"` inside a string stays intact). Used by * {@link extractReExports} so commented-out export-from statements * don't generate phantom re-export edges. * * Scanner is deliberately small: it only tracks the three contexts * relevant for JS/TS — single-quote string, double-quote string, and * template literal. Comment recognition is the JS spec subset, no * regex-literal awareness (which is fine for our use case: we don't * apply this to function bodies, only to top-level files). */ function stripJsComments(content: string): string { let out = ''; let i = 0; let str: '"' | "'" | '`' | null = null; while (i < content.length) { const ch = content[i]!; if (str !== null) { out += ch; if (ch === '\\' && i + 1 < content.length) { out += content[i + 1]!; i += 2; continue; } if (ch === str) str = null; i++; continue; } if (ch === '"' || ch === "'" || ch === '`') { str = ch; out += ch; i++; continue; } if (ch === '/' && content[i + 1] === '/') { while (i < content.length && content[i] !== '\n') i++; continue; } if (ch === '/' && content[i + 1] === '*') { i += 2; while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++; i += 2; continue; } out += ch; i++; } return out; } /** * Extract JS/TS re-export declarations from `content`. * * Recognised forms: * export { foo } from './a'; * export { foo as bar } from './a'; * export * from './a'; * export * as ns from './a'; (treated as wildcard for chasing) * export { default as Foo } from './a'; * * The walker intentionally stays regex-based — the import-resolver * elsewhere in this file already chooses regex over a fresh * tree-sitter pass, and this function shares that trade-off. Errors * fall through silently; resolution simply skips the broken file. */ export function extractReExports(content: string, language: Language): ReExport[] { if ( language !== 'typescript' && language !== 'javascript' && language !== 'tsx' && language !== 'jsx' ) { return []; } const out: ReExport[] = []; // Pre-strip block comments + line comments so a commented-out // `// export { x } from '...'` doesn't produce a phantom edge. // (Template literals are still a possible source of false positives; // a project that builds export statements as runtime strings is // out of scope.) const cleaned = stripJsComments(content); // Wildcard: `export * from '...'` or `export * as ns from '...'` const wildcardRe = /export\s*\*(?:\s+as\s+\w+)?\s*from\s*['"]([^'"]+)['"]/g; let m: RegExpExecArray | null; while ((m = wildcardRe.exec(cleaned)) !== null) { out.push({ kind: 'wildcard', source: m[1]! }); } // Named: `export { a, b as c } from '...'` const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g; while ((m = namedRe.exec(cleaned)) !== null) { const inner = m[1]!; const source = m[2]!; for (const raw of inner.split(',')) { const item = raw.trim(); if (!item) continue; const aliasMatch = item.match(/^(\w+)\s+as\s+(\w+)$/); if (aliasMatch) { out.push({ kind: 'named', exportedName: aliasMatch[2]!, originalName: aliasMatch[1]!, source, }); } else if (/^\w+$/.test(item)) { out.push({ kind: 'named', exportedName: item, originalName: item, source, }); } } } return out; } /** * Resolve a reference using import mappings */ export function resolveViaImport( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { // Use cached import mappings (avoids re-reading and re-parsing per ref) const imports = context.getImportMappings(ref.filePath, ref.language); if (imports.length === 0 && !context.readFile(ref.filePath)) { return null; } // Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName // `pkga.FuncX` and the import `github.com/example/myproject/pkga` // maps to a *package directory* containing one or more .go files. // The generic file-based lookup below can't follow that — issue #388. if (ref.language === 'go') { const goResult = resolveGoCrossPackageReference(ref, imports, context); if (goResult) return goResult; } // 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) { const exportedName = imp.isDefault ? 'default' : imp.exportedName; const memberName = imp.isNamespace ? ref.referenceName.replace(imp.localName + '.', '') : null; const targetNode = findExportedSymbol( resolvedPath, { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName }, ref.language, context, new Set() ); if (targetNode) { return { original: ref, targetNodeId: targetNode.id, confidence: 0.9, resolvedBy: 'import', }; } } } } return null; } /** * Resolve a Go cross-package qualified reference (`pkga.FuncX`) by matching * the package alias against an in-module import, stripping the module prefix * to a project-relative directory, and locating the exported symbol in any * `.go` file under that directory. Returns `null` for stdlib / third-party * imports (no `go.mod`-relative match) so the rest of `resolveViaImport` * can still try the file-based path. */ function resolveGoCrossPackageReference( ref: UnresolvedRef, imports: ImportMapping[], context: ResolutionContext ): ResolvedRef | null { const mod = context.getGoModule?.(); if (!mod) return null; // Qualified call: receiver before `.`, member after. A bare reference // (no dot) is a same-file/in-package call — handled elsewhere. const dotIdx = ref.referenceName.indexOf('.'); if (dotIdx <= 0) return null; const receiver = ref.referenceName.substring(0, dotIdx); const memberName = ref.referenceName.substring(dotIdx + 1); if (!memberName) return null; for (const imp of imports) { if (imp.localName !== receiver) continue; // Only in-module imports map to a known directory. if (imp.source !== mod.modulePath && !imp.source.startsWith(mod.modulePath + '/')) { continue; } const pkgDir = imp.source === mod.modulePath ? '' : imp.source.substring(mod.modulePath.length + 1); // Look up the member by name and pick the candidate whose file lives // directly in the package directory. Match the immediate parent dir // exactly so a call to `pkga.FuncX` doesn't accidentally land on a // `FuncX` declared in `pkga/subpkg/`. const candidates = context.getNodesByName(memberName); for (const node of candidates) { if (node.language !== 'go') continue; if (!node.isExported) continue; const fp = node.filePath.replace(/\\/g, '/'); const lastSlash = fp.lastIndexOf('/'); const fileDir = lastSlash >= 0 ? fp.substring(0, lastSlash) : ''; if (fileDir === pkgDir) { return { original: ref, targetNodeId: node.id, confidence: 0.9, resolvedBy: 'import', }; } } } return null; } /** Recursive depth cap for re-export chain following. Real codebases * rarely chain barrels more than 2–3 deep; 8 is a generous safety * net that still bounds worst-case work. */ const REEXPORT_MAX_DEPTH = 8; /** * Find an exported symbol in `filePath`, following `export { x } from * './other'` and `export * from './other'` chains until the original * declaration is reached. Cycle-safe via the `visited` set. * * Without this, every barrel-style import (`import { Foo } from * './index'` where `index.ts` only re-exports) used to resolve to * nothing — the existing code only looked for declarations IN the * resolved file, not declarations the file forwarded. */ function findExportedSymbol( filePath: string, want: { isDefault: boolean; isNamespace: boolean; exportedName: string; memberName: string | null; }, language: Language, context: ResolutionContext, visited: Set, depth = 0 ): Node | undefined { if (depth > REEXPORT_MAX_DEPTH) return undefined; if (visited.has(filePath)) return undefined; visited.add(filePath); const nodesInFile = context.getNodesInFile(filePath); // 1. Direct hit: the symbol is declared in this file. if (want.isDefault) { const direct = nodesInFile.find( (n) => n.isExported && (n.kind === 'function' || n.kind === 'class') ); if (direct) return direct; } else if (want.isNamespace && want.memberName) { const direct = nodesInFile.find( (n) => n.name === want.memberName && n.isExported ); if (direct) return direct; } else { const direct = nodesInFile.find( (n) => n.name === want.exportedName && n.isExported ); if (direct) return direct; } // 2. Re-export hit: the file forwards the symbol to another module. const reExports = context.getReExports?.(filePath, language) ?? []; if (reExports.length === 0) return undefined; // Look for explicit `export { want } from './other'` (with optional rename). const targetName = want.isDefault ? 'default' : want.exportedName; for (const rex of reExports) { if (rex.kind === 'named' && rex.exportedName === targetName) { const next = resolveImportPath(rex.source, filePath, language, context); if (!next) continue; // After rename: `export { foo as bar } from './x'` — to chase // `bar`, we look for `foo` in `./x`. const chained = findExportedSymbol( next, { isDefault: rex.originalName === 'default', isNamespace: false, exportedName: rex.originalName, memberName: null, }, language, context, visited, depth + 1 ); if (chained) return chained; } } // 3. Wildcard re-export: `export * from './other'` — try every // forwarding source. This is the barrel-of-barrels case. for (const rex of reExports) { if (rex.kind === 'wildcard') { const next = resolveImportPath(rex.source, filePath, language, context); if (!next) continue; const chained = findExportedSymbol(next, want, language, context, visited, depth + 1); if (chained) return chained; } } return undefined; }