| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- /**
- * Project-level import-path alias loading.
- *
- * Reads `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json`
- * at the project root and converts the patterns into a form the
- * import-resolver can consult.
- *
- * This is the single biggest blocker to accurate resolution on modern
- * JS/TS codebases: aliases like `@/components/Foo` (Next, Nuxt, Nest,
- * Vite scaffolds) point into a `paths` map the resolver previously
- * ignored — every import through an alias was treated as unresolvable
- * unless it happened to match the small hard-coded fallback list.
- *
- * Scope deliberately small for v1:
- * - reads tsconfig.json, then jsconfig.json
- * - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths`
- * - supports `*` wildcard (the only TS-supported wildcard)
- * - does NOT follow `extends` chains yet (most projects don't need it)
- * - does NOT read Vite/webpack/Rollup configs (separate follow-up)
- *
- * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the
- * wild routinely contain `//` and `/* *\/` comments and trailing
- * commas, which JSON.parse rejects. We strip those before parsing.
- */
- import * as fs from 'fs';
- import * as path from 'path';
- import { logDebug } from '../errors';
- /** A single alias pattern from `compilerOptions.paths`. */
- export interface AliasPattern {
- /** The literal prefix before `*` (or the whole pattern if no `*`). */
- prefix: string;
- /** The literal suffix after `*` (almost always empty). */
- suffix: string;
- /** Whether the pattern contains a `*` wildcard. */
- hasWildcard: boolean;
- /**
- * Replacement templates. When `hasWildcard` is true, `*` in the
- * replacement is filled with the captured wildcard portion of the
- * import path. Stored relative to {@link AliasMap.baseUrl}.
- * tsconfig allows multiple targets per alias (priority order).
- */
- replacements: string[];
- }
- export interface AliasMap {
- /** Absolute path. The directory `compilerOptions.paths` is rooted at. */
- baseUrl: string;
- /**
- * Patterns ordered by specificity: longer prefix first, then literal-
- * before-wildcard, so the resolver tries the most-specific match.
- */
- patterns: AliasPattern[];
- }
- /**
- * Strip JSONC comments + trailing commas so a tsconfig with the usual
- * VS Code-style annotations parses cleanly. Walks the source as a
- * tiny state machine that tracks string context — the previous
- * regex-only version corrupted any URL inside a string value
- * (`"baseUrl": "https://cdn.example.com"` had everything after `//`
- * truncated).
- */
- function stripJsonc(src: string): string {
- let out = '';
- let i = 0;
- let inString = false;
- while (i < src.length) {
- const ch = src[i]!;
- if (inString) {
- out += ch;
- if (ch === '\\' && i + 1 < src.length) {
- out += src[i + 1]!;
- i += 2;
- continue;
- }
- if (ch === '"') inString = false;
- i++;
- continue;
- }
- if (ch === '"') {
- inString = true;
- out += ch;
- i++;
- continue;
- }
- if (ch === '/' && src[i + 1] === '/') {
- while (i < src.length && src[i] !== '\n') i++;
- continue;
- }
- if (ch === '/' && src[i + 1] === '*') {
- i += 2;
- while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
- i += 2;
- continue;
- }
- out += ch;
- i++;
- }
- // Trailing commas before } or ] — outside strings, so safe to
- // run on the comment-stripped output.
- return out.replace(/,(\s*[}\]])/g, '$1');
- }
- interface RawTsconfig {
- compilerOptions?: {
- baseUrl?: string;
- paths?: Record<string, string[]>;
- };
- }
- function readTsconfigLike(filePath: string): RawTsconfig | null {
- try {
- const raw = fs.readFileSync(filePath, 'utf-8');
- const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig;
- return parsed && typeof parsed === 'object' ? parsed : null;
- } catch (err) {
- logDebug('path-aliases: failed to parse', { filePath, err: String(err) });
- return null;
- }
- }
- function splitWildcard(pattern: string): {
- prefix: string;
- suffix: string;
- hasWildcard: boolean;
- } {
- const star = pattern.indexOf('*');
- if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false };
- return {
- prefix: pattern.slice(0, star),
- suffix: pattern.slice(star + 1),
- hasWildcard: true,
- };
- }
- /**
- * Load aliases for `projectRoot`. Returns `null` when no tsconfig /
- * jsconfig is present or when the file has no usable `paths`.
- *
- * Cheap to call repeatedly — caching is the caller's job (the
- * resolver does it via {@link aliasCache}).
- */
- export function loadProjectAliases(projectRoot: string): AliasMap | null {
- const candidates = ['tsconfig.json', 'jsconfig.json'];
- let raw: RawTsconfig | null = null;
- let usedFile: string | null = null;
- for (const name of candidates) {
- const p = path.join(projectRoot, name);
- if (fs.existsSync(p)) {
- raw = readTsconfigLike(p);
- if (raw) {
- usedFile = name;
- break;
- }
- }
- }
- if (!raw) return null;
- const co = raw.compilerOptions ?? {};
- const baseUrlRel = co.baseUrl ?? '.';
- const baseUrl = path.resolve(projectRoot, baseUrlRel);
- const paths = co.paths;
- if (!paths || typeof paths !== 'object') {
- // baseUrl alone isn't an "alias" per se; with no paths we'd just
- // be redirecting the whole tree. Skip — the existing resolver
- // already handles relative imports.
- return null;
- }
- const patterns: AliasPattern[] = [];
- for (const [pattern, targets] of Object.entries(paths)) {
- if (!Array.isArray(targets) || targets.length === 0) continue;
- const filtered = targets.filter((t): t is string => typeof t === 'string');
- if (filtered.length === 0) continue;
- const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
- patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
- }
- if (patterns.length === 0) return null;
- // Specificity sort: longer prefix first; literal patterns before
- // wildcard patterns of the same prefix length. TypeScript itself
- // uses a similar "most specific match wins" rule.
- patterns.sort((a, b) => {
- if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length;
- if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1;
- return 0;
- });
- logDebug('path-aliases loaded', {
- file: usedFile,
- baseUrl,
- patternCount: patterns.length,
- });
- return { baseUrl, patterns };
- }
- /**
- * Resolve an import path through an {@link AliasMap}. Returns the list
- * of candidate filesystem paths (relative to `projectRoot`), in the
- * priority order defined by tsconfig (multiple replacements per alias
- * are tried in order). Returns `[]` when no alias matches.
- *
- * Callers still need to try each candidate with the language's
- * extension list — this function only does the alias rewrite.
- */
- export function applyAliases(
- importPath: string,
- aliases: AliasMap,
- projectRoot: string
- ): string[] {
- for (const pat of aliases.patterns) {
- if (!importPath.startsWith(pat.prefix)) continue;
- if (pat.suffix && !importPath.endsWith(pat.suffix)) continue;
- let captured = '';
- if (pat.hasWildcard) {
- captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length);
- } else if (importPath !== pat.prefix) {
- // Literal pattern must match exactly.
- continue;
- }
- const out: string[] = [];
- for (const target of pat.replacements) {
- const filled = pat.hasWildcard ? target.replace('*', captured) : target;
- // baseUrl is absolute; produce a path relative to projectRoot
- const absolute = path.resolve(aliases.baseUrl, filled);
- const relative = path.relative(projectRoot, absolute);
- // Skip if the rewrite escapes the project root (unsafe + can't
- // be looked up via the file index anyway).
- if (relative.startsWith('..')) continue;
- out.push(relative.replace(/\\/g, '/'));
- }
- return out;
- }
- return [];
- }
|