Files
codegraph/src/resolution/import-resolver.ts
T
Colby MchenryandClaude Opus 5.5 fc293f0081 fix(sync): a default import follows the module's default when an edit moves it (#2749)
An edit that changes which declaration a module exports as its default
(`export default A` becoming `export default B` while A stays, or the
default dropped) left every reference through a default import of it on
the old declaration. A sync moves other files' edges into a changed file
onto the node that replaces each target, and an `export default` statement
is no node, so no definition delta, failed-ref retry or importer walk saw
the change. A fresh index links the new default, or nothing.

- ReferenceResolver.changedDefaultExports: for each modified JS/TS module
  that unchanged files import by default or as a namespace (an imports edge
  into its file node named for the binding), compare what the whole-name
  references through those bindings resolved to with the module's default
  now (moduleDefaultExport, the lookup resolveViaImport makes). No file is
  read; a module forwarding its default samples 8 importers' edges.
- reopenReferencesThroughChangedDefaults (module-importers.ts): when one
  moved, the edges bound before this sync resolved anything go back for
  the sweep: through every binding that reads the default (importersOf
  seeded with `default`: default imports, { default as Y },
  require('./x').default, modules passing it on), the routers'
  `layout:<binding>` references, and into the old default's own members
  (typed receivers, inherited members).

Regression test sync-default-export-change.test.ts: 6 cases comparing the
synced graph with a fresh index of the final tree; all fail on main.
Fresh indexes are byte-identical on 8 repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:22:36 +00:00

4461 lines
196 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 crypto from 'crypto';
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, aliasAndBaseUrlCandidates } 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 { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, inBlock, isInModuleBlock, namespaceBlocks, type NamespaceBlock } from './ambient-modules';
import { isTestFile } from '../search/query-utils';
import { moduleReferenceKeys, moduleTail } from '../db/reference-tail';
import {
resolveMethodOnType,
resolveObjectLiteralMember,
resolveObjectLiteralBinding,
localReceiverTypePatterns,
normalizeInferredTypeName,
goRefQualifier,
inferLocalReceiverType,
arrayElementType,
tsImportedTypeFile,
reExportSource,
pythonFunctionModuleAlias,
isRustAssociatedItem,
tsGlobalScope,
} from './name-matcher';
import { JS_ARRAY_METHODS } from './js-builtins';
/**
* Extension resolution order by language
*/
const EXTENSION_RESOLUTION: Record<string, string[]> = {
// `/index.jsx` last, as tsc probes a directory: a route table resolves the
// module its `lazy(() => import('views/x'))` loads as TypeScript, whatever
// the file is written in, and admin templates keep their pages in
// `views/x/index.jsx`.
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx'],
// 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'],
// A JavaScript file imports TypeScript modules too, as the bundler and
// tsc's `allowJs` read it: mastodon's `modal_root.jsx` imports
// `./audio_modal`, which is `audio_modal.tsx`. JavaScript comes first, so a
// `helpers.ts` beside `helpers.js` doesn't take its importers. Never `.d.ts`:
// a declaration file is not the module a JavaScript file runs.
javascript: ['.js', '.jsx', '.mjs', '.cjs', '.xsjs', '.xsjslib', '.ts', '.tsx', '/index.js', '/index.jsx', '/index.ts', '/index.tsx'],
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js', '/index.jsx'],
jsx: ['.jsx', '.js', '.tsx', '.ts', '/index.jsx', '/index.js', '/index.tsx', '/index.ts'],
// 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 };
// Python has no export keyword: every module-level def is importable, and
// the extractor never flags one isExported. Without this a Python named
// import could only ever resolve through the bare-name matcher.
const exported = PYTHON_MODULE_FILE.test(filePath)
? context.getNodesInFile(filePath).filter((n) => n.isExported || isPythonModuleLevelDef(n))
: 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);
PROJECT_FOLDER_SUFFIXES.delete(context);
pythonModuleIndexes.delete(context);
pythonStarExportMemos.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). What still looks like a package
// may name a file through the importing app's own config.
if (isExternalImport(importPath, language, context)) {
return resolveFromOwnConfig(importPath, fromFile, language, context);
}
// Python names modules, not paths: one resolver answers relative and
// absolute specifiers alike, so every caller gets the same answer.
if (language === 'python') {
return resolvePythonModule(importPath, fromFile, context);
}
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;
}
/**
* Python module resolution. Python imports name MODULES (`pkg.sub.mod`,
* `..sibling`), which the interpreter finds by searching `sys.path` roots, so
* the answer depends on where packages start — not on any file that merely
* ends in `pkg/sub/mod.py`. The old suffix match bound `import json` to a
* project's `app/utils/json.py` and picked an arbitrary twin when two services
* both carry `core/models.py`.
*
* Two kinds of root, both derived from evidence:
* - PROJECT roots — what `sys.path` holds for any code in the repo: the repo
* root; each service root (a directory with its own `pyproject.toml`,
* `setup.cfg` or `setup.py`); the `src/` of any of those; and the package
* directories their build config declares. These may answer an importer
* anywhere, but only unambiguously.
* - LOCAL roots — the parent of every top-level regular package, a script's
* own directory, a namespace package's parent seen from inside it. These
* answer only importers within their REACH: a fixture's `tests/fixtures/
* proj/requests/` must never become the application's `requests`.
*
* A service root counts as a project root unless it is nested inside a
* package (its reach is then itself) or under a directory whose NAME marks
* code that isn't the project's API ({@link PYTHON_NON_PROJECT_DIRS} — its
* reach is then that directory, so sibling projects under `examples/` still
* import each other). The nearest root containing the importer wins;
* otherwise exactly one root within reach must provide the module. Only
* indexed files count — a module the index doesn't hold has no node to link to.
*/
interface PythonModuleIndex {
files: Set<string>;
/** Directories holding an `__init__.py` — regular packages. */
packageDirs: Set<string>;
/** Every directory that holds a `.py` file at any depth — namespace candidates. */
dirs: Set<string>;
projectRoots: Set<string>;
/** Local roots, each with the directory whose importers it may answer. */
localRoots: Map<string, string>;
/**
* Per top-level name, the roots (project and local) that provide it, split by kind
* (a regular package or module shadows a namespace portion). Memoised so a
* large monorepo pays for the scan once per name, not once per reference.
*/
headsByName: Map<string, { concrete: string[]; namespace: string[] }>;
}
const pythonModuleIndexes = new WeakMap<ResolutionContext, PythonModuleIndex>();
const PYTHON_MODULE_FILE = /\.py$/;
const PYTHON_MODULE_LEVEL_KINDS = new Set<string>(['function', 'class', 'variable', 'constant']);
/**
* Directory names that, by near-universal convention, hold code that is not
* the project's importable API: test inputs, sample projects, vendored copies.
* Used ONLY to keep a root nested under one from answering importers outside
* that directory — never to drop an edge inside it. A name, not content: a
* shared `conftest.py` or a `smoke_test.py` script proves a directory holds
* tests, not that the services beneath it aren't the project.
*
* The trade-off: a library kept under one of these names but installed into
* the environment (`third_party/foo` with its own `pyproject.toml`, `pip
* install -e`'d, imported as `import foo`) is not resolved from outside that
* directory — an editable install is invisible to a static index, and
* treating every such tree as importable would let fixtures capture the
* application's imports. A missed edge, never a wrong one.
*/
const PYTHON_NON_PROJECT_DIRS = new Set([
'tests', 'test', 'testing', 'fixtures', 'testdata', 'examples', 'example', 'samples', 'vendor', 'third_party',
]);
/** A def at module level — what `from mod import name` can bind. */
function isPythonModuleLevelDef(n: Node): boolean {
return PYTHON_MODULE_LEVEL_KINDS.has(n.kind) && !n.qualifiedName.includes('::');
}
function parentDir(dir: string): string {
const slash = dir.lastIndexOf('/');
return slash < 0 ? '' : dir.slice(0, slash);
}
function joinRel(dir: string, rel: string): string {
return dir ? `${dir}/${rel}` : rel;
}
/** Whether `dir` is `scope` or inside it. */
function isWithin(dir: string, scope: string): boolean {
return scope === '' || dir === scope || dir.startsWith(`${scope}/`);
}
function pythonModuleIndex(context: ResolutionContext): PythonModuleIndex {
let index = pythonModuleIndexes.get(context);
if (!index) {
index = buildPythonModuleIndex(context);
pythonModuleIndexes.set(context, index);
}
return index;
}
function buildPythonModuleIndex(context: ResolutionContext): PythonModuleIndex {
const files = new Set<string>();
const packageDirs = new Set<string>();
const dirs = new Set<string>(['']);
for (const f of context.getAllFiles()) {
const norm = f.replace(/\\/g, '/');
if (!PYTHON_MODULE_FILE.test(norm)) continue;
files.add(norm);
const dir = parentDir(norm);
if (norm === '__init__.py' || norm.endsWith('/__init__.py')) packageDirs.add(dir);
for (let d = dir; d && !dirs.has(d); d = parentDir(d)) dirs.add(d);
}
// Where a service's roots may answer from: everywhere (null), only itself
// when a package encloses it, or the nearest enclosing directory named as
// non-project code.
const serviceReach = (service: string): string | null => {
if (packageDirs.has(service)) return service;
for (let a = parentDir(service); a; a = parentDir(a)) {
if (packageDirs.has(a)) return service;
if (PYTHON_NON_PROJECT_DIRS.has(a.slice(a.lastIndexOf('/') + 1))) return a;
}
return null;
};
const projectRoots = new Set<string>(['']);
const localRoots = new Map<string, string>();
const addLocal = (root: string, reach: string): void => {
const prior = localRoots.get(root);
// Two claims on one root: the wider reach wins.
if (prior === undefined || isWithin(prior, reach)) localRoots.set(root, reach);
};
for (const dir of packageDirs) {
let top = dir;
while (top && packageDirs.has(parentDir(top))) top = parentDir(top);
if (top) addLocal(parentDir(top), parentDir(top));
}
for (const service of dirs) {
const hasConfig =
files.has(joinRel(service, 'setup.py')) ||
context.fileExists(joinRel(service, 'pyproject.toml')) ||
context.fileExists(joinRel(service, 'setup.cfg'));
if (!hasConfig && service !== '') continue;
const reach = service === '' ? null : serviceReach(service);
const roots = [service];
const src = joinRel(service, 'src');
if (dirs.has(src) && !packageDirs.has(src)) roots.push(src);
for (const declared of declaredPythonRoots(context, service)) {
if (dirs.has(declared)) roots.push(declared);
}
for (const root of roots) {
if (reach === null) projectRoots.add(root);
else addLocal(root, reach);
}
}
for (const root of projectRoots) localRoots.delete(root);
return { files, packageDirs, dirs, projectRoots, localRoots, headsByName: new Map() };
}
/**
* Package roots a build config declares, relative to the repo root. Only the
* keys that mean "packages live here" are read, each in its own table:
* setuptools `[tool.setuptools.packages.find] where` and
* `[tool.setuptools.package-dir] "" =` (or the inline `package-dir` of
* `[tool.setuptools]`), poetry `packages = [{ from = … }]`, hatch
* `packages = ["src/pkg"]`, and `setup.cfg`'s `[options] package_dir` and
* `[options.packages.find] where`. A miss only costs the namespace-package
* case, which the `__init__.py` chain can't see either.
*/
function declaredPythonRoots(context: ResolutionContext, service: string): string[] {
const out: string[] = [];
const add = (raw: string): void => {
const dir = raw.trim().replace(/^["']|["']$/g, '').replace(/^\.(?:\/|$)/, '').replace(/\/+$/, '');
if (!dir || dir.startsWith('..') || path.isAbsolute(dir)) return;
out.push(joinRel(service, dir));
};
const toml = context.readFile(joinRel(service, 'pyproject.toml'));
if (toml) {
for (const [table, body] of iniTables(toml)) {
if (table === 'tool.setuptools.packages.find') {
const m = body.match(/^\s*where\s*=\s*\[([^\]]*)\]/m);
if (m) for (const item of m[1]!.split(',')) add(item);
} else if (table === 'tool.setuptools.package-dir') {
const m = body.match(/^\s*["']{2}\s*=\s*["']([^"']+)["']/m);
if (m) add(m[1]!);
} else if (table === 'tool.setuptools') {
const m = body.match(/package-dir\s*=\s*\{[^}]*?["']{2}\s*=\s*["']([^"']+)["']/);
if (m) add(m[1]!);
} else if (table === 'tool.poetry') {
const pkgs = body.match(/^\s*packages\s*=\s*\[([\s\S]*?)\]\s*$/m);
if (pkgs) for (const m of pkgs[1]!.matchAll(/\bfrom\s*=\s*["']([^"']+)["']/g)) add(m[1]!);
} else if (table === 'tool.hatch.build.targets.wheel') {
const pkgs = body.match(/^\s*packages\s*=\s*\[([^\]]*)\]/m);
if (pkgs) {
for (const item of pkgs[1]!.split(',')) {
const p = item.trim().replace(/^["']|["']$/g, '');
if (p.includes('/')) add(p.slice(0, p.lastIndexOf('/')));
}
}
}
}
}
const cfg = context.readFile(joinRel(service, 'setup.cfg'));
if (cfg) {
for (const [table, body] of iniTables(cfg)) {
if (table === 'options') {
const m = body.match(/^\s*package_dir\s*=\s*(?:\n\s+)?=\s*(\S+)\s*$/m);
if (m) add(m[1]!);
} else if (table === 'options.packages.find') {
const m = body.match(/^\s*where\s*=\s*(\S+)\s*$/m);
if (m) add(m[1]!);
}
}
}
return out;
}
/** `[table]` sections of a TOML / INI file, with the body under each. */
function iniTables(content: string): Array<[string, string]> {
const out: Array<[string, string]> = [];
const headers = [...content.matchAll(/^[ \t]*\[([^\[\]\n]+)\][ \t]*(?:#.*)?$/gm)];
headers.forEach((h, i) => {
const end = i + 1 < headers.length ? headers[i + 1]!.index! : content.length;
out.push([h[1]!.trim().replace(/["']/g, ''), content.slice(h.index! + h[0].length, end)]);
});
return out;
}
/**
* The module file at `base` (dir-relative path), in Python's own order: a
* regular package `base/__init__.py` beats a module `base.py`; a bare
* directory (a namespace portion) has no file of its own.
*/
function pythonModuleAt(base: string, index: PythonModuleIndex): string | null {
const init = joinRel(base, '__init__.py');
if (index.files.has(init)) return init;
return index.files.has(`${base}.py`) ? `${base}.py` : null;
}
/**
* The module file for the dotted path `relPath` (slash-joined) under `root`,
* walking it the way the import system does: every prefix must be a package
* — regular, or a namespace directory — so a prefix that is a plain module
* (`pkg/sub.py` with no `pkg/sub/__init__.py`) ends the walk, and
* `pkg.sub.x` does not exist even if `pkg/sub/x.py` does.
*/
function pythonModuleUnder(root: string, relPath: string, index: PythonModuleIndex): string | null {
const parts = relPath.split('/');
let base = root;
for (let i = 0; i < parts.length - 1; i++) {
base = joinRel(base, parts[i]!);
if (index.files.has(joinRel(base, '__init__.py'))) continue;
if (index.files.has(`${base}.py`) || !index.dirs.has(base)) return null;
}
return pythonModuleAt(joinRel(root, relPath), index);
}
/** How `root` provides the top-level name `head`: concretely, as a namespace portion, or not at all. */
function pythonHeadKind(root: string, head: string, index: PythonModuleIndex): 'concrete' | 'namespace' | null {
const base = joinRel(root, head);
if (pythonModuleAt(base, index)) return 'concrete';
return index.dirs.has(base) ? 'namespace' : null;
}
/** Every root (project or local) providing the top-level name `head`, by kind. */
function pythonRootHeads(head: string, index: PythonModuleIndex): { concrete: string[]; namespace: string[] } {
let heads = index.headsByName.get(head);
if (!heads) {
heads = { concrete: [], namespace: [] };
for (const root of [...index.projectRoots, ...index.localRoots.keys()]) {
const kind = pythonHeadKind(root, head, index);
if (kind) heads[kind].push(root);
}
index.headsByName.set(head, heads);
}
return heads;
}
/**
* Resolve a Python import specifier to the file of the module it names —
* `pkg.sub.mod` → `pkg/sub/mod.py`, `pkg` → `pkg/__init__.py`, `..sibling`
* relative to `fromFile`'s package. Null when the module isn't in the index,
* when a relative import climbs above its top-level package, or when two
* project roots that don't contain the importer both provide it.
*/
function resolvePythonModule(
specifier: string,
fromFile: string,
context: ResolutionContext
): string | null {
if (!/^\.*[\w.]*$/.test(specifier) || specifier === '') return null;
const index = pythonModuleIndex(context);
const importerDir = parentDir(fromFile.replace(/\\/g, '/'));
// Relative: leading dots are package levels (one dot = the importer's own
// package), the rest a dotted path below it.
const dots = specifier.length - specifier.replace(/^\.+/, '').length;
if (dots > 0) {
let base = importerDir;
for (let level = 1; level < dots; level++) {
// Python refuses to climb out of the top-level package ("attempted
// relative import beyond top-level package"). Only a regular package
// proves where the top is; a namespace package's boundary is unknown.
if (index.packageDirs.has(base) && !index.packageDirs.has(parentDir(base))) return null;
if (!base) return null;
base = parentDir(base);
}
const rest = specifier.slice(dots);
if (rest) return pythonModuleUnder(base, rest.replace(/\./g, '/'), index);
// `from . import x` — the package itself, i.e. its `__init__.py`.
const init = joinRel(base, '__init__.py');
return index.files.has(init) ? init : null;
}
const relPath = specifier.replace(/\./g, '/');
const head = specifier.split('.')[0]!;
// Roots that contain the importer, nearest first — the ones Python would
// search for this file before any other.
const containing: string[] = [];
for (let dir: string | null = importerDir; dir !== null; dir = dir ? parentDir(dir) : null) {
const isRoot =
index.projectRoots.has(dir) ||
index.localRoots.has(dir) ||
(!index.packageDirs.has(dir) &&
(dir === importerDir || importerDir === joinRel(dir, head) || importerDir.startsWith(`${joinRel(dir, head)}/`)));
if (isRoot) containing.push(dir);
}
// A root containing the importer is on its path: the nearest one that holds
// the module answers. If one holds `head` itself as a regular package or
// module but not the rest of the path, Python stops there too.
let headIsLocal = false;
for (const root of containing) {
const hit = pythonModuleUnder(root, relPath, index);
if (hit) return hit;
if (pythonHeadKind(root, head, index) === 'concrete') headIsLocal = true;
}
if (headIsLocal) return null;
// Otherwise a root elsewhere whose reach includes the importer, as
// `sys.path` would rank them: a root holding `head` concretely shadows any
// namespace portion, and either way the answer must be unambiguous.
const heads = pythonRootHeads(head, index);
const reaches = (root: string): boolean =>
!containing.includes(root) &&
(index.projectRoots.has(root) || isWithin(importerDir, index.localRoots.get(root)!));
const concrete = heads.concrete.filter(reaches);
if (concrete.length > 0) {
return concrete.length === 1 ? pythonModuleUnder(concrete[0]!, relPath, index) : null;
}
let found: string | null = null;
for (const root of heads.namespace) {
if (!reaches(root)) continue;
const hit = pythonModuleUnder(root, relPath, index);
if (!hit) continue;
if (found !== null && found !== hit) return null;
found = hit;
}
return found;
}
/**
* A digest of the Python root set — each root with its tier and reach. A
* change here can move the answer for any import anywhere, so sync re-opens
* every Python resolution when it changes. Changes below the roots (a module
* or package added or removed) only move answers for the module names they
* touch; see {@link pythonReopenScope}. Built fresh, not from the memo.
*/
export function pythonRootFingerprint(context: ResolutionContext): string {
const index = buildPythonModuleIndex(context);
if (index.files.size === 0) return '';
const hash = crypto.createHash('sha1');
hash.update([...index.projectRoots].sort().join('\n'));
hash.update('\0');
hash.update([...index.localRoots].map(([root, reach]) => `${root}\t${reach}`).sort().join('\n'));
return hash.digest('hex');
}
/**
* What a sync must re-open when Python files were added or removed but the
* root set held. Each file names a module under every root containing it
* (`pkg/sub/__init__.py` is `pkg.sub`); an already-resolved edge can move only
* if its target is that module or inside it — shadowed (`pkg/sub/` now beats
* `pkg/sub.py`), un-shadowed, or newly ambiguous. A changed `__init__.py` also
* moves where a package starts for code inside it (relative imports, a
* script's own directory), so edges FROM that directory re-open too. The
* module names' last segments pick out the parked failures that may now
* resolve.
*/
export function pythonReopenScope(
context: ResolutionContext,
changedFiles: string[]
): { targetFiles: string[]; sourceDirs: string[]; moduleLeaves: string[] } {
const index = buildPythonModuleIndex(context);
const roots = [...index.projectRoots, ...index.localRoots.keys()];
const moduleNames = (file: string): string[] => {
const out: string[] = [];
const mod = file.replace(/(?:^|\/)__init__\.py$/, '').replace(/\.py$/, '');
for (const root of roots) {
if (root && !mod.startsWith(`${root}/`)) continue;
const rel = root ? mod.slice(root.length + 1) : mod;
if (rel) out.push(rel.replace(/\//g, '.'));
}
return out;
};
const affected = new Set<string>();
const sourceDirs = new Set<string>();
for (const raw of changedFiles) {
const file = raw.replace(/\\/g, '/');
if (!PYTHON_MODULE_FILE.test(file)) continue;
for (const m of moduleNames(file)) affected.add(m);
if (file === '__init__.py' || file.endsWith('/__init__.py')) sourceDirs.add(parentDir(file));
}
const targetFiles: string[] = [];
for (const file of index.files) {
const hit = moduleNames(file).some((m) => {
for (let i = m.length; i > 0; i = m.lastIndexOf('.', i - 1)) {
if (affected.has(m.slice(0, i))) return true;
}
return false;
});
if (hit) targetFiles.push(file);
}
const moduleLeaves = [...new Set([...affected].map((m) => m.slice(m.lastIndexOf('.') + 1)))];
return { targetFiles, sourceDirs: [...sourceDirs], moduleLeaves };
}
/**
* The Python files that import one of the packages whose `__init__.py` is in
* `initFiles`, each with the local names bound from it and what each name
* binds to NOW (`file\0name` of the definition, `file\0` for a submodule,
* null when it can't be said — a namespace import used as `pkg.N`). A caller
* compares that with what the importer's edge says it bound before and
* leaves unmoved names alone. An importer that is itself a package
* `__init__.py` passes the binding on (`from .sub import N` re-exported), so
* its own importers are included too, to a fixed point.
*/
export function pythonPackageImporters(
context: ResolutionContext,
initFiles: string[]
): Map<string, Map<string, string | null>> {
const index = pythonModuleIndex(context);
const packages = new Set(initFiles.map((f) => f.replace(/\\/g, '/')));
const out = new Map<string, Map<string, string | null>>();
const bindingNow = (imp: ImportMapping, file: string): string | null => {
if (imp.isNamespace || imp.exportedName === '*') return null;
const packageFile = resolveImportPath(imp.source, file, 'python', context);
if (!packageFile) return null;
const symbol = findExportedSymbol(
packageFile,
{ isDefault: false, isNamespace: false, exportedName: imp.exportedName, memberName: null },
'python',
context,
new Set()
);
if (symbol) return `${symbol.filePath}\0${symbol.name}`;
const sub = resolveImportPath(
imp.source.endsWith('.') ? imp.source + imp.exportedName : `${imp.source}.${imp.exportedName}`,
file,
'python',
context
);
return sub ? `${sub}\0` : '\0';
};
// Candidate importers from the import nodes (named by their specifier),
// so only files that import an edited package pay for a mapping parse.
const importNodes = context.getNodesByKind('import').filter((n) => PYTHON_MODULE_FILE.test(n.filePath));
for (let grew = true; grew; ) {
grew = false;
const candidates = new Set<string>();
for (const n of importNodes) {
const target = resolveImportPath(n.name, n.filePath, 'python', context);
if (target && packages.has(target) && index.files.has(n.filePath)) candidates.add(n.filePath);
}
for (const file of candidates) {
for (const imp of context.getImportMappings(file, 'python')) {
const target = resolveImportPath(imp.source, file, 'python', context);
if (!target || !packages.has(target) || target === file) continue;
let names = out.get(file);
if (!names) out.set(file, (names = new Map()));
names.set(imp.localName, bindingNow(imp, file));
// `import pkg.sub` is used as `pkg.sub.N`.
if (imp.isNamespace) names.set(imp.source.split('.')[0]!, null);
if (/(?:^|\/)__init__\.py$/.test(file) && !packages.has(file)) {
packages.add(file);
grew = true;
}
}
}
}
return out;
}
/** The file node of the module `specifier` names, as seen from `fromFile`. */
function pythonModuleFileNode(specifier: string, fromFile: string, context: ResolutionContext): Node | null {
const file = resolveImportPath(specifier, fromFile, 'python', context);
if (!file || file === fromFile) return null;
return context.getNodesInFile(file).find((n) => n.kind === 'file') ?? null;
}
/**
* The names `from <file> import *` binds: the string literals of the module's
* `__all__` when it assigns one, otherwise every public (non-`_`) name.
* Returns null for "every public name". Memoised per context.
*/
const pythonStarExportMemos = new WeakMap<ResolutionContext, Map<string, Set<string> | null>>();
function pythonStarExports(filePath: string, context: ResolutionContext): Set<string> | null {
let memo = pythonStarExportMemos.get(context);
if (!memo) {
memo = new Map();
pythonStarExportMemos.set(context, memo);
}
if (memo.has(filePath)) return memo.get(filePath)!;
let names: Set<string> | null = null;
const content = context.readFile(filePath);
if (content && content.includes('__all__')) {
// Locate every statement that builds `__all__` on blanked source (so a
// docstring mention is ignored), then read the literals from the original
// at the same offsets. Only a list written out in full is trusted: one
// assembled from other names (`__all__ = fields_all + [...]`,
// `__all__.extend(...)`) is unknown, and falls back to the public names.
const code = stripCommentsForRegex(content, 'python');
let literal = true;
const collected = new Set<string>();
for (const m of code.matchAll(/^[ \t]*__all__\b[ \t]*(\.|\+=|=|:[^=\n]*=)?[ \t]*(.?)/gm)) {
const open = m.index! + m[0].length - 1;
const bracket = m[2];
const close = bracket === '[' ? code.indexOf(']', open) : bracket === '(' ? code.indexOf(')', open) : -1;
const assigns = m[1] !== undefined && m[1] !== '.';
const tail = close < 0 ? '' : code.slice(close + 1, code.indexOf('\n', close) < 0 ? undefined : code.indexOf('\n', close));
if (!assigns || close < 0 || tail.trim() !== '') {
literal = false;
break;
}
for (const lit of content.slice(open, close).matchAll(/["']([A-Za-z_]\w*)["']/g)) collected.add(lit[1]!);
}
if (literal && collected.size > 0) names = collected;
}
memo.set(filePath, names);
return names;
}
function pythonStarAllows(filePath: string, name: string, context: ResolutionContext): boolean {
const names = pythonStarExports(filePath, context);
return names ? names.has(name) : !name.startsWith('_');
}
/**
* 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.
*/
export const ESM_IMPORT_LANGUAGES: ReadonlySet<Language> = 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. A catch-all `"*"` has no
// prefix and matches `react` too: what it names is left to
// resolveFromOwnConfig, which keeps the app's packages out.
const aliases = options.aliasPrefixes === false ? null : context?.getProjectAliases?.();
if (aliases) {
for (const pat of aliases.patterns) {
if (pat.prefix !== '' && 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] || [];
// 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 tryWithExt = (basePath: string): string | null => tryWithExtensions(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);
}
/** The file `basePath` names: with the language's extensions or index files, as is, or as an emitted `.js`. */
function tryWithExtensions(basePath: string, language: Language, context: ResolutionContext): string | null {
for (const ext of EXTENSION_RESOLUTION[language] || []) {
const candidate = basePath + ext;
if (context.fileExists(candidate)) return candidate;
}
if (context.fileExists(basePath)) return basePath;
return findSourceForEmittedSpecifier(basePath, language, context);
}
/**
* A bare specifier `isExternalImport` takes for a package, resolved through
* the importing app's own tsconfig / jsconfig — the one nearest the file,
* else the root's — as tsc and a paths-aware bundler resolve it: the most
* specific `paths` pattern it matches (a catch-all `"*": ["./src/*"]`, an
* alias only a nested app declares), then `baseUrl` (`"baseUrl": "src"`
* makes `layout/MainLayout` read `src/layout/MainLayout`). CRA and Vite
* admin templates import nearly everything this way.
*
* Only an indexed file binds: `fileExists` also answers for a folder and
* for `node_modules`. A package — one the file's package.json or an
* enclosing one declares, or a Node built-in — never binds to a project
* module (`lodash` stays lodash even beside a `src/lodash/`), only to a
* declaration file the project keeps for it, which is the package as tsc
* reads it: `legacy-widgets` under `"*": ["./typings/*"]` is
* `typings/legacy-widgets.d.ts`.
*/
function resolveFromOwnConfig(
importPath: string,
fromFile: string,
language: Language,
context: ResolutionContext
): string | null {
const candidates = ownConfigCandidates(importPath, fromFile, language, context);
if (!candidates) return null;
const indexed = (file: string): boolean => context.isIndexedFile?.(file) !== false;
if (context.isDeclaredPackage?.(importPath, fromFile) !== false) {
if (!EXTENSION_RESOLUTION[language]?.includes('.d.ts')) return null;
for (const candidate of candidates) {
const declarations = `${candidate}.d.ts`;
if (context.fileExists(declarations) && indexed(declarations)) return declarations;
}
return null;
}
for (const candidate of candidates) {
const hit = tryWithExtensions(candidate, language, context);
if (hit && indexed(hit)) return hit;
}
return null;
}
/**
* Where the importing app's own config reads a bare specifier from (see
* resolveFromOwnConfig), or null when it reads none. A rooted one,
* `/imports/ui/components/icon/component`, is a path: tsc maps it through
* `paths` (bigbluebutton's `"/*": ["*"]`, kept from its Meteor days) and
* never reads it from `baseUrl`.
*/
function ownConfigCandidates(
importPath: string,
fromFile: string,
language: Language,
context: ResolutionContext
): string[] | null {
if (!ESM_IMPORT_LANGUAGES.has(language) || !importPath || importPath.startsWith('//') || /^[A-Za-z][\w+.-]*:/.test(importPath)) return null;
const config = context.getNearestAliases?.(fromFile) ?? context.getProjectAliases?.();
if (!config) return null;
const candidates = importPath.startsWith('/')
? applyAliases(importPath, config, context.getProjectRoot())
: aliasAndBaseUrlCandidates(importPath, config, context.getProjectRoot());
return candidates.length > 0 ? candidates : null;
}
/**
* Whether a bare specifier names one of the project's files through the
* importing app's own config: its `paths` or `baseUrl` map it to a file the
* index holds, and no package the app declares goes by it
* (`layout/MainLayout` under `"baseUrl": "src"`). Not a name that maps to
* nothing yet: under `"baseUrl": "."` an undeclared `express` is far likelier
* a package the manifest leaves out than a module still to come.
*/
function namesOwnConfigModule(importPath: string, fromFile: string, language: Language, context: ResolutionContext): boolean {
return context.isDeclaredPackage?.(importPath, fromFile) === false &&
resolveImportPath(importPath, fromFile, language, context) !== null;
}
/**
* 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;
}
/** A JavaScript identifier — `$`, `_` and Unicode letters included. Use with the `u` flag. */
const JS_IDENT = '[$_\\p{ID_Start}][$\\u200c\\u200d\\p{ID_Continue}]*';
/** A quoted string, escapes included — an ES2022 string-named specifier. */
const JS_STRING = `"(?:[^"\\\\\\n]|\\\\.)*"|'(?:[^'\\\\\\n]|\\\\.)*'`;
/** A specifier's name: an identifier, or an ES2022 string name (`"my-fn"`). */
const SPECIFIER_NAME = `(?:${JS_IDENT}|${JS_STRING})`;
/** TypeScript's `type` modifier, or Flow's `typeof`. */
const TYPE_MODIFIER = '(?:type(?:of)?\\s+)';
const SPECIFIER_ALIAS = new RegExp(`^${TYPE_MODIFIER}?(${SPECIFIER_NAME})\\s+as\\s+(${SPECIFIER_NAME})$`, 'u');
const SPECIFIER_PLAIN = new RegExp(`^${TYPE_MODIFIER}?(${SPECIFIER_NAME})$`, 'u');
/**
* A `{ … }` specifier list: everything up to the closing `}`, with no length
* cap, so a barrel's thousand-name list still parses. A list never holds a
* brace, so the scan also stops at a `{`: an unclosed list ends at the next
* statement's brace instead of rescanning to the end of the file, which keeps
* a file of unclosed lists linear. (A brace inside a comment or string name in
* the list ends it early, as it always has; handling that needs a real
* scanner, not a regex.)
*/
const SPECIFIER_LIST = '[^{}]+';
/**
* `import [type] [Default][, ]{ … }|* as ns from '…'`. Each run of whitespace
* belongs to exactly one quantifier — adjacent optional `\\s*`s backtrack
* polynomially on long blank runs. A modifier — TypeScript `type`, Flow
* `typeof`, or `defer` (`import defer * as ns`) — is followed directly by
* `{`/`*`, or by whitespace and a name that is not `from` — `import type from
* '…'` (any spacing) is a default import NAMED `type`. A default binding is
* never followed by `{`, so the `${` of a code generator's
* `` `import ${name} from '${src}'` `` is no binding.
*/
const ES_IMPORT_RE = new RegExp(
`(?<![$.\\p{ID_Continue}])import(?:\\s+|(?=[{*]))(?:(?:type(?:of)?|defer)(?:\\s*(?=[{*])|\\s+(?!from(?![$\\p{ID_Continue}]))(?=[$_\\p{ID_Start}])))?` +
`(?:(${JS_IDENT})(?![$\\u200c\\u200d\\p{ID_Continue}{])\\s*(?:,\\s*)?)?` +
`(?:\\{(${SPECIFIER_LIST})\\}\\s*)?` +
`(?:(\\*)\\s*as\\s+(${JS_IDENT})\\s*)?` +
`from\\s*['"]([^'"]+)['"]`,
'gu'
);
/** `export [type] * [as ns] from '…'`. */
const WILDCARD_REEXPORT_RE = new RegExp(
`(?<![$.\\p{ID_Continue}])export(?![$\\p{ID_Continue}])\\s*(?:type(?![$\\p{ID_Continue}])\\s*)?\\*(?:\\s*as(?:\\s+(${JS_IDENT})|\\s*(${JS_STRING})))?\\s*from\\s*['"]([^'"]+)['"]`,
'gu'
);
/** `export [type] { … } from '…'`. */
const NAMED_REEXPORT_RE = new RegExp(
`(?<![$.\\p{ID_Continue}])export(?![$\\p{ID_Continue}])\\s*(?:type(?![$\\p{ID_Continue}])\\s*)?\\{(${SPECIFIER_LIST})\\}\\s*from\\s*['"]([^'"]+)['"]`,
'gu'
);
/**
* Split a comment-free specifier list on the commas between specifiers — not
* those inside a string name (`"a,b" as c`).
*/
function splitSpecifierList(list: string): string[] {
const out: string[] = [];
let start = 0;
let quote: string | null = null;
for (let i = 0; i < list.length; i++) {
const ch = list[i]!;
if (quote !== null) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === ',') {
out.push(list.slice(start, i));
start = i + 1;
}
}
out.push(list.slice(start));
return out;
}
const SIMPLE_ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' };
/** A captured `{ … }` list with its comments stripped (only when it has a `/` — most don't). */
function cleanSpecifierList(list: string): string {
return list.includes('/') ? stripJsComments(list) : list;
}
/** A multi-line JSDoc `@import { … }` list without its ` * ` line prefixes. */
function withoutJsDocPrefixes(list: string): string {
return list.replace(/^[ \t]*\*(?!\/)/gm, '');
}
/** A string-named specifier's name without its quotes, escapes decoded. */
function unquoteSpecifier(name: string): string {
if (!/^["']/.test(name)) return name;
return name.slice(1, -1).replace(
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g,
(_, esc: string) => {
if (esc[0] === 'u' || esc[0] === 'x') {
const hex = esc[1] === '{' ? esc.slice(2, -1) : esc.slice(1);
const code = parseInt(hex, 16);
return code <= 0x10ffff ? String.fromCodePoint(code) : esc;
}
return SIMPLE_ESCAPES[esc] ?? esc;
}
);
}
/**
* One `{ … }` import/export specifier — `a`, `a as b`, `"a-b" as c`, and the
* same with TypeScript's inline `type` modifier (`type a`, `type a as b`) — as
* the name it takes from the module and the name it binds (Flow's `typeof`
* modifier likewise). `type` alone, or `type as t`, is a specifier NAMED
* `type`. Null for anything else. Expects one comment-free item of a list
* split by {@link splitSpecifierList}.
*/
function parseSpecifier(raw: string): { imported: string; local: string } | null {
const item = raw.trim();
const alias = SPECIFIER_ALIAS.exec(item);
if (alias) return { imported: unquoteSpecifier(alias[1]!), local: unquoteSpecifier(alias[2]!) };
const plain = SPECIFIER_PLAIN.exec(item);
if (plain) {
const name = unquoteSpecifier(plain[1]!);
return { imported: name, local: name };
}
return null;
}
/**
* Extract JS/TS import mappings
*/
function extractJSImports(content: string): ImportMapping[] {
const mappings: ImportMapping[] = [];
// ES6 imports (see ES_IMPORT_RE). matchAll iterates a copy, so the shared
// regex's lastIndex never leaks between calls.
for (const match of content.matchAll(ES_IMPORT_RE)) {
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) {
// Comments inside the list are stripped first so a `,` in one can't split
// it. (Only the list: this regex also runs over whole SFCs, where a
// file-wide comment scan would desync on markup like `Don't`.)
// A JSDoc `@import` tag's list carries ` * ` line prefixes when it wraps.
const list = content[match.index! - 1] === '@' ? withoutJsDocPrefixes(namedImports) : namedImports;
for (const name of splitSpecifierList(cleanSpecifierList(list))) {
const specifier = parseSpecifier(name);
if (specifier) {
mappings.push({
localName: specifier.local,
exportedName: specifier.imported,
source: source!,
// `{ default as X }` is the default import spelled as a named one;
// no module declares an export named `default` to find.
isDefault: specifier.imported === 'default',
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`).
let match: RegExpExecArray | null;
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;
}
interface PythonFromImport {
source: string;
names: Array<{ name: string; alias: string }>;
star: boolean;
}
/**
* The `from X import …` statements of a Python file, read over comment- and
* docstring-blanked source so a usage example in a docstring binds nothing.
* Handles the parenthesised multi-line list (`from .x import (\n a,\n b,\n)`,
* the usual shape of a package `__init__.py`) and backslash continuations,
* which a single-line match silently dropped.
*/
function pythonFromImports(code: string): PythonFromImport[] {
const out: PythonFromImport[] = [];
const fromRe = /^[ \t]*from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]*(\([^)]*\)|(?:[^\n\\;]|\\\r?\n)+)/gm;
let m: RegExpExecArray | null;
while ((m = fromRe.exec(code)) !== null) {
const source = m[1]!;
if (!source) continue;
const list = m[2]!.replace(/^\(|\)$/g, '').replace(/\\\r?\n/g, ' ');
const names: PythonFromImport['names'] = [];
let star = false;
for (const raw of list.split(',')) {
const item = raw.trim();
if (item === '*') {
star = true;
continue;
}
const im = item.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
if (im) names.push({ name: im[1]!, alias: im[2] ?? im[1]! });
}
out.push({ source, names, star });
}
return out;
}
/**
* Extract Python import mappings
*/
function extractPythonImports(content: string): ImportMapping[] {
const mappings: ImportMapping[] = [];
const code = stripCommentsForRegex(content, 'python');
// from X import Y [as Z], …
for (const { source, names } of pythonFromImports(code)) {
for (const { name, alias } of names) {
mappings.push({
localName: alias,
exportedName: name,
source,
isDefault: false,
isNamespace: false,
});
}
}
// import X [as Y], … at module level. The mappings are the file's, so a
// function-local import is left to its function: `import a.b as X` there
// binds X in that function only (pythonFunctionModuleAlias).
const importRe = /^import[ \t]+((?:[^\n\\;]|\\\r?\n)+)/gm;
let match: RegExpExecArray | null;
while ((match = importRe.exec(code)) !== null) {
for (const raw of match[1]!.replace(/\\\r?\n/g, ' ').split(',')) {
const im = raw.trim().match(/^([\w.]+)(?:\s+as\s+(\w+))?$/);
if (!im) continue;
mappings.push({
localName: im[2] || im[1]!.split('.').pop()!,
exportedName: '*',
source: im[1]!,
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 });
// Go binds a name to one import of a file, so a name another import is
// given explicitly is not an unaliased import's: etcd's benchmark tool has
// `"github.com/cheggaaa/pb/v3"` (package pb) beside
// `v3 "go.etcd.io/etcd/client/v3"`, and listed first, pb took every `v3.`.
const explicit = new Set(specs.flatMap(({ name }) => (name !== undefined && name !== '.' && name !== '_' ? [name] : [])));
// A dot import (`. "pkg"`) binds no name of its own; it stays listed under
// its last path element.
const mappings = specs
.map(({ name, source }) => ({ explicitly: name !== undefined && name !== '.', localName: name === undefined || name === '.' ? source.split('/').pop()! : name, source }))
.filter(({ explicitly, localName }) => explicitly || !explicit.has(localName))
.map(({ localName, source }) => mapping(localName, 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). A block
* comment becomes one space, so the tokens either side stay apart. Used by
* {@link extractReExports} over a whole file, so commented-out export-from
* statements don't generate phantom re-export edges, and by
* {@link extractJSImports} over just an import's `{ … }` list (which can come
* from an SFC — never run it over SFC markup: `Don't` would open a string).
*
* 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 and import lists).
*/
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;
// A space, not nothing: `A/**/as B` must stay two words.
out += ' ';
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';
* and TypeScript's `type` forms of each: `export type { foo } from`,
* `export { type foo } from`, `export type * from`.
*
* 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 === 'python') return extractPythonReExports(content);
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 '...'`, optionally `export type *`; `export * as ns
// from '...'` exports `ns` alone.
for (const m of cleaned.matchAll(WILDCARD_REEXPORT_RE)) {
// `export * as ns` / `export * as "my ns"` (ES2022) exports the namespace alone.
const ns = m[1] ?? (m[2] ? m[2].slice(1, -1) : undefined);
out.push(ns ? { kind: 'namespace', exportedName: ns, source: m[3]! } : { kind: 'wildcard', source: m[3]! });
}
// Named: `export { a, b as c } from '...'`, with TypeScript's `type`
// modifier on the list (`export type { … } from`) or an item (`{ type a }`).
// `[^{}]`, not `[^}]`: a specifier list never holds a brace, so an unclosed
// `{` stops at the next one instead of rescanning to the end of the file.
for (const m of cleaned.matchAll(NAMED_REEXPORT_RE)) {
const inner = m[1]!;
const source = m[2]!;
// Stripped again here: the file-wide pass above can lose its place (a JSX
// `Don't`, a regex literal holding a quote) and leave a comment in the list.
for (const raw of splitSpecifierList(cleanSpecifierList(inner))) {
const specifier = parseSpecifier(raw);
if (!specifier) continue;
out.push({
kind: 'named',
exportedName: specifier.local,
originalName: specifier.imported,
source,
});
}
}
return out;
}
/**
* A Python module's imports ARE its re-exports: `from .impl import foo` makes
* `foo` an attribute of the module, which is exactly how a package
* `__init__.py` publishes its API. Chased only after a direct lookup misses,
* with the shared cycle guard and depth cap.
*/
function extractPythonReExports(content: string): ReExport[] {
const out: ReExport[] = [];
for (const { source, names, star } of pythonFromImports(stripCommentsForRegex(content, 'python'))) {
if (star) out.push({ kind: 'wildcard', source });
for (const { name, alias } of names) {
out.push({ kind: 'named', exportedName: alias, originalName: name, 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);
}
/**
* A JS/TS `imports` reference to the module of an import named by one bare
* segment — `'config'` in `import { DEFAULT_THEME_MODE } from 'config'` under
* `"baseUrl": "src"`. Shaped like a package, it names a file only when
* resolveImportPath finds one (the app's `baseUrl` or catch-all `paths`, a
* workspace member); a real package stays unresolved. A name some import
* binds locally is that binding, not the module, and resolves as one.
*/
function isJsBareModuleRef(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.referenceKind !== 'imports' || !ESM_IMPORT_LANGUAGES.has(ref.language) || isJsPathSpecifier(ref.referenceName)) return false;
const imports = context.getImportMappings(ref.filePath, ref.language);
return imports.some((m) => m.source === ref.referenceName) && !imports.some((m) => m.localName === ref.referenceName);
}
/**
* The file a JS/TS module reference names, the way resolveViaImport reads it:
* a path (`../lib/a`, `@/lib/x`), a scoped package a `tsconfig` path or a
* workspace member maps to the project, or a bare module under a `baseUrl`.
* Null for any other reference, and for one that names no file of the project
* but its own.
*/
export function jsModuleFile(ref: UnresolvedRef, context: ResolutionContext): string | null {
if (!isJsPathImportRef(ref) && !isJsBareModuleRef(ref, context)) return null;
// A package the repository declares itself is the file that declares it,
// whatever file its specifier resolves to (see ./ambient-modules).
if (isBareModuleSpecifier(ref.referenceName, ref.language)) {
const declaring = findAmbientModuleFile(ref.referenceName, ref.filePath, context);
if (declaring) return declaring.filePath;
}
const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
return file && file !== ref.filePath ? file : null;
}
/** 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' };
}
/** TypeScript and JavaScript files: where the extractors record type names. */
const TS_JS_FILE = /\.[cm]?[jt]sx?$/i;
/**
* The last name of a TypeScript type written qualified — `React.ReactNode`,
* `TChat.ChatMessage`, `EQueryType.CLICKHOUSE` — which both extractors record
* alone. TypeScript reads it as a member of what its qualifier names: the
* module an import names (`import * as T`, or a package the repository
* declares itself, `vscode.TextDocument`), a namespace, enum or class an
* import names or the file declares, or a namespace the project declares
* global (`Express.User`). Matched by the last name alone, outline's
* `React.ReactNode` reached its own editor `class ReactNode`, excalidraw's
* `TChat.ChatMessage` a `ChatMessage` component, signoz's
* `EQueryType.CLICKHOUSE` another enum's member, and outline's
* `UnfurlResourceType.Group` the `Group` model the file imports. undefined
* when the name was not written after a qualifier this reads
* (`import('./x').T`, a line break), names an `import X = ns.Y` alias, or
* names a module whose member it cannot follow; null when the qualifier is a
* package's or a global the project doesn't declare, or has no such member —
* so name fallbacks must not guess.
*/
export function resolveTsQualifiedTypeRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
if (ref.referenceKind !== 'references' || !ESM_IMPORT_LANGUAGES.has(ref.language) || !TS_JS_FILE.test(ref.filePath)) return undefined;
const qualifier = typeQualifier(ref, context);
if (!qualifier) return undefined;
const head = qualifier[0]!;
const path = [...qualifier.slice(1), ref.referenceName];
const imp = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === head);
let target: Node | null | undefined;
if (imp) {
target = importedTypeMember(imp, path, ref, context);
} else {
if (tsGlobalScope(ref.filePath, context)?.importAliases.has(head)) return undefined;
target = ownTypeMember(head, path, ref, context);
if (target === undefined) target = globalTypeMember(qualifier.join('.'), ref, context) ?? null;
}
if (target === undefined) return undefined;
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: imp ? 'import' : 'qualified-name' } : null;
}
/**
* The names a type's last name was written after, first to last: `['React']`
* for `React.ReactNode`, `['a', 'b']` for `a.b.C`. Null for a name written
* alone, after an expression (`import('./x').T`), or not where the reference
* puts it.
*/
function typeQualifier(ref: UnresolvedRef, context: ResolutionContext): string[] | null {
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1];
if (!line?.startsWith(ref.referenceName, ref.column)) return null;
const names: string[] = [];
let i = ref.column - 1;
for (;;) {
while (i >= 0 && (line[i] === ' ' || line[i] === '\t')) i--;
if (line[i] !== '.') break;
i--;
while (i >= 0 && (line[i] === ' ' || line[i] === '\t')) i--;
const end = i + 1;
while (i >= 0 && /[\w$]/.test(line[i]!)) i--;
const name = line.slice(i + 1, end);
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return null;
names.unshift(name);
}
return names.length > 0 ? names : null;
}
/**
* A type path through an import of the file's: the module's export the path
* starts with (`T.X`, through barrels and the repository's `declare module`
* blocks), or the member of the enum, class or namespace the import names.
* null for a package the repository doesn't declare, or a container without
* the member; undefined when the module's export can't be followed.
*/
function importedTypeMember(imp: ImportMapping, path: string[], ref: UnresolvedRef, context: ResolutionContext): Node | null | undefined {
const file = resolveImportPath(imp.source, ref.filePath, ref.language, context);
const bare = isBareModuleSpecifier(imp.source, ref.language);
let found: Node | undefined;
let rest: string[];
if (imp.isNamespace) {
const want = { isDefault: false, isNamespace: true, exportedName: path[0]!, memberName: path[0]! };
found = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ??
(file ? findExportedSymbol(file, want, ref.language, context, new Set()) : undefined);
if (found && path.length === 1) return found;
rest = path.slice(1);
} else {
const want = { isDefault: imp.isDefault, isNamespace: false, exportedName: imp.isDefault ? 'default' : imp.exportedName, memberName: null };
found = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ??
(file ? findExportedSymbol(file, want, ref.language, context, new Set()) : undefined);
rest = path;
}
if (found) {
const member = STATIC_MEMBER_CONTAINERS.has(found.kind) ? containerMember(found, rest, context) : undefined;
return member ?? typeMemberIn(found.filePath, [], [found.name, ...rest], context) ?? null;
}
// A namespace the module declares, for which the index holds no node.
const namespace = imp.isNamespace ? path[0]! : imp.isDefault ? null : imp.exportedName;
const declaring = file && namespace ? namespaceDeclaringFile(file, namespace, ref.language, context) : undefined;
if (declaring) return typeMemberIn(declaring.file, [], [declaring.name, ...rest], context) ?? null;
// A package from outside the repository, which it doesn't declare itself:
// its type is no project node. An import this resolver can't follow but no
// manifest declares, like zod's own tests' `import * as z from "zod/v3"`
// through the package's `exports`, keeps the ordinary strategies.
const outside = !file && !findAmbientModuleFile(imp.source, ref.filePath, context) &&
context.isOutOfRepoImport?.(imp.source, ref.filePath, ref.language) === true;
return outside ? null : undefined;
}
/**
* A type path through what the file declares itself as `head`: in the
* innermost namespace around the reference that declares it, else at the
* file's top level. null when the file declares `head` but nothing at the
* path; undefined when it declares no `head`.
*/
function ownTypeMember(head: string, path: string[], ref: UnresolvedRef, context: ResolutionContext): Node | null | undefined {
const at = { startLine: ref.line, startColumn: ref.column };
const scopes = namespaceBlocks(ref.filePath, context)
.filter((b) => inBlock(at, b))
.map((b) => b.path.split('.'))
.sort((a, b) => b.length - a.length);
for (const scope of [...scopes, []]) {
if (!hasNamespace(ref.filePath, [...scope, head].join('.'), context) && declarationsIn(ref.filePath, scope, head, context).length === 0) continue;
return typeMemberIn(ref.filePath, scope, [head, ...path], context) ?? null;
}
return undefined;
}
/**
* The member of a namespace the project declares global (`Express.User`, an
* interface in `declare global { namespace Express { … } }`): a declaration of
* the reference's name directly in such a block. A test's or a fixture's
* globals reach its tests only.
*/
function globalTypeMember(namespace: string, ref: UnresolvedRef, context: ResolutionContext): Node | undefined {
const fromTest = isTestFile(ref.filePath);
return context.getNodesByName(ref.referenceName)
.filter((n) => {
if (!TS_JS_FILE.test(n.filePath) || n.qualifiedName !== n.name || !MEMBER_DECLARATION_KINDS.has(n.kind)) return false;
if (!fromTest && isTestFile(n.filePath)) return false;
const block = innermostBlock(n, namespaceBlocks(n.filePath, context));
return block?.global === true && block.path === namespace;
})
.sort(typesFirst)[0];
}
/**
* What `path` names in `file`, from the namespace `scope` (the file's top
* level when empty): a namespace takes the next name as a member of its own,
* and an enum, a class or an interface takes the rest as its `::` members.
*/
function typeMemberIn(file: string, scope: string[], path: string[], context: ResolutionContext): Node | undefined {
const [name, ...rest] = path;
if (name === undefined) return undefined;
if (rest.length > 0 && hasNamespace(file, [...scope, name].join('.'), context)) {
return typeMemberIn(file, [...scope, name], rest, context);
}
const declared = declarationsIn(file, scope, name, context);
if (rest.length === 0) return declared[0];
const container = declared.find((n) => STATIC_MEMBER_CONTAINERS.has(n.kind));
return container ? containerMember(container, rest, context) : undefined;
}
/** An enum's, a class's or an interface's member at `path` (`Kind::Line`), in the container's own file. */
function containerMember(container: Node, path: string[], context: ResolutionContext): Node | undefined {
if (path.length === 0) return undefined;
return context.getNodesByQualifiedName(`${container.qualifiedName}::${path.join('::')}`)
.filter((n) => n.filePath === container.filePath)
.sort(typesFirst)[0];
}
/** Kinds a declaration at a file's or a namespace's own level has; a class's member is `Class::member`. */
const MEMBER_DECLARATION_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>([
'class', 'interface', 'type_alias', 'enum', 'struct', 'function', 'component', 'constant', 'variable',
]);
/** Kinds a type position names. */
const TYPE_DECLARATION_KINDS: ReadonlySet<Node['kind']> = new Set<Node['kind']>([
'class', 'interface', 'type_alias', 'enum', 'enum_member', 'struct',
]);
/** Types before values: an interface before the constant merged with it. */
function typesFirst(a: Node, b: Node): number {
return Number(!TYPE_DECLARATION_KINDS.has(a.kind)) - Number(!TYPE_DECLARATION_KINDS.has(b.kind));
}
/** The declarations named `name` directly in the namespace `scope` of `file` (its top level when empty), types first. */
function declarationsIn(file: string, scope: string[], name: string, context: ResolutionContext): Node[] {
const blocks = namespaceBlocks(file, context);
const want = scope.join('.');
return (context.getNodesInFileNamed?.(file, name) ?? context.getNodesInFile(file).filter((n) => n.name === name))
.filter((n) => n.qualifiedName === name && MEMBER_DECLARATION_KINDS.has(n.kind) && (innermostBlock(n, blocks)?.path ?? '') === want)
.sort(typesFirst);
}
/** Whether `file` declares the namespace `path`, itself or as the prefix of a longer one (`namespace A.B` declares `A`). */
function hasNamespace(file: string, path: string, context: ResolutionContext): boolean {
return namespaceBlocks(file, context).some((b) => b.path === path || b.path.startsWith(`${path}.`));
}
/** The innermost namespace block a node starts in. */
function innermostBlock(node: Pick<Node, 'startLine' | 'startColumn'>, blocks: readonly NamespaceBlock[]): NamespaceBlock | undefined {
let inner: NamespaceBlock | undefined;
for (const block of blocks) {
if (!inBlock(node, block)) continue;
if (!inner || block.startLine > inner.startLine || (block.startLine === inner.startLine && block.startColumn > inner.startColumn)) inner = block;
}
return inner;
}
/** The file that declares the namespace a module exports as `name`, through its re-exports, and the name it declares it under. */
function namespaceDeclaringFile(
file: string,
name: string,
language: Language,
context: ResolutionContext,
depth = 0,
): { file: string; name: string } | undefined {
if (depth > REEXPORT_MAX_DEPTH) return undefined;
if (namespaceBlocks(file, context).some((b) => b.path === name || b.path.startsWith(`${name}.`))) return { file, name };
for (const rex of context.getReExports?.(file, language) ?? []) {
if (rex.kind !== 'wildcard' && !(rex.kind === 'named' && rex.exportedName === name)) continue;
const next = resolveImportPath(rex.source, file, language, context);
const found = next ? namespaceDeclaringFile(next, rex.kind === 'named' ? rex.originalName : name, language, context, depth + 1) : undefined;
if (found) return found;
}
return undefined;
}
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.
const moduleFile = jsModuleFile(ref, context);
if (moduleFile) {
const fileNode = context.getNodesInFile(moduleFile).find((n) => n.kind === 'file');
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
// Python absolute and relative specifiers alike resolve here, from the
// project's package roots — including aliases used as receiver types (#1820).
const resolvedPath = resolveImportPath(
imp.source,
ref.filePath,
ref.language,
context
);
// A package the repository declares itself, as cord-field's
// `typings/mammoth/index.d.ts` declares `declare module 'mammoth'`:
// TypeScript takes that declaration before any file the specifier
// resolves to (see ./ambient-modules).
const bare = isBareModuleSpecifier(imp.source, ref.language);
if (resolvedPath || bare) {
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
const memberName = imp.isNamespace
? ref.referenceName.replace(imp.localName + '.', '')
: null;
const want = { isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName };
const targetNode = (bare ? findAmbientModuleExport(imp.source, want, ref, context) : undefined) ??
(resolvedPath ? findExportedSymbol(resolvedPath, want, ref.language, context, new Set()) : 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',
};
}
}
}
}
// A barrel's own `export { A, B as C } from './x'` names `A` and `B` the way
// the module calls them, and binds neither, so no import above holds them.
// Each resolves through the module the statement names, as an import does:
// looked up by its name alone, it reached a namesake elsewhere in the
// project (luci-go milo's commit table barrel, a build table's context).
if (ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language)) {
const source = reExportSource(ref.referenceName, ref, context);
const resolvedPath = source === undefined ? null : resolveImportPath(source, ref.filePath, ref.language, context);
const targetNode = resolvedPath === null ? undefined : findExportedSymbol(
resolvedPath,
{ isDefault: false, isNamespace: false, exportedName: ref.referenceName, memberName: null },
ref.language,
context,
new Set()
);
if (targetNode) return { original: ref, targetNodeId: targetNode.id, confidence: 0.9, resolvedBy: 'import' };
}
return null;
}
/** Whether `source`, imported in a file of `language`, is an ES module specifier written as a name, not a path. */
function isBareModuleSpecifier(source: string, language: Language): boolean {
return ESM_IMPORT_LANGUAGES.has(language) && !source.startsWith('.') && !source.startsWith('/');
}
/**
* Reference kinds the extractor records by a qualified name's last part: a
* type position (`doc: vscode.TextDocument`) and a construction (`new
* vscode.EventEmitter<T>()`). A call keeps what it is called through.
*/
const LAST_NAME_KINDS: ReadonlySet<string> = new Set(['references', 'extends', 'implements', 'type_of', 'returns', 'instantiates']);
/**
* Whether `ref`, in another file than the declaration, may name by its bare
* name a declaration of the repository's own `declare module '<specifier>'`
* in `declaringFile` (see ambientModuleDeclarations), the import resolver
* having bound nothing:
* - written inside a block declaring the same module, whose declarations
* merge (vscode's proposed API files add to `vscode.d.ts`);
* - the last name of a qualified one, the file importing the module or the
* declaring file: `vscode.TextDocument` under `import * as vscode from
* 'vscode'`, typescript-language-features' `Proto.Response` under `import
* type * as Proto from './protocol/protocol'`, whose `export =` hands out the
* namespace the file's block augments;
* - a name the file imports from the module, from the declaring file, or from
* a project file that imports the module and re-exports it (copilot's
* `vscodeTypes.ts`: `export import X = vscode.X`).
* A name bound to another package (an LSP `TextDocument`), to Node's own
* modules (`fs`), or to a project file that doesn't import the module, or a
* bare name a file reaching no such module writes, is not one of the module's.
*/
export function reachesAmbientModule(ref: UnresolvedRef, specifier: string, declaringFile: string, context: ResolutionContext): boolean {
if (isInModuleBlock(ref.filePath, ref.line, ref.column ?? 0, specifier, context)) return true;
const mappings = context.getImportMappings(ref.filePath, ref.language);
const relative = (source: string): boolean => source.startsWith('.') || source.startsWith('/');
const leadsTo = (source: string, through: (file: string) => boolean): boolean => {
if (source === specifier) return true;
if (!relative(source)) return false;
const file = resolveImportPath(source, ref.filePath, ref.language, context);
return file !== null && (file === declaringFile || through(file));
};
const head = ref.referenceName.split('.')[0]!;
const binding = mappings.find((m) => m.localName === head);
if (binding) {
return leadsTo(binding.source, (file) => context.getImportMappings(file, ref.language).some((m) => m.source === specifier));
}
return LAST_NAME_KINDS.has(ref.referenceKind) && mappings.some((m) => leadsTo(m.source, () => false));
}
/**
* 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;
// `import a.b as X` inside the calling function binds X there, ahead of any
// module-level binding of X, and the file's mappings don't hold it (#2600).
const local = pythonFunctionModuleAlias(receiver, ref, context);
if (local) {
// The module from the package roots, and its member as the module binds it,
// the same lookup as for a module-level binding below.
const localPath = resolveImportPath(local, ref.filePath, ref.language, context);
const target = localPath && localPath !== ref.filePath
? findExportedSymbol(localPath, { isDefault: false, isNamespace: false, exportedName: member, memberName: null }, 'python', context, new Set())
: null;
return target ? { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' } : null;
}
for (const imp of imports) {
// `import pkg.mod` binds `pkg`, so a call spells the whole path out —
// `pkg.mod.func()`. The mapping names the module by its last segment, which
// never matches that receiver; match the full dotted source instead.
const spelledOut = imp.isNamespace && imp.localName === imp.source.split('.').pop()
&& imp.source.includes('.') && ref.referenceName.startsWith(`${imp.source}.`);
if (imp.localName !== receiver && !spelledOut) 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;
let modulePath = imp.isNamespace
? imp.source
: imp.source.endsWith('.')
? imp.source + moduleName
: imp.source + '.' + moduleName;
const rest = ref.referenceName
.substring(spelledOut ? imp.source.length + 1 : dotIdx + 1)
.split('.');
if (!imp.isNamespace && pythonPackageBinds(imp, ref.filePath, context)) continue;
let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
if (!resolvedPath || resolvedPath === ref.filePath) continue;
// The symbol `name` as the module at `filePath` binds it: a module-level
// def (never a method — `mod.foo` must not land on a same-named class
// method, and never a nested def), or a name the module re-exports from
// elsewhere (`pkg/__init__.py` doing `from .impl import foo`).
const bound = (filePath: string, name: string): Node | undefined => findExportedSymbol(
filePath,
{ isDefault: false, isNamespace: false, exportedName: name, memberName: null },
'python',
context,
new Set()
);
// A CALL through a longer path — `utils.helpers.do_x()`, `mod.Klass.run()`
// — names submodules and then one symbol. Descend while the next segment
// is a module file; what is left must be a top-level callable, or a class
// and one of its methods. Anything else is not something this path can
// prove, so it is no edge — never the first segment's symbol, which for
// `mod.Klass.run()` would record a call to the class. Other reference
// kinds keep the single-member lookup below.
if (ref.referenceKind === 'calls' && rest.length > 1) {
while (rest.length > 1) {
// `modulePath` is spelled from the importer (`.utils.helpers`), so it
// resolves from there, not from the module file just reached.
const sub = resolveImportPath(`${modulePath}.${rest[0]}`, ref.filePath, ref.language, context);
if (!sub) break;
modulePath = `${modulePath}.${rest.shift()}`;
resolvedPath = sub;
}
const head = bound(resolvedPath, rest[0]!);
let target: Node | undefined;
if (rest.length === 1) {
target = head && (head.kind === 'function' || head.kind === 'class') ? head : undefined;
} else if (rest.length === 2 && head?.kind === 'class') {
target = context.getNodesInFile(head.filePath).find((n) =>
n.kind === 'method' && n.name === rest[1] && n.qualifiedName === `${head.qualifiedName}::${rest[1]}`);
}
if (target) {
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
}
continue;
}
const target = bound(resolvedPath, rest[0]!);
if (target) {
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
}
}
return null;
}
/**
* Whether `from P import N` binds something `P/__init__.py` defines or
* re-exports, rather than the submodule `P.N`. CPython's `_handle_fromlist`
* looks `N` up as an attribute of the package first and imports the
* submodule only when the package has no such attribute — so a def, a
* constant or a re-export of `N` in `__init__.py` wins over a `P/N.py` or
* `P/N/` beside it. A package that doesn't bind `N` leaves the submodule to
* answer, as before.
*/
function pythonPackageBinds(imp: ImportMapping, fromFile: string, context: ResolutionContext): boolean {
if (imp.isNamespace || imp.exportedName === '*') return false;
const packageFile = resolveImportPath(imp.source, fromFile, 'python', context);
if (!packageFile || !/(?:^|\/)__init__\.py$/.test(packageFile)) return false;
return (
findExportedSymbol(
packageFile,
{ isDefault: false, isNamespace: false, exportedName: imp.exportedName, memberName: null },
'python',
context,
new Set()
) !== undefined
);
}
/**
* 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,
// unless the package itself binds that name (see pythonPackageBinds).
// As in resolvePythonModuleMember, use the exported name so an alias
// still links to the real module file (#1626).
if (pythonPackageBinds(imp, ref.filePath, context)) continue;
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;
}
// A package the repository declares itself in a script `.d.ts`: that file
// is the module, whatever file the specifier resolves to (see ./ambient-modules).
if (isBareModuleSpecifier(modulePath, ref.language)) {
const declaring = findAmbientModuleFile(modulePath, ref.filePath, context);
if (declaring) return { original: ref, targetNodeId: declaring.id, confidence: 0.9, resolvedBy: 'import' };
}
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' };
}
}
}
return null;
}
/**
* Resolve a Python dotted module import (`import a.b.c`, `from .a.b import x`) 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 = pythonModuleFileNode(ref.referenceName, ref.filePath, context);
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;
// A module's item, never an impl's associated one: serde's `use
// crate::de::Error` is not `de/mod.rs`'s `impl SeqAccess`'s `type Error`.
const target = context.getNodesInFile(file).find(
(n) =>
n.name === leaf &&
!isRustAssociatedItem(n) &&
(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;
// Python's `from m import *` binds only `m.__all__`, else m's public names.
if (PYTHON_MODULE_FILE.test(next) && !want.isDefault && !pythonStarAllows(next, want.exportedName, context)) 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;
// The method called on the value itself: `app.jinja_env.filters.keys()`
// calls `keys`, no method of the app's type — never `jinja_env`.
const member = ref.referenceName.slice(localName.length + 1);
if (!member || member.includes('.')) 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;
// Several classes may share the name. The value's file says which one:
// the class it imports under that name, or else the one it declares.
const declaring = ESM_IMPORT_LANGUAGES.has(value.language)
? tsImportedTypeFile(m[1], { ...ref, filePath: value.filePath, language: value.language }, context) ?? value.filePath
: undefined;
const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method', declaring);
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, unless the specifier still
* names a project file through the importing app's `baseUrl` or catch-all
* `paths` (`import Base from 'components/Base'`).
*
* 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;
// A package the repository declares itself (`declare module 'x'`) has
// its declarations in the repository.
return isExternalImport(imp.source, ref.language, context) &&
resolveImportPath(imp.source, ref.filePath, ref.language, context) === null &&
!ambientModuleDeclares(imp.source, imp.exportedName, imp.isDefault, ref, context);
}
return false;
}
/**
* The tail a reference that failed to resolve is parked under when it goes
* through an import binding the module's key finds and its own tail does not:
* - the binding's whole name — the import itself, a call, `new`, a JSX tag, a
* value. 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 }`) names
* what the module declares under another name, so its own name, the
* default tail, is one the module never declares. A sync still looks it up
* by that name as well: a binding its module never resolves is linked by
* the name alone.
* A plain named import (`import { useInvocation } from './context'`) names
* what its module declares or passes on by that name. A file that declares
* it is found by the name, and the importers of a module that appears are
* re-opened through it (see module-importers), but an edit that makes a
* barrel pass the name on (`context/index.ts` gaining `export {
* useInvocation } from './context'`) declares nothing. So it waits under
* the key only when its module is a project file that has the key, the
* file whose edit that is, and gives no such name yet: not while the module
* is missing, nor for a workspace package's own name (`@vben/icons`), whose
* key is the package's folder, which its entry file (`src/index.ts`) never
* has, nor when the module already gives the name and the reference failed
* for another reason (a constant passed where a function is expected),
* which no edit of its exports changes.
* - a direct member of any binding, a named import's included
* (`NODE_PATH_EXCLUDELIST.includes(p)`, `store.get(k)`, `NS.make()`). It
* reaches the binding's declaration, or a member of it, through the module,
* and its default tail is the member's name (`includes`, `get`), which the
* module seldom declares and which too many other failed refs share for the
* retry to look it up. A sync still looks it up by that member's name, which
* is the member a namespace import's barrel forwards or the method a guess
* by name links, and by its receiver's, which a barrel's source declares.
* A chain (`URI.file(p).toString()`) or a longer path
* (`vscode.window.showErrorMessage`) keeps its member's name: what its last
* member hangs off is what another member returns or holds, seldom a type
* the module declares. Parked under the module as well, they tripled the
* refs an edit of a hot module retries on vscode, and none of them resolved.
* - a direct member call on a local the file declares with a type it
* imports (`const cache = new LRUCache(10)`, `cache: LRUCache<K, V>`, a
* typed parameter, then `cache.get(k)`), but not an array of it:
* resolution reads that declaration and calls the member of the class,
* which the type's binding imports, so the call waits for that module as a
* member of the binding itself does.
* Resolution reaches each 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, so the file that
* appears or gains the export finds it.
*
* Only for a module of the project: a package's binding keeps its tail. A
* bare specifier is the project's when the importing app's own config
* (`baseUrl`, a catch-all `"*"`) maps it to a file the index holds and no
* package the app declares goes by it. Undefined for every other reference.
* A Python reference is {@link pythonImportBindingTail}'s, by its whole name
* only.
*/
export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
if (ref.language === 'python') return pythonImportBindingTail(ref, context);
if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return undefined;
// An import's dotted name is a path, not a member.
const receiver = ref.referenceKind === 'imports' ? undefined : MEMBER_RECEIVER.exec(ref.referenceName)?.[1];
const name = receiver ?? ref.referenceName;
if (!receiver && !IDENTIFIER.test(name)) return undefined;
const mappings = context.getImportMappings(ref.filePath, ref.language);
const binding = mappings.find((m) => m.localName === name) ??
(receiver && ref.referenceKind === 'calls' ? localTypeBinding(receiver, ref, context, mappings) : undefined);
if (!binding) 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)) &&
// A module the app's `baseUrl` or catch-all `paths` reads (`layout/MainLayout`).
!namesOwnConfigModule(binding.source, ref.filePath, ref.language, context)) {
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;
const tail = moduleTail(modulePath);
if (!tail) return undefined;
if (!receiver && !binding.isDefault && !binding.isNamespace && binding.exportedName === binding.localName) {
const moduleFile = resolveImportPath(binding.source, ref.filePath, ref.language, context);
if (!moduleFile || !(context.isIndexedFile?.(moduleFile) ?? true) || !moduleReferenceKeys(moduleFile).includes(tail)) return undefined;
const want = { isDefault: false, isNamespace: false, exportedName: binding.exportedName, memberName: null };
if (findExportedSymbol(moduleFile, want, ref.language, context, new Set())) return undefined;
}
return tail;
}
/**
* {@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;
}
/**
* The import binding of the type a call's receiver is declared with, read the
* way resolution reads it for the call (inferLocalReceiverType, memoized per
* file, scope and receiver, so a call the matcher just tried costs a lookup):
* the binding the type is written through, `LRUCache` of `new LRUCache<K,
* V>(…)` or `lib` of `new lib.LRUCache()`, of the element type of an array
* (`results: URI[]`), or of the type whose property it is (`Emitter<E>['event']`).
* Undefined for a type the file doesn't import, in a file that imports
* nothing, and for a method arrays have called on an array (`results.push(…)`):
* resolution links that to no project class, so no edit of the type's module
* does. Such calls were most of what this parked on vscode, and retrying them
* nearly doubled what an edit of `uri.ts` retried.
*/
function localTypeBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext, mappings: ImportMapping[]): ImportMapping | undefined {
if (mappings.length === 0) return undefined;
const decl: { raw?: string } = {};
const type = inferLocalReceiverType(receiver, ref, context, decl);
if (!type || !decl.raw) return undefined;
if (arrayElementType(type) !== undefined && JS_ARRAY_METHODS.has(ref.referenceName.slice(receiver.length + 1))) return undefined;
const head = decl.raw.split(/[.<[\s]/)[0];
return mappings.find((m) => m.localName === head);
}
/**
* The declaration a default import of `filePath` written in `language` binds
* — `X` in `import X from './x'` or `{ default as X }` — followed through the
* modules the file forwards its default from, or undefined when it has none.
* The lookup resolveViaImport makes for that binding, memoized with it.
*/
export function moduleDefaultExport(filePath: string, language: Language, context: ResolutionContext): Node | undefined {
return findExportedSymbol(filePath, { isDefault: true, isNamespace: false, exportedName: 'default', memberName: null }, language, context, new Set());
}
/** A `.js`/`.ts`-family file, the modules whose default an `export default` statement or the first exported declaration names. */
export function isJsFamilyFile(filePath: string): boolean {
return JS_FAMILY_FILE.test(filePath);
}
/**
* Whether the name of an `imports` reference into a module's own file node is
* a binding the importing file holds on the module — `Y` of `import Y from
* './x'`, `{ default as Y }` or `import * as Y` — rather than the path it
* imports the module by.
*/
export function isModuleBindingName(name: string): boolean {
return IDENTIFIER.test(name);
}
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
/** The receiver of a direct member reference: `store` of `store.get`, none of `store.get().then` or `a.b.c`. */
const MEMBER_RECEIVER = /^([A-Za-z_$][\w$]*)\.[A-Za-z_$][\w$]*$/;