diff --git a/packages/nx/src/plugins/js/utils/register.spec.ts b/packages/nx/src/plugins/js/utils/register.spec.ts index afd26d9a081..26127a8ee97 100644 --- a/packages/nx/src/plugins/js/utils/register.spec.ts +++ b/packages/nx/src/plugins/js/utils/register.spec.ts @@ -1,6 +1,8 @@ -import type { MockInstance } from 'vitest'; +import type { Mock, MockInstance } from 'vitest'; import type { CompilerOptions } from 'typescript'; import { JsxEmit, ModuleKind, ScriptTarget } from 'typescript'; +import { join } from 'path'; +import { TempFs } from '../../../internal-testing-utils/temp-fs'; import { getTranspiler, getTsNodeCompilerOptions, @@ -11,6 +13,7 @@ import { isTsEsmSyntaxError, NODENEXT_ESM_RESOLVER_SOURCE, nodeNextEsmResolveHook, + registerTsConfigPaths, resolveTsNodeEsmCompilerOptions, } from './register'; @@ -21,6 +24,7 @@ import { createRequire, Module } from 'node:module'; import { mockCjsModule, resetCjsMocks, + unmockCjsModule, } from '../../../internal-testing-utils/cjs-mock'; { const req = createRequire(import.meta.url); @@ -688,3 +692,53 @@ new Function('s', 'return import(s)')(process.argv[3]).then( expect(result).toEqual({ ok: true, url: configUrl, kind: 1 }); }, 60_000); }); + +describe('registerTsConfigPaths', () => { + let tempFs: TempFs; + let registerPaths: Mock; + + beforeEach(() => { + tempFs = new TempFs('register-ts-config-paths', false); + // register.ts lazy-requires tsconfig-paths (CJS channel); replace it there. + // Stubbing `register` captures the baseUrl without installing a resolver hook. + registerPaths = vi.fn(() => () => {}); + mockCjsModule(import.meta.url, 'tsconfig-paths', { + ...require('tsconfig-paths'), + register: registerPaths, + }); + }); + + afterEach(() => { + unmockCjsModule(import.meta.url, 'tsconfig-paths'); + tempFs.cleanup(); + }); + + it('should resolve the baseUrl through an extends chain containing JSONC', () => { + tempFs.createFileSync( + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@lib/*': ['libs/*/src/index.ts'] }, + }, + }) + ); + tempFs.createFileSync( + 'project/tsconfig.json', + `{ + "extends": "../tsconfig.base.json", + /* a block comment */ + "compilerOptions": { + // a line comment + "strictPropertyInitialization": false, + }, +}` + ); + + registerTsConfigPaths(join(tempFs.tempDir, 'project', 'tsconfig.json')); + + expect(registerPaths).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: tempFs.tempDir }) + ); + }); +}); diff --git a/packages/nx/src/plugins/js/utils/register.ts b/packages/nx/src/plugins/js/utils/register.ts index 09e513ed243..75be7f2e8f9 100644 --- a/packages/nx/src/plugins/js/utils/register.ts +++ b/packages/nx/src/plugins/js/utils/register.ts @@ -1,7 +1,8 @@ import { dirname, isAbsolute, join, resolve, sep } from 'path'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync } from 'fs'; import type { TsConfigOptions } from 'ts-node'; import type { CompilerOptions } from 'typescript'; +import { readJsonFile } from '../../../utils/fileutils'; import { logger, NX_PREFIX, stripIndent } from '../../../utils/logger'; import { workspaceRoot } from '../../../utils/workspace-root'; import { getRootTsConfigPath, readTsConfigWithoutFiles } from './typescript'; @@ -1427,7 +1428,7 @@ function resolvePathsBaseUrl(tsconfigPath: string): string { const absolute = resolve(queue.shift()!); const dir = dirname(absolute); try { - const raw = JSON.parse(readFileSync(absolute, 'utf-8')); + const raw = readJsonFile(absolute); chain.push({ dir, raw }); const exts: string[] = raw.extends ? Array.isArray(raw.extends) diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts new file mode 100644 index 00000000000..d5d9e9d831d --- /dev/null +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -0,0 +1,196 @@ +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { join } from 'node:path'; +import * as ts from 'typescript'; + +// `var` rather than `let`: transitive imports read `workspaceRoot` while the +// module graph is still loading, before a `let` would leave its temporal dead +// zone. +var workspaceRootMock: string | undefined; +jest.mock('@nx/devkit', () => { + const actual = jest.requireActual('@nx/devkit'); + return { + ...actual, + get workspaceRoot() { + return workspaceRootMock ?? actual.workspaceRoot; + }, + }; +}); + +var failRootTsConfigLoad = false; +jest.mock('tsconfig-paths', () => { + const actual = jest.requireActual('tsconfig-paths'); + return { + ...actual, + loadConfig: (path?: string) => + failRootTsConfigLoad && path?.endsWith('tsconfig.base.json') + ? { resultType: 'failed', message: "Couldn't find tsconfig.json" } + : actual.loadConfig(path), + }; +}); + +import { nxViteTsPaths } from './nx-tsconfig-paths.plugin'; + +describe('nxViteTsPaths', () => { + let tempFs: TempFs; + let originalTsConfigPath: string | undefined; + + beforeEach(() => { + tempFs = new TempFs('nx-vite-ts-paths'); + workspaceRootMock = tempFs.tempDir; + originalTsConfigPath = process.env.NX_TSCONFIG_PATH; + failRootTsConfigLoad = false; + }); + + afterEach(() => { + if (originalTsConfigPath === undefined) { + delete process.env.NX_TSCONFIG_PATH; + } else { + process.env.NX_TSCONFIG_PATH = originalTsConfigPath; + } + tempFs.cleanup(); + }); + + const resolveWith = async (importPath: string) => { + const plugin = nxViteTsPaths(); + await (plugin as any).configResolved({ + root: join(tempFs.tempDir, 'app'), + command: 'build', + plugins: [], + }); + return (plugin as any).resolveId(importPath); + }; + + const withProjectTsConfigOutsideWorkspace = async () => { + await tempFs.createFiles({ + 'external/tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@ext/*': ['libs/*'] } }, + }), + 'app/src/main.ts': '', + }); + process.env.NX_TSCONFIG_PATH = join( + tempFs.tempDir, + 'external/tsconfig.json' + ); + }; + + it('should defer to other resolvers when the workspace has no root-level tsconfig', async () => { + await withProjectTsConfigOutsideWorkspace(); + + await expect(resolveWith('@nope/missing')).resolves.toBeNull(); + }); + + it('should defer to other resolvers when the root-level tsconfig cannot be loaded', async () => { + await withProjectTsConfigOutsideWorkspace(); + await tempFs.createFiles({ 'tsconfig.base.json': JSON.stringify({}) }); + failRootTsConfigLoad = true; + + await expect(resolveWith('@nope/missing')).resolves.toBeNull(); + }); + + it('should resolve a workspace alias through the root-level tsconfig', async () => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@repo/util': ['libs/util/index.ts'] }, + }, + }), + 'libs/util/index.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@repo/util')).resolves.toEqual( + join(tempFs.tempDir, 'libs/util/index.ts') + ); + }); + + it('should substitute the wildcard of a mapped path pointing at a directory', async () => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@lib/*': ['packages/*/src'] }, + }, + }), + 'packages/one/src/index.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@lib/one')).resolves.toEqual( + join(tempFs.tempDir, 'packages/one/src/index.ts') + ); + }); + + it('should resolve paths inherited through extends against the tsconfig that declares them', async () => { + // No `tsconfig.base.json`: the root-level lookup falls back to the + // project tsconfig, so both resolution passes share its directory and + // nothing masks a base taken from the leaf. + await tempFs.createFiles({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { paths: { '@repo/util/*': ['libs/util/*'] } }, + }), + 'app/tsconfig.json': JSON.stringify({ extends: '../tsconfig.json' }), + 'libs/util/foo.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@repo/util/foo')).resolves.toEqual( + join(tempFs.tempDir, 'libs/util/foo.ts') + ); + }); + + describe('when more than one alias resolves', () => { + const exact = { '@repo/exact': ['packages/exact'] }; + const wildcard = { '@repo/*': ['generic/*'] }; + + const resolveWithTypeScript = ( + paths: Record, + moduleResolution: ts.ModuleResolutionKind, + module: ts.ModuleKind + ) => + ts.resolveModuleName( + '@repo/exact', + join(tempFs.tempDir, 'app/src/main.ts'), + { baseUrl: tempFs.tempDir, paths, module, moduleResolution }, + ts.sys + ).resolvedModule?.resolvedFileName; + + it.each([ + ['the exact alias is declared first', { ...exact, ...wildcard }], + ['the wildcard alias is declared first', { ...wildcard, ...exact }], + ])('should pick the alias TypeScript picks when %s', async (_, paths) => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths }, + }), + 'packages/exact/index.ts': '', + 'generic/exact.ts': '', + 'app/src/main.ts': '', + }); + const expected = join(tempFs.tempDir, 'packages/exact/index.ts'); + + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.Bundler, + ts.ModuleKind.ESNext + ) + ).toEqual(expected); + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.NodeNext, + ts.ModuleKind.NodeNext + ) + ).toEqual(expected); + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.Node10, + ts.ModuleKind.CommonJS + ) + ).toEqual(expected); + await expect(resolveWith('@repo/exact')).resolves.toEqual(expected); + }); + }); +}); diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts index 6d1ea513f07..4f60e161bee 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts @@ -1,7 +1,6 @@ import { createProjectGraphAsync, getPackageManagerCommand, - joinPathFragments, workspaceRoot, } from '@nx/devkit'; import { @@ -20,7 +19,7 @@ import { } from 'tsconfig-paths'; import { Plugin } from 'vite'; import { warnNxViteTsPathsDeprecation } from '../src/utils/deprecation'; -import { findFile } from '../src/utils/nx-tsconfig-paths-find-file'; +import { loadFileFromPaths } from '../src/utils/nx-tsconfig-paths-load-file'; import { getProjectTsConfigPath } from '../src/utils/options-utils'; import { nxViteBuildCoordinationPlugin } from './nx-vite-build-coordination.plugin'; @@ -40,7 +39,7 @@ export interface nxViteTsPathsOptions { mainFields?: (string | string[])[]; /** * extensions to check when resolving files when package.json resolution fails - * @default ['.ts', '.tsx', '.js', '.jsx', '.json', '.mjs', '.cjs'] + * @default ['.ts', '.tsx', '.js', '.jsx', '.json', '.mts', '.mjs', '.cts', '.cjs', '.css', '.scss', '.less'] **/ extensions?: string[]; /** @@ -74,7 +73,7 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { let matchTsPathEsm: MatchPath; let matchTsPathFallback: MatchPath | undefined; let tsConfigPathsEsm: ConfigLoaderSuccessResult; - let tsConfigPathsFallback: ConfigLoaderSuccessResult; + let tsConfigPathsFallback: ConfigLoaderSuccessResult | undefined; options.extensions ??= [ '.ts', @@ -175,10 +174,13 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { if (parsed.resultType === 'failed') { throw new Error(`Failed loading tsconfig at ${foundTsConfigPath}`); } - tsConfigPathsEsm = parsed; + // `loadConfig` derives `absoluteBaseUrl` from the leaf tsconfig, but + // `paths` resolve against the config that declared them. + const pathsBaseUrl = resolvePathsBaseUrl(foundTsConfigPath); + tsConfigPathsEsm = { ...parsed, absoluteBaseUrl: pathsBaseUrl }; matchTsPathEsm = createMatchPath( - resolvePathsBaseUrl(foundTsConfigPath), + pathsBaseUrl, parsed.paths, options.mainFields ); @@ -186,15 +188,24 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { const rootLevelTsConfig = getTsConfig( join(workspaceRoot, 'tsconfig.base.json') ); - const rootLevelParsed = loadConfig(rootLevelTsConfig); - logIt('fallback parsed tsconfig: ', rootLevelParsed); - if (rootLevelParsed.resultType === 'success') { - tsConfigPathsFallback = rootLevelParsed; - matchTsPathFallback = createMatchPath( - resolvePathsBaseUrl(rootLevelTsConfig), - rootLevelParsed.paths, - ['main', 'module'] - ); + // A workspace may have no root-level tsconfig at all. Passing no path to + // `loadConfig` makes it search upwards from the cwd instead, which finds + // an unrelated tsconfig whose directory is not this workspace. + if (rootLevelTsConfig) { + const rootLevelParsed = loadConfig(rootLevelTsConfig); + logIt('fallback parsed tsconfig: ', rootLevelParsed); + if (rootLevelParsed.resultType === 'success') { + const rootLevelPathsBaseUrl = resolvePathsBaseUrl(rootLevelTsConfig); + tsConfigPathsFallback = { + ...rootLevelParsed, + absoluteBaseUrl: rootLevelPathsBaseUrl, + }; + matchTsPathFallback = createMatchPath( + rootLevelPathsBaseUrl, + rootLevelParsed.paths, + ['main', 'module'] + ); + } } }, resolveId(importPath: string) { @@ -220,9 +231,12 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { logIt( `Unable to resolve ${importPath} with tsconfig paths. Using fallback file matching.` ); + // The tsconfig the project builds with need not extend the + // root-level one, so the second pass covers aliases only the + // root-level config declares. resolvedFile = - loadFileFromPaths(tsConfigPathsEsm, importPath) || - loadFileFromPaths(tsConfigPathsFallback, importPath); + loadFileFromPathsWithLogging(tsConfigPathsEsm, importPath) || + loadFileFromPathsWithLogging(tsConfigPathsFallback, importPath); } else { logIt(`Unable to resolve ${importPath} with tsconfig paths`); } @@ -277,54 +291,17 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { } } - function loadFileFromPaths( - tsconfig: ConfigLoaderSuccessResult, + function loadFileFromPathsWithLogging( + tsconfig: ConfigLoaderSuccessResult | undefined, importPath: string ) { + // The root-level tsconfig is optional: a workspace without one leaves + // `tsConfigPathsFallback` unset, and the import has to defer to Vite. + if (!tsconfig) return undefined; + logIt( `Trying to resolve file from config in ${tsconfig.configFileAbsolutePath}` ); - let resolvedFile: string; - for (const alias in tsconfig.paths) { - const paths = tsconfig.paths[alias]; - - const normalizedImport = alias.replace(/\/\*$/, ''); - - if ( - importPath === normalizedImport || - importPath.startsWith(normalizedImport + '/') - ) { - for (const path of paths) { - const joinedPath = joinPathFragments( - tsconfig.absoluteBaseUrl, - path.replace(/\/\*$/, '') - ); - - resolvedFile = findFile( - importPath.replace(normalizedImport, joinedPath), - options.extensions - ); - - if ( - resolvedFile === undefined && - options.extensions.some((ext) => importPath.endsWith(ext)) - ) { - const foundExtension = options.extensions.find((ext) => - importPath.endsWith(ext) - ); - const pathWithoutExtension = importPath - .replace(normalizedImport, joinedPath) - .slice(0, -foundExtension.length); - resolvedFile = findFile(pathWithoutExtension, options.extensions); - } - - if (resolvedFile !== undefined) { - return resolvedFile; - } - } - } - } - - return resolvedFile; + return loadFileFromPaths(tsconfig, importPath, options.extensions); } } diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts new file mode 100644 index 00000000000..54cdb6cfa80 --- /dev/null +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts @@ -0,0 +1,183 @@ +import { join, resolve } from 'node:path'; +import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; +import { loadFileFromPaths as loadFileFromPathsMain } from './nx-tsconfig-paths-load-file'; + +describe('@nx/vite nx-tsconfig-paths-load-file', () => { + const extensions = ['.ts', '.tsx', '.js', '.json']; + // `findFile` returns `resolve`d paths, which on Windows carry a drive letter + // and backslashes. Anchor the fixtures the same way. + const ws = resolve('/ws'); + const fs = new Set([ + join(ws, 'packages/foo/angular.ts'), + join(ws, 'packages/foo/legacy.js'), + join(ws, 'packages/foo/react/index.ts'), + join(ws, 'packages/baz/src/index.ts'), + join(ws, 'packages/exact/index.ts'), + join(ws, 'packages/exact/thing.ts'), + join(ws, 'packages/one/src/index.ts'), + join(ws, 'packages/weird/$&.ts'), + join(ws, 'packages/broad/exact/thing.ts'), + join(ws, 'packages/narrow/thing.ts'), + ]); + const existsSyncImpl = ((path: string) => fs.has(path)) as any; + + const loadFileFromPaths = ( + paths: Record, + importPath: string + ) => + loadFileFromPathsMain( + { + absoluteBaseUrl: ws, + paths, + } as ConfigLoaderSuccessResult, + importPath, + extensions, + existsSyncImpl + ); + + it('should substitute the wildcard when it is followed by an extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/angular' + ) + ).toEqual(join(ws, 'packages/foo/angular.ts')); + }); + + it('should substitute the wildcard in the middle of the mapped path', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*/index.ts'] }, + '@repo/foo/react' + ) + ).toEqual(join(ws, 'packages/foo/react/index.ts')); + }); + + it('should fall through to the next mapped path when the first does not exist', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts', 'packages/foo/*/index.ts'] }, + '@repo/foo/react' + ) + ).toEqual(join(ws, 'packages/foo/react/index.ts')); + }); + + it('should resolve a trailing wildcard mapped path', () => { + expect( + loadFileFromPaths( + { '@repo/baz/*': ['packages/baz/src/*'] }, + '@repo/baz/index' + ) + ).toEqual(join(ws, 'packages/baz/src/index.ts')); + }); + + it('should resolve an import with an explicit extension', () => { + expect( + loadFileFromPaths( + { '@repo/baz/*': ['packages/baz/src/*'] }, + '@repo/baz/index.js' + ) + ).toEqual(join(ws, 'packages/baz/src/index.ts')); + }); + + it('should not resolve a sibling when the mapped path appends a different extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/legacy.js' + ) + ).toBeUndefined(); + }); + + it('should not resolve a sibling when the mapped path appends the import extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.js'] }, + '@repo/foo/legacy.js' + ) + ).toBeUndefined(); + }); + + it('should not resolve the mapped file when the import repeats the appended extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/angular.ts' + ) + ).toBeUndefined(); + }); + + it('should resolve a non-wildcard alias', () => { + expect( + loadFileFromPaths({ '@repo/exact': ['packages/exact'] }, '@repo/exact') + ).toEqual(join(ws, 'packages/exact/index.ts')); + }); + + it('should append the subpath of an import matching a non-wildcard alias', () => { + expect( + loadFileFromPaths( + { '@repo/exact': ['packages/exact'] }, + '@repo/exact/thing' + ) + ).toEqual(join(ws, 'packages/exact/thing.ts')); + }); + + it('should resolve a mid-pattern wildcard pointing at a directory', () => { + expect( + loadFileFromPaths({ '@lib/*': ['packages/*/src'] }, '@lib/one') + ).toEqual(join(ws, 'packages/one/src/index.ts')); + }); + + it('should resolve an import with an explicit extension for a non-wildcard alias', () => { + expect( + loadFileFromPaths( + { '@repo/exact': ['packages/exact'] }, + '@repo/exact/thing.js' + ) + ).toEqual(join(ws, 'packages/exact/thing.ts')); + }); + + it('should not expand $ substitution patterns coming from the import', () => { + expect( + loadFileFromPaths( + { '@repo/weird/*': ['packages/weird/*.ts'] }, + '@repo/weird/$&' + ) + ).toEqual(join(ws, 'packages/weird/$&.ts')); + }); + + it('should not match an alias that is only a partial prefix of the import', () => { + expect( + loadFileFromPaths({ '@repo/ex/*': ['packages/exact/*'] }, '@repo/exact') + ).toBeUndefined(); + }); + + it.each([ + [ + 'the broader alias', + { '@repo': ['packages/broad'], '@repo/exact': ['packages/narrow'] }, + 'packages/broad/exact/thing.ts', + ], + [ + 'the narrower alias', + { '@repo/exact': ['packages/narrow'], '@repo': ['packages/broad'] }, + 'packages/narrow/thing.ts', + ], + ])( + 'should keep the declaration order of non-wildcard aliases matching only a prefix, %s first', + (_, paths, expected) => { + expect(loadFileFromPaths(paths, '@repo/exact/thing')).toEqual( + join(ws, expected) + ); + } + ); + + it('should return undefined when no mapped path resolves', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/missing' + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts new file mode 100644 index 00000000000..837d5b42f64 --- /dev/null +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -0,0 +1,103 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; +import { findFile } from './nx-tsconfig-paths-find-file'; + +/** + * Fallback resolver used when `tsconfig-paths` produces nothing that exists on + * disk, whether because no alias matched or because the match pointed at a + * missing file. + * + * A wildcard alias captures the suffix of the import and substitutes it into + * the `*` of each mapped path. The `*` is not always trailing, so the suffix + * cannot simply be appended. + */ +export function loadFileFromPaths( + tsconfig: ConfigLoaderSuccessResult, + importPath: string, + extensions: string[], + existsSyncImpl: typeof existsSync = existsSync +): string { + let resolvedFile: string; + for (const alias of sortAliasesBySpecificity( + Object.keys(tsconfig.paths), + importPath + )) { + const paths = tsconfig.paths[alias]; + + const isWildcard = alias.endsWith('/*'); + const normalizedImport = alias.replace(/\/\*$/, ''); + + if ( + importPath === normalizedImport || + importPath.startsWith(normalizedImport + '/') + ) { + const suffix = importPath.slice(normalizedImport.length + 1); + + for (const path of paths) { + // The replacements go through a function because a string replacement + // would expand `$&` and friends as substitution patterns. + const joinedPath = join( + tsconfig.absoluteBaseUrl, + isWildcard + ? path.replace('*', () => suffix) + : path.replace(/\/\*$/, '') + ); + const candidate = isWildcard + ? joinedPath + : importPath.replace(normalizedImport, () => joinedPath); + + resolvedFile = findFile(candidate, extensions, existsSyncImpl); + + // The candidate ends with the import's own tail only when the wildcard + // is last. Anything the mapped path appends after the `*` + // (`packages/foo/*.ts`) is the tail instead, so dropping an extension + // from it would resolve a sibling the mapping never pointed at. + const endsWithImportTail = !isWildcard || path.endsWith('*'); + + if (resolvedFile === undefined && endsWithImportTail) { + const foundExtension = extensions.find((ext) => + importPath.endsWith(ext) + ); + if (foundExtension) { + resolvedFile = findFile( + candidate.slice(0, -foundExtension.length), + extensions, + existsSyncImpl + ); + } + } + + if (resolvedFile !== undefined) { + return resolvedFile; + } + } + } + } + + return resolvedFile; +} + +/** + * TypeScript picks between competing aliases by specificity, never by + * declaration order: an exact hit on a non-wildcard alias first, then the + * longest wildcard prefix. + */ +function sortAliasesBySpecificity( + aliases: string[], + importPath: string +): string[] { + const isWildcard = (alias: string) => alias.endsWith('/*'); + const rank = (alias: string) => + isWildcard(alias) ? 1 : importPath === alias ? 2 : 0; + const prefixLength = (alias: string) => alias.replace(/\/\*$/, '').length; + + return [...aliases].sort((a, b) => { + const byRank = rank(b) - rank(a); + if (byRank !== 0) return byRank; + + // Prefix length only separates wildcards. Reordering two aliases + // TypeScript matches neither way would change resolution for no gain. + return isWildcard(a) ? prefixLength(b) - prefixLength(a) : 0; + }); +}