Skip to content

Commit

Permalink
Implement "extends" support for "ts-node" options in tsconfigs (#1356)
Browse files Browse the repository at this point in the history
* Implement "extends" support for "ts-node" options in tsconfigs

* remove once util; is not used

* WIP test

* fix

* Finish test

* oops forgot this
  • Loading branch information
cspotcode committed Jun 7, 2021
1 parent 518c250 commit 4f16d1b
Show file tree
Hide file tree
Showing 9 changed files with 264 additions and 53 deletions.
116 changes: 80 additions & 36 deletions 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.
Expand Down Expand Up @@ -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;
Expand All @@ -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 =
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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,
Expand Down
24 changes: 8 additions & 16 deletions src/index.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -374,21 +381,6 @@ export interface Service {
*/
export type Register = Service;

/**
* Cached fs operation wrapper.
*/
function cachedLookup<T>(fn: (arg: string) => T): (arg: string) => T {
const cache = new Map<string, T>();

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'];
Expand Down
17 changes: 17 additions & 0 deletions src/test/index.spec.ts
Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions src/ts-compiler-types.ts
Expand Up @@ -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;
}

/**
Expand All @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions 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 };
}
26 changes: 25 additions & 1 deletion src/util.ts
Expand Up @@ -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<T, R>(fn: (arg: T) => R): (arg: T) => R {
const cache = new Map<T, R>();

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 {}
Empty file.
6 changes: 6 additions & 0 deletions tests/tsconfig-extends/other/tsconfig.json
@@ -0,0 +1,6 @@
{
"ts-node": {
"require": ["./require-hook"],
"scopeDir": "./scopedir"
}
}

0 comments on commit 4f16d1b

Please sign in to comment.