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

Add support for configuration inheritance via packages #27348

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions src/compiler/commandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2164,20 +2164,24 @@ namespace ts {
errors: Push<Diagnostic>,
createDiagnostic: (message: DiagnosticMessage, arg1?: string) => Diagnostic) {
extendedConfig = normalizeSlashes(extendedConfig);
// If the path isn't a rooted or relative path, don't try to resolve it (we reserve the right to special case module-id like paths in the future)
if (!(isRootedDiskPath(extendedConfig) || startsWith(extendedConfig, "./") || startsWith(extendedConfig, "../"))) {
errors.push(createDiagnostic(Diagnostics.A_path_in_an_extends_option_must_be_relative_or_rooted_but_0_is_not, extendedConfig));
return undefined;
}
let extendedConfigPath = getNormalizedAbsolutePath(extendedConfig, basePath);
if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, Extension.Json)) {
extendedConfigPath = `${extendedConfigPath}.json`;
if (!host.fileExists(extendedConfigPath)) {
errors.push(createDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig));
return undefined;
if (isRootedDiskPath(extendedConfig) || startsWith(extendedConfig, "./") || startsWith(extendedConfig, "../")) {
let extendedConfigPath = getNormalizedAbsolutePath(extendedConfig, basePath);
if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, Extension.Json)) {
extendedConfigPath = `${extendedConfigPath}.json`;
if (!host.fileExists(extendedConfigPath)) {
errors.push(createDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig));
return undefined;
}
}
return extendedConfigPath;
}
// If the path isn't a rooted or relative path, resolve like a module
const resolved = nodeModuleNameResolver(extendedConfig, combinePaths(basePath, "tsconfig.json"), { moduleResolution: ModuleResolutionKind.NodeJs }, host, /*cache*/ undefined, /*lookupConfig*/ true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no way to traceResolution this. Ideally we would want to flow that option here (esp when invoked from command prompt)? To ensure users are able to find out whats going on when resolving tsconfig.json?

if (resolved.resolvedModule) {
return resolved.resolvedModule.resolvedFileName;
}
return extendedConfigPath;
errors.push(createDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig));
return undefined;
}

function getExtendedConfig(
Expand Down
57 changes: 40 additions & 17 deletions src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ namespace ts {
TypeScript, /** '.ts', '.tsx', or '.d.ts' */
JavaScript, /** '.js' or '.jsx' */
Json, /** '.json' */
TSConfig, /** '.json' with `tsconfig` used instead of `index` */
DtsOnly /** Only '.d.ts' */
}

Expand Down Expand Up @@ -98,6 +99,7 @@ namespace ts {
types?: string;
typesVersions?: MapLike<MapLike<string[]>>;
main?: string;
tsconfig?: string;
}

interface PackageJson extends PackageJsonPathFields {
Expand Down Expand Up @@ -126,7 +128,7 @@ namespace ts {
return value;
}

function readPackageJsonPathField<K extends "typings" | "types" | "main">(jsonContent: PackageJson, fieldName: K, baseDirectory: string, state: ModuleResolutionState): PackageJson[K] | undefined {
function readPackageJsonPathField<K extends "typings" | "types" | "main" | "tsconfig">(jsonContent: PackageJson, fieldName: K, baseDirectory: string, state: ModuleResolutionState): PackageJson[K] | undefined {
const fileName = readPackageJsonField(jsonContent, fieldName, "string", state);
if (fileName === undefined) return;
const path = normalizePath(combinePaths(baseDirectory, fileName));
Expand All @@ -141,6 +143,10 @@ namespace ts {
|| readPackageJsonPathField(jsonContent, "types", baseDirectory, state);
}

function readPackageJsonTSConfigField(jsonContent: PackageJson, baseDirectory: string, state: ModuleResolutionState) {
return readPackageJsonPathField(jsonContent, "tsconfig", baseDirectory, state);
}

function readPackageJsonMainField(jsonContent: PackageJson, baseDirectory: string, state: ModuleResolutionState) {
return readPackageJsonPathField(jsonContent, "main", baseDirectory, state);
}
Expand Down Expand Up @@ -797,25 +803,27 @@ namespace ts {
return resolvedModule && resolvedModule.resolvedFileName;
}

const jsOnlyExtensions = [Extensions.JavaScript];
const tsExtensions = [Extensions.TypeScript, Extensions.JavaScript];
const tsPlusJsonExtensions = [...tsExtensions, Extensions.Json];
const tsconfigExtensions = [Extensions.TSConfig];
function tryResolveJSModuleWorker(moduleName: string, initialDir: string, host: ModuleResolutionHost): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, initialDir, { moduleResolution: ModuleResolutionKind.NodeJs, allowJs: true }, host, /*cache*/ undefined, /*jsOnly*/ true);
return nodeModuleNameResolverWorker(moduleName, initialDir, { moduleResolution: ModuleResolutionKind.NodeJs, allowJs: true }, host, /*cache*/ undefined, jsOnlyExtensions);
}

export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, getDirectoryPath(containingFile), compilerOptions, host, cache, /*jsOnly*/ false);
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations;
/* @internal */ export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, lookupConfig?: boolean): ResolvedModuleWithFailedLookupLocations; // tslint:disable-line unified-signatures
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, lookupConfig?: boolean): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, getDirectoryPath(containingFile), compilerOptions, host, cache, lookupConfig ? tsconfigExtensions : (compilerOptions.resolveJsonModule ? tsPlusJsonExtensions : tsExtensions));
}

function nodeModuleNameResolverWorker(moduleName: string, containingDirectory: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache: ModuleResolutionCache | undefined, jsOnly: boolean): ResolvedModuleWithFailedLookupLocations {
function nodeModuleNameResolverWorker(moduleName: string, containingDirectory: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache: ModuleResolutionCache | undefined, extensions: Extensions[]): ResolvedModuleWithFailedLookupLocations {
const traceEnabled = isTraceEnabled(compilerOptions, host);

const failedLookupLocations: string[] = [];
const state: ModuleResolutionState = { compilerOptions, host, traceEnabled, failedLookupLocations };

const result = jsOnly ?
tryResolve(Extensions.JavaScript) :
(tryResolve(Extensions.TypeScript) ||
tryResolve(Extensions.JavaScript) ||
(compilerOptions.resolveJsonModule ? tryResolve(Extensions.Json) : undefined));
const result = forEach(extensions, ext => tryResolve(ext));
if (result && result.value) {
const { resolved, isExternalLibraryImport } = result.value;
return createResolvedModuleWithFailedLookupLocations(resolved, isExternalLibraryImport, failedLookupLocations);
Expand Down Expand Up @@ -959,9 +967,9 @@ namespace ts {
* in cases when we know upfront that all load attempts will fail (because containing folder does not exists) however we still need to record all failed lookup locations.
*/
function loadModuleFromFile(extensions: Extensions, candidate: string, onlyRecordFailures: boolean, state: ModuleResolutionState): PathAndExtension | undefined {
if (extensions === Extensions.Json) {
if (extensions === Extensions.Json || extensions === Extensions.TSConfig) {
const extensionLess = tryRemoveExtension(candidate, Extension.Json);
return extensionLess === undefined ? undefined : tryAddingExtensions(extensionLess, extensions, onlyRecordFailures, state);
return (extensionLess === undefined && extensions === Extensions.Json) ? undefined : tryAddingExtensions(extensionLess || candidate, extensions, onlyRecordFailures, state);
}

// First, try adding an extension. An import of "foo" could be matched by a file "foo.ts", or "foo.js" by "foo.js.ts"
Expand Down Expand Up @@ -999,6 +1007,7 @@ namespace ts {
return tryExtension(Extension.Ts) || tryExtension(Extension.Tsx) || tryExtension(Extension.Dts);
case Extensions.JavaScript:
return tryExtension(Extension.Js) || tryExtension(Extension.Jsx);
case Extensions.TSConfig:
case Extensions.Json:
return tryExtension(Extension.Json);
}
Expand Down Expand Up @@ -1042,7 +1051,7 @@ namespace ts {
return fromPackageJson;
}
const directoryExists = !onlyRecordFailures && directoryProbablyExists(candidate, state.host);
return loadModuleFromFile(extensions, combinePaths(candidate, "index"), !directoryExists, state);
return loadModuleFromFile(extensions, combinePaths(candidate, extensions === Extensions.TSConfig ? "tsconfig" : "index"), !directoryExists, state);
}

interface PackageJsonInfo {
Expand Down Expand Up @@ -1105,9 +1114,22 @@ namespace ts {
}

function loadModuleFromPackageJson(jsonContent: PackageJsonPathFields, versionPaths: VersionPaths | undefined, extensions: Extensions, candidate: string, state: ModuleResolutionState): PathAndExtension | undefined {
let file = extensions !== Extensions.JavaScript && extensions !== Extensions.Json
? readPackageJsonTypesFields(jsonContent, candidate, state)
: readPackageJsonMainField(jsonContent, candidate, state);
let file: string | undefined;
switch (extensions) {
case Extensions.JavaScript:
case Extensions.Json:
file = readPackageJsonMainField(jsonContent, candidate, state);
break;
case Extensions.TypeScript:
case Extensions.DtsOnly:
file = readPackageJsonTypesFields(jsonContent, candidate, state);
break;
case Extensions.TSConfig:
file = readPackageJsonTSConfigField(jsonContent, candidate, state);
break;
default:
return Debug.assertNever(extensions);
}
if (!file) {
if (extensions === Extensions.TypeScript) {
// When resolving typescript modules, try resolving using main field as well
Expand Down Expand Up @@ -1167,6 +1189,7 @@ namespace ts {
switch (extensions) {
case Extensions.JavaScript:
return extension === Extension.Js || extension === Extension.Jsx;
case Extensions.TSConfig:
case Extensions.Json:
return extension === Extension.Json;
case Extensions.TypeScript:
Expand Down Expand Up @@ -1218,7 +1241,7 @@ namespace ts {
if (packageResult) {
return packageResult;
}
if (extensions !== Extensions.JavaScript && extensions !== Extensions.Json) {
if (extensions === Extensions.TypeScript || extensions === Extensions.DtsOnly) {
const nodeModulesAtTypes = combinePaths(nodeModulesFolder, "@types");
let nodeModulesAtTypesExists = nodeModulesFolderExists;
if (nodeModulesFolderExists && !directoryProbablyExists(nodeModulesAtTypes, state.host)) {
Expand Down
94 changes: 88 additions & 6 deletions src/testRunner/unittests/configurationExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,83 @@ namespace ts {
cwd,
files: {
[root]: {
"dev/node_modules/config-box/package.json": JSON.stringify({
name: "config-box",
version: "1.0.0",
tsconfig: "./strict.json"
}),
"dev/node_modules/config-box/strict.json": JSON.stringify({
compilerOptions: {
strict: true,
}
}),
"dev/node_modules/config-box/unstrict.json": JSON.stringify({
compilerOptions: {
strict: false,
}
}),
"dev/tsconfig.extendsBox.json": JSON.stringify({
extends: "config-box",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsStrict.json": JSON.stringify({
extends: "config-box/strict",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsUnStrict.json": JSON.stringify({
extends: "config-box/unstrict",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsStrictExtension.json": JSON.stringify({
extends: "config-box/strict.json",
files: [
"main.ts",
]
}),
"dev/node_modules/config-box-implied/package.json": JSON.stringify({
name: "config-box-implied",
version: "1.0.0",
}),
"dev/node_modules/config-box-implied/tsconfig.json": JSON.stringify({
compilerOptions: {
strict: true,
}
}),
"dev/node_modules/config-box-implied/unstrict/tsconfig.json": JSON.stringify({
compilerOptions: {
strict: false,
}
}),
"dev/tsconfig.extendsBoxImplied.json": JSON.stringify({
extends: "config-box-implied",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsBoxImpliedUnstrict.json": JSON.stringify({
extends: "config-box-implied/unstrict",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsBoxImpliedUnstrictExtension.json": JSON.stringify({
extends: "config-box-implied/unstrict/tsconfig",
files: [
"main.ts",
]
}),
"dev/tsconfig.extendsBoxImpliedPath.json": JSON.stringify({
extends: "config-box-implied/tsconfig.json",
files: [
"main.ts",
]
}),
"dev/tsconfig.json": JSON.stringify({
extends: "./configs/base",
files: [
Expand Down Expand Up @@ -226,12 +303,6 @@ namespace ts {
messageText: `Compiler option 'extends' requires a value of type string.`
}]);

testFailure("can error when 'extends' is neither relative nor rooted.", "extends2.json", [{
code: 18001,
category: DiagnosticCategory.Error,
messageText: `A path in an 'extends' option must be relative or rooted, but 'configs/base' is not.`
}]);

testSuccess("can overwrite compiler options using extended 'null'", "configs/third.json", {
allowJs: true,
noImplicitAny: true,
Expand Down Expand Up @@ -259,6 +330,17 @@ namespace ts {
combinePaths(basePath, "tests/utils.ts")
]);

describe("finding extended configs from node_modules", () => {
testSuccess("can lookup via tsconfig field", "tsconfig.extendsBox.json", { strict: true }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via package-relative path", "tsconfig.extendsStrict.json", { strict: true }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via non-redirected-to package-relative path", "tsconfig.extendsUnStrict.json", { strict: false }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via package-relative path with extension", "tsconfig.extendsStrictExtension.json", { strict: true }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via an implicit tsconfig", "tsconfig.extendsBoxImplied.json", { strict: true }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via an implicit tsconfig in a package-relative directory", "tsconfig.extendsBoxImpliedUnstrict.json", { strict: false }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via an implicit tsconfig in a package-relative directory with name", "tsconfig.extendsBoxImpliedUnstrictExtension.json", { strict: false }, [combinePaths(basePath, "main.ts")]);
testSuccess("can lookup via an implicit tsconfig in a package-relative directory with extension", "tsconfig.extendsBoxImpliedPath.json", { strict: true }, [combinePaths(basePath, "main.ts")]);
});

it("adds extendedSourceFiles only once", () => {
const sourceFile = readJsonConfigFile("configs/fourth.json", (path) => host.readFile(path));
const dir = combinePaths(basePath, "configs");
Expand Down