diff --git a/src/configuration.ts b/src/configuration.ts index 1b69d4550..87babb561 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,8 +1,10 @@ import { resolve, dirname } from 'path'; import type * as _ts from 'typescript'; import { CreateOptions, DEFAULTS, TSCommon, TsConfigOptions } from './index'; +import type { TSInternal } from './ts-compiler-types'; +import { createTsInternals } from './ts-internals'; import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs'; -import { createRequire } from './util'; +import { assign, createRequire, trace } from './util'; /** * TypeScript compiler option values required by `ts-node` which cannot be overridden. @@ -69,6 +71,12 @@ export function readConfig( */ tsNodeOptionsFromTsconfig: TsConfigOptions; } { + // Ordered [a, b, c] where config a extends b extends c + const configChain: Array<{ + config: any; + basePath: string; + configPath: string; + }> = []; let config: any = { compilerOptions: {} }; let basePath = cwd; let configFilePath: string | undefined = undefined; @@ -88,27 +96,81 @@ export function readConfig( : ts.findConfigFile(projectSearchDir, fileExists); if (configFilePath) { - const result = ts.readConfigFile(configFilePath, readFile); - - // Return diagnostics. - if (result.error) { - return { - configFilePath, - config: { errors: [result.error], fileNames: [], options: {} }, - tsNodeOptionsFromTsconfig: {}, - }; + let pathToNextConfigInChain = configFilePath; + const tsInternals = createTsInternals(ts); + const errors: Array<_ts.Diagnostic> = []; + + // Follow chain of "extends" + while (true) { + const result = ts.readConfigFile(pathToNextConfigInChain, readFile); + + // Return diagnostics. + if (result.error) { + return { + configFilePath, + config: { errors: [result.error], fileNames: [], options: {} }, + tsNodeOptionsFromTsconfig: {}, + }; + } + + const c = result.config; + const bp = dirname(pathToNextConfigInChain); + configChain.push({ + config: c, + basePath: bp, + configPath: pathToNextConfigInChain, + }); + + if (c.extends == null) break; + const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath( + c.extends, + { + fileExists, + readDirectory: ts.sys.readDirectory, + readFile, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + trace, + }, + bp, + errors, + ((ts as unknown) as TSInternal).createCompilerDiagnostic + ); + if (errors.length) { + return { + configFilePath, + config: { errors, fileNames: [], options: {} }, + tsNodeOptionsFromTsconfig: {}, + }; + } + if (resolvedExtendedConfigPath == null) break; + pathToNextConfigInChain = resolvedExtendedConfigPath; } - config = result.config; - basePath = dirname(configFilePath); + ({ config, basePath } = configChain[0]); } } - // Fix ts-node options that come from tsconfig.json - const tsNodeOptionsFromTsconfig: TsConfigOptions = Object.assign( - {}, - filterRecognizedTsConfigTsNodeOptions(config['ts-node']).recognized - ); + // Merge and fix ts-node options that come from tsconfig.json(s) + const tsNodeOptionsFromTsconfig: TsConfigOptions = {}; + for (let i = configChain.length - 1; i >= 0; i--) { + const { config, basePath, configPath } = configChain[i]; + const options = filterRecognizedTsConfigTsNodeOptions(config['ts-node']) + .recognized; + + // Some options are relative to the config file, so must be converted to absolute paths here + if (options.require) { + // Modules are found relative to the tsconfig file, not the `dir` option + const tsconfigRelativeRequire = createRequire(configPath); + options.require = options.require.map((path: string) => + tsconfigRelativeRequire.resolve(path) + ); + } + if (options.scopeDir) { + options.scopeDir = resolve(basePath, options.scopeDir!); + } + + assign(tsNodeOptionsFromTsconfig, options); + } // Remove resolution of "files". const files = @@ -160,24 +222,6 @@ export function readConfig( ) ); - // Some options are relative to the config file, so must be converted to absolute paths here - - if (tsNodeOptionsFromTsconfig.require) { - // Modules are found relative to the tsconfig file, not the `dir` option - const tsconfigRelativeRequire = createRequire(configFilePath!); - tsNodeOptionsFromTsconfig.require = tsNodeOptionsFromTsconfig.require.map( - (path: string) => { - return tsconfigRelativeRequire.resolve(path); - } - ); - } - if (tsNodeOptionsFromTsconfig.scopeDir) { - tsNodeOptionsFromTsconfig.scopeDir = resolve( - basePath, - tsNodeOptionsFromTsconfig.scopeDir - ); - } - return { configFilePath, config: fixedConfig, tsNodeOptionsFromTsconfig }; } @@ -188,7 +232,7 @@ export function readConfig( function filterRecognizedTsConfigTsNodeOptions( jsonObject: any ): { recognized: TsConfigOptions; unrecognized: any } { - if (jsonObject == null) return { recognized: jsonObject, unrecognized: {} }; + if (jsonObject == null) return { recognized: {}, unrecognized: {} }; const { compiler, compilerHost, diff --git a/src/index.ts b/src/index.ts index a3605d1ea..82313aad1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,14 @@ import { BaseError } from 'make-error'; import type * as _ts from 'typescript'; import type { Transpiler, TranspilerFactory } from './transpilers/types'; -import { assign, normalizeSlashes, parse, split, yn } from './util'; +import { + assign, + cachedLookup, + normalizeSlashes, + parse, + split, + yn, +} from './util'; import { readConfig } from './configuration'; import type { TSCommon, TSInternal } from './ts-compiler-types'; @@ -374,21 +381,6 @@ export interface Service { */ export type Register = Service; -/** - * Cached fs operation wrapper. - */ -function cachedLookup(fn: (arg: string) => T): (arg: string) => T { - const cache = new Map(); - - return (arg: string): T => { - if (!cache.has(arg)) { - cache.set(arg, fn(arg)); - } - - return cache.get(arg)!; - }; -} - /** @internal */ export function getExtensions(config: _ts.ParsedCommandLine) { const tsExtensions = ['.ts']; diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 9cc7800db..049b0481c 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -703,6 +703,23 @@ test.suite('ts-node', (test) => { join(TEST_DIR, './tsconfig-options/required1.js'), ]); }); + + if (semver.gte(ts.version, '3.2.0')) { + test('should pull ts-node options from extended `tsconfig.json`', async () => { + const { err, stdout } = await exec( + `${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json` + ); + expect(err).to.equal(null); + const config = JSON.parse(stdout); + expect(config['ts-node'].require).to.deep.equal([ + resolve(TEST_DIR, 'tsconfig-extends/other/require-hook.js'), + ]); + expect(config['ts-node'].scopeDir).to.equal( + resolve(TEST_DIR, 'tsconfig-extends/other/scopedir') + ); + expect(config['ts-node'].preferTsExts).to.equal(true); + }); + } }); test.suite( diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index 0b2236bac..b17111c3a 100644 --- a/src/ts-compiler-types.ts +++ b/src/ts-compiler-types.ts @@ -32,6 +32,9 @@ export interface TSCommon { getDefaultLibFileName: typeof _ts.getDefaultLibFileName; createIncrementalProgram: typeof _ts.createIncrementalProgram; createEmitAndSemanticDiagnosticsBuilderProgram: typeof _ts.createEmitAndSemanticDiagnosticsBuilderProgram; + + Extension: typeof _ts.Extension; + ModuleResolutionKind: typeof _ts.ModuleResolutionKind; } /** @@ -50,6 +53,22 @@ export interface TSInternal { host: TSInternal.ConvertToTSConfigHost ): any; libs?: string[]; + Diagnostics: { + File_0_not_found: _ts.DiagnosticMessage; + }; + createCompilerDiagnostic( + message: _ts.DiagnosticMessage, + ...args: (string | number | undefined)[] + ): _ts.Diagnostic; + nodeModuleNameResolver( + moduleName: string, + containingFile: string, + compilerOptions: _ts.CompilerOptions, + host: _ts.ModuleResolutionHost, + cache?: _ts.ModuleResolutionCache, + redirectedReference?: _ts.ResolvedProjectReference, + lookupConfig?: boolean + ): _ts.ResolvedModuleWithFailedLookupLocations; } /** @internal */ export namespace TSInternal { diff --git a/src/ts-internals.ts b/src/ts-internals.ts new file mode 100644 index 000000000..47a75adae --- /dev/null +++ b/src/ts-internals.ts @@ -0,0 +1,103 @@ +import { isAbsolute, resolve } from 'path'; +import { cachedLookup, normalizeSlashes } from './util'; +import type * as _ts from 'typescript'; +import type { TSCommon, TSInternal } from './ts-compiler-types'; + +export const createTsInternals = cachedLookup(createTsInternalsUncached); +/** + * Given a reference to the TS compiler, return some TS internal functions that we + * could not or did not want to grab off the `ts` object. + * These have been copy-pasted from TS's source and tweaked as necessary. + */ +function createTsInternalsUncached(_ts: TSCommon) { + const ts = _ts as TSCommon & TSInternal; + /** + * Copied from: + * https://github.com/microsoft/TypeScript/blob/v4.3.2/src/compiler/commandLineParser.ts#L2821-L2846 + */ + function getExtendsConfigPath( + extendedConfig: string, + host: _ts.ParseConfigHost, + basePath: string, + errors: _ts.Push<_ts.Diagnostic>, + createDiagnostic: ( + message: _ts.DiagnosticMessage, + arg1?: string + ) => _ts.Diagnostic + ) { + extendedConfig = normalizeSlashes(extendedConfig); + if ( + isRootedDiskPath(extendedConfig) || + startsWith(extendedConfig, './') || + startsWith(extendedConfig, '../') + ) { + let extendedConfigPath = getNormalizedAbsolutePath( + extendedConfig, + basePath + ); + if ( + !host.fileExists(extendedConfigPath) && + !endsWith(extendedConfigPath, ts.Extension.Json) + ) { + extendedConfigPath = `${extendedConfigPath}.json`; + if (!host.fileExists(extendedConfigPath)) { + errors.push( + createDiagnostic(ts.Diagnostics.File_0_not_found, extendedConfig) + ); + return undefined; + } + } + return extendedConfigPath; + } + // If the path isn't a rooted or relative path, resolve like a module + const resolved = ts.nodeModuleNameResolver( + extendedConfig, + combinePaths(basePath, 'tsconfig.json'), + { moduleResolution: ts.ModuleResolutionKind.NodeJs }, + host, + /*cache*/ undefined, + /*projectRefs*/ undefined, + /*lookupConfig*/ true + ); + if (resolved.resolvedModule) { + return resolved.resolvedModule.resolvedFileName; + } + errors.push( + createDiagnostic(ts.Diagnostics.File_0_not_found, extendedConfig) + ); + return undefined; + } + + function startsWith(str: string, prefix: string): boolean { + return str.lastIndexOf(prefix, 0) === 0; + } + function endsWith(str: string, suffix: string): boolean { + const expectedPos = str.length - suffix.length; + return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos; + } + + // These functions have alternative implementation to avoid copying too much from TS + function isRootedDiskPath(path: string) { + return isAbsolute(path); + } + function combinePaths( + path: string, + ...paths: (string | undefined)[] + ): string { + return normalizeSlashes( + resolve(path, ...(paths.filter((path) => path) as string[])) + ); + } + function getNormalizedAbsolutePath( + fileName: string, + currentDirectory: string | undefined + ) { + return normalizeSlashes( + currentDirectory != null + ? resolve(currentDirectory!, fileName) + : resolve(fileName) + ); + } + + return { getExtendsConfigPath }; +} diff --git a/src/util.ts b/src/util.ts index 3cc703ee3..63ed6a67e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -54,10 +54,34 @@ export function parse(value: string | undefined): object | undefined { return typeof value === 'string' ? JSON.parse(value) : undefined; } +const directorySeparator = '/'; +const backslashRegExp = /\\/g; /** * Replace backslashes with forward slashes. * @internal */ export function normalizeSlashes(value: string): string { - return value.replace(/\\/g, '/'); + return value.replace(backslashRegExp, directorySeparator); } + +/** + * Cached fs operation wrapper. + */ +export function cachedLookup(fn: (arg: T) => R): (arg: T) => R { + const cache = new Map(); + + return (arg: T): R => { + if (!cache.has(arg)) { + const v = fn(arg); + cache.set(arg, v); + return v; + } + return cache.get(arg)!; + }; +} + +/** + * We do not support ts's `trace` option yet. In the meantime, rather than omit + * `trace` options in hosts, I am using this placeholder. + */ +export function trace(s: string): void {} diff --git a/tests/tsconfig-extends/other/require-hook.js b/tests/tsconfig-extends/other/require-hook.js new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tsconfig-extends/other/tsconfig.json b/tests/tsconfig-extends/other/tsconfig.json new file mode 100644 index 000000000..14c5e0cec --- /dev/null +++ b/tests/tsconfig-extends/other/tsconfig.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "require": ["./require-hook"], + "scopeDir": "./scopedir" + } +} diff --git a/tests/tsconfig-extends/tsconfig.json b/tests/tsconfig-extends/tsconfig.json new file mode 100644 index 000000000..6ffbeefb6 --- /dev/null +++ b/tests/tsconfig-extends/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./other/tsconfig.json", + "ts-node": { + "preferTsExts": true + } +}