mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-22 01:07:18 +08:00
4f3eaf33ae
An import statement's own module reference linked nothing in two cases, even when its bindings resolved through the file: a Vue, Svelte or Astro component's import (`isJsPathImportRef` covered only the script languages), and a bare scoped package that a `tsconfig` path or a workspace package maps to a project file (`isJsPathSpecifier` excluded `@scope/pkg`). So a barrel or a side-effect module showed no dependency from those files, and sync's importer discovery, which follows the module edge into a changed file, never found them when a barrel they import through was deleted or added. Both now resolve through `resolveImportPath`, as a script's path import does; an outside scoped package still names no file. Component imports written with an extension, which a basename match linked before, now link through the resolver too. When a sync adds a file, it also retries the failed imports that now resolve to it but wait under no key its path gives: a scoped package parked under its last segment (`common` for `@excalidraw/common`, mapped to `packages/common/src/index.ts`, whose keys are `src` and `index`), under the module ceiling, and an import of the importer's own folder or the one above (`.`, `..`), parked under no tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3240 lines
135 KiB
TypeScript
3240 lines
135 KiB
TypeScript
/**
|
||
* Import Resolver
|
||
*
|
||
* Resolves import paths to actual files and symbols.
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { Language, Node } from '../types';
|
||
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
|
||
import { applyAliases } from './path-aliases';
|
||
import { extractLocalExportAliases } from './alias-binding';
|
||
import { resolveWorkspaceImport } from './workspace-packages';
|
||
import { stripCommentsForRegex } from './strip-comments';
|
||
import { makeLineAt } from './synth-utils';
|
||
import { dartDirectiveFile } from './dart-libraries';
|
||
import { moduleTail } from '../db/reference-tail';
|
||
import {
|
||
resolveMethodOnType,
|
||
resolveObjectLiteralMember,
|
||
resolveObjectLiteralBinding,
|
||
localReceiverTypePatterns,
|
||
normalizeInferredTypeName,
|
||
goRefQualifier,
|
||
} from './name-matcher';
|
||
|
||
/**
|
||
* Extension resolution order by language
|
||
*/
|
||
const EXTENSION_RESOLUTION: Record<string, string[]> = {
|
||
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
|
||
// ArkTS imports both `.ets` components and plain `.ts` logic modules —
|
||
// HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's
|
||
// module-entry convention, hit when a bare workspace import ("data") is
|
||
// rewritten to the member's directory; lowercase variants for safety.
|
||
arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'],
|
||
javascript: ['.js', '.jsx', '.mjs', '.cjs', '.xsjs', '.xsjslib', '/index.js', '/index.jsx'],
|
||
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
|
||
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
|
||
// SFC consumers import plain TS/JS, sibling components, and barrels
|
||
// (`./lib` → `./lib/index.ts`). Without a list, relative imports from a
|
||
// `.svelte`/`.vue` file resolve to nothing, so barrel callers vanish (#629).
|
||
svelte: ['.ts', '.js', '.svelte', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.svelte'],
|
||
vue: ['.ts', '.js', '.vue', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.vue'],
|
||
astro: ['.ts', '.js', '.astro', '.tsx', '.jsx', '/index.ts', '/index.js', '/index.astro'],
|
||
python: ['.py', '/__init__.py'],
|
||
go: ['.go'],
|
||
rust: ['.rs', '/mod.rs'],
|
||
java: ['.java'],
|
||
c: ['.h', '.c'],
|
||
cpp: ['.h', '.hpp', '.hxx', '.cpp', '.cc', '.cxx'],
|
||
csharp: ['.cs'],
|
||
php: ['.php'],
|
||
ruby: ['.rb'],
|
||
objc: ['.h', '.m', '.mm'],
|
||
nix: ['.nix', '/default.nix'],
|
||
};
|
||
|
||
export function isNixPathImportRef(ref: UnresolvedRef): boolean {
|
||
return (
|
||
ref.language === 'nix' &&
|
||
ref.referenceKind === 'imports' &&
|
||
(ref.referenceName.startsWith('./') || ref.referenceName.startsWith('../')) &&
|
||
!/[\s{}()[\];"'<>$]/.test(ref.referenceName)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve an import path to an actual file
|
||
*/
|
||
// Per-context memos for the two hottest pure lookups on the resolution path:
|
||
// import-specifier → file resolution and exported-symbol lookup. Both are pure
|
||
// given a stable file set + node table, which is exactly the window between
|
||
// ReferenceResolver.clearCaches() calls — clearImportResolverMemos() is invoked
|
||
// there, so the staleness discipline matches the resolver's own caches.
|
||
const importPathMemos = new WeakMap<ResolutionContext, Map<string, string | null>>();
|
||
const exportedSymbolMemos = new WeakMap<ResolutionContext, Map<string, Node | undefined>>();
|
||
|
||
/**
|
||
* Per-file index of exported symbols, replacing repeated linear `.find`s over
|
||
* `getNodesInFile` arrays (a barrel-heavy repo scans its biggest files once
|
||
* per referencing symbol otherwise). First-wins insertion preserves exactly
|
||
* the array-order semantics of the `.find` calls it replaces.
|
||
*
|
||
* Built from the file's exported rows alone: every worker of the resolver
|
||
* pool builds its own index for each file an import reaches, and decoding
|
||
* whole files there cost more than resolving through them. The two answers
|
||
* that need more — what the `export default` statement exports and names a
|
||
* local export clause introduces — read the source and name-targeted rows on
|
||
* first use.
|
||
*/
|
||
interface FileExportIndex {
|
||
/** Exported declarations by name. Read through {@link exportedByName}. */
|
||
byName: Map<string, Node>;
|
||
defaultComponent: Node | undefined;
|
||
defaultFnClass: Node | undefined;
|
||
/**
|
||
* What the file's own `export default` statement exports — the precise
|
||
* answer where `defaultComponent`/`defaultFnClass` are guesses. `undefined`
|
||
* until first read through {@link statedDefaultExport}.
|
||
*/
|
||
statedDefault?: StatedDefault;
|
||
/**
|
||
* Names a local export clause (`export { impl as alias }`) binds to a
|
||
* declaration the extractor never flagged isExported, for names not in
|
||
* `byName`. `undefined` until first read through {@link exportedByName}.
|
||
*/
|
||
clauseAliases?: Map<string, Node>;
|
||
}
|
||
|
||
const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
|
||
const DEFAULT_DECLARATION_KINDS = new Set<string>(['function', 'class', 'component']);
|
||
/** Any `export default …` statement: the module is an ES module with a default export. */
|
||
const ESM_DEFAULT_EXPORT_RE = /^[ \t]*export\s+default\b/m;
|
||
const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
||
|
||
/**
|
||
* What a file's own `export default` statement exports: the node it declares
|
||
* or names, `'anonymous'` for a function or class with no name (no node
|
||
* stands for it), `'unstated'` when the file says nothing a node answers.
|
||
*/
|
||
type StatedDefault = Node | 'anonymous' | 'unstated';
|
||
|
||
const EXPORT_DEFAULT_HEAD = /^[ \t]*export\s+default\s+/gm;
|
||
/** `[async] function[*] [NAME]` / `[abstract] class [NAME]`; the name is group 1 or 2. */
|
||
const DEFAULT_DECLARATION_RE =
|
||
/^(?:async\s+)?function\b\s*(?:\*\s*)?([A-Za-z_$][\w$]*)?|^(?:abstract\s+)?class\b(?:\s+(?!extends\b|implements\b)([A-Za-z_$][\w$]*))?/;
|
||
/** `[async] x =>`, or `[async] (` opening what may be an arrow function's parameters. */
|
||
const DEFAULT_ARROW_RE = /^(?:async\s+)?(?:[A-Za-z_$][\w$]*\s*=>|\()/;
|
||
/** `NAME` alone on its line. */
|
||
const DEFAULT_BINDING_RE = /^([A-Za-z_$][\w$]*)\s*;?[ \t]*(?:\r?\n|$)/;
|
||
|
||
/**
|
||
* The value of the `export default` statement whose value starts at `at`, in
|
||
* comment-stripped code: the name a declaration or a binding gives it,
|
||
* `'anonymous'` for a function, class or arrow function without one, or null
|
||
* for any other expression.
|
||
*/
|
||
function readDefaultExport(code: string, at: number): { name: string; nameAt: number; declared: boolean } | 'anonymous' | null {
|
||
const rest = code.slice(at, at + 2048);
|
||
const decl = DEFAULT_DECLARATION_RE.exec(rest);
|
||
if (decl) {
|
||
const name = decl[1] ?? decl[2];
|
||
return name ? { name, nameAt: at + decl[0].length - name.length, declared: true } : 'anonymous';
|
||
}
|
||
const arrow = DEFAULT_ARROW_RE.exec(rest);
|
||
if (arrow) {
|
||
if (!arrow[0].endsWith('(')) return 'anonymous';
|
||
// A parenthesized list is an arrow function's parameters when `=>` or a
|
||
// return type (`(…): JSX.Element =>`) follows it; otherwise it is an
|
||
// expression (`(Foo)`).
|
||
let depth = 0;
|
||
for (let i = arrow[0].length - 1; i < rest.length; i++) {
|
||
if (rest[i] === '(') depth++;
|
||
else if (rest[i] === ')' && --depth === 0) return /^\s*(?:=>|:)/.test(rest.slice(i + 1)) ? 'anonymous' : null;
|
||
}
|
||
return null;
|
||
}
|
||
const binding = DEFAULT_BINDING_RE.exec(rest);
|
||
return binding ? { name: binding[1]!, nameAt: at, declared: false } : null;
|
||
}
|
||
|
||
/**
|
||
* What `filePath`'s own `export default` statement exports. The statement
|
||
* says it outright, where the export index can only guess:
|
||
* - `export default function Vans() {…}` / `export default class Store` —
|
||
* the declaration the statement writes, found by position. A React Router
|
||
* data-router page exports its `loader` or `action` above it, and the
|
||
* first-exported guess bound the page's default import to the loader.
|
||
* - `export default Home` — the binding it names, exported at its
|
||
* declaration or not: `const Home = () => …` and the namespace object
|
||
* `const UploadApi = { uploadARCapture }` have no `export_statement`
|
||
* ancestor, so the `isExported` index never sees them.
|
||
* - `export default function () {…}`, `export default class {…}`, `export
|
||
* default () => …` — anonymous: no node is the default export, and any
|
||
* exported function beside it (or nested in it, which the extractor flags
|
||
* exported too) would be the wrong one.
|
||
* Anything else — an expression (`memo(Card)`, `new Service()`, `{ … }`), a
|
||
* binding the file doesn't declare, no statement at all, a non-JS file — is
|
||
* `'unstated'`. Comments are stripped first. Strings are not masked: a
|
||
* template whose `${…}` holds another template, or a regex with a backtick,
|
||
* sets a masker out of step, and it blanked the real statement after it
|
||
* (outline's 2,900-line styled-components `Styles.ts`). A statement must
|
||
* name a node of the file instead — one a template only writes as text
|
||
* names none — and of several statements, one that does wins.
|
||
*/
|
||
function statedDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): StatedDefault {
|
||
if (idx.statedDefault !== undefined) return idx.statedDefault;
|
||
let stated: StatedDefault | undefined;
|
||
const source = JS_FAMILY_FILE.test(filePath) ? context.readFile(filePath) : null;
|
||
if (source && /\bexport\s+default\b/.test(source)) {
|
||
const code = stripCommentsForRegex(source, 'typescript');
|
||
const lineAt = makeLineAt(code, 1);
|
||
EXPORT_DEFAULT_HEAD.lastIndex = 0;
|
||
for (let m = EXPORT_DEFAULT_HEAD.exec(code); m !== null; m = EXPORT_DEFAULT_HEAD.exec(code)) {
|
||
const value = readDefaultExport(code, m.index + m[0].length);
|
||
if (value === 'anonymous') {
|
||
stated ??= 'anonymous';
|
||
continue;
|
||
}
|
||
stated ??= 'unstated';
|
||
if (value === null) continue;
|
||
const named = nodesInFileNamed(filePath, value.name, context);
|
||
// A declaration starts on the statement's line, or on its name's when
|
||
// the statement breaks before it (`export default\nfunction X`).
|
||
const from = lineAt(m.index);
|
||
const to = lineAt(value.nameAt);
|
||
const node = value.declared
|
||
? named.find((n) => DEFAULT_DECLARATION_KINDS.has(n.kind) && n.startLine >= from && n.startLine <= to)
|
||
: named
|
||
.filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
|
||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
||
if (node) {
|
||
stated = node;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
idx.statedDefault = stated ?? 'unstated';
|
||
return idx.statedDefault;
|
||
}
|
||
const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
|
||
|
||
/**
|
||
* `module.exports = …` (also `exports = module.exports = …`): the name it
|
||
* binds (`createApplication`, `function name(`, `class Name`) or the module
|
||
* it forwards (`require('./lib/express')`), or null.
|
||
*/
|
||
const COMMONJS_DEFAULT_EXPORT =
|
||
/^[ \t]*(?:exports\s*=\s*)?module\.exports\s*=\s*(?:exports\s*=\s*)?(?:require\(\s*['"]([^'"]+)['"]\s*\)\s*;?[ \t]*$|(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|class\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/m;
|
||
|
||
function commonJsDefaultExport(filePath: string, context: ResolutionContext): { source?: string; name?: string } | null {
|
||
if (!JS_FAMILY_FILE.test(filePath)) return null;
|
||
if (context.fileContains && !context.fileContains(filePath, 'module.exports')) return null;
|
||
const source = context.readFile(filePath);
|
||
if (!source || !source.includes('module.exports')) return null;
|
||
const m = COMMONJS_DEFAULT_EXPORT.exec(source);
|
||
if (!m) return null;
|
||
return m[1] ? { source: m[1] } : { name: m[2] ?? m[3] ?? m[4] };
|
||
}
|
||
|
||
function getFileExportIndex(filePath: string, context: ResolutionContext): FileExportIndex {
|
||
let perFile = fileExportIndexes.get(context);
|
||
if (!perFile) {
|
||
perFile = new Map();
|
||
fileExportIndexes.set(context, perFile);
|
||
}
|
||
let idx = perFile.get(filePath);
|
||
if (!idx) {
|
||
idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined };
|
||
const exported = context.getExportedNodesInFile?.(filePath) ?? context.getNodesInFile(filePath).filter((n) => n.isExported);
|
||
for (const n of exported) {
|
||
if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
|
||
if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
|
||
if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n;
|
||
}
|
||
perFile.set(filePath, idx);
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
/** The file's nodes named `name`, in `getNodesInFile` order. */
|
||
function nodesInFileNamed(filePath: string, name: string, context: ResolutionContext): Node[] {
|
||
return context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name);
|
||
}
|
||
|
||
/**
|
||
* The declaration a module default-exports: what its own `export default`
|
||
* statement declares or names (see {@link statedDefaultExport}), and nothing
|
||
* for an anonymous default. Only a default the statement leaves unnamed
|
||
* (`export default memo(Card)`, `new Service()`), or a module with no
|
||
* statement, falls back: to the component a single-file component file is,
|
||
* else to the first exported function or class.
|
||
*/
|
||
function esmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): Node | undefined {
|
||
const stated = statedDefaultExport(filePath, idx, context);
|
||
if (stated === 'anonymous') return undefined;
|
||
if (stated !== 'unstated') return stated;
|
||
return idx.defaultComponent ?? idx.defaultFnClass;
|
||
}
|
||
|
||
/**
|
||
* Whether the module has an ESM default export at all: it is a single-file
|
||
* component, or a JS-family file with an `export default` statement. A
|
||
* CommonJS module has none, though its `exports.x = function` declarations are
|
||
* exported and would feed the first-exported-function guess.
|
||
*/
|
||
function hasEsmDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): boolean {
|
||
if (idx.defaultComponent) return true;
|
||
if (!JS_FAMILY_FILE.test(filePath)) return false;
|
||
const source = context.readFile(filePath);
|
||
return !!source && source.includes('default') && ESM_DEFAULT_EXPORT_RE.test(source);
|
||
}
|
||
|
||
/** What this file exports as `name`: an exported declaration, else a local export clause's binding. */
|
||
function exportedByName(filePath: string, idx: FileExportIndex, name: string, context: ResolutionContext): Node | undefined {
|
||
const direct = idx.byName.get(name);
|
||
if (direct) return direct;
|
||
if (idx.clauseAliases === undefined) {
|
||
// Bind names introduced by a local export clause to their declarations, so
|
||
// an importer asking for the renamed name gets the real symbol instead of
|
||
// falling through to the name-matcher (which cannot cross the rename).
|
||
// The declaration is the file's first node of that name, exported or not.
|
||
idx.clauseAliases = new Map();
|
||
const content = context.readFile(filePath);
|
||
if (content && content.includes('export')) {
|
||
for (const { exportedName, localName } of extractLocalExportAliases(content)) {
|
||
if (idx.byName.has(exportedName) || idx.clauseAliases.has(exportedName)) continue;
|
||
const decl = nodesInFileNamed(filePath, localName, context)[0];
|
||
if (decl) idx.clauseAliases.set(exportedName, decl);
|
||
}
|
||
}
|
||
}
|
||
return idx.clauseAliases.get(name);
|
||
}
|
||
|
||
/** Drop the per-context memo tables (see ReferenceResolver.clearCaches). */
|
||
export function clearImportResolverMemos(context: ResolutionContext): void {
|
||
importPathMemos.delete(context);
|
||
exportedSymbolMemos.delete(context);
|
||
fileExportIndexes.delete(context);
|
||
luaFileBasenameIndexes.delete(context);
|
||
cobolCopybookIndexes.delete(context);
|
||
pythonModuleFileMemos.delete(context);
|
||
PY_MODULE_SYMBOLS.delete(context);
|
||
PROJECT_FOLDER_SUFFIXES.delete(context);
|
||
}
|
||
|
||
export function resolveImportPath(
|
||
importPath: string,
|
||
fromFile: string,
|
||
language: Language,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
let memo = importPathMemos.get(context);
|
||
if (!memo) {
|
||
memo = new Map();
|
||
importPathMemos.set(context, memo);
|
||
}
|
||
const key = `${language}\0${fromFile}\0${importPath}`;
|
||
const hit = memo.get(key);
|
||
if (hit !== undefined || memo.has(key)) return hit ?? null;
|
||
const resolved = resolveImportPathUncached(importPath, fromFile, language, context);
|
||
memo.set(key, resolved);
|
||
return resolved;
|
||
}
|
||
|
||
function resolveImportPathUncached(
|
||
importPath: string,
|
||
fromFile: string,
|
||
language: Language,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
// COBOL COPY/EXEC SQL INCLUDE names a copybook member, not a path — the
|
||
// compiler searches a library, so we match against indexed file basenames.
|
||
// Must run before isExternalImport: a bare member name would otherwise be
|
||
// misclassified as an external package.
|
||
if (language === 'cobol') {
|
||
return resolveCobolCopybook(importPath, fromFile, context);
|
||
}
|
||
|
||
// Skip external/npm packages — but pass the context so the
|
||
// bare-specifier heuristic can consult the project's tsconfig
|
||
// alias map first (custom prefixes like `@components/*` would
|
||
// otherwise be misclassified as npm).
|
||
if (isExternalImport(importPath, language, context)) {
|
||
return null;
|
||
}
|
||
|
||
const projectRoot = context.getProjectRoot();
|
||
const fromDir = path.dirname(path.join(projectRoot, fromFile));
|
||
|
||
// Handle relative imports
|
||
if (importPath.startsWith('.')) {
|
||
return resolveRelativeImport(importPath, fromDir, language, context);
|
||
}
|
||
|
||
// Handle absolute/aliased imports (like @/ or src/)
|
||
const aliased = resolveAliasedImport(importPath, projectRoot, language, context, fromFile);
|
||
if (aliased) return aliased;
|
||
|
||
// C/C++ include directory search: when neither relative nor aliased
|
||
// resolution found a match, search -I directories from
|
||
// compile_commands.json or heuristic probing.
|
||
if (language === 'c' || language === 'cpp') {
|
||
return resolveCppIncludePath(importPath, language, context);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* COBOL copybook lookup: `COPY CVACT01Y` (or `EXEC SQL INCLUDE X`) names a
|
||
* library member resolved by the compiler's copybook search path, so we match
|
||
* the member against indexed file basenames, case-insensitively. `.cpy` wins
|
||
* over a same-named program; a same-directory hit wins within a tier. The
|
||
* stem index is built once per resolution context (a per-ref scan of every
|
||
* file node would go quadratic on copybook-heavy repos).
|
||
*/
|
||
const cobolCopybookIndexes = new WeakMap<ResolutionContext, Map<string, string[]>>();
|
||
|
||
/**
|
||
* Per-context basename → file-paths index for Lua/Luau require resolution
|
||
* (cobolCopybookIndexes pattern). resolveLuaRequire previously ran
|
||
* `getAllFiles().filter(endsWith)` FOUR times per require ref — ~7.5k string
|
||
* suffix scans each, measured at ~0.9ms/ref (2.7s combined on kong's 3k
|
||
* requires). Buckets preserve getAllFiles() iteration order so the per-suffix
|
||
* candidate list filters to exactly the array the full scan produced —
|
||
* identical matches, identical stable sort, identical winner.
|
||
*/
|
||
const luaFileBasenameIndexes = new WeakMap<ResolutionContext, Map<string, string[]>>();
|
||
|
||
function luaBasenameIndex(context: ResolutionContext): Map<string, string[]> {
|
||
let index = luaFileBasenameIndexes.get(context);
|
||
if (!index) {
|
||
index = new Map();
|
||
for (const f of context.getAllFiles()) {
|
||
const base = f.split('/').pop() ?? '';
|
||
const paths = index.get(base);
|
||
if (paths) paths.push(f);
|
||
else index.set(base, [f]);
|
||
}
|
||
luaFileBasenameIndexes.set(context, index);
|
||
}
|
||
return index;
|
||
}
|
||
|
||
function resolveCobolCopybook(
|
||
member: string,
|
||
fromFile: string,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
let index = cobolCopybookIndexes.get(context);
|
||
if (!index) {
|
||
index = new Map();
|
||
for (const fileNode of context.getNodesByKind('file')) {
|
||
const normalized = fileNode.filePath.replace(/\\/g, '/');
|
||
const base = normalized.split('/').pop() ?? '';
|
||
const dot = base.lastIndexOf('.');
|
||
const stem = (dot > 0 ? base.slice(0, dot) : base).toLowerCase();
|
||
const paths = index.get(stem);
|
||
if (paths) paths.push(fileNode.filePath);
|
||
else index.set(stem, [fileNode.filePath]);
|
||
}
|
||
cobolCopybookIndexes.set(context, index);
|
||
}
|
||
|
||
const candidates = index.get(member.toLowerCase());
|
||
if (!candidates || candidates.length === 0) return null;
|
||
|
||
const fromDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||
let best: string | null = null;
|
||
let bestScore = -1;
|
||
for (const candidate of candidates) {
|
||
const normalized = candidate.replace(/\\/g, '/');
|
||
const ext = normalized.slice(normalized.lastIndexOf('.')).toLowerCase();
|
||
let score = 0;
|
||
if (ext === '.cpy') score += 4;
|
||
else if (ext === '.cbl' || ext === '.cob' || ext === '.cobol') score += 2;
|
||
if (normalized.split('/').slice(0, -1).join('/') === fromDir) score += 1;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = candidate;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* C and C++ standard library header names (without delimiters).
|
||
* Used by isExternalImport to filter system includes from resolution.
|
||
*/
|
||
const C_CPP_STDLIB_HEADERS = new Set([
|
||
// C standard library headers
|
||
'assert.h', 'complex.h', 'ctype.h', 'errno.h', 'fenv.h', 'float.h',
|
||
'inttypes.h', 'iso646.h', 'limits.h', 'locale.h', 'math.h', 'setjmp.h',
|
||
'signal.h', 'stdalign.h', 'stdarg.h', 'stdatomic.h', 'stdbool.h',
|
||
'stddef.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'stdnoreturn.h',
|
||
'string.h', 'tgmath.h', 'threads.h', 'time.h', 'uchar.h', 'wchar.h',
|
||
'wctype.h',
|
||
// C++ C-library wrappers (cname form)
|
||
'cassert', 'ccomplex', 'cctype', 'cerrno', 'cfenv', 'cfloat',
|
||
'cinttypes', 'ciso646', 'climits', 'clocale', 'cmath', 'csetjmp',
|
||
'csignal', 'cstdalign', 'cstdarg', 'cstdbool', 'cstddef', 'cstdint',
|
||
'cstdio', 'cstdlib', 'cstring', 'ctgmath', 'ctime', 'cuchar',
|
||
'cwchar', 'cwctype',
|
||
// C++ STL headers
|
||
'algorithm', 'any', 'array', 'atomic', 'barrier', 'bit', 'bitset',
|
||
'charconv', 'chrono', 'codecvt', 'compare', 'complex', 'concepts',
|
||
'condition_variable', 'coroutine', 'deque', 'exception', 'execution',
|
||
'expected', 'filesystem', 'format', 'forward_list', 'fstream',
|
||
'functional', 'future', 'generator', 'initializer_list', 'iomanip',
|
||
'ios', 'iosfwd', 'iostream', 'istream', 'iterator', 'latch',
|
||
'limits', 'list', 'locale', 'map', 'mdspan', 'memory', 'memory_resource',
|
||
'mutex', 'new', 'numbers', 'numeric', 'optional', 'ostream', 'print',
|
||
'queue', 'random', 'ranges', 'ratio', 'regex', 'scoped_allocator',
|
||
'semaphore', 'set', 'shared_mutex', 'source_location', 'span',
|
||
'spanstream', 'sstream', 'stack', 'stacktrace', 'stdexcept',
|
||
'stdfloat', 'stop_token', 'streambuf', 'string', 'string_view',
|
||
'strstream', 'syncstream', 'system_error', 'thread', 'tuple',
|
||
'type_traits', 'typeindex', 'typeinfo', 'unordered_map',
|
||
'unordered_set', 'utility', 'valarray', 'variant', 'vector',
|
||
'version',
|
||
]);
|
||
|
||
/**
|
||
* Languages whose imports are ES-module specifiers, extracted by
|
||
* `extractJSImports` and therefore classified by the same bare-specifier /
|
||
* alias / workspace rules. Svelte, Vue and Astro belong here: an SFC imports
|
||
* inside its `<script>` block (Astro: the `---` frontmatter) with exactly the
|
||
* same syntax, and leaving them out made `isExternalImport` answer "not
|
||
* external" for every npm specifier in an SFC.
|
||
*/
|
||
const ESM_IMPORT_LANGUAGES = new Set<Language>([
|
||
'typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'svelte', 'vue', 'astro',
|
||
]);
|
||
|
||
/** Rust path roots that always name a standard-library crate. */
|
||
const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']);
|
||
|
||
/**
|
||
* Check if an import is external (npm package, etc.)
|
||
*
|
||
* `context` is consulted for project-defined path aliases
|
||
* (tsconfig/jsconfig `paths`). Without that check, custom prefixes
|
||
* like `@components/*` would fail the bare-specifier heuristic and
|
||
* be classified as external before alias resolution can run.
|
||
*
|
||
* `aliasPrefixes: false` skips that check, for a caller that has already
|
||
* asked `resolveImportPath` whether an alias maps the specifier to a file.
|
||
* Matching a prefix proves nothing by itself: a catch-all `"*"` pattern
|
||
* (`"*": ["./typings/*"]`) has an empty prefix and matches every package.
|
||
*/
|
||
export function isExternalImport(
|
||
importPath: string,
|
||
language: Language,
|
||
context?: ResolutionContext,
|
||
options: { aliasPrefixes?: boolean } = {}
|
||
): boolean {
|
||
// Relative imports are not external
|
||
if (importPath.startsWith('.')) {
|
||
return false;
|
||
}
|
||
|
||
// Workspace-member imports (`@scope/ui`, `@scope/ui/widgets`) are LOCAL to
|
||
// a monorepo even though they look like bare npm specifiers. Consult the
|
||
// workspace map first so they aren't misclassified as external (#629). The
|
||
// map is null for single-package repos, so this is a no-op there.
|
||
const workspaces = context?.getWorkspacePackages?.();
|
||
if (workspaces && resolveWorkspaceImport(importPath, workspaces)) {
|
||
return false;
|
||
}
|
||
|
||
// Common external patterns
|
||
if (ESM_IMPORT_LANGUAGES.has(language)) {
|
||
// Node built-ins
|
||
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
|
||
return true;
|
||
}
|
||
// Project-defined alias prefix? Treat as local.
|
||
const aliases = options.aliasPrefixes === false ? null : context?.getProjectAliases?.();
|
||
if (aliases) {
|
||
for (const pat of aliases.patterns) {
|
||
if (importPath.startsWith(pat.prefix)) return false;
|
||
}
|
||
}
|
||
// Scoped packages or bare specifiers that don't start with aliases
|
||
if (!importPath.startsWith('@/') && !importPath.startsWith('~/') && !importPath.startsWith('src/')) {
|
||
// Likely an npm package
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (language === 'python') {
|
||
// Standard library modules
|
||
const stdLibs = ['os', 'sys', 'json', 're', 'math', 'datetime', 'collections', 'typing', 'pathlib', 'logging'];
|
||
if (stdLibs.includes(importPath.split('.')[0]!)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (language === 'go') {
|
||
// Relative imports (rare in idiomatic Go but the grammar allows them).
|
||
if (importPath.startsWith('.')) {
|
||
return false;
|
||
}
|
||
// In-module imports look like `<module-path>/sub/pkg` — local to
|
||
// this project. Without the module-path check we'd flag every
|
||
// cross-package call in a Go monorepo as external (issue #388) — and,
|
||
// for a module whose go.mod is below the root or beside other modules,
|
||
// every import of it (#2322).
|
||
if (context?.getGoPackageDir?.(importPath) != null) {
|
||
return false;
|
||
}
|
||
// `internal/` packages stay local even when go.mod is missing —
|
||
// preserves the pre-#388 escape hatch for repos without a parsed module path.
|
||
if (importPath.includes('/internal/')) {
|
||
return false;
|
||
}
|
||
// Anything else is the Go standard library or a third-party module.
|
||
return true;
|
||
}
|
||
|
||
if (language === 'c' || language === 'cpp') {
|
||
// C/C++ standard library headers — both C-style (<stdio.h>) and
|
||
// C++-style (<cstdio>, <vector>) forms. Checked against the import
|
||
// path (which the extractor strips of <> or "" delimiters).
|
||
if (C_CPP_STDLIB_HEADERS.has(importPath)) return true;
|
||
// C++ headers without .h extension (e.g. "vector", "string")
|
||
const withoutExt = importPath.replace(/\.h$/, '');
|
||
if (C_CPP_STDLIB_HEADERS.has(withoutExt)) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Resolve a relative import
|
||
*/
|
||
function resolveRelativeImport(
|
||
importPath: string,
|
||
fromDir: string,
|
||
language: Language,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
const projectRoot = context.getProjectRoot();
|
||
const extensions = EXTENSION_RESOLUTION[language] || [];
|
||
|
||
// Python dotted-relative imports (`from .certs import x`, `from ..pkg.mod
|
||
// import y`): leading dots are PACKAGE levels (1 = current package), and the
|
||
// remainder is a dotted submodule path. `path.resolve(dir, '.certs')` would
|
||
// treat `.certs` as a literal hidden filename, so translate the Python form
|
||
// to a real filesystem-relative path before resolving.
|
||
if (language === 'python' && importPath.startsWith('.')) {
|
||
const dots = importPath.length - importPath.replace(/^\.+/, '').length;
|
||
const up = '../'.repeat(Math.max(0, dots - 1)); // 1 dot = current dir
|
||
const rest = importPath.slice(dots).replace(/\./g, '/'); // 'sub.mod' -> 'sub/mod'
|
||
const pyBase = path.resolve(fromDir, up + rest);
|
||
const pyRel = path.relative(projectRoot, pyBase).replace(/\\/g, '/');
|
||
for (const ext of extensions) {
|
||
if (context.fileExists(pyRel + ext)) return pyRel + ext;
|
||
}
|
||
if (pyRel && context.fileExists(pyRel)) return pyRel;
|
||
return null;
|
||
}
|
||
|
||
// Try the path as-is first
|
||
const basePath = path.resolve(fromDir, importPath);
|
||
const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
|
||
|
||
// Try each extension. `require('..')` up to the project root is its `index.js`.
|
||
for (const ext of extensions) {
|
||
if (relativePath === '' && !ext.startsWith('/')) continue;
|
||
const candidatePath = relativePath === '' ? ext.slice(1) : relativePath + ext;
|
||
if (context.fileExists(candidatePath)) {
|
||
return candidatePath;
|
||
}
|
||
}
|
||
|
||
// Try without extension (might already have one)
|
||
if (context.fileExists(relativePath)) {
|
||
return relativePath;
|
||
}
|
||
|
||
return findSourceForEmittedSpecifier(relativePath, language, context);
|
||
}
|
||
|
||
/**
|
||
* TypeScript under `moduleResolution: node16 | nodenext | bundler` writes the
|
||
* EMITTED extension in the specifier (`import x from './util.js'` for
|
||
* `util.ts`, `.mjs` for `.mts`, `.cjs` for `.cts`), and the source file with that
|
||
* exact name never exists in the repo. Without this remap the import resolver
|
||
* returned null for every such import, so each imported name fell through to
|
||
* bare-name matching: a method wrapping the same-named helper it imports
|
||
* (`renderDockStyles() { return renderDockStyles() }`) resolved to ITSELF, and
|
||
* any repo-wide same-named symbol could win the cross-module edge.
|
||
*/
|
||
const EMITTED_TO_SOURCE_EXTENSIONS: ReadonlyArray<readonly [RegExp, readonly string[]]> = [
|
||
[/\.js$/, ['.ts', '.tsx', '.d.ts']],
|
||
[/\.jsx$/, ['.tsx']],
|
||
[/\.mjs$/, ['.mts', '.d.mts']],
|
||
[/\.cjs$/, ['.cts', '.d.cts']],
|
||
];
|
||
|
||
function findSourceForEmittedSpecifier(
|
||
relativePath: string,
|
||
language: Language,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
if (!EMITTED_SPECIFIER_LANGUAGES.has(language)) return null;
|
||
for (const [emitted, sources] of EMITTED_TO_SOURCE_EXTENSIONS) {
|
||
if (!emitted.test(relativePath)) continue;
|
||
const stem = relativePath.replace(emitted, '');
|
||
for (const ext of sources) {
|
||
const candidate = stem + ext;
|
||
if (context.fileExists(candidate)) return candidate;
|
||
}
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Languages whose import specifiers can name the emitted `.js` of a `.ts` source. */
|
||
const EMITTED_SPECIFIER_LANGUAGES: ReadonlySet<string> = new Set([
|
||
'typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro', 'arkts',
|
||
]);
|
||
|
||
/**
|
||
* Resolve an aliased/absolute import.
|
||
*
|
||
* Tries, in order:
|
||
* 1. Project-defined `compilerOptions.paths` (tsconfig/jsconfig).
|
||
* Each pattern can have multiple replacements; tried in tsconfig
|
||
* priority order with extension permutations.
|
||
* 2. The legacy hard-coded fallback list (`@/`, `~/`, `src/`, ...)
|
||
* for projects that have aliases but no tsconfig paths block.
|
||
* 3. Direct path lookup (with extensions).
|
||
*/
|
||
function resolveAliasedImport(
|
||
importPath: string,
|
||
projectRoot: string,
|
||
language: Language,
|
||
context: ResolutionContext,
|
||
fromFile?: string
|
||
): string | null {
|
||
const extensions = EXTENSION_RESOLUTION[language] || [];
|
||
const tryWithExt = (basePath: string): string | null => {
|
||
for (const ext of extensions) {
|
||
const candidate = basePath + ext;
|
||
if (context.fileExists(candidate)) return candidate;
|
||
}
|
||
if (context.fileExists(basePath)) return basePath;
|
||
return findSourceForEmittedSpecifier(basePath, language, context);
|
||
};
|
||
|
||
// 1. tsconfig/jsconfig paths: the config nearest the importing file (an
|
||
// app of a monorepo keeps its own `@/*`), then the project root's.
|
||
const nearest = fromFile ? context.getNearestAliases?.(fromFile) : null;
|
||
const rootMap = context.getProjectAliases?.();
|
||
for (const aliasMap of nearest && nearest !== rootMap ? [nearest, rootMap] : [rootMap]) {
|
||
if (!aliasMap) continue;
|
||
const candidates = applyAliases(importPath, aliasMap, projectRoot);
|
||
for (const c of candidates) {
|
||
const hit = tryWithExt(c);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
|
||
// 1.5 Workspace packages (`@scope/ui/widgets` → `packages/ui/widgets`).
|
||
// Resolves a monorepo member import to the member's directory; the
|
||
// extension/index permutations below then find its barrel (#629).
|
||
const workspaces = context.getWorkspacePackages?.();
|
||
if (workspaces) {
|
||
const base = resolveWorkspaceImport(importPath, workspaces);
|
||
if (base) {
|
||
const hit = tryWithExt(base);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
|
||
// 2. Hard-coded fallback list. Kept for projects that use these
|
||
// conventional aliases without declaring them in tsconfig.
|
||
const fallbackAliases: Record<string, string> = {
|
||
'@/': 'src/',
|
||
'~/': 'src/',
|
||
'@src/': 'src/',
|
||
'src/': 'src/',
|
||
'@app/': 'app/',
|
||
'app/': 'app/',
|
||
};
|
||
for (const [alias, replacement] of Object.entries(fallbackAliases)) {
|
||
if (importPath.startsWith(alias)) {
|
||
const hit = tryWithExt(importPath.replace(alias, replacement));
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
|
||
// 3. Direct path.
|
||
return tryWithExt(importPath);
|
||
}
|
||
|
||
/**
|
||
* C/C++ include directory cache (keyed by project root).
|
||
* Loaded once per resolver instance, shared across calls.
|
||
*/
|
||
const cppIncludeDirCache = new Map<string, string[]>();
|
||
|
||
/**
|
||
* Clear the C/C++ include directory cache (call between indexing runs)
|
||
*/
|
||
export function clearCppIncludeDirCache(): void {
|
||
cppIncludeDirCache.clear();
|
||
}
|
||
|
||
/**
|
||
* Discover C/C++ include search directories for a project.
|
||
*
|
||
* Strategy:
|
||
* 1. Look for compile_commands.json (Clang compilation database) in the
|
||
* project root and common build subdirectories. Parse -I and -isystem
|
||
* flags from compiler commands.
|
||
* 2. If no compilation database is found, probe for common convention
|
||
* directories (include/, src/, lib/, api/) and top-level directories
|
||
* containing .h/.hpp files.
|
||
*
|
||
* Returns paths relative to projectRoot.
|
||
*/
|
||
export function loadCppIncludeDirs(projectRoot: string): string[] {
|
||
const cached = cppIncludeDirCache.get(projectRoot);
|
||
if (cached !== undefined) return cached;
|
||
|
||
const dirs = loadCppIncludeDirsFromCompileDB(projectRoot)
|
||
|| loadCppIncludeDirsHeuristic(projectRoot);
|
||
|
||
cppIncludeDirCache.set(projectRoot, dirs);
|
||
return dirs;
|
||
}
|
||
|
||
/**
|
||
* Try to load include directories from compile_commands.json.
|
||
* Returns null if no compilation database is found (so the heuristic
|
||
* fallback can run). Returns an array (possibly empty) otherwise.
|
||
*/
|
||
function loadCppIncludeDirsFromCompileDB(projectRoot: string): string[] | null {
|
||
const candidates = [
|
||
path.join(projectRoot, 'compile_commands.json'),
|
||
path.join(projectRoot, 'build', 'compile_commands.json'),
|
||
path.join(projectRoot, 'cmake-build-debug', 'compile_commands.json'),
|
||
path.join(projectRoot, 'cmake-build-release', 'compile_commands.json'),
|
||
path.join(projectRoot, 'out', 'compile_commands.json'),
|
||
];
|
||
|
||
let dbPath: string | undefined;
|
||
for (const c of candidates) {
|
||
try {
|
||
if (fs.existsSync(c)) {
|
||
dbPath = c;
|
||
break;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (!dbPath) return null;
|
||
|
||
try {
|
||
const content = fs.readFileSync(dbPath, 'utf-8');
|
||
const entries = JSON.parse(content) as Array<{
|
||
directory: string;
|
||
command?: string;
|
||
arguments?: string[];
|
||
}>;
|
||
if (!Array.isArray(entries)) return null;
|
||
|
||
const dirSet = new Set<string>();
|
||
for (const entry of entries) {
|
||
const dir = entry.directory || projectRoot;
|
||
const args = entry.arguments || (entry.command ? shlexSplit(entry.command) : []);
|
||
for (let i = 0; i < args.length; i++) {
|
||
const arg = args[i]!;
|
||
let includeDir: string | undefined;
|
||
// -I<dir> (no space)
|
||
if (arg.startsWith('-I') && arg.length > 2) {
|
||
includeDir = arg.substring(2);
|
||
}
|
||
// -isystem <dir> (space-separated)
|
||
else if ((arg === '-isystem' || arg === '-I') && i + 1 < args.length) {
|
||
includeDir = args[i + 1];
|
||
i++; // skip next arg
|
||
}
|
||
if (includeDir) {
|
||
// Normalize: resolve relative to the compilation directory
|
||
const absPath = path.isAbsolute(includeDir)
|
||
? includeDir
|
||
: path.resolve(dir, includeDir);
|
||
const relPath = path.relative(projectRoot, absPath).replace(/\\/g, '/');
|
||
// Skip system directories and paths outside the project
|
||
// (relative paths starting with .. or absolute paths like
|
||
// /usr/include or C:\usr on Windows)
|
||
if (!relPath.startsWith('..') && relPath.length > 0 && !path.isAbsolute(relPath)) {
|
||
dirSet.add(relPath);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return Array.from(dirSet);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Minimal shlex-style split for compiler command strings.
|
||
* Handles double-quoted and single-quoted arguments.
|
||
*/
|
||
function shlexSplit(cmd: string): string[] {
|
||
const result: string[] = [];
|
||
let i = 0;
|
||
while (i < cmd.length) {
|
||
// Skip whitespace
|
||
while (i < cmd.length && /\s/.test(cmd[i]!)) i++;
|
||
if (i >= cmd.length) break;
|
||
const ch = cmd[i]!;
|
||
if (ch === '"') {
|
||
i++;
|
||
let arg = '';
|
||
while (i < cmd.length && cmd[i] !== '"') {
|
||
if (cmd[i] === '\\' && i + 1 < cmd.length) { i++; arg += cmd[i]; }
|
||
else { arg += cmd[i]; }
|
||
i++;
|
||
}
|
||
i++; // closing quote
|
||
result.push(arg);
|
||
} else if (ch === "'") {
|
||
i++;
|
||
let arg = '';
|
||
while (i < cmd.length && cmd[i] !== "'") { arg += cmd[i]; i++; }
|
||
i++; // closing quote
|
||
result.push(arg);
|
||
} else {
|
||
let arg = '';
|
||
while (i < cmd.length && !/\s/.test(cmd[i]!)) { arg += cmd[i]; i++; }
|
||
result.push(arg);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Heuristic include directory discovery when no compile_commands.json exists.
|
||
* Checks common convention directories and scans top-level dirs for headers.
|
||
*/
|
||
function loadCppIncludeDirsHeuristic(projectRoot: string): string[] {
|
||
const dirs: string[] = [];
|
||
const conventionDirs = ['include', 'src', 'lib', 'api', 'inc'];
|
||
|
||
try {
|
||
const entries = fs.readdirSync(projectRoot, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
const name = entry.name;
|
||
// Convention directories
|
||
if (conventionDirs.includes(name.toLowerCase())) {
|
||
dirs.push(name);
|
||
continue;
|
||
}
|
||
// Any top-level directory containing .h or .hpp files
|
||
try {
|
||
const subFiles = fs.readdirSync(path.join(projectRoot, name));
|
||
if (subFiles.some(f => /\.(h|hpp|hxx|hh)$/i.test(f))) {
|
||
dirs.push(name);
|
||
}
|
||
} catch {
|
||
// ignore permission errors
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
return dirs;
|
||
}
|
||
|
||
/**
|
||
* Resolve a C/C++ include path by searching include directories.
|
||
* Called as a fallback after relative and aliased resolution fail.
|
||
*/
|
||
function resolveCppIncludePath(
|
||
importPath: string,
|
||
language: Language,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
const includeDirs = context.getCppIncludeDirs?.() ?? [];
|
||
const extensions = EXTENSION_RESOLUTION[language] ?? [];
|
||
|
||
for (const dir of includeDirs) {
|
||
const normalizedDir = dir.replace(/\\/g, '/');
|
||
for (const ext of extensions) {
|
||
const candidate = normalizedDir + '/' + importPath + ext;
|
||
if (context.fileExists(candidate)) return candidate;
|
||
}
|
||
// Try as-is (already has extension)
|
||
const candidate = normalizedDir + '/' + importPath;
|
||
if (context.fileExists(candidate)) return candidate;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Is this reference a PHP include/require PATH (vs a namespace `use` symbol)?
|
||
*
|
||
* include/require emit a file path ("lib.php", "inc/db.php", "../x.php"),
|
||
* whereas namespace use is an FQN (App\Foo\Bar) or a bare class symbol
|
||
* (Closure). PHP identifiers contain neither '/' nor '.', so a slash or dot
|
||
* marks a path-shaped include. Such references resolve to files only — never
|
||
* to a same-named symbol — so callers must not fall back to the name-matcher.
|
||
*/
|
||
export function isPhpIncludePathRef(ref: UnresolvedRef): boolean {
|
||
return (
|
||
ref.language === 'php' &&
|
||
ref.referenceKind === 'imports' &&
|
||
(ref.referenceName.includes('/') || ref.referenceName.includes('.'))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Is this a COBOL COPY / EXEC SQL INCLUDE copybook reference? These resolve
|
||
* to files only (or stay unresolved for compiler-supplied members) — never
|
||
* to a same-named symbol via the name-matcher.
|
||
*/
|
||
export function isCobolCopybookRef(ref: UnresolvedRef): boolean {
|
||
return ref.language === 'cobol' && ref.referenceKind === 'imports';
|
||
}
|
||
|
||
/**
|
||
* Is this a Dart `import`, `export` or `part`? Its name is the URI, which
|
||
* names one file (see ./dart-libraries) or none — `dart:async`, a package from
|
||
* outside the project, a generated part nobody committed — and never a
|
||
* symbol: the name-matcher took the URI's last segment for a file name
|
||
* (`package:flutter/foundation.dart` went to riverpod's own foundation.dart)
|
||
* or bound it to the file's own `import` node.
|
||
*/
|
||
export function isDartImportRef(ref: UnresolvedRef): boolean {
|
||
return ref.language === 'dart' && ref.referenceKind === 'imports';
|
||
}
|
||
|
||
/**
|
||
* Is this a Lua / Luau `require`? It names a module file (see
|
||
* resolveLuaRequire) or a module from outside the project, never a symbol:
|
||
* the name-matcher bound `local lfs = require "lfs"` and `local Signal =
|
||
* require(script.Parent.Signal)` to the local each is assigned to.
|
||
*/
|
||
export function isLuaRequireRef(ref: UnresolvedRef): boolean {
|
||
return (ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports';
|
||
}
|
||
|
||
/**
|
||
* Resolve a PHP include/require path to a project-relative file path.
|
||
*
|
||
* PHP resolves includes relative to the including file's directory (the
|
||
* common case for procedural codebases); php.ini `include_path` is not
|
||
* modeled. Callers pass an already-extracted static literal path.
|
||
*/
|
||
function resolvePhpIncludePath(
|
||
includePath: string,
|
||
fromFile: string,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
const projectRoot = context.getProjectRoot();
|
||
const fromDir = path.dirname(path.join(projectRoot, fromFile));
|
||
const basePath = path.resolve(fromDir, includePath);
|
||
const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
|
||
if (context.fileExists(relativePath)) return relativePath;
|
||
// The literal may omit the .php extension (e.g. include "config").
|
||
for (const ext of EXTENSION_RESOLUTION.php ?? []) {
|
||
if (context.fileExists(relativePath + ext)) return relativePath + ext;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Extract import mappings from a file
|
||
*/
|
||
export function extractImportMappings(
|
||
_filePath: string,
|
||
content: string,
|
||
language: Language
|
||
): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
|
||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
|
||
mappings.push(...extractJSImports(content));
|
||
} else if (language === 'svelte' || language === 'vue' || language === 'astro') {
|
||
// Svelte/Vue single-file components import via plain ES6 inside their
|
||
// `<script>` block (Astro: the `---` frontmatter). Without this, a
|
||
// `.svelte`/`.vue`/`.astro` consumer produces
|
||
// zero import mappings, so `resolveViaImport` can't run and a barrel
|
||
// import (`import { Foo } from './lib'`) falls back to name-matching —
|
||
// which silently fails whenever the re-export alias differs from the
|
||
// component's real name, yielding a false 0 callers (#629). The ES6
|
||
// import regex only matches `import … from '…'`, so running it over the
|
||
// whole SFC (markup + styles included) is safe.
|
||
mappings.push(...extractJSImports(content));
|
||
} else if (language === 'python') {
|
||
mappings.push(...extractPythonImports(stripCommentsForRegex(content, 'python')));
|
||
} else if (language === 'go') {
|
||
mappings.push(...extractGoImports(content));
|
||
} else if (language === 'java' || language === 'kotlin') {
|
||
mappings.push(...extractJavaImports(content));
|
||
} else if (language === 'php') {
|
||
mappings.push(...extractPHPImports(content));
|
||
} else if (language === 'c' || language === 'cpp') {
|
||
mappings.push(...extractCppImports(content));
|
||
}
|
||
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Extract JS/TS import mappings
|
||
*/
|
||
function extractJSImports(content: string): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
|
||
// ES6 imports. `import type { X }` / `import type * as ns` is TypeScript's
|
||
// type-only form, not a default import named `type` — which every such
|
||
// line used to add, making `type.innerType()` a call on an import.
|
||
// (`import type from './x'` still binds `type`: backtracking gives it back.)
|
||
// A binding is any JS identifier, `$` included: `\w` stops at the `$` of
|
||
// `import $store from './store'`, failing the whole statement. A default
|
||
// binding is never followed by `{`, so the `${` of a code generator's
|
||
// `` `import ${name} from '${src}'` `` is no binding.
|
||
const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from(?![\w$]))[A-Za-z_$]))?(?:([A-Za-z_$][\w$]*)(?![\w${])\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
|
||
|
||
let match;
|
||
while ((match = importRegex.exec(content)) !== null) {
|
||
const [, defaultImport, namedImports, star, namespaceAlias, source] = match;
|
||
|
||
// Default import
|
||
if (defaultImport) {
|
||
mappings.push({
|
||
localName: defaultImport,
|
||
exportedName: 'default',
|
||
source: source!,
|
||
isDefault: true,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
|
||
// Named imports
|
||
if (namedImports) {
|
||
// `{ util, type objectUtil }`: an inline `type` modifier is not part of the name.
|
||
const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=[A-Za-z_$])/, ''));
|
||
for (const name of names) {
|
||
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/);
|
||
if (aliasMatch) {
|
||
mappings.push({
|
||
localName: aliasMatch[2]!,
|
||
exportedName: aliasMatch[1]!,
|
||
source: source!,
|
||
// `{ default as X }` is the default import spelled as a named one;
|
||
// no module declares an export named `default` to find.
|
||
isDefault: aliasMatch[1] === 'default',
|
||
isNamespace: false,
|
||
});
|
||
} else if (name) {
|
||
mappings.push({
|
||
localName: name,
|
||
exportedName: name,
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Namespace import
|
||
if (star && namespaceAlias) {
|
||
mappings.push({
|
||
localName: namespaceAlias,
|
||
exportedName: '*',
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Require statements — each declarator of a list too (`var express = require('../'),
|
||
// request = require('supertest')`), and a member of the module (`require('./utils').methods`).
|
||
const requireRegex = /(?:\b(?:const|let|var)\s+|,\s*)(?:([A-Za-z_$][\w$]*)|{([^}]+)})\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([A-Za-z_$][\w$]*))?/g;
|
||
while ((match = requireRegex.exec(content)) !== null) {
|
||
const [, defaultName, destructured, source, member] = match;
|
||
|
||
if (defaultName) {
|
||
mappings.push({
|
||
localName: defaultName,
|
||
exportedName: member ?? 'default',
|
||
source: source!,
|
||
isDefault: member === undefined,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
|
||
if (destructured) {
|
||
const names = destructured.split(',').map((s) => s.trim());
|
||
for (const name of names) {
|
||
const aliasMatch = name.match(/([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)/);
|
||
if (aliasMatch) {
|
||
mappings.push({
|
||
localName: aliasMatch[2]!,
|
||
exportedName: aliasMatch[1]!,
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
} else if (name) {
|
||
mappings.push({
|
||
localName: name,
|
||
exportedName: name,
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Extract Python import mappings
|
||
*/
|
||
function extractPythonImports(content: string): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
|
||
// from X import Y, and the parenthesized form `from X import (\n Y,\n Z,\n)`
|
||
const fromImportRegex = /from\s+([\w.]+)\s+import\s+(\([^)]*\)|[^#\n]+)/g;
|
||
let match;
|
||
|
||
while ((match = fromImportRegex.exec(content)) !== null) {
|
||
const [, source, imports] = match;
|
||
const names = imports!.trim().replace(/^\(|\)$/g, '')
|
||
.split(',').map((s) => s.trim());
|
||
|
||
for (const name of names) {
|
||
const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
|
||
if (aliasMatch) {
|
||
mappings.push({
|
||
localName: aliasMatch[2]!,
|
||
exportedName: aliasMatch[1]!,
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
} else if (name && name !== '*') {
|
||
mappings.push({
|
||
localName: name,
|
||
exportedName: name,
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// import X
|
||
const importRegex = /^import\s+([\w.]+)(?:\s+as\s+(\w+))?/gm;
|
||
while ((match = importRegex.exec(content)) !== null) {
|
||
const [, source, alias] = match;
|
||
const localName = alias || source!.split('.').pop()!;
|
||
mappings.push({
|
||
localName,
|
||
exportedName: '*',
|
||
source: source!,
|
||
isDefault: false,
|
||
isNamespace: true,
|
||
});
|
||
}
|
||
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Extract Go import mappings. An import is bound to the name written before
|
||
* its path, or else to the name of the package it imports — which the path
|
||
* alone doesn't settle: `k8s.io/api/core/v1` is package `v1`, while
|
||
* `go.yaml.in/yaml/v3` and `gopkg.in/yaml.v3` are `yaml`, and
|
||
* `github.com/mattn/go-sqlite3` is `sqlite3`. So an unaliased import keeps its
|
||
* last path element and also takes the name goimports assumes for the path,
|
||
* unless another of the file's imports is bound to that name.
|
||
*/
|
||
function extractGoImports(content: string): ImportMapping[] {
|
||
const specs: Array<{ name: string | undefined; source: string }> = [];
|
||
// Only the imports, which Go puts before any other declaration, and without
|
||
// their comments: a word in one is no name — `"fmt" // for printing` used to
|
||
// name the next import `printing`. A name is on its path's own line.
|
||
const code = stripCommentsForRegex(content.slice(0, goImportsEnd(content)), 'go');
|
||
|
||
// import "path" or import name "path"
|
||
const singleImportRegex = /\bimport\s+(?:([A-Za-z_]\w*)[ \t]*)?["']([^"']+)["']/g;
|
||
let match;
|
||
while ((match = singleImportRegex.exec(code)) !== null) {
|
||
specs.push({ name: match[1], source: match[2]! });
|
||
}
|
||
|
||
// import ( ... ) block
|
||
const blockImportRegex = /\bimport\s*\(\s*([^)]+)\s*\)/g;
|
||
while ((match = blockImportRegex.exec(code)) !== null) {
|
||
const lineRegex = /(?:([A-Za-z_]\w*|\.)[ \t]*)?["']([^"']+)["']/g;
|
||
let lineMatch;
|
||
while ((lineMatch = lineRegex.exec(match[1]!)) !== null) {
|
||
specs.push({ name: lineMatch[1], source: lineMatch[2]! });
|
||
}
|
||
}
|
||
|
||
const mapping = (localName: string, source: string): ImportMapping =>
|
||
({ localName, exportedName: '*', source, isDefault: false, isNamespace: true });
|
||
// A dot import (`. "pkg"`) binds no name of its own; it stays listed under
|
||
// its last path element.
|
||
const mappings = specs.map(({ name, source }) =>
|
||
mapping(name === undefined || name === '.' ? source.split('/').pop()! : name, source));
|
||
const bound = new Set(mappings.map((m) => m.localName));
|
||
const assumed = new Map<string, string[]>();
|
||
for (const { name, source } of specs) {
|
||
if (name !== undefined) continue;
|
||
const assumedName = goAssumedPackageName(source);
|
||
if (assumedName && !bound.has(assumedName)) assumed.set(assumedName, [...(assumed.get(assumedName) ?? []), source]);
|
||
}
|
||
// Two imports that assume one name can't both be that package.
|
||
for (const [localName, sources] of assumed) {
|
||
if (sources.length === 1) mappings.push(mapping(localName, sources[0]!));
|
||
}
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Where a Go file's imports end: at its first other top-level declaration, a
|
||
* line opening with `func`, `type`, `var` or `const` outside a block comment.
|
||
*/
|
||
function goImportsEnd(content: string): number {
|
||
const decl = /^(?:func|type|var|const)\b/gm;
|
||
for (let m; (m = decl.exec(content)) !== null;) {
|
||
if (content.lastIndexOf('/*', m.index) <= content.lastIndexOf('*/', m.index)) return m.index;
|
||
}
|
||
return content.length;
|
||
}
|
||
|
||
/**
|
||
* The package name goimports assumes for an import path
|
||
* (ImportPathToAssumedName): its last element that isn't a major version
|
||
* (`go.yaml.in/yaml/v3` → `yaml`), without a `go-` prefix
|
||
* (`github.com/mattn/go-sqlite3` → `sqlite3`), cut where an identifier can't
|
||
* go on (`gopkg.in/yaml.v3` → `yaml`).
|
||
*/
|
||
function goAssumedPackageName(importPath: string): string {
|
||
const elements = importPath.split('/');
|
||
let base = elements[elements.length - 1]!;
|
||
if (elements.length > 1 && /^v\d+$/.test(base)) base = elements[elements.length - 2]!;
|
||
if (base.startsWith('go-')) base = base.slice(3);
|
||
return /^[\p{L}\p{Nd}_]*/u.exec(base)![0];
|
||
}
|
||
|
||
/**
|
||
* Extract Java / Kotlin import mappings.
|
||
*
|
||
* Java/Kotlin imports carry the full qualified name of the imported
|
||
* symbol — `import com.example.dao.converter.FooConverter;` — which is
|
||
* exactly the disambiguation signal we need when two packages both
|
||
* declare a `FooConverter`. Pre-#314 the resolver had no Java branch
|
||
* here at all, so this mapping was empty and cross-module name
|
||
* collisions were resolved by file-path proximity (often wrongly).
|
||
*
|
||
* `import static com.example.Foo.bar;` is parsed as a local-name `bar`
|
||
* pointing at FQN `com.example.Foo.bar` so static-method call sites
|
||
* (`bar(...)`) can resolve through the same import lookup.
|
||
*/
|
||
function extractJavaImports(content: string): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
// Strip line and block comments so `// import foo;` doesn't false-match.
|
||
const stripped = content
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/\/\/[^\n]*/g, '');
|
||
// `import [static] <fqn>[.*];` — and Kotlin's `import <fqn> [as Alias]`, with no `;`.
|
||
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)(?:\s+as\s+([\w]+))?\s*(?:;|$)/gm;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = re.exec(stripped)) !== null) {
|
||
const fqn = match[2]!;
|
||
// `import com.example.*;` — wildcard. We can't materialize a single
|
||
// local name; skip and let name-matching handle members reachable
|
||
// through the wildcard. (Future enhancement: enumerate package files.)
|
||
if (fqn.endsWith('.*')) continue;
|
||
const parts = fqn.split('.');
|
||
const localName = match[3] ?? parts[parts.length - 1];
|
||
if (!localName) continue;
|
||
mappings.push({
|
||
localName,
|
||
exportedName: parts[parts.length - 1]!,
|
||
source: fqn,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Extract PHP import mappings (use statements)
|
||
*/
|
||
function extractPHPImports(content: string): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
|
||
// use Namespace\Class; or use Namespace\Class as Alias;
|
||
const useRegex = /use\s+([\w\\]+)(?:\s+as\s+(\w+))?;/g;
|
||
let match;
|
||
|
||
while ((match = useRegex.exec(content)) !== null) {
|
||
const [, fullPath, alias] = match;
|
||
const className = fullPath!.split('\\').pop()!;
|
||
mappings.push({
|
||
localName: alias || className,
|
||
exportedName: className,
|
||
source: fullPath!,
|
||
isDefault: false,
|
||
isNamespace: false,
|
||
});
|
||
}
|
||
|
||
return mappings;
|
||
}
|
||
|
||
/**
|
||
* Extract C/C++ import mappings from #include directives.
|
||
*
|
||
* #include brings all symbols from the included header into scope
|
||
* (namespace import), so each mapping uses isNamespace: true and
|
||
* exportedName: '*'. The localName is set to the header's basename
|
||
* without extension so that symbol references like `MyClass` can
|
||
* match against any include that might provide it.
|
||
*/
|
||
function extractCppImports(content: string): ImportMapping[] {
|
||
const mappings: ImportMapping[] = [];
|
||
|
||
// Match both #include <...> and #include "..."
|
||
const includeRegex = /^\s*#\s*include\s+[<"]([^>"]+)[>"]/gm;
|
||
let match;
|
||
|
||
while ((match = includeRegex.exec(content)) !== null) {
|
||
const modulePath = match[1]!;
|
||
// Basename without extension for localName matching
|
||
const basename = modulePath.split('/').pop()!.replace(/\.(h|hpp|hxx|hh|inl|ipp|cxx|cc|cpp)$/,'');
|
||
mappings.push({
|
||
localName: basename || modulePath,
|
||
exportedName: '*',
|
||
source: modulePath,
|
||
isDefault: false,
|
||
isNamespace: true,
|
||
});
|
||
}
|
||
|
||
return mappings;
|
||
}
|
||
|
||
// Cache import mappings per file to avoid re-reading and re-parsing
|
||
const importMappingCache = new Map<string, ImportMapping[]>();
|
||
|
||
/**
|
||
* Clear the import mapping cache (call between indexing runs)
|
||
*/
|
||
export function clearImportMappingCache(): void {
|
||
importMappingCache.clear();
|
||
cppIncludeDirCache.clear();
|
||
}
|
||
|
||
/**
|
||
* Strip JS line + block comments from `content` while preserving
|
||
* string literals (so `"//"` inside a string stays intact). Used by
|
||
* {@link extractReExports} so commented-out export-from statements
|
||
* don't generate phantom re-export edges.
|
||
*
|
||
* Scanner is deliberately small: it only tracks the three contexts
|
||
* relevant for JS/TS — single-quote string, double-quote string, and
|
||
* template literal. Comment recognition is the JS spec subset, no
|
||
* regex-literal awareness (which is fine for our use case: we don't
|
||
* apply this to function bodies, only to top-level files).
|
||
*/
|
||
function stripJsComments(content: string): string {
|
||
let out = '';
|
||
let i = 0;
|
||
let str: '"' | "'" | '`' | null = null;
|
||
while (i < content.length) {
|
||
const ch = content[i]!;
|
||
if (str !== null) {
|
||
out += ch;
|
||
if (ch === '\\' && i + 1 < content.length) {
|
||
out += content[i + 1]!;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === str) str = null;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === '"' || ch === "'" || ch === '`') {
|
||
str = ch;
|
||
out += ch;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === '/' && content[i + 1] === '/') {
|
||
while (i < content.length && content[i] !== '\n') i++;
|
||
continue;
|
||
}
|
||
if (ch === '/' && content[i + 1] === '*') {
|
||
i += 2;
|
||
while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
out += ch;
|
||
i++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Extract JS/TS re-export declarations from `content`.
|
||
*
|
||
* Recognised forms:
|
||
* export { foo } from './a';
|
||
* export { foo as bar } from './a';
|
||
* export * from './a';
|
||
* export * as ns from './a'; (treated as wildcard for chasing)
|
||
* export { default as Foo } from './a';
|
||
*
|
||
* The walker intentionally stays regex-based — the import-resolver
|
||
* elsewhere in this file already chooses regex over a fresh
|
||
* tree-sitter pass, and this function shares that trade-off. Errors
|
||
* fall through silently; resolution simply skips the broken file.
|
||
*/
|
||
export function extractReExports(content: string, language: Language): ReExport[] {
|
||
if (
|
||
language !== 'typescript' &&
|
||
language !== 'javascript' &&
|
||
language !== 'tsx' &&
|
||
language !== 'jsx' &&
|
||
language !== 'arkts'
|
||
) {
|
||
return [];
|
||
}
|
||
const out: ReExport[] = [];
|
||
|
||
// Pre-strip block comments + line comments so a commented-out
|
||
// `// export { x } from '...'` doesn't produce a phantom edge.
|
||
// (Template literals are still a possible source of false positives;
|
||
// a project that builds export statements as runtime strings is
|
||
// out of scope.)
|
||
const cleaned = stripJsComments(content);
|
||
|
||
// Wildcard: `export * from '...'`; `export * as ns from '...'` exports `ns` alone.
|
||
const wildcardRe = /export\s*\*(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*from\s*['"]([^'"]+)['"]/g;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = wildcardRe.exec(cleaned)) !== null) {
|
||
out.push(m[1] ? { kind: 'namespace', exportedName: m[1], source: m[2]! } : { kind: 'wildcard', source: m[2]! });
|
||
}
|
||
|
||
// Named: `export { a, b as c } from '...'`
|
||
const namedRe = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
|
||
while ((m = namedRe.exec(cleaned)) !== null) {
|
||
const inner = m[1]!;
|
||
const source = m[2]!;
|
||
for (const raw of inner.split(',')) {
|
||
const item = raw.trim();
|
||
if (!item) continue;
|
||
const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
|
||
if (aliasMatch) {
|
||
out.push({
|
||
kind: 'named',
|
||
exportedName: aliasMatch[2]!,
|
||
originalName: aliasMatch[1]!,
|
||
source,
|
||
});
|
||
} else if (/^[A-Za-z_$][\w$]*$/.test(item)) {
|
||
out.push({
|
||
kind: 'named',
|
||
exportedName: item,
|
||
originalName: item,
|
||
source,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Resolve a reference using import mappings
|
||
*/
|
||
/**
|
||
* JVM (Java / Kotlin) imports use fully-qualified names (`import
|
||
* com.example.foo.Bar`) decoupled from filenames, so the JS/Python
|
||
* style filesystem path lookup misses them whenever the file isn't
|
||
* named after its primary symbol (Kotlin `Utils.kt` exporting `Bar`,
|
||
* top-level fns, extension fns). Resolve them through the
|
||
* `qualifiedName` index instead — populated by the package_header /
|
||
* package_declaration namespace wrappers in the extractor.
|
||
*/
|
||
export function resolveJvmImport(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (ref.referenceKind !== 'imports') return null;
|
||
if (ref.language !== 'java' && ref.language !== 'kotlin') return null;
|
||
|
||
const fqn = ref.referenceName;
|
||
const lastDot = fqn.lastIndexOf('.');
|
||
if (lastDot <= 0) return null;
|
||
const pkg = fqn.substring(0, lastDot);
|
||
const sym = fqn.substring(lastDot + 1);
|
||
// Wildcard imports (`com.example.*`) deliberately punt to name-matcher.
|
||
if (sym === '*') return null;
|
||
|
||
const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`);
|
||
if (candidates.length === 0) return null;
|
||
|
||
// Kotlin Multiplatform: an `expect` declaration and its `actual`s share one
|
||
// FQN across source sets (commonMain / androidMain / appleMain). Taking the
|
||
// first candidate let a single platform `actual` absorb every common-side
|
||
// import, so the `expect` (the canonical API a commonMain file imports)
|
||
// looked unused. Prefer the candidate CLOSEST to the importing file by
|
||
// directory proximity — a commonMain import resolves to the commonMain
|
||
// declaration — with the `expect` side as a tiebreak.
|
||
const best = candidates.length === 1 ? candidates[0]! : pickClosestJvmCandidate(candidates, ref.filePath);
|
||
return {
|
||
original: ref,
|
||
targetNodeId: best.id,
|
||
confidence: 0.95,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
|
||
/** `Alias` / `Alias.member` through a renaming Kotlin import, by the imported FQN. */
|
||
function resolveJvmAlias(ref: UnresolvedRef, imports: ImportMapping[], context: ResolutionContext): ResolvedRef | null {
|
||
const dot = ref.referenceName.indexOf('.');
|
||
const root = dot < 0 ? ref.referenceName : ref.referenceName.slice(0, dot);
|
||
const imp = imports.find((m) => m.localName === root && m.localName !== m.exportedName);
|
||
if (!imp) return null;
|
||
const parts = imp.source.split('.');
|
||
// The package is some prefix of the FQN; the rest is the type path (`Outer::Inner`).
|
||
for (let i = parts.length - 1; i > 0; i--) {
|
||
const target = context.getNodesByQualifiedName(`${parts.slice(0, i).join('.')}::${parts.slice(i).join('::')}`)[0];
|
||
if (!target) continue;
|
||
const member = dot < 0 ? null : ref.referenceName.slice(dot + 1);
|
||
if (member === null) return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||
if (member.includes('.')) return null;
|
||
const found = context.getNodesByQualifiedName(`${target.qualifiedName}::${member}`)[0];
|
||
return found ? { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'import' } : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Pick the same-FQN candidate closest to `fromPath` by shared directory
|
||
* prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
|
||
* Multiplatform `expect`/`actual` import resolving within the importer's own
|
||
* source set instead of an arbitrary platform `actual`.
|
||
*/
|
||
function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node {
|
||
const fromDirs = fromPath.split('/').slice(0, -1);
|
||
const sharedPrefix = (p: string): number => {
|
||
const d = p.split('/').slice(0, -1);
|
||
let shared = 0;
|
||
for (let i = 0; i < Math.min(fromDirs.length, d.length); i++) {
|
||
if (fromDirs[i] === d[i]) shared++;
|
||
else break;
|
||
}
|
||
return shared;
|
||
};
|
||
const isExpect = (n: Node): boolean => Array.isArray(n.decorators) && n.decorators.includes('expect');
|
||
let best = candidates[0]!;
|
||
let bestProx = sharedPrefix(best.filePath);
|
||
for (let i = 1; i < candidates.length; i++) {
|
||
const c = candidates[i]!;
|
||
const prox = sharedPrefix(c.filePath);
|
||
if (prox > bestProx || (prox === bestProx && isExpect(c) && !isExpect(best))) {
|
||
best = c;
|
||
bestProx = prox;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* PHP scoped calls are encoded as "Alias.method" by both extractors. A use
|
||
* mapping names a namespace, not a filesystem path, so resolve the receiver
|
||
* through its localName and look up the method on that exact imported type.
|
||
* undefined means this is not an imported static call; null means the import
|
||
* owns the call but its method is unavailable, so name fallbacks must not guess.
|
||
*/
|
||
export function resolvePhpImportedStaticCall(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext,
|
||
): ResolvedRef | null | undefined {
|
||
if (ref.language !== 'php' || ref.referenceKind !== 'calls') return undefined;
|
||
const call = /^(\w+)\.(\w+)$/.exec(ref.referenceName);
|
||
if (!call) return undefined;
|
||
const [, receiver, member] = call;
|
||
const imp = context.getImportMappings(ref.filePath, ref.language)
|
||
.find((i) => i.localName === receiver);
|
||
if (!imp) return undefined;
|
||
|
||
// PHP variables occupy a different namespace from class imports. Extraction
|
||
// strips the leading "$" from "$Alias->method()" too; leave that receiver to
|
||
// local type inference even when a class import has the same local name.
|
||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n');
|
||
const line = lines?.[ref.line - 1];
|
||
if (line?.slice(ref.column).startsWith('$')) return undefined;
|
||
|
||
const fqn = imp.source.replace(/^\\/, '');
|
||
const separator = fqn.lastIndexOf('\\');
|
||
const typeName = separator < 0
|
||
? fqn
|
||
: `${fqn.slice(0, separator)}::${fqn.slice(separator + 1)}`;
|
||
const owners = context.getNodesByQualifiedName(typeName)
|
||
.filter((n) => n.language === 'php' && STATIC_MEMBER_CONTAINERS.has(n.kind));
|
||
if (owners.length !== 1) return null;
|
||
const owner = owners[0]!;
|
||
const methods = context.getNodesByQualifiedName(`${owner.qualifiedName}::${member}`)
|
||
.filter((n) => n.language === 'php' && n.kind === 'method' && n.filePath === owner.filePath);
|
||
if (methods.length !== 1) return null;
|
||
return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' };
|
||
}
|
||
|
||
/**
|
||
* A module specifier written as a path — relative (`./x`, `../x`), rooted, or
|
||
* an alias with a folder in it (`@/lib/x`, `~/x`, `$lib/x`) — or a scoped
|
||
* package (`@excalidraw/common`), which no binding can be named and which a
|
||
* `tsconfig` path or a workspace member can map to a project file; rather
|
||
* than an imported binding's name or a bare package (`react`, `lodash`).
|
||
*/
|
||
function isJsPathSpecifier(name: string): boolean {
|
||
return name.startsWith('./') || name.startsWith('../') || name === '.' || name === '..' ||
|
||
(name.includes('/') && !/\s/.test(name));
|
||
}
|
||
|
||
/**
|
||
* A JS/TS `imports` reference that names a module by path — from a script, or
|
||
* from a Vue, Svelte or Astro component's. It names a FILE, not a symbol, so
|
||
* it skips the resolver's name-exists pre-filter — a CommonJS `require('./x')`
|
||
* has no import node of that name to pass it.
|
||
*/
|
||
export function isJsPathImportRef(ref: UnresolvedRef): boolean {
|
||
return ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName);
|
||
}
|
||
|
||
/** PHP reference kinds whose name is a class name, written as in the source. */
|
||
const PHP_CLASS_NAME_REFS: ReadonlySet<string> = new Set(['instantiates', 'extends', 'implements', 'references']);
|
||
|
||
/**
|
||
* A PHP class name written with a namespace in it (#2256). `use App\Fields as
|
||
* Field;` aliases a namespace, so `new Field\FirstName()`, `extends Field\Base`
|
||
* and `Field\FirstName::make()` all name `App\Fields\FirstName`. PHP reads a
|
||
* qualified name one way: a leading `\` makes it absolute; otherwise a first
|
||
* segment a `use` imports is replaced by what it imports, and any other name is
|
||
* relative to the current namespace. Both extractors emit the written name
|
||
* verbatim, and with no `.` or `::` in it the pre-filter would drop it.
|
||
* undefined means the ref is not a qualified class name (or names a method the
|
||
* class inherits); null means it is but no single project class has that name
|
||
* — it lives outside the project — so name fallbacks must not guess.
|
||
*/
|
||
export function resolvePhpQualifiedClassRef(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext,
|
||
): ResolvedRef | null | undefined {
|
||
if (ref.language !== 'php') return undefined;
|
||
let name = ref.referenceName;
|
||
let member: string | null = null;
|
||
if (ref.referenceKind === 'calls') {
|
||
// A static call on the class: `Field\FirstName::make()` is written `Field\FirstName.make`.
|
||
const call = /^(.*\\[^\\.:]+)(?:\.|::)(\w+)$/.exec(name);
|
||
if (!call) return undefined;
|
||
name = call[1]!;
|
||
member = call[2]!;
|
||
} else if (!PHP_CLASS_NAME_REFS.has(ref.referenceKind)) {
|
||
return undefined;
|
||
}
|
||
const separator = name.indexOf('\\');
|
||
if (separator < 0) return undefined;
|
||
|
||
let fqn: string;
|
||
if (separator === 0) {
|
||
fqn = name.slice(1);
|
||
} else {
|
||
const head = name.slice(0, separator);
|
||
const imp = context.getImportMappings(ref.filePath, ref.language).find((i) => i.localName === head);
|
||
if (imp) {
|
||
fqn = imp.source.replace(/^\\/, '') + name.slice(separator);
|
||
} else {
|
||
// `namespace App;` applies until the next namespace statement.
|
||
const namespace = context.getNodesInFile(ref.filePath)
|
||
.filter((n) => n.kind === 'namespace' && n.startLine <= ref.line)
|
||
.sort((a, b) => b.startLine - a.startLine)[0];
|
||
fqn = namespace ? `${namespace.qualifiedName}\\${name}` : name;
|
||
}
|
||
}
|
||
|
||
const cut = fqn.lastIndexOf('\\');
|
||
const qualifiedName = cut < 0 ? fqn : `${fqn.slice(0, cut)}::${fqn.slice(cut + 1)}`;
|
||
const classes = context.getNodesByQualifiedName(qualifiedName)
|
||
.filter((n) => n.language === 'php' && STATIC_MEMBER_CONTAINERS.has(n.kind));
|
||
// A type mention can name something other than a class (a namespaced
|
||
// constant or function), so it keeps the ordinary strategies.
|
||
if (classes.length !== 1) return ref.referenceKind === 'references' ? undefined : null;
|
||
const owner = classes[0]!;
|
||
if (!member) return { original: ref, targetNodeId: owner.id, confidence: 0.95, resolvedBy: 'import' };
|
||
const methods = context.getNodesByQualifiedName(`${owner.qualifiedName}::${member}`)
|
||
.filter((n) => n.language === 'php' && n.kind === 'method' && n.filePath === owner.filePath);
|
||
// A method the class inherits is left to the strategies that walk supertypes.
|
||
if (methods.length !== 1) return undefined;
|
||
return { original: ref, targetNodeId: methods[0]!.id, confidence: 0.95, resolvedBy: 'import' };
|
||
}
|
||
|
||
export function resolveViaImport(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
// A JS/TS module specifier — `import './polyfills'`, the module of `import x
|
||
// from '../lib/a'`, a CommonJS `require('./application')` — names a FILE,
|
||
// found the way the runtime finds it: extensions, `index` files, path
|
||
// aliases. Matching the basename instead missed every extensionless one
|
||
// (the common spelling) and could land on a same-named file elsewhere.
|
||
if (isJsPathImportRef(ref)) {
|
||
const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
|
||
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
|
||
if (fileNode) return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
// A Dart `import` / `export` URI names a library file, and a `part` URI one
|
||
// of the library's own files: `package:app/x.dart` is app's lib/x.dart, any
|
||
// other path is from the file the directive is in.
|
||
if (isDartImportRef(ref)) {
|
||
const file = dartDirectiveFile(ref.filePath, ref.referenceName, context);
|
||
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
|
||
return fileNode ? { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' } : null;
|
||
}
|
||
// C/C++ #include references — resolve directly to the included file
|
||
// (file→file edge), bypassing symbol lookup. The extractor emits these
|
||
// with `referenceKind: 'imports'` and `referenceName: <include path>`
|
||
// (e.g. "uint256.h" or "common/args.h"). Without this branch the
|
||
// include-dir scan path inside resolveImportPath never produces an
|
||
// edge — resolveViaImport's symbol lookup below would search the
|
||
// resolved file for a symbol named like the file extension and fail.
|
||
if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') {
|
||
// C/C++ quoted includes (`#include "X.h"`) resolve relative to the
|
||
// INCLUDING file's own directory first (the C standard's quoted-include
|
||
// search order). Prefer a same-directory header over an -I directory or a
|
||
// same-named header on another platform (windows/code/RNCAsyncStorage.h vs
|
||
// apple/.../RNCAsyncStorage.h) — the include-dir heuristic below would
|
||
// otherwise pick an arbitrary same-named header, leaving the real local one
|
||
// with no dependents.
|
||
const slash = ref.filePath.lastIndexOf('/');
|
||
const fromDir = slash >= 0 ? ref.filePath.slice(0, slash) : '';
|
||
const siblingPath = path.posix.normalize(fromDir ? `${fromDir}/${ref.referenceName}` : ref.referenceName);
|
||
const siblingBase = siblingPath.split('/').pop()!;
|
||
const sibling = context
|
||
.getNodesByName(siblingBase)
|
||
.find((n) => n.kind === 'file' && n.filePath === siblingPath);
|
||
if (sibling) {
|
||
return { original: ref, targetNodeId: sibling.id, confidence: 0.92, resolvedBy: 'import' };
|
||
}
|
||
const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
|
||
if (!resolvedPath) return null;
|
||
const basename = resolvedPath.split('/').pop()!;
|
||
const fileNodes = context.getNodesByName(basename).filter((n) => n.kind === 'file');
|
||
const fileNode = fileNodes.find((n) => n.filePath === resolvedPath);
|
||
if (fileNode) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: fileNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// COBOL COPY / EXEC SQL INCLUDE — resolve the copybook member to a
|
||
// file→file edge, mirroring the C/C++ include branch above. A member that
|
||
// matches no indexed file (compiler-supplied copybooks like SQLCA/DFHAID)
|
||
// stays unresolved — callers must not fall back to the symbol name-matcher,
|
||
// which would connect it to a same-named import symbol elsewhere.
|
||
if (isCobolCopybookRef(ref)) {
|
||
const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language!, context);
|
||
if (!resolvedPath) return null;
|
||
const basename = resolvedPath.split('/').pop()!;
|
||
const fileNode = context
|
||
.getNodesByName(basename)
|
||
.find((n) => n.kind === 'file' && n.filePath === resolvedPath);
|
||
if (fileNode) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: fileNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// PHP include/require — resolve the static string path to a file→file
|
||
// edge, mirroring the C/C++ branch above. Distinguish include PATHS from
|
||
// namespace `use` symbols by shape: an include path contains a slash or a
|
||
// file extension ("lib.php", "inc/db.php", "../x.php"), whereas a namespace
|
||
// use is an FQN (App\Foo\Bar) or a bare class symbol (Closure) — PHP
|
||
// identifiers contain neither '/' nor '.'. Only path-shaped references are
|
||
// includes; symbol references fall through to the namespace resolution.
|
||
if (isPhpIncludePathRef(ref)) {
|
||
const resolvedPath = resolvePhpIncludePath(ref.referenceName, ref.filePath, context);
|
||
if (resolvedPath) {
|
||
const basename = resolvedPath.split('/').pop()!;
|
||
const fileNode = context
|
||
.getNodesByName(basename)
|
||
.find((n) => n.kind === 'file' && n.filePath === resolvedPath);
|
||
if (fileNode) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: fileNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
}
|
||
// A path-shaped include that doesn't resolve to a known project file is a
|
||
// dead end. Return unresolved rather than falling through to the symbol
|
||
// name-matcher, which would mis-connect e.g. "inc/db.php" to an unrelated
|
||
// db.php elsewhere in the tree — a wrong edge is worse than a missing one.
|
||
return null;
|
||
}
|
||
|
||
// Nix static project-path imports (`import ./x.nix`, `builtins.import ./dir`,
|
||
// `import ./x.nix {}`) resolve to file nodes only. Do not resolve
|
||
// angle-bracket channels, attribute expressions, variables, or other dynamic
|
||
// expressions as project files.
|
||
if (isNixPathImportRef(ref)) {
|
||
const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
|
||
if (!resolvedPath) return null;
|
||
|
||
const basename = resolvedPath.split('/').pop()!;
|
||
const fileNode = context
|
||
.getNodesByName(basename)
|
||
.find((n) => n.kind === 'file' && n.filePath === resolvedPath);
|
||
|
||
if (fileNode) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: fileNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Use cached import mappings (avoids re-reading and re-parsing per ref)
|
||
const imports = context.getImportMappings(ref.filePath, ref.language);
|
||
if (imports.length === 0 && !context.readFile(ref.filePath)) {
|
||
return null;
|
||
}
|
||
|
||
// Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName
|
||
// `pkga.FuncX` and the import `github.com/example/myproject/pkga`
|
||
// maps to a *package directory* containing one or more .go files.
|
||
// The generic file-based lookup below can't follow that — issue #388.
|
||
if (ref.language === 'go') {
|
||
const goResult = resolveGoCrossPackageReference(ref, imports, context);
|
||
if (goResult) return goResult;
|
||
}
|
||
|
||
// Java / Kotlin: imports are FQNs (`import com.example.Foo;`) — no
|
||
// resolvable file path the JS/TS-style chain below could follow. Look
|
||
// up the symbol by name and filter to the candidate whose file path
|
||
// matches the imported FQN. This is the disambiguation signal that
|
||
// breaks the same-name class collision the path-proximity matcher
|
||
// can't resolve (issue #314).
|
||
if (ref.language === 'java' || ref.language === 'kotlin') {
|
||
const javaResult = resolveJavaImportedReference(ref, imports, context);
|
||
if (javaResult) return javaResult;
|
||
}
|
||
|
||
// Python qualified access through an imported MODULE: `certs.where()` after
|
||
// `from . import certs`, `mod.func()` after `import mod`. The receiver names a
|
||
// submodule (a file), not a symbol, so the generic symbol lookup below would
|
||
// search the *package* for `certs` instead of looking inside the module.
|
||
if (ref.language === 'python') {
|
||
const pyResult = resolvePythonModuleMember(ref, imports, context);
|
||
if (pyResult) return pyResult;
|
||
// Absolute dotted module import: `import conduit.apps.articles.signals`
|
||
// (the standard Django AppConfig.ready() signal-registration pattern, and
|
||
// any side-effect `import pkg.mod`). Map the dotted path to its file.
|
||
const pyModResult = resolvePythonAbsoluteModule(ref, context);
|
||
if (pyModResult) return pyModResult;
|
||
}
|
||
|
||
// Rust qualified path: resolve the module prefix of `crate::m::Item` /
|
||
// `self::sub::Item` / `super::m::func` to a file, then find the leaf symbol in
|
||
// it. Disambiguates common-name `pub use self::read::read` re-exports that
|
||
// name-matching would land on the wrong same-named symbol.
|
||
if (ref.language === 'rust' && ref.referenceName.includes('::')) {
|
||
const rustResult = resolveRustPathReference(ref, context);
|
||
if (rustResult) return rustResult;
|
||
}
|
||
|
||
// Lua / Luau `require(...)`: a dotted module path (`a.b.c` from
|
||
// `require("a.b.c")`) or an instance-path leaf (`Signal` from
|
||
// `require(script.Parent.Signal)`) — map it to a module file. There's no static
|
||
// import statement, so the generic path-matcher can't bridge the dot↔slash /
|
||
// leaf↔basename gap; resolve it explicitly to the module file.
|
||
if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports') {
|
||
const luaResult = resolveLuaRequire(ref, context);
|
||
if (luaResult) return luaResult;
|
||
}
|
||
|
||
// Whole-module / namespace imports → link the importing file to the module
|
||
// file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
|
||
// from './x'` (so a namespace touched only via a value-member read still
|
||
// records the dependency). A named TS/JS import returns null here and falls
|
||
// through to symbol resolution below.
|
||
if (
|
||
ref.language === 'python' ||
|
||
ref.language === 'typescript' ||
|
||
ref.language === 'tsx' ||
|
||
ref.language === 'javascript' ||
|
||
ref.language === 'jsx' ||
|
||
ref.language === 'arkts'
|
||
) {
|
||
const moduleFile = resolveModuleImportToFile(ref, imports, context);
|
||
if (moduleFile) return moduleFile;
|
||
}
|
||
|
||
// Kotlin's `import app.model.Outer.Inner as Made`: `Made.create()` is the
|
||
// aliased class's member, found by its FQN — nothing else binds the alias.
|
||
if (ref.language === 'kotlin' && ref.referenceKind !== 'imports') {
|
||
const aliased = resolveJvmAlias(ref, imports, context);
|
||
if (aliased) return aliased;
|
||
}
|
||
|
||
// Check if the reference name matches any import
|
||
for (const imp of imports) {
|
||
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
||
// Resolve the import path
|
||
let resolvedPath = resolveImportPath(
|
||
imp.source,
|
||
ref.filePath,
|
||
ref.language,
|
||
context
|
||
);
|
||
|
||
// Named Python imports need the same absolute-module lookup as namespace
|
||
// imports, including aliases used as receiver types (#1820).
|
||
if (!resolvedPath && ref.language === 'python') {
|
||
resolvedPath = findPythonModuleFile(imp.source, context, ref.filePath)?.filePath ?? null;
|
||
}
|
||
|
||
if (resolvedPath) {
|
||
const exportedName = imp.isDefault ? 'default' : imp.exportedName;
|
||
const memberName = imp.isNamespace
|
||
? ref.referenceName.replace(imp.localName + '.', '')
|
||
: null;
|
||
|
||
const targetNode = findExportedSymbol(
|
||
resolvedPath,
|
||
{ isDefault: imp.isDefault, isNamespace: imp.isNamespace, exportedName, memberName },
|
||
ref.language,
|
||
context,
|
||
new Set()
|
||
) ?? (ref.language === 'python'
|
||
? pythonModuleSymbol(resolvedPath, memberName ?? exportedName, context, 0)
|
||
: undefined);
|
||
|
||
if (targetNode) {
|
||
// `Foo.bar()` / `Foo.CONST` — a NAMED (non-namespace) class import
|
||
// accessed through a member. `findExportedSymbol` resolved `Foo` to
|
||
// the class itself; descend into it so the reference links to the
|
||
// member `bar`, not the class. Without this the edge points at the
|
||
// class and `createEdges` then mis-promotes the call to an
|
||
// `instantiates` edge, so the static method shows zero callers and a
|
||
// hollow impact radius. (#825)
|
||
if (!imp.isNamespace && ref.referenceName.startsWith(imp.localName + '.')) {
|
||
const memberNode = resolveStaticMember(targetNode, ref, imp.localName, context);
|
||
if (memberNode) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: memberNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
// An imported object literal used as a namespace (#1573):
|
||
// `api.call()` after `import { api } from './api'` where `api` is
|
||
// `export const api = { call() {…} }`. Its members have bare
|
||
// qualified names inside the constant's extent, so the
|
||
// `Container::member` lookup above can't see them and the edge
|
||
// landed on the constant — every cross-file caller of the method
|
||
// went missing. Resolve the member by containment instead.
|
||
if (targetNode.kind === 'constant' || targetNode.kind === 'variable') {
|
||
const member = ref.referenceName.slice(imp.localName.length + 1).split('.')[0];
|
||
if (member) {
|
||
const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
|
||
if (literalMember) return literalMember;
|
||
const aliasMember = resolveObjectLiteralAlias(targetNode, member, ref, context);
|
||
if (aliasMember) return aliasMember;
|
||
}
|
||
}
|
||
// An imported VALUE (singleton constant / shared instance) called
|
||
// through a member: `reproStore.notifyJoinGuildStatus()` after
|
||
// `import { reproStore } from './store'`. findExportedSymbol
|
||
// resolved the CONSTANT itself; linking the CALL there hides the
|
||
// real callee — callers of the method miss every cross-file use
|
||
// and the method can look unused (#1292). Infer the value's type
|
||
// from its own declaration in the exporting file and resolve the
|
||
// member on that type. resolveMethodOnType VALIDATES the type
|
||
// declares the method, so a mis-inference falls through to the
|
||
// constant edge below rather than fabricating a wrong one.
|
||
const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context);
|
||
if (instanceMember) return instanceMember;
|
||
|
||
// Finding a named Python import proves the receiver exists, not
|
||
// its requested attribute. In particular, task.delay() enqueues
|
||
// work; it does not call the imported task function directly.
|
||
// Keep unknown members unresolved (including callback values)
|
||
// instead of falling back to the receiver as their target.
|
||
if (ref.language === 'python') return null;
|
||
}
|
||
|
||
return {
|
||
original: ref,
|
||
targetNodeId: targetNode.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a Python qualified reference whose receiver is an imported MODULE:
|
||
* `certs.where()` after `from . import certs`, `mod.func()` after `import mod`
|
||
* or `from pkg import mod`. The receiver names a submodule (a file), not a
|
||
* symbol, so the generic symbol lookup in `resolveViaImport` can't follow it —
|
||
* it would search the *package* for `certs`/`mod` instead of looking inside the
|
||
* module. This is the Python half of the cross-package qualified-call problem
|
||
* (cf. `resolveGoCrossPackageReference` for Go's `pkg.Func`, issue #388).
|
||
*
|
||
* Builds the module's dotted import path from the binding — `from . import
|
||
* certs` → `.certs`; `from pkg import mod` → `pkg.mod`; `import mod` → `mod` —
|
||
* resolves it to the module file, and finds the member defined there. Returns
|
||
* null when no module file exists at that path, so attribute access on an
|
||
* imported *value* (`helper.attr` where `helper` is a function) falls through
|
||
* to the other strategies untouched.
|
||
*/
|
||
function resolvePythonModuleMember(
|
||
ref: UnresolvedRef,
|
||
imports: ImportMapping[],
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
const dotIdx = ref.referenceName.indexOf('.');
|
||
if (dotIdx <= 0) return null;
|
||
const receiver = ref.referenceName.substring(0, dotIdx);
|
||
// The immediate member of the module (first segment after the receiver).
|
||
const member = ref.referenceName.substring(dotIdx + 1).split('.')[0];
|
||
if (!member) return null;
|
||
|
||
for (const imp of imports) {
|
||
if (imp.localName !== receiver) continue;
|
||
|
||
// `import mod` / `import numpy as np` bind the module at `source` itself;
|
||
// `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
|
||
// dotted path is the source joined with the imported name.
|
||
//
|
||
// Join with the EXPORTED name, not the local one: under
|
||
// `from pkg import mod as alias` the receiver is `alias` but the module on
|
||
// disk is `pkg.mod`, and building `pkg.alias` looked for a file that does
|
||
// not exist — so the aliased form dropped its `calls` edge while the plain
|
||
// form (where the two names coincide) worked (#1626). For an unaliased
|
||
// import the two are identical, so this changes nothing there.
|
||
const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName;
|
||
const modulePath = imp.isNamespace
|
||
? imp.source
|
||
: imp.source.endsWith('.')
|
||
? imp.source + moduleName
|
||
: imp.source + '.' + moduleName;
|
||
|
||
// resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an
|
||
// ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare
|
||
// `import pkg.mod`) resolves to null there, so fall back to the dotted-module
|
||
// file lookup — the same asymmetry resolveModuleImportToFile already handles
|
||
// for the file→file import edge. Without this, a `module.func()` call after
|
||
// `from pkg import module` dropped its `calls` edge even though the import
|
||
// edge resolved (#578).
|
||
let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
|
||
if (!resolvedPath) {
|
||
resolvedPath = findPythonModuleFile(modulePath, context, ref.filePath)?.filePath ?? null;
|
||
}
|
||
if (!resolvedPath || resolvedPath === ref.filePath) continue;
|
||
|
||
// Find the member as a top-level definition in the module file, or one it
|
||
// re-exports (a package's `__init__.py`). Exclude `method` so `mod.foo`
|
||
// never lands on a same-named class method.
|
||
const target = pythonModuleSymbol(resolvedPath, member, context, 0);
|
||
if (target) {
|
||
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a whole-MODULE import to that module's file (a file→file dependency).
|
||
* The imported name is a module, not a symbol, so there's nothing to resolve to
|
||
* — but importing a module IS a dependency on it. Covers:
|
||
* - Python submodule imports — `from . import certs`, `from pkg import sub`;
|
||
* - namespace imports — Python `import mod` / `import numpy as np`, and
|
||
* TS/JS `import * as ns from './x'`.
|
||
*
|
||
* It is also the robust backstop for {@link resolvePythonModuleMember} and for
|
||
* TS namespace usage: it records the dependency even when the used member is
|
||
* re-exported elsewhere (requests' `certs.where`, re-exported from `certifi`),
|
||
* the usage is module-level code that isn't extracted as a call, or a TS
|
||
* namespace is touched only via a value-member read (`ns.SOME_CONST`).
|
||
*
|
||
* Only fires for dot-free `imports`-kind refs whose module path resolves to a
|
||
* real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
|
||
* returns null and normal symbol resolution handles it.
|
||
*/
|
||
/**
|
||
* Resolve a Lua/Luau `require(...)` to its module file. The reference name is
|
||
* either a dotted module path (`telescope.config` → `telescope/config.lua`) or a
|
||
* Roblox instance-path leaf (`Signal` from `require(script.Parent.Signal)` →
|
||
* `Signal.luau`). We try `<path>.lua|.luau` and `<path>/init.lua|.luau`, matched
|
||
* by path suffix (the module root — `lua/`, `src/`, … — is project-specific).
|
||
* Among suffix matches, the one sharing the longest directory prefix with the
|
||
* requiring file wins (instance-path requires resolve within the same package).
|
||
*/
|
||
function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||
const name = ref.referenceName;
|
||
if (!name) return null;
|
||
const base = name.includes('.') ? name.replace(/\./g, '/') : name;
|
||
const suffixes = [`${base}.lua`, `${base}.luau`, `${base}/init.lua`, `${base}/init.luau`];
|
||
const byBasename = luaBasenameIndex(context);
|
||
const shared = (a: string, b: string): number => {
|
||
let i = 0;
|
||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||
return i;
|
||
};
|
||
for (const suffix of suffixes) {
|
||
// Only files sharing the suffix's basename can match — the bucket is in
|
||
// getAllFiles() order, so this filter yields exactly what the full-list
|
||
// scan did.
|
||
const candidates = byBasename.get(suffix.split('/').pop() ?? '') ?? [];
|
||
const matches = candidates.filter((f) => f === suffix || f.endsWith('/' + suffix));
|
||
if (matches.length === 0) continue;
|
||
matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath));
|
||
const best = matches[0]!;
|
||
if (best === ref.filePath) continue;
|
||
const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file');
|
||
if (fileNode) {
|
||
// Confidence ≥ 0.9 so this deterministic path/suffix match wins over
|
||
// name-matching, which otherwise resolves the require to the import node
|
||
// itself (a same-name self-match).
|
||
return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* `UploadApi.uploadARCapture()` where `UploadApi` is a NAMESPACE OBJECT — the
|
||
* default-export façade most React Native API layers are written as:
|
||
*
|
||
* import { uploadARCapture } from './frames'
|
||
* const UploadApi = { uploadARCapture, createFolder }
|
||
* export default UploadApi
|
||
*
|
||
* The member is a shorthand (or `key: ident`) property whose value is a
|
||
* binding of the object's file, not a function defined inside the literal,
|
||
* so containment (`resolveObjectLiteralMember`) finds nothing and the call
|
||
* landed on the constant — every cross-file caller of the API function went
|
||
* missing. Read the literal's source, take the binding the member names, and
|
||
* resolve it where the object's file would: a symbol declared there, else
|
||
* through its own imports. Calls accept callable targets only.
|
||
*/
|
||
function resolveObjectLiteralAlias(
|
||
container: Node,
|
||
member: string,
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (container.kind !== 'constant' && container.kind !== 'variable') return null;
|
||
if (!JS_FAMILY_FILE.test(container.filePath)) return null;
|
||
const resolved = resolveObjectLiteralBinding(container, member, ref, context);
|
||
return resolved ? { ...resolved, confidence: 0.9, resolvedBy: 'import' } : null;
|
||
}
|
||
|
||
function resolveModuleImportToFile(
|
||
ref: UnresolvedRef,
|
||
imports: ImportMapping[],
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (ref.referenceKind !== 'imports') return null;
|
||
if (ref.referenceName.includes('.')) return null;
|
||
|
||
for (const imp of imports) {
|
||
if (imp.localName !== ref.referenceName) continue;
|
||
|
||
let modulePath: string;
|
||
if (imp.isNamespace || imp.isDefault) {
|
||
// `import * as ns from './x'` (namespace) or `import x from './x'`
|
||
// (default) — the dependency is on the MODULE FILE. A default import binds
|
||
// a (possibly renamed) local to whatever the module's default export is
|
||
// (`import articlesController from './article.controller'` ← `export
|
||
// default router`), so the binding name can't be found as a symbol — link
|
||
// the file the import resolves to instead. External modules don't resolve
|
||
// (no file), so `import React from 'react'` creates no edge.
|
||
modulePath = imp.source;
|
||
} else if (ref.language === 'python') {
|
||
// `from . import certs` — the imported NAME is a submodule of the source.
|
||
// As in resolvePythonModuleMember, use the exported name so an alias
|
||
// still links to the real module file (#1626).
|
||
const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName;
|
||
modulePath = imp.source.endsWith('.')
|
||
? imp.source + moduleName
|
||
: imp.source + '.' + moduleName;
|
||
} else {
|
||
// A named TS/JS import binds a symbol, not a module — leave it alone.
|
||
continue;
|
||
}
|
||
|
||
const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
|
||
if (resolvedPath && resolvedPath !== ref.filePath) {
|
||
const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file');
|
||
if (fileNode) {
|
||
return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
}
|
||
|
||
// Python absolute `from a.b import submodule` (a FastAPI router aggregator's
|
||
// `from app.api.routes import authentication`): resolveImportPath only maps
|
||
// RELATIVE dotted paths to a file, so resolve the absolute dotted module
|
||
// directly to its file node.
|
||
if (ref.language === 'python') {
|
||
const modFile = findPythonModuleFile(modulePath, context, ref.filePath);
|
||
if (modFile) {
|
||
return { original: ref, targetNodeId: modFile.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Find the file node for a Python dotted module path `a.b.c` — a module file
|
||
* ending in `a/b/c.py`, or a package `a/b/c/__init__.py` (suffix-matched, so a
|
||
* package rooted under `src/` etc. still resolves). Returns null for
|
||
* stdlib/external modules (no matching repo file node), so `import os` creates
|
||
* no edge. Shared by absolute `import a.b.c` and absolute `from a.b import c`
|
||
* (where `c` is a submodule) resolution.
|
||
*/
|
||
/**
|
||
* Per-context memo for findPythonModuleFile: module path → the `<mod>.py` and
|
||
* `<mod>/__init__.py` file nodes whose path ends with it, in name-lookup
|
||
* order. Only the importing file's own path is excluded per call, so taking
|
||
* the first survivor returns the node the unmemoized scan found. Without it,
|
||
* every ref naming a module outside the project (`from unittest import mock`)
|
||
* rescanned every `__init__.py` in the tree. Same stable window as the name
|
||
* caches; dropped by clearImportResolverMemos.
|
||
*/
|
||
const pythonModuleFileMemos = new WeakMap<ResolutionContext, Map<string, { module: Node[]; pkg: Node[] }>>();
|
||
|
||
/**
|
||
* A top-level class / function / value named `name` in a Python module, or
|
||
* one the module re-exports — `from .users import *`, `from .users import
|
||
* User` — a few packages deep. netbox's `from users.models import User` names
|
||
* `users/models/__init__.py`, which star-imports `.users`, where `User` is.
|
||
*/
|
||
const PY_MODULE_SYMBOLS = new WeakMap<ResolutionContext, Map<string, Node | null>>();
|
||
|
||
function pythonModuleSymbol(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined {
|
||
let memo = PY_MODULE_SYMBOLS.get(context);
|
||
if (!memo) PY_MODULE_SYMBOLS.set(context, (memo = new Map()));
|
||
const key = `${file}\0${name}`;
|
||
const hit = memo.get(key);
|
||
if (hit !== undefined) return hit ?? undefined;
|
||
// (A cycle of star imports reads as "not here" while it is being walked.)
|
||
memo.set(key, null);
|
||
const found = pythonModuleSymbolUncached(file, name, context, depth);
|
||
memo.set(key, found ?? null);
|
||
return found;
|
||
}
|
||
|
||
function pythonModuleSymbolUncached(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined {
|
||
const own = context.getNodesInFile(file).find((n) =>
|
||
n.name === name && !n.qualifiedName.includes('::') &&
|
||
(n.kind === 'class' || n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant'));
|
||
if (own || depth >= 3) return own;
|
||
// Re-exported by name (`from .users import User`), else through a star import
|
||
// (`from .users import *` — not among the import mappings, so read here).
|
||
const sources: Array<{ source: string; exported: string }> = context.getImportMappings(file, 'python')
|
||
.filter((imp) => !imp.isNamespace && imp.localName === name)
|
||
.map((imp) => ({ source: imp.source, exported: imp.exportedName }));
|
||
for (const m of (context.readFile(file) ?? '').matchAll(/^\s*from\s+([\w.]+)\s+import\s+\*/gm)) {
|
||
sources.push({ source: m[1]!, exported: name });
|
||
}
|
||
for (const { source, exported } of sources) {
|
||
const target = resolveImportPath(source, file, 'python', context) ?? findPythonModuleFile(source, context, file)?.filePath ?? null;
|
||
if (!target || target === file) continue;
|
||
const found = pythonModuleSymbol(target, exported, context, depth + 1);
|
||
if (found) return found;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function findPythonModuleFile(
|
||
mod: string,
|
||
context: ResolutionContext,
|
||
excludeFilePath: string
|
||
): Node | null {
|
||
if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere
|
||
let memo = pythonModuleFileMemos.get(context);
|
||
if (!memo) {
|
||
memo = new Map();
|
||
pythonModuleFileMemos.set(context, memo);
|
||
}
|
||
let files = memo.get(mod);
|
||
if (!files) {
|
||
const rel = mod.replace(/\./g, '/');
|
||
const lastSeg = mod.split('.').pop()!;
|
||
const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want);
|
||
files = {
|
||
module: context
|
||
.getNodesByName(`${lastSeg}.py`)
|
||
.filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}.py`)),
|
||
pkg: context
|
||
.getNodesByName('__init__.py')
|
||
.filter((n) => n.kind === 'file' && endsWith(n.filePath, `${rel}/__init__.py`)),
|
||
};
|
||
memo.set(mod, files);
|
||
}
|
||
return (
|
||
files.module.find((n) => n.filePath !== excludeFilePath) ??
|
||
files.pkg.find((n) => n.filePath !== excludeFilePath) ??
|
||
null
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve a Python ABSOLUTE dotted module import (`import a.b.c`) to its file —
|
||
* the Django `AppConfig.ready(): import myapp.signals` pattern and any
|
||
* side-effect module import.
|
||
*/
|
||
function resolvePythonAbsoluteModule(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (ref.referenceKind !== 'imports') return null;
|
||
// Only a DOTTED `import a.b.c` ref carries its full module path. A bare leaf
|
||
// (`from app.api.routes import authentication`) is ambiguous on its own — three
|
||
// `authentication.py` files may exist — so leave it to resolveModuleImportToFile,
|
||
// which uses the import's source (`app.api.routes`) to build the full path.
|
||
if (!ref.referenceName.includes('.')) return null;
|
||
const hit = findPythonModuleFile(ref.referenceName, context, ref.filePath);
|
||
return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix
|
||
* (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust
|
||
* analog of {@link resolvePythonModuleMember} / {@link resolveGoCrossPackageReference}
|
||
* and the precise answer to common-name re-exports (`pub use self::read::read`)
|
||
* that name-matching can't disambiguate. Returns null when the prefix isn't a
|
||
* real module path (e.g. `Widget::new` — `Widget` is a struct, not a module),
|
||
* so associated-function calls and enum-variant paths fall through untouched.
|
||
*/
|
||
function resolveRustPathReference(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
const segments = ref.referenceName.split('::').filter((s) => s.length > 0);
|
||
if (segments.length < 2) return null;
|
||
const leaf = segments[segments.length - 1]!;
|
||
const modSegs = segments.slice(0, -1);
|
||
|
||
const file = resolveRustModuleFile(modSegs, ref.filePath, context);
|
||
if (!file || file === ref.filePath) return null;
|
||
|
||
const target = context.getNodesInFile(file).find(
|
||
(n) =>
|
||
n.name === leaf &&
|
||
(n.kind === 'function' ||
|
||
n.kind === 'struct' ||
|
||
n.kind === 'union' ||
|
||
n.kind === 'enum' ||
|
||
n.kind === 'trait' ||
|
||
n.kind === 'type_alias' ||
|
||
n.kind === 'constant' ||
|
||
n.kind === 'method' ||
|
||
n.kind === 'class' ||
|
||
n.kind === 'interface')
|
||
);
|
||
if (target) {
|
||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */
|
||
function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null {
|
||
const projectRoot = context.getProjectRoot();
|
||
const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
|
||
let dir = path.dirname(fromFileAbs);
|
||
for (let i = 0; i < 64; i++) {
|
||
if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) ||
|
||
context.fileExists(toRel(path.join(dir, 'main.rs')))) {
|
||
return dir;
|
||
}
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) return null;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Directory under which the current file's module declares its SUBMODULES. */
|
||
function rustSelfModuleDir(fromFileAbs: string): string {
|
||
const base = path.basename(fromFileAbs);
|
||
const dir = path.dirname(fromFileAbs);
|
||
// mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`.
|
||
if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir;
|
||
return path.join(dir, base.replace(/\.rs$/, ''));
|
||
}
|
||
|
||
/**
|
||
* Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of
|
||
* the last module segment — `crate::a::b` → `<crate>/a/b.rs` (or `.../b/mod.rs`).
|
||
* Anchors on `crate` / `self` / `super`; a bare path is tried crate-relative.
|
||
*/
|
||
function resolveRustModuleFile(
|
||
segments: string[],
|
||
fromFile: string,
|
||
context: ResolutionContext
|
||
): string | null {
|
||
if (segments.length === 0) return null;
|
||
const projectRoot = context.getProjectRoot();
|
||
const fromAbs = path.join(projectRoot, fromFile);
|
||
const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
|
||
|
||
// Walk a sequence of module segments down from `startDir`, mapping each to a
|
||
// `<seg>.rs` or `<seg>/mod.rs` file. Returns the leaf module's file, or null
|
||
// if `startDir` is null or any segment has no file on disk.
|
||
const resolveUnder = (startDir: string | null, rest: string[]): string | null => {
|
||
if (!startDir) return null;
|
||
let dir = startDir;
|
||
let targetFile: string | null = null;
|
||
for (const seg of rest) {
|
||
if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
|
||
const asFile = toRel(path.join(dir, seg + '.rs'));
|
||
const asMod = toRel(path.join(dir, seg, 'mod.rs'));
|
||
if (context.fileExists(asFile)) targetFile = asFile;
|
||
else if (context.fileExists(asMod)) targetFile = asMod;
|
||
else return null;
|
||
dir = path.join(dir, seg);
|
||
}
|
||
return targetFile;
|
||
};
|
||
|
||
const first = segments[0]!;
|
||
if (first === 'crate') {
|
||
return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1));
|
||
}
|
||
if (first === 'self') {
|
||
return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1));
|
||
}
|
||
if (first === 'super') {
|
||
let supers = 0;
|
||
while (segments[supers] === 'super') supers++;
|
||
let dir: string | null = rustSelfModuleDir(fromAbs);
|
||
for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir);
|
||
return resolveUnder(dir, segments.slice(supers));
|
||
}
|
||
// Bare path. In expression position (`submodule::item()` — the router-assembly
|
||
// and general cross-module-call pattern) the prefix is a SUBMODULE of the
|
||
// current module, i.e. 2018 `self::`-relative — so try self-relative FIRST.
|
||
// Fall back to crate-relative for 2015-edition / crate-root items. External
|
||
// crate paths (`serde::de::Error`) miss both and fall through to name-matching.
|
||
return (
|
||
resolveUnder(rustSelfModuleDir(fromAbs), segments) ??
|
||
resolveUnder(rustCrateRootDir(fromAbs, context), segments)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve a Java/Kotlin reference whose receiver is the simple name of
|
||
* an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The
|
||
* imported FQN converts to a file-path suffix (`com/example/Foo.java`
|
||
* or `.kt`) which uniquely identifies the right symbol when multiple
|
||
* classes share the same simple name.
|
||
*
|
||
* Also handles bare references to the imported class itself
|
||
* (`new Foo()` extraction emits `Foo` as a `references`/`instantiates`
|
||
* ref) and `import static <Foo>.bar` style imports of a single member.
|
||
*/
|
||
function resolveJavaImportedReference(
|
||
ref: UnresolvedRef,
|
||
imports: ImportMapping[],
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (imports.length === 0) return null;
|
||
|
||
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
|
||
|
||
for (const imp of imports) {
|
||
const matchesBare = imp.localName === ref.referenceName;
|
||
const matchesQualified = ref.referenceName.startsWith(imp.localName + '.');
|
||
if (!matchesBare && !matchesQualified) continue;
|
||
|
||
// Convert FQN to a file-path suffix. `com.example.Foo` ->
|
||
// `com/example/Foo.java` (or `.kt`). The actual file may live
|
||
// under any source root (`src/main/java/`, `src/`, etc.), so match
|
||
// by suffix rather than exact path.
|
||
const fqnPath = imp.source.replace(/\./g, '/') + ext;
|
||
|
||
// Which symbol name to look up: the class itself, or a member.
|
||
const memberName = matchesBare
|
||
? imp.localName
|
||
: ref.referenceName.substring(imp.localName.length + 1);
|
||
|
||
const candidates = context.getNodesByName(memberName);
|
||
for (const node of candidates) {
|
||
if (node.language !== ref.language) continue;
|
||
const fp = node.filePath.replace(/\\/g, '/');
|
||
if (fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath)) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: node.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
}
|
||
|
||
// `import static com.example.Foo.bar;` — the FQN's tail is the
|
||
// member name, the part before is the owner class. Look up the
|
||
// member named `<imp.localName>` (e.g. `bar`) and prefer the
|
||
// candidate whose file matches the parent FQN's path.
|
||
if (matchesBare) {
|
||
const dot = imp.source.lastIndexOf('.');
|
||
if (dot > 0) {
|
||
const ownerFqn = imp.source.substring(0, dot);
|
||
const ownerPath = ownerFqn.replace(/\./g, '/') + ext;
|
||
for (const node of candidates) {
|
||
if (node.language !== ref.language) continue;
|
||
const fp = node.filePath.replace(/\\/g, '/');
|
||
if (fp.endsWith(ownerPath) || fp.endsWith('/' + ownerPath)) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: node.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a Go cross-package qualified reference (`pkga.FuncX`) by matching
|
||
* the package alias against an in-project import, mapping the import to the
|
||
* package's project directory, and locating the exported symbol in a `.go`
|
||
* file directly in that directory. Returns `null` for stdlib / third-party
|
||
* imports (no project module declares them) so the rest of
|
||
* `resolveViaImport` can still try the file-based path.
|
||
*/
|
||
function resolveGoCrossPackageReference(
|
||
ref: UnresolvedRef,
|
||
imports: ImportMapping[],
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
// Qualified call: receiver before `.`, member after. A bare reference
|
||
// (no dot) is a same-package name — unless the source spells a package in
|
||
// front of it: `store.Manager` in a field or a signature, which the index
|
||
// keeps as `Manager` (#2322).
|
||
const dotIdx = ref.referenceName.indexOf('.');
|
||
if (dotIdx <= 0) {
|
||
const imp = goRefQualifier(ref, context);
|
||
return imp ? findGoPackageMember(ref.referenceName, imp, ref, context) : null;
|
||
}
|
||
const receiver = ref.referenceName.substring(0, dotIdx);
|
||
const memberName = ref.referenceName.substring(dotIdx + 1);
|
||
if (!memberName) return null;
|
||
// A parameter or local named like the import holds the call there:
|
||
// `store := newStore()`, then `store.Get(k)` is no call into package store.
|
||
if (!goRefQualifier(ref, context)) return null;
|
||
|
||
for (const imp of imports) {
|
||
if (imp.localName !== receiver) continue;
|
||
const found = findGoPackageMember(memberName, imp, ref, context);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The exported `memberName` of the project package `imp` names, looked up in
|
||
* the package's directory: its module's directory followed by the rest of
|
||
* the import path (#2322). Matched on the immediate directory so `pkga.FuncX`
|
||
* never lands on a `FuncX` declared in `pkga/subpkg/`. Null when the import
|
||
* is no project package.
|
||
*/
|
||
function findGoPackageMember(
|
||
memberName: string,
|
||
imp: ImportMapping,
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
const pkgDir = context.getGoPackageDir?.(imp.source, ref.filePath);
|
||
if (pkgDir == null) return null;
|
||
for (const node of context.getNodesByName(memberName)) {
|
||
if (node.language !== 'go') continue;
|
||
if (!node.isExported) continue;
|
||
if (path.posix.dirname(node.filePath.replace(/\\/g, '/')) === pkgDir) {
|
||
return {
|
||
original: ref,
|
||
targetNodeId: node.id,
|
||
confidence: 0.9,
|
||
resolvedBy: 'import',
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Recursive depth cap for re-export chain following. Real codebases
|
||
* rarely chain barrels more than 2–3 deep; 8 is a generous safety
|
||
* net that still bounds worst-case work. */
|
||
export const REEXPORT_MAX_DEPTH = 8;
|
||
|
||
/**
|
||
* Find an exported symbol in `filePath`, following `export { x } from
|
||
* './other'` and `export * from './other'` chains until the original
|
||
* declaration is reached. Cycle-safe via the `visited` set.
|
||
*
|
||
* Without this, every barrel-style import (`import { Foo } from
|
||
* './index'` where `index.ts` only re-exports) used to resolve to
|
||
* nothing — the existing code only looked for declarations IN the
|
||
* resolved file, not declarations the file forwarded.
|
||
*/
|
||
function findExportedSymbol(
|
||
filePath: string,
|
||
want: {
|
||
isDefault: boolean;
|
||
isNamespace: boolean;
|
||
exportedName: string;
|
||
memberName: string | null;
|
||
},
|
||
language: Language,
|
||
context: ResolutionContext,
|
||
visited: Set<string>,
|
||
depth = 0
|
||
): Node | undefined {
|
||
// Memoize fresh (top-level) lookups only: recursive re-export steps carry a
|
||
// populated `visited` set, whose contents change the reachable answer.
|
||
// Every ref to the same imported symbol repeats this exact walk, so the
|
||
// top-level memo removes the re-export chase + per-file linear scans from
|
||
// all but the first occurrence.
|
||
if (depth === 0 && visited.size === 0) {
|
||
let memo = exportedSymbolMemos.get(context);
|
||
if (!memo) {
|
||
memo = new Map();
|
||
exportedSymbolMemos.set(context, memo);
|
||
}
|
||
const key = `${filePath}\0${want.isDefault ? 1 : 0}${want.isNamespace ? 1 : 0}\0${want.exportedName}\0${want.memberName ?? ''}\0${language}`;
|
||
if (memo.has(key)) return memo.get(key);
|
||
const result = findExportedSymbolWalk(filePath, want, language, context, visited, depth);
|
||
memo.set(key, result);
|
||
return result;
|
||
}
|
||
return findExportedSymbolWalk(filePath, want, language, context, visited, depth);
|
||
}
|
||
|
||
function findExportedSymbolWalk(
|
||
filePath: string,
|
||
want: {
|
||
isDefault: boolean;
|
||
isNamespace: boolean;
|
||
exportedName: string;
|
||
memberName: string | null;
|
||
},
|
||
language: Language,
|
||
context: ResolutionContext,
|
||
visited: Set<string>,
|
||
depth: number
|
||
): Node | undefined {
|
||
if (depth > REEXPORT_MAX_DEPTH) return undefined;
|
||
if (visited.has(filePath)) return undefined;
|
||
visited.add(filePath);
|
||
|
||
const exportIndex = getFileExportIndex(filePath, context);
|
||
|
||
// 1. Direct hit: the symbol is declared in this file.
|
||
if (want.isDefault) {
|
||
// What the file's own `export default` statement exports beats any
|
||
// guess; an anonymous default is no node at all, and no neighbour is it.
|
||
if (statedDefaultExport(filePath, exportIndex, context) === 'anonymous') return undefined;
|
||
// Svelte/Vue single-file components ARE the module's default export,
|
||
// but are extracted as kind 'component' (not function/class), so the
|
||
// fallback prefers the component node. Without it, an `export { default
|
||
// as X } from './X.svelte'` barrel never resolves and the component shows
|
||
// a false 0 callers (#629).
|
||
const direct = esmDefaultExport(filePath, exportIndex, context);
|
||
if (direct) return direct;
|
||
// CommonJS: `module.exports = createApplication`, or `= require('./lib/express')`.
|
||
const commonJs = commonJsDefaultExport(filePath, context);
|
||
if (commonJs?.source) {
|
||
const next = resolveImportPath(commonJs.source, filePath, language, context);
|
||
if (next) return findExportedSymbol(next, want, language, context, visited, depth + 1);
|
||
} else if (commonJs?.name) {
|
||
const bound = nodesInFileNamed(filePath, commonJs.name, context)
|
||
.filter((n) => DEFAULT_BINDING_KINDS.has(n.kind))
|
||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
||
if (bound) return bound;
|
||
}
|
||
} else if (want.isNamespace && want.memberName) {
|
||
const direct = exportedByName(filePath, exportIndex, want.memberName, context);
|
||
if (direct) return direct;
|
||
} else {
|
||
const direct = exportedByName(filePath, exportIndex, want.exportedName, context);
|
||
if (direct) return direct;
|
||
// `require('./x').default` and `const { default: X } = require('./x')` read
|
||
// the `default` property of module.exports. A CommonJS module sets it by
|
||
// name (`exports.default = fn`, the dual `module.exports.default = X`),
|
||
// found just above. A module written as an ES module sets it to its
|
||
// default export, `export default class Foo`, which is no named export —
|
||
// the answer when the module has no such property. Only in the module
|
||
// asked for: `export * from` never forwards a default.
|
||
if (
|
||
depth === 0 &&
|
||
want.exportedName === 'default' &&
|
||
ESM_IMPORT_LANGUAGES.has(language) &&
|
||
hasEsmDefaultExport(filePath, exportIndex, context)
|
||
) {
|
||
const esmDefault = esmDefaultExport(filePath, exportIndex, context);
|
||
if (esmDefault) return esmDefault;
|
||
}
|
||
}
|
||
|
||
// 2. Re-export hit: the file forwards the symbol to another module.
|
||
const reExports = context.getReExports?.(filePath, language) ?? [];
|
||
if (reExports.length === 0) return undefined;
|
||
|
||
// Look for explicit `export { want } from './other'` (with optional rename) — for
|
||
// `ns.member` through `import * as ns`, the member is the name wanted.
|
||
const targetName = want.isDefault ? 'default' : want.isNamespace && want.memberName ? want.memberName : want.exportedName;
|
||
for (const rex of reExports) {
|
||
if (rex.kind === 'named' && rex.exportedName === targetName) {
|
||
const next = resolveImportPath(rex.source, filePath, language, context);
|
||
if (!next) continue;
|
||
// After rename: `export { foo as bar } from './x'` — to chase
|
||
// `bar`, we look for `foo` in `./x`.
|
||
const chained = findExportedSymbol(
|
||
next,
|
||
{
|
||
isDefault: rex.originalName === 'default',
|
||
isNamespace: false,
|
||
exportedName: rex.originalName,
|
||
memberName: null,
|
||
},
|
||
language,
|
||
context,
|
||
visited,
|
||
depth + 1
|
||
);
|
||
if (chained) return chained;
|
||
}
|
||
}
|
||
|
||
// `z.core.util.fn` through `export * as core from './core'`: the member continues in that module.
|
||
if (want.isNamespace && want.memberName) {
|
||
const dot = want.memberName.indexOf('.');
|
||
const head = dot < 0 ? want.memberName : want.memberName.slice(0, dot);
|
||
const rex = dot < 0 ? undefined : reExports.find((r) => r.kind === 'namespace' && r.exportedName === head);
|
||
if (rex) {
|
||
const next = resolveImportPath(rex.source, filePath, language, context);
|
||
const rest = want.memberName.slice(dot + 1);
|
||
return next
|
||
? findExportedSymbol(next, { ...want, exportedName: rest.split('.')[0]!, memberName: rest }, language, context, visited, depth + 1)
|
||
: undefined;
|
||
}
|
||
}
|
||
|
||
// 3. Wildcard re-export: `export * from './other'` — try every
|
||
// forwarding source. This is the barrel-of-barrels case.
|
||
for (const rex of reExports) {
|
||
if (rex.kind === 'wildcard') {
|
||
const next = resolveImportPath(rex.source, filePath, language, context);
|
||
if (!next) continue;
|
||
const chained = findExportedSymbol(next, want, language, context, visited, depth + 1);
|
||
if (chained) return chained;
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/** Node kinds that own static members reachable as `Container.member`. */
|
||
const STATIC_MEMBER_CONTAINERS = new Set<Node['kind']>([
|
||
'class', 'struct', 'union', 'interface', 'enum', 'trait', 'protocol',
|
||
]);
|
||
|
||
/**
|
||
* Resolve `Container.member` — a static method/property access on a NAMED class
|
||
* import (`import { Foo } …; Foo.bar()`) — to the member node, given the
|
||
* already-resolved container class.
|
||
*
|
||
* Members carry a `Container::member` qualifiedName, so we look up
|
||
* `${container.qualifiedName}::${member}` within the container's own file (the
|
||
* file filter disambiguates same-named classes in other modules). Returns
|
||
* undefined when the container isn't a member-owning kind or the member isn't
|
||
* found, so the caller falls back to the container itself (prior behavior) —
|
||
* languages whose members aren't `::`-qualified, and genuine class references,
|
||
* are unaffected. See #825.
|
||
*/
|
||
/**
|
||
* Resolve a CALL through an imported value to the method on the value's own
|
||
* type: `reproStore.notifyJoinGuildStatus()` where `reproStore` is
|
||
* `export const reproStore = new ReproStore()` in the imported file (#1292).
|
||
* The same-file form of this call already resolves via local-variable
|
||
* receiver inference (#1108); this is the cross-file/import half. The type is
|
||
* recovered from the VALUE'S OWN declaration lines in the exporting file
|
||
* (initializer `= new T(...)` or a type annotation, per the shared #1108
|
||
* pattern table), then the member is resolved AND VALIDATED on that type by
|
||
* resolveMethodOnType — a failed inference or validation returns null so the
|
||
* caller keeps its existing constant-edge behavior.
|
||
*/
|
||
function resolveImportedInstanceMember(
|
||
value: Node,
|
||
ref: UnresolvedRef,
|
||
localName: string,
|
||
context: ResolutionContext
|
||
): ResolvedRef | null {
|
||
if (ref.referenceKind !== 'calls') return null;
|
||
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
|
||
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
|
||
if (!member) return null;
|
||
|
||
const source = context.readFile(value.filePath);
|
||
if (!source) return null;
|
||
// Only the value's own declaration lines — never the whole file, so a
|
||
// same-named identifier elsewhere can't donate a type.
|
||
const lines = source.split('\n');
|
||
const declSlice = lines.slice(Math.max(0, value.startLine - 1), value.endLine).join('\n');
|
||
|
||
const receiver = value.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
for (const pattern of localReceiverTypePatterns(value.language as Language, receiver)) {
|
||
const m = declSlice.match(pattern);
|
||
if (!m || !m[1]) continue;
|
||
const typeName = normalizeInferredTypeName(m[1]);
|
||
if (!typeName) continue;
|
||
const resolved = resolveMethodOnType(typeName, member, ref, context, 0.85, 'instance-method');
|
||
if (resolved) return resolved;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveStaticMember(
|
||
container: Node,
|
||
ref: UnresolvedRef,
|
||
localName: string,
|
||
context: ResolutionContext
|
||
): Node | undefined {
|
||
if (!STATIC_MEMBER_CONTAINERS.has(container.kind)) return undefined;
|
||
// First segment after the receiver: `Foo.bar.baz` → `bar`.
|
||
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
|
||
if (!member) return undefined;
|
||
|
||
const candidates = context
|
||
.getNodesByQualifiedName(`${container.qualifiedName}::${member}`)
|
||
.filter((n) => n.filePath === container.filePath);
|
||
if (candidates.length === 0) return undefined;
|
||
|
||
// When the reference is a call, prefer a callable member if several nodes
|
||
// share the qualifiedName (e.g. a static property and a method).
|
||
if (ref.referenceKind === 'calls') {
|
||
const callable = candidates.find((n) => n.kind === 'method' || n.kind === 'function');
|
||
if (callable) return callable;
|
||
}
|
||
return candidates[0];
|
||
}
|
||
|
||
/**
|
||
* Rust `use` declarations, flattened to `localName → full path`.
|
||
*
|
||
* Rust is the one supported language with NO `ImportMapping` extraction (see
|
||
* `extractImportMappings`), so this is the only channel that can tell whether
|
||
* a bare type name in a Rust file was brought in by a `use`. Handles nested
|
||
* groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single
|
||
* name), and `as` aliases.
|
||
*/
|
||
function collectRustUseBindings(content: string): Map<string, string> {
|
||
const out = new Map<string, string>();
|
||
|
||
// Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens.
|
||
const expand = (spec: string): string[] => {
|
||
const open = spec.indexOf('{');
|
||
if (open === -1) return [spec.trim()];
|
||
const prefix = spec.slice(0, open);
|
||
let depth = 0;
|
||
let close = -1;
|
||
for (let i = open; i < spec.length; i++) {
|
||
if (spec[i] === '{') depth++;
|
||
else if (spec[i] === '}') {
|
||
depth--;
|
||
if (depth === 0) { close = i; break; }
|
||
}
|
||
}
|
||
if (close === -1) return [];
|
||
const suffix = spec.slice(close + 1);
|
||
const inner = spec.slice(open + 1, close);
|
||
const parts: string[] = [];
|
||
let depth2 = 0;
|
||
let start = 0;
|
||
for (let i = 0; i <= inner.length; i++) {
|
||
const ch = inner[i];
|
||
if (ch === '{') depth2++;
|
||
else if (ch === '}') depth2--;
|
||
if (i === inner.length || (ch === ',' && depth2 === 0)) {
|
||
const seg = inner.slice(start, i).trim();
|
||
if (seg) parts.push(seg);
|
||
start = i + 1;
|
||
}
|
||
}
|
||
return parts.flatMap((p) => expand(prefix + p + suffix));
|
||
};
|
||
|
||
// `use` items end at the first `;`. Attributes/visibility (`pub use`) are
|
||
// irrelevant to the binding itself.
|
||
const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = useRe.exec(content)) !== null) {
|
||
for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) {
|
||
const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec);
|
||
const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim();
|
||
if (!rawPath || rawPath.endsWith('*')) continue;
|
||
const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean);
|
||
const leaf = segments[segments.length - 1];
|
||
if (!leaf) continue;
|
||
const local = aliasMatch ? aliasMatch[2]! : leaf;
|
||
out.set(local, segments.join('::'));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Is `name`, as used in `ref`'s file, bound by an import whose module lives
|
||
* OUTSIDE the repository?
|
||
*
|
||
* When it is, no in-repo node can be the referent: the symbol is defined in a
|
||
* third-party crate/package, and any same-named local symbol the name-matcher
|
||
* finds is a coincidence. Rust `use std::error::Error;` + `impl Error for
|
||
* MapperError {}` bound to a local `MapperError::Error` variant, and once
|
||
* non-type kinds were filtered out it simply moved to an unrelated local
|
||
* `type Error` alias — restricting kinds alone RELOCATES the false edge
|
||
* instead of removing it, so locality has to be checked too.
|
||
*
|
||
* Answers only when it can be CERTAIN, because a false "yes" deletes a real
|
||
* edge. Two languages qualify, each with an oracle that cannot be wrong:
|
||
*
|
||
* - **Rust** — the `use` path is rooted at a standard-library crate
|
||
* (`std`/`core`/`alloc`/`proc_macro`), which by definition ships outside
|
||
* any repository. Deliberately NOT generalized to "the module path doesn't
|
||
* resolve to a file": a crate can re-export another workspace crate's
|
||
* modules (`pub use pupil_core::{ports, domain};`), so `crate::ports::X`
|
||
* has no `src/ports/` directory to walk yet is entirely in-repo — that
|
||
* generalization measured 13 real trait implementations deleted.
|
||
* - **ES modules** — `isExternalImport`, which already accounts for tsconfig
|
||
* path aliases and monorepo workspace packages.
|
||
*
|
||
* Everything else returns false and resolves exactly as before. JVM and Python
|
||
* imports notably do NOT go through `resolveImportPath` (they have dedicated
|
||
* FQN/module matchers), so there is no trustworthy oracle to consult here.
|
||
*/
|
||
export function isBoundToOutOfRepoImport(
|
||
ref: UnresolvedRef,
|
||
context: ResolutionContext
|
||
): boolean {
|
||
const name = ref.referenceName;
|
||
if (name.includes('::') || name.includes('.')) return false; // qualified refs resolve by path
|
||
|
||
if (ref.language === 'rust') {
|
||
const content = context.readFile(ref.filePath);
|
||
if (!content) return false;
|
||
const usePath = collectRustUseBindings(content).get(name);
|
||
if (!usePath) return false;
|
||
const segments = usePath.split('::');
|
||
if (segments.length < 2 || !RUST_STDLIB_ROOTS.has(segments[0]!)) return false;
|
||
// 2015-edition crate-relative paths can shadow a stdlib root with a local
|
||
// module of the same name — if the path walks to a real file, it's local.
|
||
return resolveRustModuleFile(segments.slice(0, -1), ref.filePath, context) === null;
|
||
}
|
||
|
||
if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false;
|
||
for (const imp of context.getImportMappings(ref.filePath, ref.language)) {
|
||
if (imp.localName !== name) continue;
|
||
return isExternalImport(imp.source, ref.language, context);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The tail a reference that failed to resolve is parked under when its name
|
||
* is an import binding the module it imports does not declare by that name:
|
||
* a default import (`import tagsController from './tag/tag.controller'`, a
|
||
* CommonJS `require`), a namespace import, or an aliased one (`import {
|
||
* Component as Wrapper }`, `{ default as X }`). Resolution reaches it through
|
||
* the module, so a sync's retry has to find it by the module: under the
|
||
* module's {@link moduleTail} ('module:tag'), the key a sync looks up for
|
||
* every file it adds or changes. Its own name, the default tail, is one the
|
||
* module never declares, so the file that appears or gains the export never
|
||
* found it. A sync still looks it up by that name as well: a binding its
|
||
* module never resolves is linked by the name alone.
|
||
*
|
||
* Only a whole-name reference to the binding — the import itself, a call,
|
||
* `new`, a JSX tag, a value — and only for a module of the project: a member
|
||
* read (`NS.member`) waits for the member's name, and a package's binding
|
||
* keeps its tail. Undefined for every other reference.
|
||
*/
|
||
export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
|
||
if (ref.language === 'python') return pythonImportBindingTail(ref, context);
|
||
if (!ESM_IMPORT_LANGUAGES.has(ref.language) || !IDENTIFIER.test(ref.referenceName)) return undefined;
|
||
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === ref.referenceName);
|
||
if (!binding || (!binding.isDefault && !binding.isNamespace && binding.exportedName === binding.localName)) return undefined;
|
||
if (isExternalImport(binding.source, ref.language, context) &&
|
||
// A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only.
|
||
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix))) {
|
||
return undefined;
|
||
}
|
||
// `..` names a folder through the importing file's own location.
|
||
const modulePath = binding.source.startsWith('.')
|
||
? path.posix.join(path.posix.dirname(ref.filePath), binding.source)
|
||
: binding.source;
|
||
return moduleTail(modulePath) || undefined;
|
||
}
|
||
|
||
/**
|
||
* {@link importBindingTail} for Python: `from .models import User as
|
||
* UserModel` and `from . import helpers as h` bind a name their module has as
|
||
* `User` and `helpers`, `import app.tools as tl` one it has as `tools`. The
|
||
* module is the one the import names by its dotted path, counted from the
|
||
* importing file's own package when it starts with dots (`.` is that package,
|
||
* each further dot the one above): `.models` in `app/main.py` is `app/models`,
|
||
* parked as 'module:models', and `.` is `app`, 'module:app'. The name may
|
||
* import a submodule of it (`helpers` is `app/helpers.py`), whose keys hold
|
||
* the package's name too (moduleReferenceKeys).
|
||
*
|
||
* An installed package's alias can't be told from a module the project doesn't
|
||
* have yet (`from settings import Config as Cfg`), so it waits for its module
|
||
* too, and a sync still finds it by its own name. An import of the standard
|
||
* library keeps its tail.
|
||
*
|
||
* So does an unaliased `import x`, and an unaliased `from fastapi import
|
||
* FastAPI` unless its module is in one of the project's own folders
|
||
* ({@link isProjectPythonModule}). The name is the one the module has, but a
|
||
* package often re-exports it from the module that declares it
|
||
* (`fastapi/__init__.py`: `from .applications import FastAPI as FastAPI`), and
|
||
* a package `__init__.py` that appears or gains the re-export declares no node
|
||
* of that name. An outside package's names stay off the module keys: every
|
||
* `from django.db.models import Q` would crowd 'module:models' past the retry
|
||
* ceiling, and the project's own modules with it.
|
||
*/
|
||
function pythonImportBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
|
||
if (!IDENTIFIER.test(ref.referenceName)) return undefined;
|
||
const binding = context.getImportMappings(ref.filePath, 'python').find((m) => m.localName === ref.referenceName);
|
||
if (!binding || isExternalImport(binding.source, 'python', context)) return undefined;
|
||
const declared = binding.isNamespace ? binding.source.slice(binding.source.lastIndexOf('.') + 1) : binding.exportedName;
|
||
if (declared === binding.localName && (binding.isNamespace || !isProjectPythonModule(binding.source, context))) return undefined;
|
||
const modulePath = pythonModulePath(binding.source, ref.filePath);
|
||
return modulePath ? moduleTail(modulePath) || undefined : undefined;
|
||
}
|
||
|
||
/**
|
||
* Whether a Python import's module is in one of the project's own folders: a
|
||
* relative one is, and an absolute one when a folder of the project ends in its
|
||
* dotted path (`app/api` for `app.api`, the package) or in the package holding
|
||
* it (`app` for `app.api`, a module file), at any depth, as `src/` layouts put
|
||
* it. A folder, not a module: `fastapi/__init__.py` is what goes missing, while
|
||
* `fastapi/` keeps its other files.
|
||
*/
|
||
function isProjectPythonModule(source: string, context: ResolutionContext): boolean {
|
||
if (source.startsWith('.')) return true;
|
||
const folders = projectFolderSuffixes(context);
|
||
const modulePath = source.replace(/\./g, '/');
|
||
const slash = modulePath.lastIndexOf('/');
|
||
return folders.has(modulePath) || (slash > 0 && folders.has(modulePath.slice(0, slash)));
|
||
}
|
||
|
||
/**
|
||
* Per-context memo: every trailing run of folder names the project's files sit
|
||
* in (`a/b/c.py` gives 'a/b', 'b' and 'a'). The file set is that of the
|
||
* resolution pass; dropped by clearImportResolverMemos.
|
||
*/
|
||
const PROJECT_FOLDER_SUFFIXES = new WeakMap<ResolutionContext, Set<string>>();
|
||
|
||
function projectFolderSuffixes(context: ResolutionContext): Set<string> {
|
||
let suffixes = PROJECT_FOLDER_SUFFIXES.get(context);
|
||
if (suffixes) return suffixes;
|
||
const folders = new Set<string>();
|
||
for (const file of context.getAllFiles()) {
|
||
for (let i = file.lastIndexOf('/'); i > 0; i = file.lastIndexOf('/', i - 1)) {
|
||
const folder = file.slice(0, i);
|
||
if (folders.has(folder)) break;
|
||
folders.add(folder);
|
||
}
|
||
}
|
||
suffixes = new Set<string>();
|
||
for (const folder of folders) {
|
||
for (let i = -1; ; i = folder.indexOf('/', i + 1)) {
|
||
suffixes.add(folder.slice(i + 1));
|
||
if (folder.indexOf('/', i + 1) === -1) break;
|
||
}
|
||
}
|
||
PROJECT_FOLDER_SUFFIXES.set(context, suffixes);
|
||
return suffixes;
|
||
}
|
||
|
||
/**
|
||
* A Python import's module as a project path: `app.models` → 'app/models';
|
||
* from `app/sub/views.py`, `.models` → 'app/sub/models', `..` → 'app' and
|
||
* `..forms` → 'app/forms'. Null when its dots climb above the project.
|
||
*/
|
||
export function pythonModulePath(source: string, fromFile: string): string | null {
|
||
const name = source.replace(/^\.+/, '');
|
||
const dots = source.length - name.length;
|
||
if (dots === 0) return name.replace(/\./g, '/');
|
||
const modulePath = path.posix.join(path.posix.dirname(fromFile), '../'.repeat(dots - 1), name.replace(/\./g, '/'));
|
||
return modulePath === '..' || modulePath.startsWith('../') ? null : modulePath;
|
||
}
|
||
|
||
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|