Files
codegraph/src/resolution/import-resolver.ts
T
Colby Mchenry 4f3eaf33ae fix(resolution): component and scoped-alias imports link the module file (#2528)
An import statement's own module reference linked nothing in two cases,
even when its bindings resolved through the file: a Vue, Svelte or Astro
component's import (`isJsPathImportRef` covered only the script languages),
and a bare scoped package that a `tsconfig` path or a workspace package maps
to a project file (`isJsPathSpecifier` excluded `@scope/pkg`). So a barrel or
a side-effect module showed no dependency from those files, and sync's
importer discovery, which follows the module edge into a changed file,
never found them when a barrel they import through was deleted or added.

Both now resolve through `resolveImportPath`, as a script's path import
does; an outside scoped package still names no file. Component imports
written with an extension, which a basename match linked before, now link
through the resolver too. When a sync adds a file, it also retries the
failed imports that now resolve to it but wait under no key its path gives:
a scoped package parked under its last segment (`common` for
`@excalidraw/common`, mapped to `packages/common/src/index.ts`, whose keys
are `src` and `index`), under the module ceiling, and an import of the
importer's own folder or the one above (`.`, `..`), parked under no tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:23:41 +00:00

3240 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { extractLocalExportAliases } from './alias-binding';
import { resolveWorkspaceImport } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
import { makeLineAt } from './synth-utils';
import { dartDirectiveFile } from './dart-libraries';
import { moduleTail } from '../db/reference-tail';
import {
resolveMethodOnType,
resolveObjectLiteralMember,
resolveObjectLiteralBinding,
localReceiverTypePatterns,
normalizeInferredTypeName,
goRefQualifier,
} 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', '.xsjs', '.xsjslib', '/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.
*
* Built from the file's exported rows alone: every worker of the resolver
* pool builds its own index for each file an import reaches, and decoding
* whole files there cost more than resolving through them. The two answers
* that need more — what the `export default` statement exports and names a
* local export clause introduces — read the source and name-targeted rows on
* first use.
*/
interface FileExportIndex {
/** Exported declarations by name. Read through {@link exportedByName}. */
byName: Map<string, Node>;
defaultComponent: Node | undefined;
defaultFnClass: Node | undefined;
/**
* What the file's own `export default` statement exports — the precise
* answer where `defaultComponent`/`defaultFnClass` are guesses. `undefined`
* until first read through {@link statedDefaultExport}.
*/
statedDefault?: StatedDefault;
/**
* Names a local export clause (`export { impl as alias }`) binds to a
* declaration the extractor never flagged isExported, for names not in
* `byName`. `undefined` until first read through {@link exportedByName}.
*/
clauseAliases?: Map<string, Node>;
}
const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
const DEFAULT_DECLARATION_KINDS = new Set<string>(['function', 'class', 'component']);
/** Any `export default …` statement: the module is an ES module with a default export. */
const ESM_DEFAULT_EXPORT_RE = /^[ \t]*export\s+default\b/m;
const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
/**
* What a file's own `export default` statement exports: the node it declares
* or names, `'anonymous'` for a function or class with no name (no node
* stands for it), `'unstated'` when the file says nothing a node answers.
*/
type StatedDefault = Node | 'anonymous' | 'unstated';
const EXPORT_DEFAULT_HEAD = /^[ \t]*export\s+default\s+/gm;
/** `[async] function[*] [NAME]` / `[abstract] class [NAME]`; the name is group 1 or 2. */
const DEFAULT_DECLARATION_RE =
/^(?:async\s+)?function\b\s*(?:\*\s*)?([A-Za-z_$][\w$]*)?|^(?:abstract\s+)?class\b(?:\s+(?!extends\b|implements\b)([A-Za-z_$][\w$]*))?/;
/** `[async] x =>`, or `[async] (` opening what may be an arrow function's parameters. */
const DEFAULT_ARROW_RE = /^(?:async\s+)?(?:[A-Za-z_$][\w$]*\s*=>|\()/;
/** `NAME` alone on its line. */
const DEFAULT_BINDING_RE = /^([A-Za-z_$][\w$]*)\s*;?[ \t]*(?:\r?\n|$)/;
/**
* The value of the `export default` statement whose value starts at `at`, in
* comment-stripped code: the name a declaration or a binding gives it,
* `'anonymous'` for a function, class or arrow function without one, or null
* for any other expression.
*/
function readDefaultExport(code: string, at: number): { name: string; nameAt: number; declared: boolean } | 'anonymous' | null {
const rest = code.slice(at, at + 2048);
const decl = DEFAULT_DECLARATION_RE.exec(rest);
if (decl) {
const name = decl[1] ?? decl[2];
return name ? { name, nameAt: at + decl[0].length - name.length, declared: true } : 'anonymous';
}
const arrow = DEFAULT_ARROW_RE.exec(rest);
if (arrow) {
if (!arrow[0].endsWith('(')) return 'anonymous';
// A parenthesized list is an arrow function's parameters when `=>` or a
// return type (`(…): JSX.Element =>`) follows it; otherwise it is an
// expression (`(Foo)`).
let depth = 0;
for (let i = arrow[0].length - 1; i < rest.length; i++) {
if (rest[i] === '(') depth++;
else if (rest[i] === ')' && --depth === 0) return /^\s*(?:=>|:)/.test(rest.slice(i + 1)) ? 'anonymous' : null;
}
return null;
}
const binding = DEFAULT_BINDING_RE.exec(rest);
return binding ? { name: binding[1]!, nameAt: at, declared: false } : null;
}
/**
* What `filePath`'s own `export default` statement exports. The statement
* says it outright, where the export index can only guess:
* - `export default function Vans() {…}` / `export default class Store` —
* the declaration the statement writes, found by position. A React Router
* data-router page exports its `loader` or `action` above it, and the
* first-exported guess bound the page's default import to the loader.
* - `export default Home` — the binding it names, exported at its
* declaration or not: `const Home = () => …` and the namespace object
* `const UploadApi = { uploadARCapture }` have no `export_statement`
* ancestor, so the `isExported` index never sees them.
* - `export default function () {…}`, `export default class {…}`, `export
* default () => …` — anonymous: no node is the default export, and any
* exported function beside it (or nested in it, which the extractor flags
* exported too) would be the wrong one.
* Anything else — an expression (`memo(Card)`, `new Service()`, `{ … }`), a
* binding the file doesn't declare, no statement at all, a non-JS file — is
* `'unstated'`. Comments are stripped first. Strings are not masked: a
* template whose `${…}` holds another template, or a regex with a backtick,
* sets a masker out of step, and it blanked the real statement after it
* (outline's 2,900-line styled-components `Styles.ts`). A statement must
* name a node of the file instead — one a template only writes as text
* names none — and of several statements, one that does wins.
*/
function statedDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): StatedDefault {
if (idx.statedDefault !== undefined) return idx.statedDefault;
let stated: StatedDefault | undefined;
const source = JS_FAMILY_FILE.test(filePath) ? context.readFile(filePath) : null;
if (source && /\bexport\s+default\b/.test(source)) {
const code = stripCommentsForRegex(source, 'typescript');
const lineAt = makeLineAt(code, 1);
EXPORT_DEFAULT_HEAD.lastIndex = 0;
for (let m = EXPORT_DEFAULT_HEAD.exec(code); m !== null; m = EXPORT_DEFAULT_HEAD.exec(code)) {
const value = readDefaultExport(code, m.index + m[0].length);
if (value === 'anonymous') {
stated ??= 'anonymous';
continue;
}
stated ??= 'unstated';
if (value === null) continue;
const named = nodesInFileNamed(filePath, value.name, context);
// A declaration starts on the statement's line, or on its name's when
// the statement breaks before it (`export default\nfunction X`).
const from = lineAt(m.index);
const to = lineAt(value.nameAt);
const node = value.declared
? named.find((n) => DEFAULT_DECLARATION_KINDS.has(n.kind) && n.startLine >= from && n.startLine <= to)
: named
.filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
if (node) {
stated = node;
break;
}
}
}
idx.statedDefault = stated ?? 'unstated';
return idx.statedDefault;
}
const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
/**
* `module.exports = …` (also `exports = module.exports = …`): the name it
* binds (`createApplication`, `function name(`, `class Name`) or the module
* it forwards (`require('./lib/express')`), or null.
*/
const COMMONJS_DEFAULT_EXPORT =
/^[ \t]*(?:exports\s*=\s*)?module\.exports\s*=\s*(?:exports\s*=\s*)?(?:require\(\s*['"]([^'"]+)['"]\s*\)\s*;?[ \t]*$|(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|class\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/m;
function commonJsDefaultExport(filePath: string, context: ResolutionContext): { source?: string; name?: string } | null {
if (!JS_FAMILY_FILE.test(filePath)) return null;
if (context.fileContains && !context.fileContains(filePath, 'module.exports')) return null;
const source = context.readFile(filePath);
if (!source || !source.includes('module.exports')) return null;
const m = COMMONJS_DEFAULT_EXPORT.exec(source);
if (!m) return null;
return m[1] ? { source: m[1] } : { name: m[2] ?? m[3] ?? m[4] };
}
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 };
const exported = context.getExportedNodesInFile?.(filePath) ?? context.getNodesInFile(filePath).filter((n) => n.isExported);
for (const n of exported) {
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;
}
/** The file's nodes named `name`, in `getNodesInFile` order. */
function nodesInFileNamed(filePath: string, name: string, context: ResolutionContext): Node[] {
return context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name);
}
/**
* The declaration a module default-exports: what its own `export default`
* statement declares or names (see {@link statedDefaultExport}), and nothing
* for an anonymous default. Only a default the statement leaves unnamed
* (`export default memo(Card)`, `new Service()`), or a module with no
* statement, falls back: to the component a single-file component file is,
* else to the first exported function or class.
*/
function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined {
const stated = statedDefaultExport(filePath, idx, context);
if (stated === 'anonymous') return undefined;
if (stated !== 'unstated') return stated;
return idx.defaultComponent ?? idx.defaultFnClass;
}
/**
* Whether the module has an ESM default export at all: it is a single-file
* component, or a JS-family file with an `export default` statement. A
* CommonJS module has none, though its `exports.x = function` declarations are
* exported and would feed the first-exported-function guess.
*/
function hasEsmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): boolean {
if (idx.defaultComponent) return true;
if (!JS_FAMILY_FILE.test(filePath)) return false;
const source = context.readFile(filePath);
return !!source && source.includes('default') && ESM_DEFAULT_EXPORT_RE.test(source);
}
/** What this file exports as `name`: an exported declaration, else a local export clause's binding. */
function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined {
const direct = idx.byName.get(name);
if (direct) return direct;
if (idx.clauseAliases === undefined) {
// Bind names introduced by a local export clause to their declarations, so
// an importer asking for the renamed name gets the real symbol instead of
// falling through to the name-matcher (which cannot cross the rename).
// The declaration is the file's first node of that name, exported or not.
idx.clauseAliases = new Map();
const content = context.readFile(filePath);
if (content && content.includes('export')) {
for (const { exportedName, localName } of extractLocalExportAliases(content)) {
if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName)) continue;
const decl = nodesInFileNamed(filePath, localName, context)[0];
if (decl) idx.clauseAliases.set(exportedName, decl);
}
}
}
return idx.clauseAliases.get(name);
}
/** 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);
pythonModuleFileMemos.delete(context);
PY_MODULE_SYMBOLS.delete(context);
PROJECT_FOLDER_SUFFIXES.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, fromFile);
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',
]);
/**
* Languages whose imports are ES-module specifiers, extracted by
* `extractJSImports` and therefore classified by the same bare-specifier /
* alias / workspace rules. Svelte, Vue and Astro belong here: an SFC imports
* inside its `<script>` block (Astro: the `---` frontmatter) with exactly the
* same syntax, and leaving them out made `isExternalImport` answer "not
* external" for every npm specifier in an SFC.
*/
const ESM_IMPORT_LANGUAGES = new Set<Language>([
'typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'svelte', 'vue', 'astro',
]);
/** Rust path roots that always name a standard-library crate. */
const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']);
/**
* 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.
*
* `aliasPrefixes: false` skips that check, for a caller that has already
* asked `resolveImportPath` whether an alias maps the specifier to a file.
* Matching a prefix proves nothing by itself: a catch-all `"*"` pattern
* (`"*": ["./typings/*"]`) has an empty prefix and matches every package.
*/
export function isExternalImport(
importPath: string,
language: Language,
context?: ResolutionContext,
options: { aliasPrefixes?: boolean } = {}
): 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 (ESM_IMPORT_LANGUAGES.has(language)) {
// 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 = options.aliasPrefixes === false ? null : 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) — and,
// for a module whose go.mod is below the root or beside other modules,
// every import of it (#2322).
if (context?.getGoPackageDir?.(importPath) != null) {
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. `require('..')` up to the project root is its `index.js`.
for (const ext of extensions) {
if (relativePath === '' && !ext.startsWith('/')) continue;
const candidatePath = relativePath === '' ? ext.slice(1) : relativePath + ext;
if (context.fileExists(candidatePath)) {
return candidatePath;
}
}
// Try without extension (might already have one)
if (context.fileExists(relativePath)) {
return relativePath;
}
return findSourceForEmittedSpecifier(relativePath, language, context);
}
/**
* TypeScript under `moduleResolution: node16 | nodenext | bundler` writes the
* EMITTED extension in the specifier (`import x from './util.js'` for
* `util.ts`, `.mjs` for `.mts`, `.cjs` for `.cts`), and the source file with that
* exact name never exists in the repo. Without this remap the import resolver
* returned null for every such import, so each imported name fell through to
* bare-name matching: a method wrapping the same-named helper it imports
* (`renderDockStyles() { return renderDockStyles() }`) resolved to ITSELF, and
* any repo-wide same-named symbol could win the cross-module edge.
*/
const EMITTED_TO_SOURCE_EXTENSIONS: ReadonlyArray<readonly [RegExp, readonly string[]]> = [
[/\.js$/, ['.ts', '.tsx', '.d.ts']],
[/\.jsx$/, ['.tsx']],
[/\.mjs$/, ['.mts', '.d.mts']],
[/\.cjs$/, ['.cts', '.d.cts']],
];
function findSourceForEmittedSpecifier(
relativePath: string,
language: Language,
context: ResolutionContext
): string | null {
if (!EMITTED_SPECIFIER_LANGUAGES.has(language)) return null;
for (const [emitted, sources] of EMITTED_TO_SOURCE_EXTENSIONS) {
if (!emitted.test(relativePath)) continue;
const stem = relativePath.replace(emitted, '');
for (const ext of sources) {
const candidate = stem + ext;
if (context.fileExists(candidate)) return candidate;
}
return null;
}
return null;
}
/** Languages whose import specifiers can name the emitted `.js` of a `.ts` source. */
const EMITTED_SPECIFIER_LANGUAGES: ReadonlySet<string> = new Set([
'typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro', 'arkts',
]);
/**
* 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,
fromFile?: string
): 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 findSourceForEmittedSpecifier(basePath, language, context);
};
// 1. tsconfig/jsconfig paths: the config nearest the importing file (an
// app of a monorepo keeps its own `@/*`), then the project root's.
const nearest = fromFile ? context.getNearestAliases?.(fromFile) : null;
const rootMap = context.getProjectAliases?.();
for (const aliasMap of nearest && nearest !== rootMap ? [nearest, rootMap] : [rootMap]) {
if (!aliasMap) continue;
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';
}
/**
* Is this a Dart `import`, `export` or `part`? Its name is the URI, which
* names one file (see ./dart-libraries) or none — `dart:async`, a package from
* outside the project, a generated part nobody committed — and never a
* symbol: the name-matcher took the URI's last segment for a file name
* (`package:flutter/foundation.dart` went to riverpod's own foundation.dart)
* or bound it to the file's own `import` node.
*/
export function isDartImportRef(ref: UnresolvedRef): boolean {
return ref.language === 'dart' && ref.referenceKind === 'imports';
}
/**
* Is this a Lua / Luau `require`? It names a module file (see
* resolveLuaRequire) or a module from outside the project, never a symbol:
* the name-matcher bound `local lfs = require "lfs"` and `local Signal =
* require(script.Parent.Signal)` to the local each is assigned to.
*/
export function isLuaRequireRef(ref: UnresolvedRef): boolean {
return (ref.language === 'lua' || ref.language === 'luau') && 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(stripCommentsForRegex(content, 'python')));
} 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. `import type { X }` / `import type * as ns` is TypeScript's
// type-only form, not a default import named `type` — which every such
// line used to add, making `type.innerType()` a call on an import.
// (`import type from './x'` still binds `type`: backtracking gives it back.)
// A binding is any JS identifier, `$` included: `\w` stops at the `$` of
// `import $store from './store'`, failing the whole statement. A default
// binding is never followed by `{`, so the `${` of a code generator's
// `` `import ${name} from '${src}'` `` is no binding.
const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from(?![\w$]))[A-Za-z_$]))?(?:([A-Za-z_$][\w$]*)(?![\w${])\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+([A-Za-z_$][\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) {
// `{ util, type objectUtil }`: an inline `type` modifier is not part of the name.
const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=[A-Za-z_$])/, ''));
for (const name of names) {
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
if (aliasMatch) {
mappings.push({
localName: aliasMatch[2]!,
exportedName: aliasMatch[1]!,
source: source!,
// `{ default as X }` is the default import spelled as a named one;
// no module declares an export named `default` to find.
isDefault: aliasMatch[1] === 'default',
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 — each declarator of a list too (`var express = require('../'),
// request = require('supertest')`), and a member of the module (`require('./utils').methods`).
const requireRegex = /(?:\b(?:const|let|var)\s+|,\s*)(?:([A-Za-z_$][\w$]*)|{([^}]+)})\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([A-Za-z_$][\w$]*))?/g;
while ((match = requireRegex.exec(content)) !== null) {
const [, defaultName, destructured, source, member] = match;
if (defaultName) {
mappings.push({
localName: defaultName,
exportedName: member ?? 'default',
source: source!,
isDefault: member === undefined,
isNamespace: false,
});
}
if (destructured) {
const names = destructured.split(',').map((s) => s.trim());
for (const name of names) {
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\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, and the parenthesized form `from X import (\n Y,\n Z,\n)`
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!.trim().replace(/^\(|\)$/g, '')
.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. An import is bound to the name written before
* its path, or else to the name of the package it imports — which the path
* alone doesn't settle: `k8s.io/api/core/v1` is package `v1`, while
* `go.yaml.in/yaml/v3` and `gopkg.in/yaml.v3` are `yaml`, and
* `github.com/mattn/go-sqlite3` is `sqlite3`. So an unaliased import keeps its
* last path element and also takes the name goimports assumes for the path,
* unless another of the file's imports is bound to that name.
*/
function extractGoImports(content: string): ImportMapping[] {
const specs: Array<{ name: string | undefined; source: string }> = [];
// Only the imports, which Go puts before any other declaration, and without
// their comments: a word in one is no name — `"fmt" // for printing` used to
// name the next import `printing`. A name is on its path's own line.
const code = stripCommentsForRegex(content.slice(0, goImportsEnd(content)), 'go');
// import "path" or import name "path"
const singleImportRegex = /\bimport\s+(?:([A-Za-z_]\w*)[ \t]*)?["']([^"']+)["']/g;
let match;
while ((match = singleImportRegex.exec(code)) !== null) {
specs.push({ name: match[1], source: match[2]! });
}
// import ( ... ) block
const blockImportRegex = /\bimport\s*\(\s*([^)]+)\s*\)/g;
while ((match = blockImportRegex.exec(code)) !== null) {
const lineRegex = /(?:([A-Za-z_]\w*|\.)[ \t]*)?["']([^"']+)["']/g;
let lineMatch;
while ((lineMatch = lineRegex.exec(match[1]!)) !== null) {
specs.push({ name: lineMatch[1], source: lineMatch[2]! });
}
}
const mapping = (localName: string, source: string): ImportMapping =>
({ localName, exportedName: '*', source, isDefault: false, isNamespace: true });
// A dot import (`. "pkg"`) binds no name of its own; it stays listed under
// its last path element.
const mappings = specs.map(({ name, source }) =>
mapping(name === undefined || name === '.' ? source.split('/').pop()! : name, source));
const bound = new Set(mappings.map((m) => m.localName));
const assumed = new Map<string, string[]>();
for (const { name, source } of specs) {
if (name !== undefined) continue;
const assumedName = goAssumedPackageName(source);
if (assumedName && !bound.has(assumedName)) assumed.set(assumedName, [...(assumed.get(assumedName) ?? []), source]);
}
// Two imports that assume one name can't both be that package.
for (const [localName, sources] of assumed) {
if (sources.length === 1) mappings.push(mapping(localName, sources[0]!));
}
return mappings;
}
/**
* Where a Go file's imports end: at its first other top-level declaration, a
* line opening with `func`, `type`, `var` or `const` outside a block comment.
*/
function goImportsEnd(content: string): number {
const decl = /^(?:func|type|var|const)\b/gm;
for (let m; (m = decl.exec(content)) !== null;) {
if (content.lastIndexOf('/*', m.index) <= content.lastIndexOf('*/', m.index)) return m.index;
}
return content.length;
}
/**
* The package name goimports assumes for an import path
* (ImportPathToAssumedName): its last element that isn't a major version
* (`go.yaml.in/yaml/v3` → `yaml`), without a `go-` prefix
* (`github.com/mattn/go-sqlite3` → `sqlite3`), cut where an identifier can't
* go on (`gopkg.in/yaml.v3` → `yaml`).
*/
function goAssumedPackageName(importPath: string): string {
const elements = importPath.split('/');
let base = elements[elements.length - 1]!;
if (elements.length > 1 && /^v\d+$/.test(base)) base = elements[elements.length - 2]!;
if (base.startsWith('go-')) base = base.slice(3);
return /^[\p{L}\p{Nd}_]*/u.exec(base)![0];
}
/**
* 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>[.*];` — and Kotlin's `import <fqn> [as Alias]`, with no `;`.
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)(?:\s+as\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 = match[3] ?? parts[parts.length - 1];
if (!localName) continue;
mappings.push({
localName,
exportedName: parts[parts.length - 1]!,
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 '...'`; `export * as ns from '...'` exports `ns` alone.
const wildcardRe = /export\s*\*(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
let m: RegExpExecArray | null;
while ((m = wildcardRe.exec(cleaned)) !== null) {
out.push(m[1] ? { kind: 'namespace', exportedName: m[1], source: m[2]! } : { kind: 'wildcard', source: m[2]! });
}
// 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(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
if (aliasMatch) {
out.push({
kind: 'named',
exportedName: aliasMatch[2]!,
originalName: aliasMatch[1]!,
source,
});
} else if (/^[A-Za-z_$][\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',
};
}
/** `Alias` / `Alias.member` through a renaming Kotlin import, by the imported FQN. */
function resolveJvmAlias(ref: UnresolvedRef, imports: ImportMapping[], context: ResolutionContext): ResolvedRef | null {
const dot = ref.referenceName.indexOf('.');
const root = dot < 0 ? ref.referenceName : ref.referenceName.slice(0, dot);
const imp = imports.find((m) => m.localName === root && m.localName !== m.exportedName);
if (!imp) return null;
const parts = imp.source.split('.');
// The package is some prefix of the FQN; the rest is the type path (`Outer::Inner`).
for (let i = parts.length - 1; i > 0; i--) {
const target = context.getNodesByQualifiedName(`${parts.slice(0, i).join('.')}::${parts.slice(i).join('::')}`)[0];
if (!target) continue;
const member = dot < 0 ? null : ref.referenceName.slice(dot + 1);
if (member === null) return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
if (member.includes('.')) return null;
const found = context.getNodesByQualifiedName(`${target.qualifiedName}::${member}`)[0];
return found ? { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'import' } : null;
}
return null;
}
/**
* 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;
}
/**
* PHP scoped calls are encoded as "Alias.method" by both extractors. A use
* mapping names a namespace, not a filesystem path, so resolve the receiver
* through its localName and look up the method on that exact imported type.
* undefined means this is not an imported static call; null means the import
* owns the call but its method is unavailable, so name fallbacks must not guess.
*/
export function resolvePhpImportedStaticCall(
ref: UnresolvedRef,
context: ResolutionContext,
): ResolvedRef | null | undefined {
if (ref.language !== 'php' || ref.referenceKind !== 'calls') return undefined;
const call = /^(\w+)\.(\w+)$/.exec(ref.referenceName);
if (!call) return undefined;
const [, receiver, member] = call;
const imp = context.getImportMappings(ref.filePath, ref.language)
.find((i) => i.localName === receiver);
if (!imp) return undefined;
// PHP variables occupy a different namespace from class imports. Extraction
// strips the leading "$" from "$Alias->method()" too; leave that receiver to
// local type inference even when a class import has the same local name.
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n');
const line = lines?.[ref.line - 1];
if (line?.slice(ref.column).startsWith('$')) return undefined;
const fqn = imp.source.replace(/^\\/, '');
const separator = fqn.lastIndexOf('\\');
const typeName = separator < 0
? fqn
: `${fqn.slice(0, separator)}::${fqn.slice(separator + 1)}`;
const owners = context.getNodesByQualifiedName(typeName)
.filter((n) => n.language === 'php' && STATIC_MEMBER_CONTAINERS.has(n.kind));
if (owners.length !== 1) return null;
const owner = owners[0]!;
const methods = context.getNodesByQualifiedName(`${owner.qualifiedName}::${member}`)
.filter((n) => n.language === 'php' && n.kind === 'method' && n.filePath === owner.filePath);
if (methods.length !== 1) return null;
return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' };
}
/**
* A module specifier written as a path — relative (`./x`, `../x`), rooted, or
* an alias with a folder in it (`@/lib/x`, `~/x`, `$lib/x`) — or a scoped
* package (`@excalidraw/common`), which no binding can be named and which a
* `tsconfig` path or a workspace member can map to a project file; rather
* than an imported binding's name or a bare package (`react`, `lodash`).
*/
function isJsPathSpecifier(name: string): boolean {
return name.startsWith('./') || name.startsWith('../') || name === '.' || name === '..' ||
(name.includes('/') && !/\s/.test(name));
}
/**
* A JS/TS `imports` reference that names a module by path — from a script, or
* from a Vue, Svelte or Astro component's. It names a FILE, not a symbol, so
* it skips the resolver's name-exists pre-filter — a CommonJS `require('./x')`
* has no import node of that name to pass it.
*/
export function isJsPathImportRef(ref: UnresolvedRef): boolean {
return ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName);
}
/** PHP reference kinds whose name is a class name, written as in the source. */
const PHP_CLASS_NAME_REFS: ReadonlySet<string> = new Set(['instantiates', 'extends', 'implements', 'references']);
/**
* A PHP class name written with a namespace in it (#2256). `use App\Fields as
* Field;` aliases a namespace, so `new Field\FirstName()`, `extends Field\Base`
* and `Field\FirstName::make()` all name `App\Fields\FirstName`. PHP reads a
* qualified name one way: a leading `\` makes it absolute; otherwise a first
* segment a `use` imports is replaced by what it imports, and any other name is
* relative to the current namespace. Both extractors emit the written name
* verbatim, and with no `.` or `::` in it the pre-filter would drop it.
* undefined means the ref is not a qualified class name (or names a method the
* class inherits); null means it is but no single project class has that name
* — it lives outside the project — so name fallbacks must not guess.
*/
export function resolvePhpQualifiedClassRef(
ref: UnresolvedRef,
context: ResolutionContext,
): ResolvedRef | null | undefined {
if (ref.language !== 'php') return undefined;
let name = ref.referenceName;
let member: string | null = null;
if (ref.referenceKind === 'calls') {
// A static call on the class: `Field\FirstName::make()` is written `Field\FirstName.make`.
const call = /^(.*\\[^\\.:]+)(?:\.|::)(\w+)$/.exec(name);
if (!call) return undefined;
name = call[1]!;
member = call[2]!;
} else if (!PHP_CLASS_NAME_REFS.has(ref.referenceKind)) {
return undefined;
}
const separator = name.indexOf('\\');
if (separator < 0) return undefined;
let fqn: string;
if (separator === 0) {
fqn = name.slice(1);
} else {
const head = name.slice(0, separator);
const imp = context.getImportMappings(ref.filePath, ref.language).find((i) => i.localName === head);
if (imp) {
fqn = imp.source.replace(/^\\/, '') + name.slice(separator);
} else {
// `namespace App;` applies until the next namespace statement.
const namespace = context.getNodesInFile(ref.filePath)
.filter((n) => n.kind === 'namespace' && n.startLine <= ref.line)
.sort((a, b) => b.startLine - a.startLine)[0];
fqn = namespace ? `${namespace.qualifiedName}\\${name}` : name;
}
}
const cut = fqn.lastIndexOf('\\');
const qualifiedName = cut < 0 ? fqn : `${fqn.slice(0, cut)}::${fqn.slice(cut + 1)}`;
const classes = context.getNodesByQualifiedName(qualifiedName)
.filter((n) => n.language === 'php' && STATIC_MEMBER_CONTAINERS.has(n.kind));
// A type mention can name something other than a class (a namespaced
// constant or function), so it keeps the ordinary strategies.
if (classes.length !== 1) return ref.referenceKind === 'references' ? undefined : null;
const owner = classes[0]!;
if (!member) return { original: ref, targetNodeId: owner.id, confidence: 0.95, resolvedBy: 'import' };
const methods = context.getNodesByQualifiedName(`${owner.qualifiedName}::${member}`)
.filter((n) => n.language === 'php' && n.kind === 'method' && n.filePath === owner.filePath);
// A method the class inherits is left to the strategies that walk supertypes.
if (methods.length !== 1) return undefined;
return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' };
}
export function resolveViaImport(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// A JS/TS module specifier — `import './polyfills'`, the module of `import x
// from '../lib/a'`, a CommonJS `require('./application')` — names a FILE,
// found the way the runtime finds it: extensions, `index` files, path
// aliases. Matching the basename instead missed every extensionless one
// (the common spelling) and could land on a same-named file elsewhere.
if (isJsPathImportRef(ref)) {
const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
if (fileNode) return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
}
// A Dart `import` / `export` URI names a library file, and a `part` URI one
// of the library's own files: `package:app/x.dart` is app's lib/x.dart, any
// other path is from the file the directive is in.
if (isDartImportRef(ref)) {
const file = dartDirectiveFile(ref.filePath, ref.referenceName, context);
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
return fileNode ? { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' } : 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;
}
// Kotlin's `import app.model.Outer.Inner as Made`: `Made.create()` is the
// aliased class's member, found by its FQN — nothing else binds the alias.
if (ref.language === 'kotlin' && ref.referenceKind !== 'imports') {
const aliased = resolveJvmAlias(ref, imports, context);
if (aliased) return aliased;
}
// 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
let resolvedPath = resolveImportPath(
imp.source,
ref.filePath,
ref.language,
context
);
// Named Python imports need the same absolute-module lookup as namespace
// imports, including aliases used as receiver types (#1820).
if (!resolvedPath && ref.language === 'python') {
resolvedPath = findPythonModuleFile(imp.source, context, ref.filePath)?.filePath ?? null;
}
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()
) ?? (ref.language === 'python'
? pythonModuleSymbol(resolvedPath, memberName ?? exportedName, context, 0)
: undefined);
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 object literal used as a namespace (#1573):
// `api.call()` after `import { api } from './api'` where `api` is
// `export const api = { call() {…} }`. Its members have bare
// qualified names inside the constant's extent, so the
// `Container::member` lookup above can't see them and the edge
// landed on the constant — every cross-file caller of the method
// went missing. Resolve the member by containment instead.
if (targetNode.kind === 'constant' || targetNode.kind === 'variable') {
const member = ref.referenceName.slice(imp.localName.length + 1).split('.')[0];
if (member) {
const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
if (literalMember) return literalMember;
const aliasMember = resolveObjectLiteralAlias(targetNode, member, ref, context);
if (aliasMember) return aliasMember;
}
}
// 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;
// Finding a named Python import proves the receiver exists, not
// its requested attribute. In particular, task.delay() enqueues
// work; it does not call the imported task function directly.
// Keep unknown members unresolved (including callback values)
// instead of falling back to the receiver as their target.
if (ref.language === 'python') return null;
}
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.
//
// Join with the EXPORTED name, not the local one: under
// `from pkg import mod as alias` the receiver is `alias` but the module on
// disk is `pkg.mod`, and building `pkg.alias` looked for a file that does
// not exist — so the aliased form dropped its `calls` edge while the plain
// form (where the two names coincide) worked (#1626). For an unaliased
// import the two are identical, so this changes nothing there.
const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName;
const modulePath = imp.isNamespace
? imp.source
: imp.source.endsWith('.')
? imp.source + moduleName
: imp.source + '.' + moduleName;
// 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, or one it
// re-exports (a package's `__init__.py`). Exclude `method` so `mod.foo`
// never lands on a same-named class method.
const target = pythonModuleSymbol(resolvedPath, member, context, 0);
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;
}
/**
* `UploadApi.uploadARCapture()` where `UploadApi` is a NAMESPACE OBJECT — the
* default-export façade most React Native API layers are written as:
*
* import { uploadARCapture } from './frames'
* const UploadApi = { uploadARCapture, createFolder }
* export default UploadApi
*
* The member is a shorthand (or `key: ident`) property whose value is a
* binding of the object's file, not a function defined inside the literal,
* so containment (`resolveObjectLiteralMember`) finds nothing and the call
* landed on the constant — every cross-file caller of the API function went
* missing. Read the literal's source, take the binding the member names, and
* resolve it where the object's file would: a symbol declared there, else
* through its own imports. Calls accept callable targets only.
*/
function resolveObjectLiteralAlias(
container: Node,
member: string,
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
if (container.kind !== 'constant' && container.kind !== 'variable') return null;
if (!JS_FAMILY_FILE.test(container.filePath)) return null;
const resolved = resolveObjectLiteralBinding(container, member, ref, context);
return resolved ? { ...resolved, confidence: 0.9, resolvedBy: 'import' } : 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.
// As in resolvePythonModuleMember, use the exported name so an alias
// still links to the real module file (#1626).
const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName;
modulePath = imp.source.endsWith('.')
? imp.source + moduleName
: imp.source + '.' + moduleName;
} 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.
*/
/**
* Per-context memo for findPythonModuleFile: module path → the `<mod>.py` and
* `<mod>/__init__.py` file nodes whose path ends with it, in name-lookup
* order. Only the importing file's own path is excluded per call, so taking
* the first survivor returns the node the unmemoized scan found. Without it,
* every ref naming a module outside the project (`from unittest import mock`)
* rescanned every `__init__.py` in the tree. Same stable window as the name
* caches; dropped by clearImportResolverMemos.
*/
const pythonModuleFileMemos = new WeakMap<ResolutionContext, Map<string, { module: Node[]; pkg: Node[] }>>();
/**
* A top-level class / function / value named `name` in a Python module, or
* one the module re-exports — `from .users import *`, `from .users import
* User` — a few packages deep. netbox's `from users.models import User` names
* `users/models/__init__.py`, which star-imports `.users`, where `User` is.
*/
const PY_MODULE_SYMBOLS = new WeakMap<ResolutionContext, Map<string, Node | null>>();
function pythonModuleSymbol(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined {
let memo = PY_MODULE_SYMBOLS.get(context);
if (!memo) PY_MODULE_SYMBOLS.set(context, (memo = new Map()));
const key = `${file}\0${name}`;
const hit = memo.get(key);
if (hit !== undefined) return hit ?? undefined;
// (A cycle of star imports reads as "not here" while it is being walked.)
memo.set(key, null);
const found = pythonModuleSymbolUncached(file, name, context, depth);
memo.set(key, found ?? null);
return found;
}
function pythonModuleSymbolUncached(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined {
const own = context.getNodesInFile(file).find((n) =>
n.name === name && !n.qualifiedName.includes('::') &&
(n.kind === 'class' || n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant'));
if (own || depth >= 3) return own;
// Re-exported by name (`from .users import User`), else through a star import
// (`from .users import *` — not among the import mappings, so read here).
const sources: Array<{ source: string; exported: string }> = context.getImportMappings(file, 'python')
.filter((imp) => !imp.isNamespace && imp.localName === name)
.map((imp) => ({ source: imp.source, exported: imp.exportedName }));
for (const m of (context.readFile(file) ?? '').matchAll(/^\s*from\s+([\w.]+)\s+import\s+\*/gm)) {
sources.push({ source: m[1]!, exported: name });
}
for (const { source, exported } of sources) {
const target = resolveImportPath(source, file, 'python', context) ?? findPythonModuleFile(source, context, file)?.filePath ?? null;
if (!target || target === file) continue;
const found = pythonModuleSymbol(target, exported, context, depth + 1);
if (found) return found;
}
return undefined;
}
function findPythonModuleFile(
mod: string,
context: ResolutionContext,
excludeFilePath: string
): Node | null {
if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere
let memo = pythonModuleFileMemos.get(context);
if (!memo) {
memo = new Map();
pythonModuleFileMemos.set(context, memo);
}
let files = memo.get(mod);
if (!files) {
const rel = mod.replace(/\./g, '/');
const lastSeg = mod.split('.').pop()!;
const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want);
files = {
module: context
.getNodesByName(`${lastSeg}.py`)
.filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}.py`)),
pkg: context
.getNodesByName('__init__.py')
.filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}/__init__.py`)),
};
memo.set(mod, files);
}
return (
files.module.find((n) => n.filePath !== excludeFilePath) ??
files.pkg.find((n) => n.filePath !== excludeFilePath) ??
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 === 'union' ||
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-project import, mapping the import to the
* package's project directory, and locating the exported symbol in a `.go`
* file directly in that directory. Returns `null` for stdlib / third-party
* imports (no project module declares them) so the rest of
* `resolveViaImport` can still try the file-based path.
*/
function resolveGoCrossPackageReference(
ref: UnresolvedRef,
imports: ImportMapping[],
context: ResolutionContext
): ResolvedRef | null {
// Qualified call: receiver before `.`, member after. A bare reference
// (no dot) is a same-package name — unless the source spells a package in
// front of it: `store.Manager` in a field or a signature, which the index
// keeps as `Manager` (#2322).
const dotIdx = ref.referenceName.indexOf('.');
if (dotIdx <= 0) {
const imp = goRefQualifier(ref, context);
return imp ? findGoPackageMember(ref.referenceName, imp, ref, context) : null;
}
const receiver = ref.referenceName.substring(0, dotIdx);
const memberName = ref.referenceName.substring(dotIdx + 1);
if (!memberName) return null;
// A parameter or local named like the import holds the call there:
// `store := newStore()`, then `store.Get(k)` is no call into package store.
if (!goRefQualifier(ref, context)) return null;
for (const imp of imports) {
if (imp.localName !== receiver) continue;
const found = findGoPackageMember(memberName, imp, ref, context);
if (found) return found;
}
return null;
}
/**
* The exported `memberName` of the project package `imp` names, looked up in
* the package's directory: its module's directory followed by the rest of
* the import path (#2322). Matched on the immediate directory so `pkga.FuncX`
* never lands on a `FuncX` declared in `pkga/subpkg/`. Null when the import
* is no project package.
*/
function findGoPackageMember(
memberName: string,
imp: ImportMapping,
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
const pkgDir = context.getGoPackageDir?.(imp.source, ref.filePath);
if (pkgDir == null) return null;
for (const node of context.getNodesByName(memberName)) {
if (node.language !== 'go') continue;
if (!node.isExported) continue;
if (path.posix.dirname(node.filePath.replace(/\\/g, '/')) === 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. */
export 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) {
// What the file's own `export default` statement exports beats any
// guess; an anonymous default is no node at all, and no neighbour is it.
if (statedDefaultExport(filePath, exportIndex, context) === 'anonymous') return undefined;
// Svelte/Vue single-file components ARE the module's default export,
// but are extracted as kind 'component' (not function/class), so the
// fallback prefers the component node. Without it, an `export { default
// as X } from './X.svelte'` barrel never resolves and the component shows
// a false 0 callers (#629).
const direct = esmDefaultExport(filePath, exportIndex, context);
if (direct) return direct;
// CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`.
const commonJs = commonJsDefaultExport(filePath, context);
if (commonJs?.source) {
const next = resolveImportPath(commonJs.source, filePath, language, context);
if (next) return findExportedSymbol(next, want, language, context, visited, depth + 1);
} else if (commonJs?.name) {
const bound = nodesInFileNamed(filePath, commonJs.name, context)
.filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
if (bound) return bound;
}
} else if (want.isNamespace && want.memberName) {
const direct = exportedByName(filePath, exportIndex, want.memberName, context);
if (direct) return direct;
} else {
const direct = exportedByName(filePath, exportIndex, want.exportedName, context);
if (direct) return direct;
// `require('./x').default` and `const { default: X } = require('./x')` read
// the `default` property of module.exports. A CommonJS module sets it by
// name (`exports.default = fn`, the dual `module.exports.default = X`),
// found just above. A module written as an ES module sets it to its
// default export, `export default class Foo`, which is no named export —
// the answer when the module has no such property. Only in the module
// asked for: `export * from` never forwards a default.
if (
depth === 0 &&
want.exportedName === 'default' &&
ESM_IMPORT_LANGUAGES.has(language) &&
hasEsmDefaultExport(filePath, exportIndex, context)
) {
const esmDefault = esmDefaultExport(filePath, exportIndex, context);
if (esmDefault) return esmDefault;
}
}
// 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) — for
// `ns.member` through `import * as ns`, the member is the name wanted.
const targetName = want.isDefault ? 'default' : want.isNamespace && want.memberName ? want.memberName : 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;
}
}
// `z.core.util.fn` through `export * as core from './core'`: the member continues in that module.
if (want.isNamespace && want.memberName) {
const dot = want.memberName.indexOf('.');
const head = dot < 0 ? want.memberName : want.memberName.slice(0, dot);
const rex = dot < 0 ? undefined : reExports.find((r) => r.kind === 'namespace' && r.exportedName === head);
if (rex) {
const next = resolveImportPath(rex.source, filePath, language, context);
const rest = want.memberName.slice(dot + 1);
return next
? findExportedSymbol(next, { ...want, exportedName: rest.split('.')[0]!, memberName: rest }, language, context, visited, depth + 1)
: undefined;
}
}
// 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', 'union', '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];
}
/**
* Rust `use` declarations, flattened to `localName → full path`.
*
* Rust is the one supported language with NO `ImportMapping` extraction (see
* `extractImportMappings`), so this is the only channel that can tell whether
* a bare type name in a Rust file was brought in by a `use`. Handles nested
* groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single
* name), and `as` aliases.
*/
function collectRustUseBindings(content: string): Map<string, string> {
const out = new Map<string, string>();
// Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens.
const expand = (spec: string): string[] => {
const open = spec.indexOf('{');
if (open === -1) return [spec.trim()];
const prefix = spec.slice(0, open);
let depth = 0;
let close = -1;
for (let i = open; i < spec.length; i++) {
if (spec[i] === '{') depth++;
else if (spec[i] === '}') {
depth--;
if (depth === 0) { close = i; break; }
}
}
if (close === -1) return [];
const suffix = spec.slice(close + 1);
const inner = spec.slice(open + 1, close);
const parts: string[] = [];
let depth2 = 0;
let start = 0;
for (let i = 0; i <= inner.length; i++) {
const ch = inner[i];
if (ch === '{') depth2++;
else if (ch === '}') depth2--;
if (i === inner.length || (ch === ',' && depth2 === 0)) {
const seg = inner.slice(start, i).trim();
if (seg) parts.push(seg);
start = i + 1;
}
}
return parts.flatMap((p) => expand(prefix + p + suffix));
};
// `use` items end at the first `;`. Attributes/visibility (`pub use`) are
// irrelevant to the binding itself.
const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g;
let m: RegExpExecArray | null;
while ((m = useRe.exec(content)) !== null) {
for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) {
const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec);
const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim();
if (!rawPath || rawPath.endsWith('*')) continue;
const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean);
const leaf = segments[segments.length - 1];
if (!leaf) continue;
const local = aliasMatch ? aliasMatch[2]! : leaf;
out.set(local, segments.join('::'));
}
}
return out;
}
/**
* Is `name`, as used in `ref`'s file, bound by an import whose module lives
* OUTSIDE the repository?
*
* When it is, no in-repo node can be the referent: the symbol is defined in a
* third-party crate/package, and any same-named local symbol the name-matcher
* finds is a coincidence. Rust `use std::error::Error;` + `impl Error for
* MapperError {}` bound to a local `MapperError::Error` variant, and once
* non-type kinds were filtered out it simply moved to an unrelated local
* `type Error` alias — restricting kinds alone RELOCATES the false edge
* instead of removing it, so locality has to be checked too.
*
* Answers only when it can be CERTAIN, because a false "yes" deletes a real
* edge. Two languages qualify, each with an oracle that cannot be wrong:
*
* - **Rust** — the `use` path is rooted at a standard-library crate
* (`std`/`core`/`alloc`/`proc_macro`), which by definition ships outside
* any repository. Deliberately NOT generalized to "the module path doesn't
* resolve to a file": a crate can re-export another workspace crate's
* modules (`pub use pupil_core::{ports, domain};`), so `crate::ports::X`
* has no `src/ports/` directory to walk yet is entirely in-repo — that
* generalization measured 13 real trait implementations deleted.
* - **ES modules** — `isExternalImport`, which already accounts for tsconfig
* path aliases and monorepo workspace packages.
*
* Everything else returns false and resolves exactly as before. JVM and Python
* imports notably do NOT go through `resolveImportPath` (they have dedicated
* FQN/module matchers), so there is no trustworthy oracle to consult here.
*/
export function isBoundToOutOfRepoImport(
ref: UnresolvedRef,
context: ResolutionContext
): boolean {
const name = ref.referenceName;
if (name.includes('::') || name.includes('.')) return false; // qualified refs resolve by path
if (ref.language === 'rust') {
const content = context.readFile(ref.filePath);
if (!content) return false;
const usePath = collectRustUseBindings(content).get(name);
if (!usePath) return false;
const segments = usePath.split('::');
if (segments.length < 2 || !RUST_STDLIB_ROOTS.has(segments[0]!)) return false;
// 2015-edition crate-relative paths can shadow a stdlib root with a local
// module of the same name — if the path walks to a real file, it's local.
return resolveRustModuleFile(segments.slice(0, -1), ref.filePath, context) === null;
}
if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false;
for (const imp of context.getImportMappings(ref.filePath, ref.language)) {
if (imp.localName !== name) continue;
return isExternalImport(imp.source, ref.language, context);
}
return false;
}
/**
* The tail a reference that failed to resolve is parked under when its name
* is an import binding the module it imports does not declare by that name:
* a default import (`import tagsController from './tag/tag.controller'`, a
* CommonJS `require`), a namespace import, or an aliased one (`import {
* Component as Wrapper }`, `{ default as X }`). Resolution reaches it through
* the module, so a sync's retry has to find it by the module: under the
* module's {@link moduleTail} ('module:tag'), the key a sync looks up for
* every file it adds or changes. Its own name, the default tail, is one the
* module never declares, so the file that appears or gains the export never
* found it. A sync still looks it up by that name as well: a binding its
* module never resolves is linked by the name alone.
*
* Only a whole-name reference to the binding — the import itself, a call,
* `new`, a JSX tag, a value — and only for a module of the project: a member
* read (`NS.member`) waits for the member's name, and a package's binding
* keeps its tail. Undefined for every other reference.
*/
export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
if (ref.language === 'python') return pythonImportBindingTail(ref, context);
if (!ESM_IMPORT_LANGUAGES.has(ref.language) || !IDENTIFIER.test(ref.referenceName)) return undefined;
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === ref.referenceName);
if (!binding || (!binding.isDefault && !binding.isNamespace && binding.exportedName === binding.localName)) return undefined;
if (isExternalImport(binding.source, ref.language, context) &&
// A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only.
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix))) {
return undefined;
}
// `..` names a folder through the importing file's own location.
const modulePath = binding.source.startsWith('.')
? path.posix.join(path.posix.dirname(ref.filePath), binding.source)
: binding.source;
return moduleTail(modulePath) || undefined;
}
/**
* {@link importBindingTail} for Python: `from .models import User as
* UserModel` and `from . import helpers as h` bind a name their module has as
* `User` and `helpers`, `import app.tools as tl` one it has as `tools`. The
* module is the one the import names by its dotted path, counted from the
* importing file's own package when it starts with dots (`.` is that package,
* each further dot the one above): `.models` in `app/main.py` is `app/models`,
* parked as 'module:models', and `.` is `app`, 'module:app'. The name may
* import a submodule of it (`helpers` is `app/helpers.py`), whose keys hold
* the package's name too (moduleReferenceKeys).
*
* An installed package's alias can't be told from a module the project doesn't
* have yet (`from settings import Config as Cfg`), so it waits for its module
* too, and a sync still finds it by its own name. An import of the standard
* library keeps its tail.
*
* So does an unaliased `import x`, and an unaliased `from fastapi import
* FastAPI` unless its module is in one of the project's own folders
* ({@link isProjectPythonModule}). The name is the one the module has, but a
* package often re-exports it from the module that declares it
* (`fastapi/__init__.py`: `from .applications import FastAPI as FastAPI`), and
* a package `__init__.py` that appears or gains the re-export declares no node
* of that name. An outside package's names stay off the module keys: every
* `from django.db.models import Q` would crowd 'module:models' past the retry
* ceiling, and the project's own modules with it.
*/
function pythonImportBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
if (!IDENTIFIER.test(ref.referenceName)) return undefined;
const binding = context.getImportMappings(ref.filePath, 'python').find((m) => m.localName === ref.referenceName);
if (!binding || isExternalImport(binding.source, 'python', context)) return undefined;
const declared = binding.isNamespace ? binding.source.slice(binding.source.lastIndexOf('.') + 1) : binding.exportedName;
if (declared === binding.localName && (binding.isNamespace || !isProjectPythonModule(binding.source, context))) return undefined;
const modulePath = pythonModulePath(binding.source, ref.filePath);
return modulePath ? moduleTail(modulePath) || undefined : undefined;
}
/**
* Whether a Python import's module is in one of the project's own folders: a
* relative one is, and an absolute one when a folder of the project ends in its
* dotted path (`app/api` for `app.api`, the package) or in the package holding
* it (`app` for `app.api`, a module file), at any depth, as `src/` layouts put
* it. A folder, not a module: `fastapi/__init__.py` is what goes missing, while
* `fastapi/` keeps its other files.
*/
function isProjectPythonModule(source: string, context: ResolutionContext): boolean {
if (source.startsWith('.')) return true;
const folders = projectFolderSuffixes(context);
const modulePath = source.replace(/\./g, '/');
const slash = modulePath.lastIndexOf('/');
return folders.has(modulePath) || (slash > 0 && folders.has(modulePath.slice(0, slash)));
}
/**
* Per-context memo: every trailing run of folder names the project's files sit
* in (`a/b/c.py` gives 'a/b', 'b' and 'a'). The file set is that of the
* resolution pass; dropped by clearImportResolverMemos.
*/
const PROJECT_FOLDER_SUFFIXES = new WeakMap<ResolutionContext, Set<string>>();
function projectFolderSuffixes(context: ResolutionContext): Set<string> {
let suffixes = PROJECT_FOLDER_SUFFIXES.get(context);
if (suffixes) return suffixes;
const folders = new Set<string>();
for (const file of context.getAllFiles()) {
for (let i = file.lastIndexOf('/'); i > 0; i = file.lastIndexOf('/', i - 1)) {
const folder = file.slice(0, i);
if (folders.has(folder)) break;
folders.add(folder);
}
}
suffixes = new Set<string>();
for (const folder of folders) {
for (let i = -1; ; i = folder.indexOf('/', i + 1)) {
suffixes.add(folder.slice(i + 1));
if (folder.indexOf('/', i + 1) === -1) break;
}
}
PROJECT_FOLDER_SUFFIXES.set(context, suffixes);
return suffixes;
}
/**
* A Python import's module as a project path: `app.models` → 'app/models';
* from `app/sub/views.py`, `.models` → 'app/sub/models', `..` → 'app' and
* `..forms` → 'app/forms'. Null when its dots climb above the project.
*/
export function pythonModulePath(source: string, fromFile: string): string | null {
const name = source.replace(/^\.+/, '');
const dots = source.length - name.length;
if (dots === 0) return name.replace(/\./g, '/');
const modulePath = path.posix.join(path.posix.dirname(fromFile), '../'.repeat(dots - 1), name.replace(/\./g, '/'));
return modulePath === '..' || modulePath.startsWith('../') ? null : modulePath;
}
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;