path-aliases.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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 deliberately small for v1:
  15. * - reads tsconfig.json, then jsconfig.json
  16. * - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths`
  17. * - supports `*` wildcard (the only TS-supported wildcard)
  18. * - does NOT follow `extends` chains yet (most projects don't need it)
  19. * - does NOT read Vite/webpack/Rollup configs (separate follow-up)
  20. *
  21. * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the
  22. * wild routinely contain `//` and `/* *\/` comments and trailing
  23. * commas, which JSON.parse rejects. We strip those before parsing.
  24. */
  25. import * as fs from 'fs';
  26. import * as path from 'path';
  27. import { logDebug } from '../errors';
  28. /** A single alias pattern from `compilerOptions.paths`. */
  29. export interface AliasPattern {
  30. /** The literal prefix before `*` (or the whole pattern if no `*`). */
  31. prefix: string;
  32. /** The literal suffix after `*` (almost always empty). */
  33. suffix: string;
  34. /** Whether the pattern contains a `*` wildcard. */
  35. hasWildcard: boolean;
  36. /**
  37. * Replacement templates. When `hasWildcard` is true, `*` in the
  38. * replacement is filled with the captured wildcard portion of the
  39. * import path. Stored relative to {@link AliasMap.baseUrl}.
  40. * tsconfig allows multiple targets per alias (priority order).
  41. */
  42. replacements: string[];
  43. }
  44. export interface AliasMap {
  45. /** Absolute path. The directory `compilerOptions.paths` is rooted at. */
  46. baseUrl: string;
  47. /**
  48. * Patterns ordered by specificity: longer prefix first, then literal-
  49. * before-wildcard, so the resolver tries the most-specific match.
  50. */
  51. patterns: AliasPattern[];
  52. }
  53. /**
  54. * Strip JSONC comments + trailing commas so a tsconfig with the usual
  55. * VS Code-style annotations parses cleanly. Walks the source as a
  56. * tiny state machine that tracks string context — the previous
  57. * regex-only version corrupted any URL inside a string value
  58. * (`"baseUrl": "https://cdn.example.com"` had everything after `//`
  59. * truncated).
  60. */
  61. function stripJsonc(src: string): string {
  62. let out = '';
  63. let i = 0;
  64. let inString = false;
  65. while (i < src.length) {
  66. const ch = src[i]!;
  67. if (inString) {
  68. out += ch;
  69. if (ch === '\\' && i + 1 < src.length) {
  70. out += src[i + 1]!;
  71. i += 2;
  72. continue;
  73. }
  74. if (ch === '"') inString = false;
  75. i++;
  76. continue;
  77. }
  78. if (ch === '"') {
  79. inString = true;
  80. out += ch;
  81. i++;
  82. continue;
  83. }
  84. if (ch === '/' && src[i + 1] === '/') {
  85. while (i < src.length && src[i] !== '\n') i++;
  86. continue;
  87. }
  88. if (ch === '/' && src[i + 1] === '*') {
  89. i += 2;
  90. while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
  91. i += 2;
  92. continue;
  93. }
  94. out += ch;
  95. i++;
  96. }
  97. // Trailing commas before } or ] — outside strings, so safe to
  98. // run on the comment-stripped output.
  99. return out.replace(/,(\s*[}\]])/g, '$1');
  100. }
  101. interface RawTsconfig {
  102. compilerOptions?: {
  103. baseUrl?: string;
  104. paths?: Record<string, string[]>;
  105. };
  106. }
  107. function readTsconfigLike(filePath: string): RawTsconfig | null {
  108. try {
  109. const raw = fs.readFileSync(filePath, 'utf-8');
  110. const parsed = JSON.parse(stripJsonc(raw)) as RawTsconfig;
  111. return parsed && typeof parsed === 'object' ? parsed : null;
  112. } catch (err) {
  113. logDebug('path-aliases: failed to parse', { filePath, err: String(err) });
  114. return null;
  115. }
  116. }
  117. function splitWildcard(pattern: string): {
  118. prefix: string;
  119. suffix: string;
  120. hasWildcard: boolean;
  121. } {
  122. const star = pattern.indexOf('*');
  123. if (star === -1) return { prefix: pattern, suffix: '', hasWildcard: false };
  124. return {
  125. prefix: pattern.slice(0, star),
  126. suffix: pattern.slice(star + 1),
  127. hasWildcard: true,
  128. };
  129. }
  130. /**
  131. * Load aliases for `projectRoot`. Returns `null` when no tsconfig /
  132. * jsconfig is present or when the file has no usable `paths`.
  133. *
  134. * Cheap to call repeatedly — caching is the caller's job (the
  135. * resolver does it via {@link aliasCache}).
  136. */
  137. export function loadProjectAliases(projectRoot: string): AliasMap | null {
  138. const candidates = ['tsconfig.json', 'jsconfig.json'];
  139. let raw: RawTsconfig | null = null;
  140. let usedFile: string | null = null;
  141. for (const name of candidates) {
  142. const p = path.join(projectRoot, name);
  143. if (fs.existsSync(p)) {
  144. raw = readTsconfigLike(p);
  145. if (raw) {
  146. usedFile = name;
  147. break;
  148. }
  149. }
  150. }
  151. if (!raw) return null;
  152. const co = raw.compilerOptions ?? {};
  153. const baseUrlRel = co.baseUrl ?? '.';
  154. const baseUrl = path.resolve(projectRoot, baseUrlRel);
  155. const paths = co.paths;
  156. if (!paths || typeof paths !== 'object') {
  157. // baseUrl alone isn't an "alias" per se; with no paths we'd just
  158. // be redirecting the whole tree. Skip — the existing resolver
  159. // already handles relative imports.
  160. return null;
  161. }
  162. const patterns: AliasPattern[] = [];
  163. for (const [pattern, targets] of Object.entries(paths)) {
  164. if (!Array.isArray(targets) || targets.length === 0) continue;
  165. const filtered = targets.filter((t): t is string => typeof t === 'string');
  166. if (filtered.length === 0) continue;
  167. const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
  168. patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
  169. }
  170. if (patterns.length === 0) return null;
  171. // Specificity sort: longer prefix first; literal patterns before
  172. // wildcard patterns of the same prefix length. TypeScript itself
  173. // uses a similar "most specific match wins" rule.
  174. patterns.sort((a, b) => {
  175. if (a.prefix.length !== b.prefix.length) return b.prefix.length - a.prefix.length;
  176. if (a.hasWildcard !== b.hasWildcard) return a.hasWildcard ? 1 : -1;
  177. return 0;
  178. });
  179. logDebug('path-aliases loaded', {
  180. file: usedFile,
  181. baseUrl,
  182. patternCount: patterns.length,
  183. });
  184. return { baseUrl, patterns };
  185. }
  186. /**
  187. * Resolve an import path through an {@link AliasMap}. Returns the list
  188. * of candidate filesystem paths (relative to `projectRoot`), in the
  189. * priority order defined by tsconfig (multiple replacements per alias
  190. * are tried in order). Returns `[]` when no alias matches.
  191. *
  192. * Callers still need to try each candidate with the language's
  193. * extension list — this function only does the alias rewrite.
  194. */
  195. export function applyAliases(
  196. importPath: string,
  197. aliases: AliasMap,
  198. projectRoot: string
  199. ): string[] {
  200. for (const pat of aliases.patterns) {
  201. if (!importPath.startsWith(pat.prefix)) continue;
  202. if (pat.suffix && !importPath.endsWith(pat.suffix)) continue;
  203. let captured = '';
  204. if (pat.hasWildcard) {
  205. captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length);
  206. } else if (importPath !== pat.prefix) {
  207. // Literal pattern must match exactly.
  208. continue;
  209. }
  210. const out: string[] = [];
  211. for (const target of pat.replacements) {
  212. const filled = pat.hasWildcard ? target.replace('*', captured) : target;
  213. // baseUrl is absolute; produce a path relative to projectRoot
  214. const absolute = path.resolve(aliases.baseUrl, filled);
  215. const relative = path.relative(projectRoot, absolute);
  216. // Skip if the rewrite escapes the project root (unsafe + can't
  217. // be looked up via the file index anyway).
  218. if (relative.startsWith('..')) continue;
  219. out.push(relative.replace(/\\/g, '/'));
  220. }
  221. return out;
  222. }
  223. return [];
  224. }