Skip to content

Commit 4f16d1b

Browse files
authored
Implement "extends" support for "ts-node" options in tsconfigs (#1356)
* Implement "extends" support for "ts-node" options in tsconfigs * remove once util; is not used * WIP test * fix * Finish test * oops forgot this
1 parent 518c250 commit 4f16d1b

File tree

9 files changed

+264
-53
lines changed

9 files changed

+264
-53
lines changed

src/configuration.ts

+80-36
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { resolve, dirname } from 'path';
22
import type * as _ts from 'typescript';
33
import { CreateOptions, DEFAULTS, TSCommon, TsConfigOptions } from './index';
4+
import type { TSInternal } from './ts-compiler-types';
5+
import { createTsInternals } from './ts-internals';
46
import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs';
5-
import { createRequire } from './util';
7+
import { assign, createRequire, trace } from './util';
68

79
/**
810
* TypeScript compiler option values required by `ts-node` which cannot be overridden.
@@ -69,6 +71,12 @@ export function readConfig(
6971
*/
7072
tsNodeOptionsFromTsconfig: TsConfigOptions;
7173
} {
74+
// Ordered [a, b, c] where config a extends b extends c
75+
const configChain: Array<{
76+
config: any;
77+
basePath: string;
78+
configPath: string;
79+
}> = [];
7280
let config: any = { compilerOptions: {} };
7381
let basePath = cwd;
7482
let configFilePath: string | undefined = undefined;
@@ -88,27 +96,81 @@ export function readConfig(
8896
: ts.findConfigFile(projectSearchDir, fileExists);
8997

9098
if (configFilePath) {
91-
const result = ts.readConfigFile(configFilePath, readFile);
92-
93-
// Return diagnostics.
94-
if (result.error) {
95-
return {
96-
configFilePath,
97-
config: { errors: [result.error], fileNames: [], options: {} },
98-
tsNodeOptionsFromTsconfig: {},
99-
};
99+
let pathToNextConfigInChain = configFilePath;
100+
const tsInternals = createTsInternals(ts);
101+
const errors: Array<_ts.Diagnostic> = [];
102+
103+
// Follow chain of "extends"
104+
while (true) {
105+
const result = ts.readConfigFile(pathToNextConfigInChain, readFile);
106+
107+
// Return diagnostics.
108+
if (result.error) {
109+
return {
110+
configFilePath,
111+
config: { errors: [result.error], fileNames: [], options: {} },
112+
tsNodeOptionsFromTsconfig: {},
113+
};
114+
}
115+
116+
const c = result.config;
117+
const bp = dirname(pathToNextConfigInChain);
118+
configChain.push({
119+
config: c,
120+
basePath: bp,
121+
configPath: pathToNextConfigInChain,
122+
});
123+
124+
if (c.extends == null) break;
125+
const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath(
126+
c.extends,
127+
{
128+
fileExists,
129+
readDirectory: ts.sys.readDirectory,
130+
readFile,
131+
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
132+
trace,
133+
},
134+
bp,
135+
errors,
136+
((ts as unknown) as TSInternal).createCompilerDiagnostic
137+
);
138+
if (errors.length) {
139+
return {
140+
configFilePath,
141+
config: { errors, fileNames: [], options: {} },
142+
tsNodeOptionsFromTsconfig: {},
143+
};
144+
}
145+
if (resolvedExtendedConfigPath == null) break;
146+
pathToNextConfigInChain = resolvedExtendedConfigPath;
100147
}
101148

102-
config = result.config;
103-
basePath = dirname(configFilePath);
149+
({ config, basePath } = configChain[0]);
104150
}
105151
}
106152

107-
// Fix ts-node options that come from tsconfig.json
108-
const tsNodeOptionsFromTsconfig: TsConfigOptions = Object.assign(
109-
{},
110-
filterRecognizedTsConfigTsNodeOptions(config['ts-node']).recognized
111-
);
153+
// Merge and fix ts-node options that come from tsconfig.json(s)
154+
const tsNodeOptionsFromTsconfig: TsConfigOptions = {};
155+
for (let i = configChain.length - 1; i >= 0; i--) {
156+
const { config, basePath, configPath } = configChain[i];
157+
const options = filterRecognizedTsConfigTsNodeOptions(config['ts-node'])
158+
.recognized;
159+
160+
// Some options are relative to the config file, so must be converted to absolute paths here
161+
if (options.require) {
162+
// Modules are found relative to the tsconfig file, not the `dir` option
163+
const tsconfigRelativeRequire = createRequire(configPath);
164+
options.require = options.require.map((path: string) =>
165+
tsconfigRelativeRequire.resolve(path)
166+
);
167+
}
168+
if (options.scopeDir) {
169+
options.scopeDir = resolve(basePath, options.scopeDir!);
170+
}
171+
172+
assign(tsNodeOptionsFromTsconfig, options);
173+
}
112174

113175
// Remove resolution of "files".
114176
const files =
@@ -160,24 +222,6 @@ export function readConfig(
160222
)
161223
);
162224

163-
// Some options are relative to the config file, so must be converted to absolute paths here
164-
165-
if (tsNodeOptionsFromTsconfig.require) {
166-
// Modules are found relative to the tsconfig file, not the `dir` option
167-
const tsconfigRelativeRequire = createRequire(configFilePath!);
168-
tsNodeOptionsFromTsconfig.require = tsNodeOptionsFromTsconfig.require.map(
169-
(path: string) => {
170-
return tsconfigRelativeRequire.resolve(path);
171-
}
172-
);
173-
}
174-
if (tsNodeOptionsFromTsconfig.scopeDir) {
175-
tsNodeOptionsFromTsconfig.scopeDir = resolve(
176-
basePath,
177-
tsNodeOptionsFromTsconfig.scopeDir
178-
);
179-
}
180-
181225
return { configFilePath, config: fixedConfig, tsNodeOptionsFromTsconfig };
182226
}
183227

@@ -188,7 +232,7 @@ export function readConfig(
188232
function filterRecognizedTsConfigTsNodeOptions(
189233
jsonObject: any
190234
): { recognized: TsConfigOptions; unrecognized: any } {
191-
if (jsonObject == null) return { recognized: jsonObject, unrecognized: {} };
235+
if (jsonObject == null) return { recognized: {}, unrecognized: {} };
192236
const {
193237
compiler,
194238
compilerHost,

src/index.ts

+8-16
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import { BaseError } from 'make-error';
88
import type * as _ts from 'typescript';
99

1010
import type { Transpiler, TranspilerFactory } from './transpilers/types';
11-
import { assign, normalizeSlashes, parse, split, yn } from './util';
11+
import {
12+
assign,
13+
cachedLookup,
14+
normalizeSlashes,
15+
parse,
16+
split,
17+
yn,
18+
} from './util';
1219
import { readConfig } from './configuration';
1320
import type { TSCommon, TSInternal } from './ts-compiler-types';
1421

@@ -374,21 +381,6 @@ export interface Service {
374381
*/
375382
export type Register = Service;
376383

377-
/**
378-
* Cached fs operation wrapper.
379-
*/
380-
function cachedLookup<T>(fn: (arg: string) => T): (arg: string) => T {
381-
const cache = new Map<string, T>();
382-
383-
return (arg: string): T => {
384-
if (!cache.has(arg)) {
385-
cache.set(arg, fn(arg));
386-
}
387-
388-
return cache.get(arg)!;
389-
};
390-
}
391-
392384
/** @internal */
393385
export function getExtensions(config: _ts.ParsedCommandLine) {
394386
const tsExtensions = ['.ts'];

src/test/index.spec.ts

+17
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,23 @@ test.suite('ts-node', (test) => {
703703
join(TEST_DIR, './tsconfig-options/required1.js'),
704704
]);
705705
});
706+
707+
if (semver.gte(ts.version, '3.2.0')) {
708+
test('should pull ts-node options from extended `tsconfig.json`', async () => {
709+
const { err, stdout } = await exec(
710+
`${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json`
711+
);
712+
expect(err).to.equal(null);
713+
const config = JSON.parse(stdout);
714+
expect(config['ts-node'].require).to.deep.equal([
715+
resolve(TEST_DIR, 'tsconfig-extends/other/require-hook.js'),
716+
]);
717+
expect(config['ts-node'].scopeDir).to.equal(
718+
resolve(TEST_DIR, 'tsconfig-extends/other/scopedir')
719+
);
720+
expect(config['ts-node'].preferTsExts).to.equal(true);
721+
});
722+
}
706723
});
707724

708725
test.suite(

src/ts-compiler-types.ts

+19
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface TSCommon {
3232
getDefaultLibFileName: typeof _ts.getDefaultLibFileName;
3333
createIncrementalProgram: typeof _ts.createIncrementalProgram;
3434
createEmitAndSemanticDiagnosticsBuilderProgram: typeof _ts.createEmitAndSemanticDiagnosticsBuilderProgram;
35+
36+
Extension: typeof _ts.Extension;
37+
ModuleResolutionKind: typeof _ts.ModuleResolutionKind;
3538
}
3639

3740
/**
@@ -50,6 +53,22 @@ export interface TSInternal {
5053
host: TSInternal.ConvertToTSConfigHost
5154
): any;
5255
libs?: string[];
56+
Diagnostics: {
57+
File_0_not_found: _ts.DiagnosticMessage;
58+
};
59+
createCompilerDiagnostic(
60+
message: _ts.DiagnosticMessage,
61+
...args: (string | number | undefined)[]
62+
): _ts.Diagnostic;
63+
nodeModuleNameResolver(
64+
moduleName: string,
65+
containingFile: string,
66+
compilerOptions: _ts.CompilerOptions,
67+
host: _ts.ModuleResolutionHost,
68+
cache?: _ts.ModuleResolutionCache,
69+
redirectedReference?: _ts.ResolvedProjectReference,
70+
lookupConfig?: boolean
71+
): _ts.ResolvedModuleWithFailedLookupLocations;
5372
}
5473
/** @internal */
5574
export namespace TSInternal {

src/ts-internals.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { isAbsolute, resolve } from 'path';
2+
import { cachedLookup, normalizeSlashes } from './util';
3+
import type * as _ts from 'typescript';
4+
import type { TSCommon, TSInternal } from './ts-compiler-types';
5+
6+
export const createTsInternals = cachedLookup(createTsInternalsUncached);
7+
/**
8+
* Given a reference to the TS compiler, return some TS internal functions that we
9+
* could not or did not want to grab off the `ts` object.
10+
* These have been copy-pasted from TS's source and tweaked as necessary.
11+
*/
12+
function createTsInternalsUncached(_ts: TSCommon) {
13+
const ts = _ts as TSCommon & TSInternal;
14+
/**
15+
* Copied from:
16+
* https://github.com/microsoft/TypeScript/blob/v4.3.2/src/compiler/commandLineParser.ts#L2821-L2846
17+
*/
18+
function getExtendsConfigPath(
19+
extendedConfig: string,
20+
host: _ts.ParseConfigHost,
21+
basePath: string,
22+
errors: _ts.Push<_ts.Diagnostic>,
23+
createDiagnostic: (
24+
message: _ts.DiagnosticMessage,
25+
arg1?: string
26+
) => _ts.Diagnostic
27+
) {
28+
extendedConfig = normalizeSlashes(extendedConfig);
29+
if (
30+
isRootedDiskPath(extendedConfig) ||
31+
startsWith(extendedConfig, './') ||
32+
startsWith(extendedConfig, '../')
33+
) {
34+
let extendedConfigPath = getNormalizedAbsolutePath(
35+
extendedConfig,
36+
basePath
37+
);
38+
if (
39+
!host.fileExists(extendedConfigPath) &&
40+
!endsWith(extendedConfigPath, ts.Extension.Json)
41+
) {
42+
extendedConfigPath = `${extendedConfigPath}.json`;
43+
if (!host.fileExists(extendedConfigPath)) {
44+
errors.push(
45+
createDiagnostic(ts.Diagnostics.File_0_not_found, extendedConfig)
46+
);
47+
return undefined;
48+
}
49+
}
50+
return extendedConfigPath;
51+
}
52+
// If the path isn't a rooted or relative path, resolve like a module
53+
const resolved = ts.nodeModuleNameResolver(
54+
extendedConfig,
55+
combinePaths(basePath, 'tsconfig.json'),
56+
{ moduleResolution: ts.ModuleResolutionKind.NodeJs },
57+
host,
58+
/*cache*/ undefined,
59+
/*projectRefs*/ undefined,
60+
/*lookupConfig*/ true
61+
);
62+
if (resolved.resolvedModule) {
63+
return resolved.resolvedModule.resolvedFileName;
64+
}
65+
errors.push(
66+
createDiagnostic(ts.Diagnostics.File_0_not_found, extendedConfig)
67+
);
68+
return undefined;
69+
}
70+
71+
function startsWith(str: string, prefix: string): boolean {
72+
return str.lastIndexOf(prefix, 0) === 0;
73+
}
74+
function endsWith(str: string, suffix: string): boolean {
75+
const expectedPos = str.length - suffix.length;
76+
return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos;
77+
}
78+
79+
// These functions have alternative implementation to avoid copying too much from TS
80+
function isRootedDiskPath(path: string) {
81+
return isAbsolute(path);
82+
}
83+
function combinePaths(
84+
path: string,
85+
...paths: (string | undefined)[]
86+
): string {
87+
return normalizeSlashes(
88+
resolve(path, ...(paths.filter((path) => path) as string[]))
89+
);
90+
}
91+
function getNormalizedAbsolutePath(
92+
fileName: string,
93+
currentDirectory: string | undefined
94+
) {
95+
return normalizeSlashes(
96+
currentDirectory != null
97+
? resolve(currentDirectory!, fileName)
98+
: resolve(fileName)
99+
);
100+
}
101+
102+
return { getExtendsConfigPath };
103+
}

src/util.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,34 @@ export function parse(value: string | undefined): object | undefined {
5454
return typeof value === 'string' ? JSON.parse(value) : undefined;
5555
}
5656

57+
const directorySeparator = '/';
58+
const backslashRegExp = /\\/g;
5759
/**
5860
* Replace backslashes with forward slashes.
5961
* @internal
6062
*/
6163
export function normalizeSlashes(value: string): string {
62-
return value.replace(/\\/g, '/');
64+
return value.replace(backslashRegExp, directorySeparator);
6365
}
66+
67+
/**
68+
* Cached fs operation wrapper.
69+
*/
70+
export function cachedLookup<T, R>(fn: (arg: T) => R): (arg: T) => R {
71+
const cache = new Map<T, R>();
72+
73+
return (arg: T): R => {
74+
if (!cache.has(arg)) {
75+
const v = fn(arg);
76+
cache.set(arg, v);
77+
return v;
78+
}
79+
return cache.get(arg)!;
80+
};
81+
}
82+
83+
/**
84+
* We do not support ts's `trace` option yet. In the meantime, rather than omit
85+
* `trace` options in hosts, I am using this placeholder.
86+
*/
87+
export function trace(s: string): void {}

tests/tsconfig-extends/other/require-hook.js

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"ts-node": {
3+
"require": ["./require-hook"],
4+
"scopeDir": "./scopedir"
5+
}
6+
}

0 commit comments

Comments
 (0)