/** * Import Resolver * * Resolves import paths to actual files and symbols. */ import * as fs from 'fs'; import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; /** * 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'], // SFC consumers import plain TS/JS, sibling components, and barrels // (`./lib` → `./lib/index.ts`). Without a list, relative imports from a // `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629). svelte: ['.ts', '.js', '.svelte', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.svelte'], vue: ['.ts', '.js', '.vue', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.vue'], python: ['.py', '/__init__.py'], go: ['.go'], rust: ['.rs', '/mod.rs'], java: ['.java'], c: ['.h', '.c'], cpp: ['.h', '.hpp', '.hxx', '.cpp', '.cc', '.cxx'], 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/) const aliased = resolveAliasedImport(importPath, projectRoot, language, context); if (aliased) return aliased; // C/C++ include directory search: when neither relative nor aliased // resolution found a match, search -I directories from // compile_commands.json or heuristic probing. if (language === 'c' || language === 'cpp') { return resolveCppIncludePath(importPath, language, context); } return null; } /** * C and C++ standard library header names (without delimiters). * Used by isExternalImport to filter system includes from resolution. */ const C_CPP_STDLIB_HEADERS = new Set([ // C standard library headers 'assert.h', 'complex.h', 'ctype.h', 'errno.h', 'fenv.h', 'float.h', 'inttypes.h', 'iso646.h', 'limits.h', 'locale.h', 'math.h', 'setjmp.h', 'signal.h', 'stdalign.h', 'stdarg.h', 'stdatomic.h', 'stdbool.h', 'stddef.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'stdnoreturn.h', 'string.h', 'tgmath.h', 'threads.h', 'time.h', 'uchar.h', 'wchar.h', 'wctype.h', // C++ C-library wrappers (cname form) 'cassert', 'ccomplex', 'cctype', 'cerrno', 'cfenv', 'cfloat', 'cinttypes', 'ciso646', 'climits', 'clocale', 'cmath', 'csetjmp', 'csignal', 'cstdalign', 'cstdarg', 'cstdbool', 'cstddef', 'cstdint', 'cstdio', 'cstdlib', 'cstring', 'ctgmath', 'ctime', 'cuchar', 'cwchar', 'cwctype', // C++ STL headers 'algorithm', 'any', 'array', 'atomic', 'barrier', 'bit', 'bitset', 'charconv', 'chrono', 'codecvt', 'compare', 'complex', 'concepts', 'condition_variable', 'coroutine', 'deque', 'exception', 'execution', 'expected', 'filesystem', 'format', 'forward_list', 'fstream', 'functional', 'future', 'generator', 'initializer_list', 'iomanip', 'ios', 'iosfwd', 'iostream', 'istream', 'iterator', 'latch', 'limits', 'list', 'locale', 'map', 'mdspan', 'memory', 'memory_resource', 'mutex', 'new', 'numbers', 'numeric', 'optional', 'ostream', 'print', 'queue', 'random', 'ranges', 'ratio', 'regex', 'scoped_allocator', 'semaphore', 'set', 'shared_mutex', 'source_location', 'span', 'spanstream', 'sstream', 'stack', 'stacktrace', 'stdexcept', 'stdfloat', 'stop_token', 'streambuf', 'string', 'string_view', 'strstream', 'syncstream', 'system_error', 'thread', 'tuple', 'type_traits', 'typeindex', 'typeinfo', 'unordered_map', 'unordered_set', 'utility', 'valarray', 'variant', 'vector', 'version', ]); /** * 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; } // Workspace-member imports (`@scope/ui`, `@scope/ui/widgets`) are LOCAL to // a monorepo even though they look like bare npm specifiers. Consult the // workspace map first so they aren't misclassified as external (#629). The // map is null for single-package repos, so this is a no-op there. const workspaces = context?.getWorkspacePackages?.(); if (workspaces && resolveWorkspaceImport(importPath, workspaces)) { 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; } if (language === 'c' || language === 'cpp') { // C/C++ standard library headers — both C-style () and // C++-style (, ) forms. Checked against the import // path (which the extractor strips of <> or "" delimiters). if (C_CPP_STDLIB_HEADERS.has(importPath)) return true; // C++ headers without .h extension (e.g. "vector", "string") const withoutExt = importPath.replace(/\.h$/, ''); if (C_CPP_STDLIB_HEADERS.has(withoutExt)) 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] || []; // Python dotted-relative imports (`from .certs import x`, `from ..pkg.mod // import y`): leading dots are PACKAGE levels (1 = current package), and the // remainder is a dotted submodule path. `path.resolve(dir, '.certs')` would // treat `.certs` as a literal hidden filename, so translate the Python form // to a real filesystem-relative path before resolving. if (language === 'python' && importPath.startsWith('.')) { const dots = importPath.length - importPath.replace(/^\.+/, '').length; const up = '../'.repeat(Math.max(0, dots - 1)); // 1 dot = current dir const rest = importPath.slice(dots).replace(/\./g, '/'); // 'sub.mod' -> 'sub/mod' const pyBase = path.resolve(fromDir, up + rest); const pyRel = path.relative(projectRoot, pyBase).replace(/\\/g, '/'); for (const ext of extensions) { if (context.fileExists(pyRel + ext)) return pyRel + ext; } if (pyRel && context.fileExists(pyRel)) return pyRel; return null; } // 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; } } // 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`). // Resolves a monorepo member import to the member's directory; the // extension/index permutations below then find its barrel (#629). const workspaces = context.getWorkspacePackages?.(); if (workspaces) { const base = resolveWorkspaceImport(importPath, workspaces); if (base) { const hit = tryWithExt(base); 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); } /** * C/C++ include directory cache (keyed by project root). * Loaded once per resolver instance, shared across calls. */ const cppIncludeDirCache = new Map(); /** * Clear the C/C++ include directory cache (call between indexing runs) */ export function clearCppIncludeDirCache(): void { cppIncludeDirCache.clear(); } /** * Discover C/C++ include search directories for a project. * * Strategy: * 1. Look for compile_commands.json (Clang compilation database) in the * project root and common build subdirectories. Parse -I and -isystem * flags from compiler commands. * 2. If no compilation database is found, probe for common convention * directories (include/, src/, lib/, api/) and top-level directories * containing .h/.hpp files. * * Returns paths relative to projectRoot. */ export function loadCppIncludeDirs(projectRoot: string): string[] { const cached = cppIncludeDirCache.get(projectRoot); if (cached !== undefined) return cached; const dirs = loadCppIncludeDirsFromCompileDB(projectRoot) || loadCppIncludeDirsHeuristic(projectRoot); cppIncludeDirCache.set(projectRoot, dirs); return dirs; } /** * Try to load include directories from compile_commands.json. * Returns null if no compilation database is found (so the heuristic * fallback can run). Returns an array (possibly empty) otherwise. */ function loadCppIncludeDirsFromCompileDB(projectRoot: string): string[] | null { const candidates = [ path.join(projectRoot, 'compile_commands.json'), path.join(projectRoot, 'build', 'compile_commands.json'), path.join(projectRoot, 'cmake-build-debug', 'compile_commands.json'), path.join(projectRoot, 'cmake-build-release', 'compile_commands.json'), path.join(projectRoot, 'out', 'compile_commands.json'), ]; let dbPath: string | undefined; for (const c of candidates) { try { if (fs.existsSync(c)) { dbPath = c; break; } } catch { // ignore } } if (!dbPath) return null; try { const content = fs.readFileSync(dbPath, 'utf-8'); const entries = JSON.parse(content) as Array<{ directory: string; command?: string; arguments?: string[]; }>; if (!Array.isArray(entries)) return null; const dirSet = new Set(); for (const entry of entries) { const dir = entry.directory || projectRoot; const args = entry.arguments || (entry.command ? shlexSplit(entry.command) : []); for (let i = 0; i < args.length; i++) { const arg = args[i]!; let includeDir: string | undefined; // -I (no space) if (arg.startsWith('-I') && arg.length > 2) { includeDir = arg.substring(2); } // -isystem (space-separated) else if ((arg === '-isystem' || arg === '-I') && i + 1 < args.length) { includeDir = args[i + 1]; i++; // skip next arg } if (includeDir) { // Normalize: resolve relative to the compilation directory const absPath = path.isAbsolute(includeDir) ? includeDir : path.resolve(dir, includeDir); const relPath = path.relative(projectRoot, absPath).replace(/\\/g, '/'); // Skip system directories and paths outside the project // (relative paths starting with .. or absolute paths like // /usr/include or C:\usr on Windows) if (!relPath.startsWith('..') && relPath.length > 0 && !path.isAbsolute(relPath)) { dirSet.add(relPath); } } } } return Array.from(dirSet); } catch { return null; } } /** * Minimal shlex-style split for compiler command strings. * Handles double-quoted and single-quoted arguments. */ function shlexSplit(cmd: string): string[] { const result: string[] = []; let i = 0; while (i < cmd.length) { // Skip whitespace while (i < cmd.length && /\s/.test(cmd[i]!)) i++; if (i >= cmd.length) break; const ch = cmd[i]!; if (ch === '"') { i++; let arg = ''; while (i < cmd.length && cmd[i] !== '"') { if (cmd[i] === '\\' && i + 1 < cmd.length) { i++; arg += cmd[i]; } else { arg += cmd[i]; } i++; } i++; // closing quote result.push(arg); } else if (ch === "'") { i++; let arg = ''; while (i < cmd.length && cmd[i] !== "'") { arg += cmd[i]; i++; } i++; // closing quote result.push(arg); } else { let arg = ''; while (i < cmd.length && !/\s/.test(cmd[i]!)) { arg += cmd[i]; i++; } result.push(arg); } } return result; } /** * Heuristic include directory discovery when no compile_commands.json exists. * Checks common convention directories and scans top-level dirs for headers. */ function loadCppIncludeDirsHeuristic(projectRoot: string): string[] { const dirs: string[] = []; const conventionDirs = ['include', 'src', 'lib', 'api', 'inc']; try { const entries = fs.readdirSync(projectRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const name = entry.name; // Convention directories if (conventionDirs.includes(name.toLowerCase())) { dirs.push(name); continue; } // Any top-level directory containing .h or .hpp files try { const subFiles = fs.readdirSync(path.join(projectRoot, name)); if (subFiles.some(f => /\.(h|hpp|hxx|hh)$/i.test(f))) { dirs.push(name); } } catch { // ignore permission errors } } } catch { // ignore } return dirs; } /** * Resolve a C/C++ include path by searching include directories. * Called as a fallback after relative and aliased resolution fail. */ function resolveCppIncludePath( importPath: string, language: Language, context: ResolutionContext ): string | null { const includeDirs = context.getCppIncludeDirs?.() ?? []; const extensions = EXTENSION_RESOLUTION[language] ?? []; for (const dir of includeDirs) { const normalizedDir = dir.replace(/\\/g, '/'); for (const ext of extensions) { const candidate = normalizedDir + '/' + importPath + ext; if (context.fileExists(candidate)) return candidate; } // Try as-is (already has extension) const candidate = normalizedDir + '/' + importPath; if (context.fileExists(candidate)) return candidate; } 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 === 'svelte' || language === 'vue') { // Svelte/Vue single-file components import via plain ES6 inside their // `