path-aliases.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. /**
  2. * Project-level import-path alias loading.
  3. *
  4. * Reads `compilerOptions.paths` from `tsconfig.json` / `jsconfig.json`
  5. * at the project root and converts the patterns into a form the
  6. * import-resolver can consult.
  7. *
  8. * This is the single biggest blocker to accurate resolution on modern
  9. * JS/TS codebases: aliases like `@/components/Foo` (Next, Nuxt, Nest,
  10. * Vite scaffolds) point into a `paths` map the resolver previously
  11. * ignored — every import through an alias was treated as unresolvable
  12. * unless it happened to match the small hard-coded fallback list.
  13. *
  14. * Scope:
  15. * - reads tsconfig.json, then jsconfig.json, then tsconfig.base.json
  16. * - honours `compilerOptions.baseUrl` and `compilerOptions.paths`
  17. * - follows `extends` chains, nearest config wins (#1534) — Nx-style
  18. * monorepos keep every alias in a `tsconfig.base.json` the root
  19. * config merely inherits, so without this they resolved nothing
  20. * - supports `*` wildcard (the only TS-supported wildcard)
  21. * - does NOT read Vite/webpack/Rollup configs (separate follow-up)
  22. *
  23. * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the
  24. * wild routinely contain `//` and `/* *\/` comments and trailing
  25. * commas, which JSON.parse rejects. We strip those before parsing.
  26. */
  27. import * as fs from 'fs';
  28. import * as path from 'path';
  29. import { logDebug } from '../errors';
  30. /** A single alias pattern from `compilerOptions.paths`. */
  31. export interface AliasPattern {
  32. /** The literal prefix before `*` (or the whole pattern if no `*`). */
  33. prefix: string;
  34. /** The literal suffix after `*` (almost always empty). */
  35. suffix: string;
  36. /** Whether the pattern contains a `*` wildcard. */
  37. hasWildcard: boolean;
  38. /**
  39. * Replacement templates. When `hasWildcard` is true, `*` in the
  40. * replacement is filled with the captured wildcard portion of the
  41. * import path. Stored relative to {@link AliasMap.baseUrl}.
  42. * tsconfig allows multiple targets per alias (priority order).
  43. */
  44. replacements: string[];
  45. }
  46. export interface AliasMap {
  47. /** Absolute path. The directory `compilerOptions.paths` is rooted at. */
  48. baseUrl: string;
  49. /**
  50. * Patterns ordered by specificity: longer prefix first, then literal-
  51. * before-wildcard, so the resolver tries the most-specific match.
  52. */
  53. patterns: AliasPattern[];
  54. }
  55. /**
  56. * Strip JSONC comments + trailing commas so a tsconfig with the usual
  57. * VS Code-style annotations parses cleanly. Walks the source as a
  58. * tiny state machine that tracks string context — the previous
  59. * regex-only version corrupted any URL inside a string value
  60. * (`"baseUrl": "https://cdn.example.com"` had everything after `//`
  61. * truncated).
  62. */
  63. function stripJsonc(src: string): string {
  64. let out = '';
  65. let i = 0;
  66. let inString = false;
  67. while (i < src.length) {
  68. const ch = src[i]!;
  69. if (inString) {
  70. out += ch;
  71. if (ch === '\\' && i + 1 < src.length) {
  72. out += src[i + 1]!;
  73. i += 2;
  74. continue;
  75. }
  76. if (ch === '"') inString = false;
  77. i++;
  78. continue;
  79. }
  80. if (ch === '"') {
  81. inString = true;
  82. out += ch;
  83. i++;
  84. continue;
  85. }
  86. if (ch === '/' && src[i + 1] === '/') {
  87. while (i < src.length && src[i] !== '\n') i++;
  88. continue;
  89. }
  90. if (ch === '/' && src[i + 1] === '*') {
  91. i += 2;
  92. while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
  93. i += 2;
  94. continue;
  95. }
  96. out += ch;
  97. i++;
  98. }
  99. // Trailing commas before } or ] — outside strings, so safe to
  100. // run on the comment-stripped output.
  101. return out.replace(/,(\s*[}\]])/g, '$1');
  102. }
  103. interface RawTsconfig {
  104. extends?: string | string[];
  105. compilerOptions?: {
  106. baseUrl?: string;
  107. paths?: Record<string, string[]>;
  108. };
  109. }
  110. /**
  111. * The `baseUrl`/`paths` a config ends up with once its `extends` chain has
  112. * been folded in. `pathsDir` is the directory of the config that actually
  113. * declared `paths` — with no `baseUrl` anywhere, tsc anchors the targets
  114. * there, not at the project root.
  115. */
  116. interface EffectiveOptions {
  117. baseUrl?: string;
  118. paths?: Record<string, string[]>;
  119. pathsDir?: string;
  120. }
  121. /** Guards against a pathological chain; real ones are 1-3 deep. */
  122. const MAX_EXTENDS_DEPTH = 32;
  123. /**
  124. * Locate an `extends` target the way tsc does: `./x`-style values are
  125. * relative to the referencing config, anything else is a node_modules
  126. * package specifier resolved by walking up from that config. A missing
  127. * `.json` extension is implied, and a bare package name means its
  128. * `tsconfig.json`.
  129. */
  130. function resolveExtendsTarget(spec: string, fromDir: string): string | null {
  131. const isFile = (p: string): boolean => {
  132. try {
  133. return fs.statSync(p).isFile();
  134. } catch {
  135. return false;
  136. }
  137. };
  138. if (spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec)) {
  139. const base = path.resolve(fromDir, spec);
  140. for (const cand of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) {
  141. if (isFile(cand)) return cand;
  142. }
  143. return null;
  144. }
  145. let dir = fromDir;
  146. for (;;) {
  147. const base = path.join(dir, 'node_modules', spec);
  148. for (const cand of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) {
  149. if (isFile(cand)) return cand;
  150. }
  151. const parent = path.dirname(dir);
  152. if (parent === dir) return null;
  153. dir = parent;
  154. }
  155. }
  156. /**
  157. * Read `filePath` and fold its `extends` chain into a single set of
  158. * effective options. Parents are applied first and the nearest config
  159. * wins — tsc replaces `paths` wholesale rather than merging it.
  160. *
  161. * `stack` holds the configs currently being resolved, so a cycle
  162. * (`a extends b extends a`) stops instead of recursing forever.
  163. */
  164. function loadEffectiveOptions(
  165. filePath: string,
  166. stack: Set<string>,
  167. depth: number
  168. ): EffectiveOptions | null {
  169. const abs = path.resolve(filePath);
  170. if (stack.has(abs) || depth > MAX_EXTENDS_DEPTH) {
  171. logDebug('path-aliases: extends chain cycle or too deep', { filePath: abs, depth });
  172. return null;
  173. }
  174. const raw = readTsconfigLike(abs);
  175. if (!raw) return null;
  176. stack.add(abs);
  177. const dir = path.dirname(abs);
  178. const effective: EffectiveOptions = {};
  179. const parents = typeof raw.extends === 'string' ? [raw.extends] : (raw.extends ?? []);
  180. for (const spec of parents) {
  181. if (typeof spec !== 'string') continue;
  182. const target = resolveExtendsTarget(spec, dir);
  183. if (!target) {
  184. logDebug('path-aliases: unresolved extends', { from: abs, spec });
  185. continue;
  186. }
  187. const inherited = loadEffectiveOptions(target, stack, depth + 1);
  188. if (!inherited) continue;
  189. if (inherited.baseUrl !== undefined) effective.baseUrl = inherited.baseUrl;
  190. if (inherited.paths !== undefined) {
  191. effective.paths = inherited.paths;
  192. effective.pathsDir = inherited.pathsDir;
  193. }
  194. }
  195. stack.delete(abs);
  196. const co = raw.compilerOptions ?? {};
  197. // Both are relative to the file that declared them, not to whichever
  198. // config started the chain.
  199. if (typeof co.baseUrl === 'string') effective.baseUrl = path.resolve(dir, co.baseUrl);
  200. if (co.paths && typeof co.paths === 'object') {
  201. effective.paths = co.paths;
  202. effective.pathsDir = dir;
  203. }
  204. return effective;
  205. }
  206. function readTsconfigLike(filePath: string): RawTsconfig | null {
  207. try {
  208. const raw = fs.readFileSync(filePath, 'utf-8');
  209. const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig;
  210. return parsed && typeof parsed === 'object' ? parsed : null;
  211. } catch (err) {
  212. logDebug('path-aliases: failed to parse', { filePath, err: String(err) });
  213. return null;
  214. }
  215. }
  216. function splitWildcard(pattern: string): {
  217. prefix: string;
  218. suffix: string;
  219. hasWildcard: boolean;
  220. } {
  221. const star = pattern.indexOf('*');
  222. if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false };
  223. return {
  224. prefix: pattern.slice(0, star),
  225. suffix: pattern.slice(star + 1),
  226. hasWildcard: true,
  227. };
  228. }
  229. /**
  230. * Load aliases for `projectRoot`. Returns `null` when no tsconfig /
  231. * jsconfig is present or when the file has no usable `paths`.
  232. *
  233. * Cheap to call repeatedly — caching is the caller's job (the
  234. * resolver does it via {@link aliasCache}).
  235. */
  236. export function loadProjectAliases(projectRoot: string): AliasMap | null {
  237. // `tsconfig.base.json` comes last on purpose: when a root `tsconfig.json`
  238. // exists it stays authoritative and reaches the base through `extends`.
  239. // The fallback is for the Nx layouts where that never happens — a
  240. // solution-style root config (`references`, no `extends`, no `paths`), or
  241. // no root `tsconfig.json` at all.
  242. const candidates = ['tsconfig.json', 'jsconfig.json', 'tsconfig.base.json'];
  243. let effective: EffectiveOptions | null = null;
  244. let usedFile: string | null = null;
  245. for (const name of candidates) {
  246. const p = path.join(projectRoot, name);
  247. if (!fs.existsSync(p)) continue;
  248. const opts = loadEffectiveOptions(p, new Set(), 0);
  249. if (!opts) continue;
  250. // Remember the first readable config so a `paths`-less project still
  251. // logs the file it was judged on, but keep looking: a config that
  252. // contributes no aliases must not shadow one that does.
  253. if (!effective) {
  254. effective = opts;
  255. usedFile = name;
  256. }
  257. if (opts.paths) {
  258. effective = opts;
  259. usedFile = name;
  260. break;
  261. }
  262. }
  263. if (!effective) return null;
  264. // With no explicit baseUrl, `paths` targets are relative to the config that
  265. // declared them — which is the project root only when that config is the
  266. // root one (the pre-`extends` assumption).
  267. const baseUrl = effective.baseUrl ?? effective.pathsDir ?? projectRoot;
  268. const paths = effective.paths;
  269. if (!paths || typeof paths !== 'object') {
  270. // baseUrl alone isn't an "alias" per se; with no paths we'd just
  271. // be redirecting the whole tree. Skip — the existing resolver
  272. // already handles relative imports.
  273. return null;
  274. }
  275. const patterns: AliasPattern[] = [];
  276. for (const [pattern, targets] of Object.entries(paths)) {
  277. if (!Array.isArray(targets) || targets.length === 0) continue;
  278. const filtered = targets.filter((t): t is string => typeof t === 'string');
  279. if (filtered.length === 0) continue;
  280. const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
  281. patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
  282. }
  283. if (patterns.length === 0) return null;
  284. // Specificity sort: longer prefix first; literal patterns before
  285. // wildcard patterns of the same prefix length. TypeScript itself
  286. // uses a similar "most specific match wins" rule.
  287. patterns.sort((a, b) => {
  288. if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length;
  289. if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1;
  290. return 0;
  291. });
  292. logDebug('path-aliases loaded', {
  293. file: usedFile,
  294. baseUrl,
  295. patternCount: patterns.length,
  296. });
  297. return { baseUrl, patterns };
  298. }
  299. /**
  300. * Resolve an import path through an {@link AliasMap}. Returns the list
  301. * of candidate filesystem paths (relative to `projectRoot`), in the
  302. * priority order defined by tsconfig (multiple replacements per alias
  303. * are tried in order). Returns `[]` when no alias matches.
  304. *
  305. * Callers still need to try each candidate with the language's
  306. * extension list — this function only does the alias rewrite.
  307. */
  308. export function applyAliases(
  309. importPath: string,
  310. aliases: AliasMap,
  311. projectRoot: string
  312. ): string[] {
  313. for (const pat of aliases.patterns) {
  314. if (!importPath.startsWith(pat.prefix)) continue;
  315. if (pat.suffix && !importPath.endsWith(pat.suffix)) continue;
  316. let captured = '';
  317. if (pat.hasWildcard) {
  318. captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length);
  319. } else if (importPath !== pat.prefix) {
  320. // Literal pattern must match exactly.
  321. continue;
  322. }
  323. const out: string[] = [];
  324. for (const target of pat.replacements) {
  325. const filled = pat.hasWildcard ? target.replace('*', captured) : target;
  326. // baseUrl is absolute; produce a path relative to projectRoot
  327. const absolute = path.resolve(aliases.baseUrl, filled);
  328. const relative = path.relative(projectRoot, absolute);
  329. // Skip if the rewrite escapes the project root (unsafe + can't
  330. // be looked up via the file index anyway).
  331. if (relative.startsWith('..')) continue;
  332. out.push(relative.replace(/\\/g, '/'));
  333. }
  334. return out;
  335. }
  336. return [];
  337. }