| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275 |
- /**
- * 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';
- import {
- resolveMethodOnType,
- localReceiverTypePatterns,
- normalizeInferredTypeName,
- } from './name-matcher';
- /**
- * Extension resolution order by language
- */
- const EXTENSION_RESOLUTION: Record<string, string[]> = {
- typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
- // ArkTS imports both `.ets` components and plain `.ts` logic modules —
- // HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's
- // module-entry convention, hit when a bare workspace import ("data") is
- // rewritten to the member's directory; lowercase variants for safety.
- arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/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'],
- astro: ['.ts', '.js', '.astro', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.astro'],
- 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'],
- nix: ['.nix', '/default.nix'],
- };
- export function isNixPathImportRef(ref: UnresolvedRef): boolean {
- return (
- ref.language === 'nix' &&
- ref.referenceKind === 'imports' &&
- (ref.referenceName.startsWith('./') || ref.referenceName.startsWith('../')) &&
- !/[\s{}()[\];"'<>$]/.test(ref.referenceName)
- );
- }
- /**
- * Resolve an import path to an actual file
- */
- // Per-context memos for the two hottest pure lookups on the resolution path:
- // import-specifier → file resolution and exported-symbol lookup. Both are pure
- // given a stable file set + node table, which is exactly the window between
- // ReferenceResolver.clearCaches() calls — clearImportResolverMemos() is invoked
- // there, so the staleness discipline matches the resolver's own caches.
- const importPathMemos = new WeakMap<ResolutionContext, Map<string, string | null>>();
- const exportedSymbolMemos = new WeakMap<ResolutionContext, Map<string, Node | undefined>>();
- /**
- * Per-file index of exported symbols, replacing repeated linear `.find`s over
- * `getNodesInFile` arrays (a barrel-heavy repo scans its biggest files once
- * per referencing symbol otherwise). First-wins insertion preserves exactly
- * the array-order semantics of the `.find` calls it replaces.
- */
- interface FileExportIndex {
- byName: Map<string, Node>;
- defaultComponent: Node | undefined;
- defaultFnClass: Node | undefined;
- }
- const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
- function getFileExportIndex(filePath: string, context: ResolutionContext): FileExportIndex {
- let perFile = fileExportIndexes.get(context);
- if (!perFile) {
- perFile = new Map();
- fileExportIndexes.set(context, perFile);
- }
- let idx = perFile.get(filePath);
- if (!idx) {
- idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined };
- for (const n of context.getNodesInFile(filePath)) {
- if (!n.isExported) continue;
- if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
- if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
- if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n;
- }
- perFile.set(filePath, idx);
- }
- return idx;
- }
- /** Drop the per-context memo tables (see ReferenceResolver.clearCaches). */
- export function clearImportResolverMemos(context: ResolutionContext): void {
- importPathMemos.delete(context);
- exportedSymbolMemos.delete(context);
- fileExportIndexes.delete(context);
- luaFileBasenameIndexes.delete(context);
- cobolCopybookIndexes.delete(context);
- }
- export function resolveImportPath(
- importPath: string,
- fromFile: string,
- language: Language,
- context: ResolutionContext
- ): string | null {
- let memo = importPathMemos.get(context);
- if (!memo) {
- memo = new Map();
- importPathMemos.set(context, memo);
- }
- const key = `${language}\0${fromFile}\0${importPath}`;
- const hit = memo.get(key);
- if (hit !== undefined || memo.has(key)) return hit ?? null;
- const resolved = resolveImportPathUncached(importPath, fromFile, language, context);
- memo.set(key, resolved);
- return resolved;
- }
- function resolveImportPathUncached(
- importPath: string,
- fromFile: string,
- language: Language,
- context: ResolutionContext
- ): string | null {
- // COBOL COPY/EXEC SQL INCLUDE names a copybook member, not a path — the
- // compiler searches a library, so we match against indexed file basenames.
- // Must run before isExternalImport: a bare member name would otherwise be
- // misclassified as an external package.
- if (language === 'cobol') {
- return resolveCobolCopybook(importPath, fromFile, context);
- }
- // 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;
- }
- /**
- * COBOL copybook lookup: `COPY CVACT01Y` (or `EXEC SQL INCLUDE X`) names a
- * library member resolved by the compiler's copybook search path, so we match
- * the member against indexed file basenames, case-insensitively. `.cpy` wins
- * over a same-named program; a same-directory hit wins within a tier. The
- * stem index is built once per resolution context (a per-ref scan of every
- * file node would go quadratic on copybook-heavy repos).
- */
- const cobolCopybookIndexes = new WeakMap<ResolutionContext, Map<string, string[]>>();
- /**
- * Per-context basename → file-paths index for Lua/Luau require resolution
- * (cobolCopybookIndexes pattern). resolveLuaRequire previously ran
- * `getAllFiles().filter(endsWith)` FOUR times per require ref — ~7.5k string
- * suffix scans each, measured at ~0.9ms/ref (2.7s combined on kong's 3k
- * requires). Buckets preserve getAllFiles() iteration order so the per-suffix
- * candidate list filters to exactly the array the full scan produced —
- * identical matches, identical stable sort, identical winner.
- */
- const luaFileBasenameIndexes = new WeakMap<ResolutionContext, Map<string, string[]>>();
- function luaBasenameIndex(context: ResolutionContext): Map<string, string[]> {
- let index = luaFileBasenameIndexes.get(context);
- if (!index) {
- index = new Map();
- for (const f of context.getAllFiles()) {
- const base = f.split('/').pop() ?? '';
- const paths = index.get(base);
- if (paths) paths.push(f);
- else index.set(base, [f]);
- }
- luaFileBasenameIndexes.set(context, index);
- }
- return index;
- }
- function resolveCobolCopybook(
- member: string,
- fromFile: string,
- context: ResolutionContext
- ): string | null {
- let index = cobolCopybookIndexes.get(context);
- if (!index) {
- index = new Map();
- for (const fileNode of context.getNodesByKind('file')) {
- const normalized = fileNode.filePath.replace(/\\/g, '/');
- const base = normalized.split('/').pop() ?? '';
- const dot = base.lastIndexOf('.');
- const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
- const paths = index.get(stem);
- if (paths) paths.push(fileNode.filePath);
- else index.set(stem, [fileNode.filePath]);
- }
- cobolCopybookIndexes.set(context, index);
- }
- const candidates = index.get(member.toLowerCase());
- if (!candidates || candidates.length === 0) return null;
- const fromDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
- let best: string | null = null;
- let bestScore = -1;
- for (const candidate of candidates) {
- const normalized = candidate.replace(/\\/g, '/');
- const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase();
- let score = 0;
- if (ext === '.cpy') score += 4;
- else if (ext === '.cbl' || ext === '.cob' || ext === '.cobol') score += 2;
- if (normalized.split('/').slice(0, -1).join('/') === fromDir) score += 1;
- if (score > bestScore) {
- bestScore = score;
- best = candidate;
- }
- }
- return best;
- }
- /**
- * 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' || language === 'arkts') {
- // 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 `<module-path>/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 (<stdio.h>) and
- // C++-style (<cstdio>, <vector>) 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<string, string> = {
- '@/': '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<string, string[]>();
- /**
- * 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<string>();
- 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<dir> (no space)
- if (arg.startsWith('-I') && arg.length > 2) {
- includeDir = arg.substring(2);
- }
- // -isystem <dir> (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;
- }
- /**
- * Is this reference a PHP include/require PATH (vs a namespace `use` symbol)?
- *
- * include/require emit a file path ("lib.php", "inc/db.php", "../x.php"),
- * whereas namespace use is an FQN (App\Foo\Bar) or a bare class symbol
- * (Closure). PHP identifiers contain neither '/' nor '.', so a slash or dot
- * marks a path-shaped include. Such references resolve to files only — never
- * to a same-named symbol — so callers must not fall back to the name-matcher.
- */
- export function isPhpIncludePathRef(ref: UnresolvedRef): boolean {
- return (
- ref.language === 'php' &&
- ref.referenceKind === 'imports' &&
- (ref.referenceName.includes('/') || ref.referenceName.includes('.'))
- );
- }
- /**
- * Is this a COBOL COPY / EXEC SQL INCLUDE copybook reference? These resolve
- * to files only (or stay unresolved for compiler-supplied members) — never
- * to a same-named symbol via the name-matcher.
- */
- export function isCobolCopybookRef(ref: UnresolvedRef): boolean {
- return ref.language === 'cobol' && ref.referenceKind === 'imports';
- }
- /**
- * Resolve a PHP include/require path to a project-relative file path.
- *
- * PHP resolves includes relative to the including file's directory (the
- * common case for procedural codebases); php.ini `include_path` is not
- * modeled. Callers pass an already-extracted static literal path.
- */
- function resolvePhpIncludePath(
- includePath: string,
- fromFile: string,
- context: ResolutionContext
- ): string | null {
- const projectRoot = context.getProjectRoot();
- const fromDir = path.dirname(path.join(projectRoot, fromFile));
- const basePath = path.resolve(fromDir, includePath);
- const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
- if (context.fileExists(relativePath)) return relativePath;
- // The literal may omit the .php extension (e.g. include "config").
- for (const ext of EXTENSION_RESOLUTION.php ?? []) {
- if (context.fileExists(relativePath + ext)) return relativePath + ext;
- }
- 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' || language === 'arkts') {
- mappings.push(...extractJSImports(content));
- } else if (language === 'svelte' || language === 'vue' || language === 'astro') {
- // Svelte/Vue single-file components import via plain ES6 inside their
- // `<script>` block (Astro: the `---` frontmatter). Without this, a
- // `.svelte`/`.vue`/`.astro` consumer produces
- // zero import mappings, so `resolveViaImport` can't run and a barrel
- // import (`import { Foo } from './lib'`) falls back to name-matching —
- // which silently fails whenever the re-export alias differs from the
- // component's real name, yielding a false 0 callers (#629). The ES6
- // import regex only matches `import … from '…'`, so running it over the
- // whole SFC (markup + styles included) is safe.
- mappings.push(...extractJSImports(content));
- } else if (language === 'python') {
- mappings.push(...extractPythonImports(content));
- } else if (language === 'go') {
- mappings.push(...extractGoImports(content));
- } else if (language === 'java' || language === 'kotlin') {
- mappings.push(...extractJavaImports(content));
- } else if (language === 'php') {
- mappings.push(...extractPHPImports(content));
- } else if (language === 'c' || language === 'cpp') {
- mappings.push(...extractCppImports(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 Java / Kotlin import mappings.
- *
- * Java/Kotlin imports carry the full qualified name of the imported
- * symbol — `import com.example.dao.converter.FooConverter;` — which is
- * exactly the disambiguation signal we need when two packages both
- * declare a `FooConverter`. Pre-#314 the resolver had no Java branch
- * here at all, so this mapping was empty and cross-module name
- * collisions were resolved by file-path proximity (often wrongly).
- *
- * `import static com.example.Foo.bar;` is parsed as a local-name `bar`
- * pointing at FQN `com.example.Foo.bar` so static-method call sites
- * (`bar(...)`) can resolve through the same import lookup.
- */
- function extractJavaImports(content: string): ImportMapping[] {
- const mappings: ImportMapping[] = [];
- // Strip line and block comments so `// import foo;` doesn't false-match.
- const stripped = content
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/\/\/[^\n]*/g, '');
- // `import [static] <fqn>[.*];`
- const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
- let match: RegExpExecArray | null;
- while ((match = re.exec(stripped)) !== null) {
- const fqn = match[2]!;
- // `import com.example.*;` — wildcard. We can't materialize a single
- // local name; skip and let name-matching handle members reachable
- // through the wildcard. (Future enhancement: enumerate package files.)
- if (fqn.endsWith('.*')) continue;
- const parts = fqn.split('.');
- const localName = parts[parts.length - 1];
- if (!localName) continue;
- mappings.push({
- localName,
- exportedName: localName,
- source: fqn,
- isDefault: false,
- isNamespace: false,
- });
- }
- 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;
- }
- /**
- * Extract C/C++ import mappings from #include directives.
- *
- * #include brings all symbols from the included header into scope
- * (namespace import), so each mapping uses isNamespace: true and
- * exportedName: '*'. The localName is set to the header's basename
- * without extension so that symbol references like `MyClass` can
- * match against any include that might provide it.
- */
- function extractCppImports(content: string): ImportMapping[] {
- const mappings: ImportMapping[] = [];
- // Match both #include <...> and #include "..."
- const includeRegex = /^\s*#\s*include\s+[<"]([^>"]+)[>"]/gm;
- let match;
- while ((match = includeRegex.exec(content)) !== null) {
- const modulePath = match[1]!;
- // Basename without extension for localName matching
- const basename = modulePath.split('/').pop()!.replace(/\.(h|hpp|hxx|hh|inl|ipp|cxx|cc|cpp)$/,'');
- mappings.push({
- localName: basename || modulePath,
- exportedName: '*',
- source: modulePath,
- isDefault: false,
- isNamespace: true,
- });
- }
- 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();
- cppIncludeDirCache.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' &&
- language !== 'arkts'
- ) {
- 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
- */
- /**
- * JVM (Java / Kotlin) imports use fully-qualified names (`import
- * com.example.foo.Bar`) decoupled from filenames, so the JS/Python
- * style filesystem path lookup misses them whenever the file isn't
- * named after its primary symbol (Kotlin `Utils.kt` exporting `Bar`,
- * top-level fns, extension fns). Resolve them through the
- * `qualifiedName` index instead — populated by the package_header /
- * package_declaration namespace wrappers in the extractor.
- */
- export function resolveJvmImport(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- if (ref.referenceKind !== 'imports') return null;
- if (ref.language !== 'java' && ref.language !== 'kotlin') return null;
- const fqn = ref.referenceName;
- const lastDot = fqn.lastIndexOf('.');
- if (lastDot <= 0) return null;
- const pkg = fqn.substring(0, lastDot);
- const sym = fqn.substring(lastDot + 1);
- // Wildcard imports (`com.example.*`) deliberately punt to name-matcher.
- if (sym === '*') return null;
- const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`);
- if (candidates.length === 0) return null;
- // Kotlin Multiplatform: an `expect` declaration and its `actual`s share one
- // FQN across source sets (commonMain / androidMain / appleMain). Taking the
- // first candidate let a single platform `actual` absorb every common-side
- // import, so the `expect` (the canonical API a commonMain file imports)
- // looked unused. Prefer the candidate CLOSEST to the importing file by
- // directory proximity — a commonMain import resolves to the commonMain
- // declaration — with the `expect` side as a tiebreak.
- const best = candidates.length === 1 ? candidates[0]! : pickClosestJvmCandidate(candidates, ref.filePath);
- return {
- original: ref,
- targetNodeId: best.id,
- confidence: 0.95,
- resolvedBy: 'import',
- };
- }
- /**
- * Pick the same-FQN candidate closest to `fromPath` by shared directory
- * prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
- * Multiplatform `expect`/`actual` import resolving within the importer's own
- * source set instead of an arbitrary platform `actual`.
- */
- function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node {
- const fromDirs = fromPath.split('/').slice(0, -1);
- const sharedPrefix = (p: string): number => {
- const d = p.split('/').slice(0, -1);
- let shared = 0;
- for (let i = 0; i < Math.min(fromDirs.length, d.length); i++) {
- if (fromDirs[i] === d[i]) shared++;
- else break;
- }
- return shared;
- };
- const isExpect = (n: Node): boolean => Array.isArray(n.decorators) && n.decorators.includes('expect');
- let best = candidates[0]!;
- let bestProx = sharedPrefix(best.filePath);
- for (let i = 1; i < candidates.length; i++) {
- const c = candidates[i]!;
- const prox = sharedPrefix(c.filePath);
- if (prox > bestProx || (prox === bestProx && isExpect(c) && !isExpect(best))) {
- best = c;
- bestProx = prox;
- }
- }
- return best;
- }
- export function resolveViaImport(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // C/C++ #include references — resolve directly to the included file
- // (file→file edge), bypassing symbol lookup. The extractor emits these
- // with `referenceKind: 'imports'` and `referenceName: <include path>`
- // (e.g. "uint256.h" or "common/args.h"). Without this branch the
- // include-dir scan path inside resolveImportPath never produces an
- // edge — resolveViaImport's symbol lookup below would search the
- // resolved file for a symbol named like the file extension and fail.
- if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') {
- // C/C++ quoted includes (`#include "X.h"`) resolve relative to the
- // INCLUDING file's own directory first (the C standard's quoted-include
- // search order). Prefer a same-directory header over an -I directory or a
- // same-named header on another platform (windows/code/RNCAsyncStorage.h vs
- // apple/.../RNCAsyncStorage.h) — the include-dir heuristic below would
- // otherwise pick an arbitrary same-named header, leaving the real local one
- // with no dependents.
- const slash = ref.filePath.lastIndexOf('/');
- const fromDir = slash >= 0 ? ref.filePath.slice(0, slash) : '';
- const siblingPath = path.posix.normalize(fromDir ? `${fromDir}/${ref.referenceName}` : ref.referenceName);
- const siblingBase = siblingPath.split('/').pop()!;
- const sibling = context
- .getNodesByName(siblingBase)
- .find((n) => n.kind === 'file' && n.filePath === siblingPath);
- if (sibling) {
- return { original: ref, targetNodeId: sibling.id, confidence: 0.92, resolvedBy: 'import' };
- }
- const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
- if (!resolvedPath) return null;
- const basename = resolvedPath.split('/').pop()!;
- const fileNodes = context.getNodesByName(basename).filter((n) => n.kind === 'file');
- const fileNode = fileNodes.find((n) => n.filePath === resolvedPath);
- if (fileNode) {
- return {
- original: ref,
- targetNodeId: fileNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- return null;
- }
- // COBOL COPY / EXEC SQL INCLUDE — resolve the copybook member to a
- // file→file edge, mirroring the C/C++ include branch above. A member that
- // matches no indexed file (compiler-supplied copybooks like SQLCA/DFHAID)
- // stays unresolved — callers must not fall back to the symbol name-matcher,
- // which would connect it to a same-named import symbol elsewhere.
- if (isCobolCopybookRef(ref)) {
- const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language!, context);
- if (!resolvedPath) return null;
- const basename = resolvedPath.split('/').pop()!;
- const fileNode = context
- .getNodesByName(basename)
- .find((n) => n.kind === 'file' && n.filePath === resolvedPath);
- if (fileNode) {
- return {
- original: ref,
- targetNodeId: fileNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- return null;
- }
- // PHP include/require — resolve the static string path to a file→file
- // edge, mirroring the C/C++ branch above. Distinguish include PATHS from
- // namespace `use` symbols by shape: an include path contains a slash or a
- // file extension ("lib.php", "inc/db.php", "../x.php"), whereas a namespace
- // use is an FQN (App\Foo\Bar) or a bare class symbol (Closure) — PHP
- // identifiers contain neither '/' nor '.'. Only path-shaped references are
- // includes; symbol references fall through to the namespace resolution.
- if (isPhpIncludePathRef(ref)) {
- const resolvedPath = resolvePhpIncludePath(ref.referenceName, ref.filePath, context);
- if (resolvedPath) {
- const basename = resolvedPath.split('/').pop()!;
- const fileNode = context
- .getNodesByName(basename)
- .find((n) => n.kind === 'file' && n.filePath === resolvedPath);
- if (fileNode) {
- return {
- original: ref,
- targetNodeId: fileNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- }
- // A path-shaped include that doesn't resolve to a known project file is a
- // dead end. Return unresolved rather than falling through to the symbol
- // name-matcher, which would mis-connect e.g. "inc/db.php" to an unrelated
- // db.php elsewhere in the tree — a wrong edge is worse than a missing one.
- return null;
- }
- // Nix static project-path imports (`import ./x.nix`, `builtins.import ./dir`,
- // `import ./x.nix {}`) resolve to file nodes only. Do not resolve
- // angle-bracket channels, attribute expressions, variables, or other dynamic
- // expressions as project files.
- if (isNixPathImportRef(ref)) {
- const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
- if (!resolvedPath) return null;
- const basename = resolvedPath.split('/').pop()!;
- const fileNode = context
- .getNodesByName(basename)
- .find((n) => n.kind === 'file' && n.filePath === resolvedPath);
- if (fileNode) {
- return {
- original: ref,
- targetNodeId: fileNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- return 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;
- }
- // Java / Kotlin: imports are FQNs (`import com.example.Foo;`) — no
- // resolvable file path the JS/TS-style chain below could follow. Look
- // up the symbol by name and filter to the candidate whose file path
- // matches the imported FQN. This is the disambiguation signal that
- // breaks the same-name class collision the path-proximity matcher
- // can't resolve (issue #314).
- if (ref.language === 'java' || ref.language === 'kotlin') {
- const javaResult = resolveJavaImportedReference(ref, imports, context);
- if (javaResult) return javaResult;
- }
- // Python qualified access through an imported MODULE: `certs.where()` after
- // `from . import certs`, `mod.func()` after `import mod`. The receiver names a
- // submodule (a file), not a symbol, so the generic symbol lookup below would
- // search the *package* for `certs` instead of looking inside the module.
- if (ref.language === 'python') {
- const pyResult = resolvePythonModuleMember(ref, imports, context);
- if (pyResult) return pyResult;
- // Absolute dotted module import: `import conduit.apps.articles.signals`
- // (the standard Django AppConfig.ready() signal-registration pattern, and
- // any side-effect `import pkg.mod`). Map the dotted path to its file.
- const pyModResult = resolvePythonAbsoluteModule(ref, context);
- if (pyModResult) return pyModResult;
- }
- // Rust qualified path: resolve the module prefix of `crate::m::Item` /
- // `self::sub::Item` / `super::m::func` to a file, then find the leaf symbol in
- // it. Disambiguates common-name `pub use self::read::read` re-exports that
- // name-matching would land on the wrong same-named symbol.
- if (ref.language === 'rust' && ref.referenceName.includes('::')) {
- const rustResult = resolveRustPathReference(ref, context);
- if (rustResult) return rustResult;
- }
- // Lua / Luau `require(...)`: a dotted module path (`a.b.c` from
- // `require("a.b.c")`) or an instance-path leaf (`Signal` from
- // `require(script.Parent.Signal)`) — map it to a module file. There's no static
- // import statement, so the generic path-matcher can't bridge the dot↔slash /
- // leaf↔basename gap; resolve it explicitly to the module file.
- if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports') {
- const luaResult = resolveLuaRequire(ref, context);
- if (luaResult) return luaResult;
- }
- // Whole-module / namespace imports → link the importing file to the module
- // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
- // from './x'` (so a namespace touched only via a value-member read still
- // records the dependency). A named TS/JS import returns null here and falls
- // through to symbol resolution below.
- if (
- ref.language === 'python' ||
- ref.language === 'typescript' ||
- ref.language === 'tsx' ||
- ref.language === 'javascript' ||
- ref.language === 'jsx' ||
- ref.language === 'arkts'
- ) {
- const moduleFile = resolveModuleImportToFile(ref, imports, context);
- if (moduleFile) return moduleFile;
- }
- // 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) {
- // `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import
- // accessed through a member. `findExportedSymbol` resolved `Foo` to
- // the class itself; descend into it so the reference links to the
- // member `bar`, not the class. Without this the edge points at the
- // class and `createEdges` then mis-promotes the call to an
- // `instantiates` edge, so the static method shows zero callers and a
- // hollow impact radius. (#825)
- if (!imp.isNamespace && ref.referenceName.startsWith(imp.localName + '.')) {
- const memberNode = resolveStaticMember(targetNode, ref, imp.localName, context);
- if (memberNode) {
- return {
- original: ref,
- targetNodeId: memberNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- // An imported VALUE (singleton constant / shared instance) called
- // through a member: `reproStore.notifyJoinGuildStatus()` after
- // `import { reproStore } from './store'`. findExportedSymbol
- // resolved the CONSTANT itself; linking the CALL there hides the
- // real callee — callers of the method miss every cross-file use
- // and the method can look unused (#1292). Infer the value's type
- // from its own declaration in the exporting file and resolve the
- // member on that type. resolveMethodOnType VALIDATES the type
- // declares the method, so a mis-inference falls through to the
- // constant edge below rather than fabricating a wrong one.
- const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context);
- if (instanceMember) return instanceMember;
- }
- return {
- original: ref,
- targetNodeId: targetNode.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- }
- }
- }
- return null;
- }
- /**
- * Resolve a Python qualified reference whose receiver is an imported MODULE:
- * `certs.where()` after `from . import certs`, `mod.func()` after `import mod`
- * or `from pkg import mod`. The receiver names a submodule (a file), not a
- * symbol, so the generic symbol lookup in `resolveViaImport` can't follow it —
- * it would search the *package* for `certs`/`mod` instead of looking inside the
- * module. This is the Python half of the cross-package qualified-call problem
- * (cf. `resolveGoCrossPackageReference` for Go's `pkg.Func`, issue #388).
- *
- * Builds the module's dotted import path from the binding — `from . import
- * certs` → `.certs`; `from pkg import mod` → `pkg.mod`; `import mod` → `mod` —
- * resolves it to the module file, and finds the member defined there. Returns
- * null when no module file exists at that path, so attribute access on an
- * imported *value* (`helper.attr` where `helper` is a function) falls through
- * to the other strategies untouched.
- */
- function resolvePythonModuleMember(
- ref: UnresolvedRef,
- imports: ImportMapping[],
- context: ResolutionContext
- ): ResolvedRef | null {
- const dotIdx = ref.referenceName.indexOf('.');
- if (dotIdx <= 0) return null;
- const receiver = ref.referenceName.substring(0, dotIdx);
- // The immediate member of the module (first segment after the receiver).
- const member = ref.referenceName.substring(dotIdx + 1).split('.')[0];
- if (!member) return null;
- for (const imp of imports) {
- if (imp.localName !== receiver) continue;
- // `import mod` / `import numpy as np` bind the module at `source` itself;
- // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
- // dotted path is the source joined with the imported name.
- const modulePath = imp.isNamespace
- ? imp.source
- : imp.source.endsWith('.')
- ? imp.source + imp.localName
- : imp.source + '.' + imp.localName;
- // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an
- // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare
- // `import pkg.mod`) resolves to null there, so fall back to the dotted-module
- // file lookup — the same asymmetry resolveModuleImportToFile already handles
- // for the file→file import edge. Without this, a `module.func()` call after
- // `from pkg import module` dropped its `calls` edge even though the import
- // edge resolved (#578).
- let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
- if (!resolvedPath) {
- resolvedPath = findPythonModuleFile(modulePath, context, ref.filePath)?.filePath ?? null;
- }
- if (!resolvedPath || resolvedPath === ref.filePath) continue;
- // Find the member as a top-level definition in the module file. Exclude
- // `method` so `mod.foo` never lands on a same-named class method.
- const target = context.getNodesInFile(resolvedPath).find(
- (n) =>
- n.name === member &&
- (n.kind === 'function' ||
- n.kind === 'class' ||
- n.kind === 'variable' ||
- n.kind === 'constant')
- );
- if (target) {
- return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
- }
- }
- return null;
- }
- /**
- * Resolve a whole-MODULE import to that module's file (a file→file dependency).
- * The imported name is a module, not a symbol, so there's nothing to resolve to
- * — but importing a module IS a dependency on it. Covers:
- * - Python submodule imports — `from . import certs`, `from pkg import sub`;
- * - namespace imports — Python `import mod` / `import numpy as np`, and
- * TS/JS `import * as ns from './x'`.
- *
- * It is also the robust backstop for {@link resolvePythonModuleMember} and for
- * TS namespace usage: it records the dependency even when the used member is
- * re-exported elsewhere (requests' `certs.where`, re-exported from `certifi`),
- * the usage is module-level code that isn't extracted as a call, or a TS
- * namespace is touched only via a value-member read (`ns.SOME_CONST`).
- *
- * Only fires for dot-free `imports`-kind refs whose module path resolves to a
- * real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
- * returns null and normal symbol resolution handles it.
- */
- /**
- * Resolve a Lua/Luau `require(...)` to its module file. The reference name is
- * either a dotted module path (`telescope.config` → `telescope/config.lua`) or a
- * Roblox instance-path leaf (`Signal` from `require(script.Parent.Signal)` →
- * `Signal.luau`). We try `<path>.lua|.luau` and `<path>/init.lua|.luau`, matched
- * by path suffix (the module root — `lua/`, `src/`, … — is project-specific).
- * Among suffix matches, the one sharing the longest directory prefix with the
- * requiring file wins (instance-path requires resolve within the same package).
- */
- function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
- const name = ref.referenceName;
- if (!name) return null;
- const base = name.includes('.') ? name.replace(/\./g, '/') : name;
- const suffixes = [`${base}.lua`, `${base}.luau`, `${base}/init.lua`, `${base}/init.luau`];
- const byBasename = luaBasenameIndex(context);
- const shared = (a: string, b: string): number => {
- let i = 0;
- while (i < a.length && i < b.length && a[i] === b[i]) i++;
- return i;
- };
- for (const suffix of suffixes) {
- // Only files sharing the suffix's basename can match — the bucket is in
- // getAllFiles() order, so this filter yields exactly what the full-list
- // scan did.
- const candidates = byBasename.get(suffix.split('/').pop() ?? '') ?? [];
- const matches = candidates.filter((f) => f === suffix || f.endsWith('/' + suffix));
- if (matches.length === 0) continue;
- matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath));
- const best = matches[0]!;
- if (best === ref.filePath) continue;
- const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file');
- if (fileNode) {
- // Confidence ≥ 0.9 so this deterministic path/suffix match wins over
- // name-matching, which otherwise resolves the require to the import node
- // itself (a same-name self-match).
- return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
- }
- }
- return null;
- }
- function resolveModuleImportToFile(
- ref: UnresolvedRef,
- imports: ImportMapping[],
- context: ResolutionContext
- ): ResolvedRef | null {
- if (ref.referenceKind !== 'imports') return null;
- if (ref.referenceName.includes('.')) return null;
- for (const imp of imports) {
- if (imp.localName !== ref.referenceName) continue;
- let modulePath: string;
- if (imp.isNamespace || imp.isDefault) {
- // `import * as ns from './x'` (namespace) or `import x from './x'`
- // (default) — the dependency is on the MODULE FILE. A default import binds
- // a (possibly renamed) local to whatever the module's default export is
- // (`import articlesController from './article.controller'` ← `export
- // default router`), so the binding name can't be found as a symbol — link
- // the file the import resolves to instead. External modules don't resolve
- // (no file), so `import React from 'react'` creates no edge.
- modulePath = imp.source;
- } else if (ref.language === 'python') {
- // `from . import certs` — the imported NAME is a submodule of the source.
- modulePath = imp.source.endsWith('.')
- ? imp.source + imp.localName
- : imp.source + '.' + imp.localName;
- } else {
- // A named TS/JS import binds a symbol, not a module — leave it alone.
- continue;
- }
- const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
- if (resolvedPath && resolvedPath !== ref.filePath) {
- const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file');
- if (fileNode) {
- return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
- }
- }
- // Python absolute `from a.b import submodule` (a FastAPI router aggregator's
- // `from app.api.routes import authentication`): resolveImportPath only maps
- // RELATIVE dotted paths to a file, so resolve the absolute dotted module
- // directly to its file node.
- if (ref.language === 'python') {
- const modFile = findPythonModuleFile(modulePath, context, ref.filePath);
- if (modFile) {
- return { original: ref, targetNodeId: modFile.id, confidence: 0.9, resolvedBy: 'import' };
- }
- }
- }
- return null;
- }
- /**
- * Find the file node for a Python dotted module path `a.b.c` — a module file
- * ending in `a/b/c.py`, or a package `a/b/c/__init__.py` (suffix-matched, so a
- * package rooted under `src/` etc. still resolves). Returns null for
- * stdlib/external modules (no matching repo file node), so `import os` creates
- * no edge. Shared by absolute `import a.b.c` and absolute `from a.b import c`
- * (where `c` is a submodule) resolution.
- */
- function findPythonModuleFile(
- mod: string,
- context: ResolutionContext,
- excludeFilePath: string
- ): Node | null {
- if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere
- const rel = mod.replace(/\./g, '/');
- const lastSeg = mod.split('.').pop()!;
- const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want);
- const moduleFile = context
- .getNodesByName(`${lastSeg}.py`)
- .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}.py`));
- if (moduleFile) return moduleFile;
- const pkgFile = context
- .getNodesByName('__init__.py')
- .find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`));
- return pkgFile ?? null;
- }
- /**
- * Resolve a Python ABSOLUTE dotted module import (`import a.b.c`) to its file —
- * the Django `AppConfig.ready(): import myapp.signals` pattern and any
- * side-effect module import.
- */
- function resolvePythonAbsoluteModule(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- if (ref.referenceKind !== 'imports') return null;
- // Only a DOTTED `import a.b.c` ref carries its full module path. A bare leaf
- // (`from app.api.routes import authentication`) is ambiguous on its own — three
- // `authentication.py` files may exist — so leave it to resolveModuleImportToFile,
- // which uses the import's source (`app.api.routes`) to build the full path.
- if (!ref.referenceName.includes('.')) return null;
- const hit = findPythonModuleFile(ref.referenceName, context, ref.filePath);
- return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null;
- }
- /**
- * Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix
- * (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust
- * analog of {@link resolvePythonModuleMember} / {@link resolveGoCrossPackageReference}
- * and the precise answer to common-name re-exports (`pub use self::read::read`)
- * that name-matching can't disambiguate. Returns null when the prefix isn't a
- * real module path (e.g. `Widget::new` — `Widget` is a struct, not a module),
- * so associated-function calls and enum-variant paths fall through untouched.
- */
- function resolveRustPathReference(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- const segments = ref.referenceName.split('::').filter((s) => s.length > 0);
- if (segments.length < 2) return null;
- const leaf = segments[segments.length - 1]!;
- const modSegs = segments.slice(0, -1);
- const file = resolveRustModuleFile(modSegs, ref.filePath, context);
- if (!file || file === ref.filePath) return null;
- const target = context.getNodesInFile(file).find(
- (n) =>
- n.name === leaf &&
- (n.kind === 'function' ||
- n.kind === 'struct' ||
- n.kind === 'enum' ||
- n.kind === 'trait' ||
- n.kind === 'type_alias' ||
- n.kind === 'constant' ||
- n.kind === 'method' ||
- n.kind === 'class' ||
- n.kind === 'interface')
- );
- if (target) {
- return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
- }
- return null;
- }
- /** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */
- function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null {
- const projectRoot = context.getProjectRoot();
- const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
- let dir = path.dirname(fromFileAbs);
- for (let i = 0; i < 64; i++) {
- if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) ||
- context.fileExists(toRel(path.join(dir, 'main.rs')))) {
- return dir;
- }
- const parent = path.dirname(dir);
- if (parent === dir) return null;
- dir = parent;
- }
- return null;
- }
- /** Directory under which the current file's module declares its SUBMODULES. */
- function rustSelfModuleDir(fromFileAbs: string): string {
- const base = path.basename(fromFileAbs);
- const dir = path.dirname(fromFileAbs);
- // mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`.
- if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir;
- return path.join(dir, base.replace(/\.rs$/, ''));
- }
- /**
- * Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of
- * the last module segment — `crate::a::b` → `<crate>/a/b.rs` (or `.../b/mod.rs`).
- * Anchors on `crate` / `self` / `super`; a bare path is tried crate-relative.
- */
- function resolveRustModuleFile(
- segments: string[],
- fromFile: string,
- context: ResolutionContext
- ): string | null {
- if (segments.length === 0) return null;
- const projectRoot = context.getProjectRoot();
- const fromAbs = path.join(projectRoot, fromFile);
- const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
- // Walk a sequence of module segments down from `startDir`, mapping each to a
- // `<seg>.rs` or `<seg>/mod.rs` file. Returns the leaf module's file, or null
- // if `startDir` is null or any segment has no file on disk.
- const resolveUnder = (startDir: string | null, rest: string[]): string | null => {
- if (!startDir) return null;
- let dir = startDir;
- let targetFile: string | null = null;
- for (const seg of rest) {
- if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
- const asFile = toRel(path.join(dir, seg + '.rs'));
- const asMod = toRel(path.join(dir, seg, 'mod.rs'));
- if (context.fileExists(asFile)) targetFile = asFile;
- else if (context.fileExists(asMod)) targetFile = asMod;
- else return null;
- dir = path.join(dir, seg);
- }
- return targetFile;
- };
- const first = segments[0]!;
- if (first === 'crate') {
- return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1));
- }
- if (first === 'self') {
- return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1));
- }
- if (first === 'super') {
- let supers = 0;
- while (segments[supers] === 'super') supers++;
- let dir: string | null = rustSelfModuleDir(fromAbs);
- for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir);
- return resolveUnder(dir, segments.slice(supers));
- }
- // Bare path. In expression position (`submodule::item()` — the router-assembly
- // and general cross-module-call pattern) the prefix is a SUBMODULE of the
- // current module, i.e. 2018 `self::`-relative — so try self-relative FIRST.
- // Fall back to crate-relative for 2015-edition / crate-root items. External
- // crate paths (`serde::de::Error`) miss both and fall through to name-matching.
- return (
- resolveUnder(rustSelfModuleDir(fromAbs), segments) ??
- resolveUnder(rustCrateRootDir(fromAbs, context), segments)
- );
- }
- /**
- * Resolve a Java/Kotlin reference whose receiver is the simple name of
- * an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The
- * imported FQN converts to a file-path suffix (`com/example/Foo.java`
- * or `.kt`) which uniquely identifies the right symbol when multiple
- * classes share the same simple name.
- *
- * Also handles bare references to the imported class itself
- * (`new Foo()` extraction emits `Foo` as a `references`/`instantiates`
- * ref) and `import static <Foo>.bar` style imports of a single member.
- */
- function resolveJavaImportedReference(
- ref: UnresolvedRef,
- imports: ImportMapping[],
- context: ResolutionContext
- ): ResolvedRef | null {
- if (imports.length === 0) return null;
- const ext = ref.language === 'kotlin' ? '.kt' : '.java';
- for (const imp of imports) {
- const matchesBare = imp.localName === ref.referenceName;
- const matchesQualified = ref.referenceName.startsWith(imp.localName + '.');
- if (!matchesBare && !matchesQualified) continue;
- // Convert FQN to a file-path suffix. `com.example.Foo` ->
- // `com/example/Foo.java` (or `.kt`). The actual file may live
- // under any source root (`src/main/java/`, `src/`, etc.), so match
- // by suffix rather than exact path.
- const fqnPath = imp.source.replace(/\./g, '/') + ext;
- // Which symbol name to look up: the class itself, or a member.
- const memberName = matchesBare
- ? imp.localName
- : ref.referenceName.substring(imp.localName.length + 1);
- const candidates = context.getNodesByName(memberName);
- for (const node of candidates) {
- if (node.language !== ref.language) continue;
- const fp = node.filePath.replace(/\\/g, '/');
- if (fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath)) {
- return {
- original: ref,
- targetNodeId: node.id,
- confidence: 0.9,
- resolvedBy: 'import',
- };
- }
- }
- // `import static com.example.Foo.bar;` — the FQN's tail is the
- // member name, the part before is the owner class. Look up the
- // member named `<imp.localName>` (e.g. `bar`) and prefer the
- // candidate whose file matches the parent FQN's path.
- if (matchesBare) {
- const dot = imp.source.lastIndexOf('.');
- if (dot > 0) {
- const ownerFqn = imp.source.substring(0, dot);
- const ownerPath = ownerFqn.replace(/\./g, '/') + ext;
- for (const node of candidates) {
- if (node.language !== ref.language) continue;
- const fp = node.filePath.replace(/\\/g, '/');
- if (fp.endsWith(ownerPath) || fp.endsWith('/' + ownerPath)) {
- return {
- original: ref,
- targetNodeId: node.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<string>,
- depth = 0
- ): Node | undefined {
- // Memoize fresh (top-level) lookups only: recursive re-export steps carry a
- // populated `visited` set, whose contents change the reachable answer.
- // Every ref to the same imported symbol repeats this exact walk, so the
- // top-level memo removes the re-export chase + per-file linear scans from
- // all but the first occurrence.
- if (depth === 0 && visited.size === 0) {
- let memo = exportedSymbolMemos.get(context);
- if (!memo) {
- memo = new Map();
- exportedSymbolMemos.set(context, memo);
- }
- const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${language}`;
- if (memo.has(key)) return memo.get(key);
- const result = findExportedSymbolWalk(filePath, want, language, context, visited, depth);
- memo.set(key, result);
- return result;
- }
- return findExportedSymbolWalk(filePath, want, language, context, visited, depth);
- }
- function findExportedSymbolWalk(
- filePath: string,
- want: {
- isDefault: boolean;
- isNamespace: boolean;
- exportedName: string;
- memberName: string | null;
- },
- language: Language,
- context: ResolutionContext,
- visited: Set<string>,
- depth: number
- ): Node | undefined {
- if (depth > REEXPORT_MAX_DEPTH) return undefined;
- if (visited.has(filePath)) return undefined;
- visited.add(filePath);
- const exportIndex = getFileExportIndex(filePath, context);
- // 1. Direct hit: the symbol is declared in this file.
- if (want.isDefault) {
- // Svelte/Vue single-file components ARE the module's default export,
- // but are extracted as kind 'component' (not function/class). Prefer
- // the component node; fall back to an exported function/class for the
- // `.ts`/`.tsx` `export default fn`/`class` case. Without the component
- // branch, an `export { default as X } from './X.svelte'` barrel never
- // resolves and the component shows a false 0 callers (#629).
- const direct = exportIndex.defaultComponent ?? exportIndex.defaultFnClass;
- if (direct) return direct;
- } else if (want.isNamespace && want.memberName) {
- const direct = exportIndex.byName.get(want.memberName);
- if (direct) return direct;
- } else {
- const direct = exportIndex.byName.get(want.exportedName);
- 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;
- }
- /** Node kinds that own static members reachable as `Container.member`. */
- const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
- 'class', 'struct', 'interface', 'enum', 'trait', 'protocol',
- ]);
- /**
- * Resolve `Container.member` — a static method/property access on a NAMED class
- * import (`import { Foo } …; Foo.bar()`) — to the member node, given the
- * already-resolved container class.
- *
- * Members carry a `Container::member` qualifiedName, so we look up
- * `${container.qualifiedName}::${member}` within the container's own file (the
- * file filter disambiguates same-named classes in other modules). Returns
- * undefined when the container isn't a member-owning kind or the member isn't
- * found, so the caller falls back to the container itself (prior behavior) —
- * languages whose members aren't `::`-qualified, and genuine class references,
- * are unaffected. See #825.
- */
- /**
- * Resolve a CALL through an imported value to the method on the value's own
- * type: `reproStore.notifyJoinGuildStatus()` where `reproStore` is
- * `export const reproStore = new ReproStore()` in the imported file (#1292).
- * The same-file form of this call already resolves via local-variable
- * receiver inference (#1108); this is the cross-file/import half. The type is
- * recovered from the VALUE'S OWN declaration lines in the exporting file
- * (initializer `= new T(...)` or a type annotation, per the shared #1108
- * pattern table), then the member is resolved AND VALIDATED on that type by
- * resolveMethodOnType — a failed inference or validation returns null so the
- * caller keeps its existing constant-edge behavior.
- */
- function resolveImportedInstanceMember(
- value: Node,
- ref: UnresolvedRef,
- localName: string,
- context: ResolutionContext
- ): ResolvedRef | null {
- if (ref.referenceKind !== 'calls') return null;
- if (value.kind !== 'constant' && value.kind !== 'variable') return null;
- const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
- if (!member) return null;
- const source = context.readFile(value.filePath);
- if (!source) return null;
- // Only the value's own declaration lines — never the whole file, so a
- // same-named identifier elsewhere can't donate a type.
- const lines = source.split('\n');
- const declSlice = lines.slice(Math.max(0, value.startLine - 1), value.endLine).join('\n');
- const receiver = value.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- for (const pattern of localReceiverTypePatterns(value.language as Language, receiver)) {
- const m = declSlice.match(pattern);
- if (!m || !m[1]) continue;
- const typeName = normalizeInferredTypeName(m[1]);
- if (!typeName) continue;
- const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method');
- if (resolved) return resolved;
- }
- return null;
- }
- function resolveStaticMember(
- container: Node,
- ref: UnresolvedRef,
- localName: string,
- context: ResolutionContext
- ): Node | undefined {
- if (!STATIC_MEMBER_CONTAINERS.has(container.kind)) return undefined;
- // First segment after the receiver: `Foo.bar.baz` → `bar`.
- const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
- if (!member) return undefined;
- const candidates = context
- .getNodesByQualifiedName(`${container.qualifiedName}::${member}`)
- .filter((n) => n.filePath === container.filePath);
- if (candidates.length === 0) return undefined;
- // When the reference is a call, prefer a callable member if several nodes
- // share the qualifiedName (e.g. a static property and a method).
- if (ref.referenceKind === 'calls') {
- const callable = candidates.find((n) => n.kind === 'method' || n.kind === 'function');
- if (callable) return callable;
- }
- return candidates[0];
- }
|