Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "extends" support for "ts-node" options in tsconfigs #1356

Merged
merged 7 commits into from Jun 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
}
}