diff --git a/src/parse-tsconfig/index.ts b/src/parse-tsconfig/index.ts index 05bafc0..5aebab3 100644 --- a/src/parse-tsconfig/index.ts +++ b/src/parse-tsconfig/index.ts @@ -4,6 +4,7 @@ import type { TsConfigJson, TsConfigJsonResolved } from '../types.js'; import { normalizePath } from '../utils/normalize-path.js'; import { readJsonc } from '../utils/read-jsonc.js'; import { realpath } from '../utils/fs-cached.js'; +import { implicitBaseUrlSymbol } from '../utils/symbols.js'; import { resolveExtendsPath } from './resolve-extends-path.js'; const resolveExtends = ( @@ -34,20 +35,6 @@ const resolveExtends = ( const { compilerOptions } = extendsConfig; if (compilerOptions) { - const { baseUrl = '.', paths } = compilerOptions; - if (paths) { - for (const key in paths) { - if (Array.isArray(paths[key])) { - paths[key] = paths[key].map( - p => normalizePath(path.relative( - fromDirectoryPath, - path.join(extendsDirectoryPath, baseUrl, p), - )), - ); - } - } - } - const resolvePaths = ['baseUrl', 'outDir'] as const; for (const property of resolvePaths) { const unresolvedPath = compilerOptions[property]; @@ -119,6 +106,20 @@ const _parseTsconfig = ( } const directoryPath = path.dirname(realTsconfigPath); + + if (config.compilerOptions) { + const { compilerOptions } = config; + if ( + compilerOptions.paths + && !compilerOptions.baseUrl + ) { + type WithImplicitBaseUrl = TsConfigJson.CompilerOptions & { + [implicitBaseUrlSymbol]: string; + }; + (compilerOptions as WithImplicitBaseUrl)[implicitBaseUrlSymbol] = directoryPath; + } + } + if (config.extends) { const extendsPathList = ( Array.isArray(config.extends) @@ -157,7 +158,6 @@ const _parseTsconfig = ( if (config.compilerOptions) { const { compilerOptions } = config; - const normalizedPaths = [ 'baseUrl', 'rootDir', diff --git a/src/paths-matcher/index.ts b/src/paths-matcher/index.ts index 2f55630..65634fd 100644 --- a/src/paths-matcher/index.ts +++ b/src/paths-matcher/index.ts @@ -2,6 +2,7 @@ import path from 'path'; import slash from 'slash'; import type { TsConfigResult } from '../types.js'; import { isRelativePathPattern } from '../utils/is-relative-path-pattern.js'; +import { implicitBaseUrlSymbol } from '../utils/symbols.js'; import { assertStarCount, parsePattern, @@ -21,7 +22,7 @@ const parsePaths = ( substitutions: substitutions!.map((substitution) => { assertStarCount( substitution, - `Substitution '${substitution}' in pattern '${pattern}' can have at most one '*' character.`, + `Substitution '${substitution}' in pattern '${pattern}' can have at most one '*' character.`, ); if (!baseUrl && !isRelativePathPattern.test(substitution)) { @@ -45,13 +46,17 @@ export const createPathsMatcher = ( } const { baseUrl, paths } = tsconfig.config.compilerOptions; + const implicitBaseUrl = ( + implicitBaseUrlSymbol in tsconfig.config.compilerOptions + && (tsconfig.config.compilerOptions[implicitBaseUrlSymbol] as string) + ); if (!baseUrl && !paths) { return null; } const resolvedBaseUrl = path.resolve( path.dirname(tsconfig.path), - baseUrl || '.', + baseUrl || implicitBaseUrl || '.', ); const pathEntries = ( diff --git a/src/utils/symbols.ts b/src/utils/symbols.ts new file mode 100644 index 0000000..6a53ab7 --- /dev/null +++ b/src/utils/symbols.ts @@ -0,0 +1,12 @@ +/** + * When a tsconfig extends another file with relative `paths` entries and the final tsconfig + * doesn't have a `baseUrl` set, the relative paths are resolved relative to the tsconfig that + * defined the `paths` + * + * However, this is impossible to compute from a flattened tsconfig, because we no longer know + * the path of the tsconfig that defined the `paths` entry. + * + * This is why we store the implicit baseUrl in the flattened tsconfig, so that the pathsMatcher + * can use it to resolve relative paths. + */ +export const implicitBaseUrlSymbol = Symbol('implicitBaseUrl'); diff --git a/tests/specs/create-paths-matcher.ts b/tests/specs/create-paths-matcher.ts index d92fb50..205b587 100644 --- a/tests/specs/create-paths-matcher.ts +++ b/tests/specs/create-paths-matcher.ts @@ -12,7 +12,7 @@ import { getTsconfig, createPathsMatcher } from '#get-tsconfig'; export default testSuite(({ describe }) => { describe('paths', ({ describe, test }) => { describe('error cases', ({ test }) => { - test('no baseUrl or paths', async () => { + test('no baseUrl or paths should be fine', async () => { const fixture = await createFixture({ 'tsconfig.json': createTsconfigJson({ compilerOptions: {}, @@ -26,7 +26,7 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); - test('no baseUrl & non-relative paths', async () => { + test('no baseUrl nor relative paths', async () => { const fixture = await createFixture({ 'tsconfig.json': createTsconfigJson({ compilerOptions: { @@ -54,6 +54,40 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); + test('no baseUrl nor relative paths in extends', async () => { + const fixture = await createFixture({ + 'some-dir2/tsconfig.json': createTsconfigJson({ + compilerOptions: { + paths: { + '@': ['src'], + }, + }, + }), + 'some-dir/tsconfig.json': createTsconfigJson({ + extends: '../some-dir2/tsconfig.json', + }), + 'tsconfig.json': createTsconfigJson({ + extends: './some-dir/tsconfig.json', + }), + }); + + let throwsError = false; + const errorMessage = 'Non-relative paths are not allowed when \'baseUrl\' is not set. Did you forget a leading \'./\'?'; + try { + await getTscResolution('@', fixture.path); + } catch (error) { + throwsError = true; + expect((error as any).stdout).toMatch(errorMessage); + } + expect(throwsError).toBe(true); + + const tsconfig = getTsconfig(fixture.path); + expect(tsconfig).not.toBeNull(); + expect(() => createPathsMatcher(tsconfig!)).toThrow(errorMessage); + + await fixture.rm(); + }); + test('multiple * in pattern', async () => { const fixture = await createFixture({ 'tsconfig.json': createTsconfigJson({ @@ -472,36 +506,102 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); - test('extended config should resolve relative to self', async () => { - const fixture = await createFixture({ - tsconfigs: { + describe('extends w/ no baseUrl', ({ test }) => { + test('extended config should resolve relative to self', async () => { + const fixture = await createFixture({ + tsconfigs: { + 'tsconfig.json': createTsconfigJson({ + compilerOptions: { + paths: { + '@': [ + './file', + ], + }, + }, + }), + }, 'tsconfig.json': createTsconfigJson({ + extends: './tsconfigs/tsconfig.json', + }), + }); + + const tsconfig = getTsconfig(fixture.path); + expect(tsconfig).not.toBeNull(); + + const matcher = createPathsMatcher(tsconfig!)!; + expect(tsconfig).not.toBeNull(); + + const resolvedAttempts = await getTscResolution('@', fixture.path); + expect(matcher('@')).toStrictEqual([ + resolvedAttempts[0].filePath.slice(0, -3), + ]); + + await fixture.rm(); + }); + + test('extended config should implicitly resolve paths from self', async () => { + const fixture = await createFixture({ + tsconfigs: { + 'tsconfig.json': createTsconfigJson({ + compilerOptions: { + paths: { + '@': [ + './file', + ], + }, + }, + }), + }, + 'tsconfig.json': createTsconfigJson({ + extends: './tsconfigs/tsconfig.json', + }), + }); + + const tsconfig = getTsconfig(fixture.path); + expect(tsconfig).not.toBeNull(); + + const matcher = createPathsMatcher(tsconfig!)!; + expect(tsconfig).not.toBeNull(); + + const resolvedAttempts = await getTscResolution('@', fixture.path); + expect(matcher('@')).toStrictEqual([ + resolvedAttempts[0].filePath.slice(0, -3), + ]); + + await fixture.rm(); + }); + + test('extended config should implicitly resolve paths from self - complex', async () => { + const fixture = await createFixture({ + 'file.ts': '', + 'some-dir2/tsconfig.json': createTsconfigJson({ compilerOptions: { paths: { - '#imports': [ - './imports', - ], + '@': ['./a'], }, }, }), - }, - 'tsconfig.json': createTsconfigJson({ - extends: './tsconfigs/tsconfig.json', - }), - }); + 'some-dir/tsconfig.json': createTsconfigJson({ + extends: '../some-dir2/tsconfig.json', + }), + 'tsconfig.json': createTsconfigJson({ + extends: './some-dir/tsconfig.json', + }), + }); - const tsconfig = getTsconfig(fixture.path); - expect(tsconfig).not.toBeNull(); + const tsconfig = getTsconfig(fixture.path); + expect(tsconfig).not.toBeNull(); - const matcher = createPathsMatcher(tsconfig!)!; - expect(tsconfig).not.toBeNull(); + const matcher = createPathsMatcher(tsconfig!)!; + expect(tsconfig).not.toBeNull(); - const resolvedAttempts = await getTscResolution('#imports', fixture.path); - expect(matcher('#imports')).toStrictEqual([ - resolvedAttempts[0].filePath.slice(0, -3), - ]); + const resolvedAttempts = await getTscResolution('@', fixture.path); + expect(matcher('@')).toStrictEqual([ + resolvedAttempts[0].filePath.slice(0, -3), + ]); - await fixture.rm(); + await fixture.rm(); + }); }); }); }); diff --git a/tests/specs/parse-tsconfig/extends/merges.spec.ts b/tests/specs/parse-tsconfig/extends/merges.spec.ts index 23239be..f2efa12 100644 --- a/tests/specs/parse-tsconfig/extends/merges.spec.ts +++ b/tests/specs/parse-tsconfig/extends/merges.spec.ts @@ -312,6 +312,31 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); + + test('resolves parent baseUrl & paths', async () => { + const fixture = await createFixture({ + 'project/tsconfig.json': createTsconfigJson({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + 'tsconfig.json': createTsconfigJson({ + extends: './project/tsconfig.json', + }), + 'a.ts': '', + }); + + const expectedTsconfig = await getTscTsconfig(fixture.path); + delete expectedTsconfig.files; + + const tsconfig = parseTsconfig(path.join(fixture.path, 'tsconfig.json')); + expect(tsconfig).toStrictEqual(expectedTsconfig); + + await fixture.rm(); + }); }); test('nested extends', async () => {