Skip to content

Commit 873850b

Browse files
authored
Merge pull request #9941 from Microsoft/configuration-inheritance
Configuration Inheritance
2 parents e647933 + d8ff546 commit 873850b

File tree

11 files changed

+319
-13
lines changed

11 files changed

+319
-13
lines changed

Jakefile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ var harnessSources = harnessCoreSources.concat([
216216
"moduleResolution.ts",
217217
"tsconfigParsing.ts",
218218
"commandLineParsing.ts",
219+
"configurationExtension.ts",
219220
"convertCompilerOptionsFromJson.ts",
220221
"convertTypingOptionsFromJson.ts",
221222
"tsserverProjectSystem.ts",

src/compiler/commandLineParser.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -806,12 +806,45 @@ namespace ts {
806806
* @param basePath A root directory to resolve relative path entries in the config
807807
* file to. e.g. outDir
808808
*/
809-
export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string): ParsedCommandLine {
809+
export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string, resolutionStack: Path[] = []): ParsedCommandLine {
810810
const errors: Diagnostic[] = [];
811-
const compilerOptions: CompilerOptions = convertCompilerOptionsFromJsonWorker(json["compilerOptions"], basePath, errors, configFileName);
812-
const options = extend(existingOptions, compilerOptions);
811+
const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames);
812+
const resolvedPath = toPath(configFileName || "", basePath, getCanonicalFileName);
813+
if (resolutionStack.indexOf(resolvedPath) >= 0) {
814+
return {
815+
options: {},
816+
fileNames: [],
817+
typingOptions: {},
818+
raw: json,
819+
errors: [createCompilerDiagnostic(Diagnostics.Circularity_detected_while_resolving_configuration_Colon_0, [...resolutionStack, resolvedPath].join(" -> "))],
820+
wildcardDirectories: {}
821+
};
822+
}
823+
824+
let options: CompilerOptions = convertCompilerOptionsFromJsonWorker(json["compilerOptions"], basePath, errors, configFileName);
813825
const typingOptions: TypingOptions = convertTypingOptionsFromJsonWorker(json["typingOptions"], basePath, errors, configFileName);
814826

827+
if (json["extends"]) {
828+
let [include, exclude, files, baseOptions]: [string[], string[], string[], CompilerOptions] = [undefined, undefined, undefined, {}];
829+
if (typeof json["extends"] === "string") {
830+
[include, exclude, files, baseOptions] = (tryExtendsName(json["extends"]) || [include, exclude, files, baseOptions]);
831+
}
832+
else {
833+
errors.push(createCompilerDiagnostic(Diagnostics.Compiler_option_0_requires_a_value_of_type_1, "extends", "string"));
834+
}
835+
if (include && !json["include"]) {
836+
json["include"] = include;
837+
}
838+
if (exclude && !json["exclude"]) {
839+
json["exclude"] = exclude;
840+
}
841+
if (files && !json["files"]) {
842+
json["files"] = files;
843+
}
844+
options = assign({}, baseOptions, options);
845+
}
846+
847+
options = extend(existingOptions, options);
815848
options.configFilePath = configFileName;
816849

817850
const { fileNames, wildcardDirectories } = getFileNames(errors);
@@ -825,6 +858,39 @@ namespace ts {
825858
wildcardDirectories
826859
};
827860

861+
function tryExtendsName(extendedConfig: string): [string[], string[], string[], CompilerOptions] {
862+
// 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)
863+
if (!(isRootedDiskPath(extendedConfig) || startsWith(normalizeSlashes(extendedConfig), "./") || startsWith(normalizeSlashes(extendedConfig), "../"))) {
864+
errors.push(createCompilerDiagnostic(Diagnostics.The_path_in_an_extends_options_must_be_relative_or_rooted));
865+
return;
866+
}
867+
let extendedConfigPath = toPath(extendedConfig, basePath, getCanonicalFileName);
868+
if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, ".json")) {
869+
extendedConfigPath = `${extendedConfigPath}.json` as Path;
870+
if (!host.fileExists(extendedConfigPath)) {
871+
errors.push(createCompilerDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig));
872+
return;
873+
}
874+
}
875+
const extendedResult = readConfigFile(extendedConfigPath, path => host.readFile(path));
876+
if (extendedResult.error) {
877+
errors.push(extendedResult.error);
878+
return;
879+
}
880+
const extendedDirname = getDirectoryPath(extendedConfigPath);
881+
const relativeDifference = convertToRelativePath(extendedDirname, basePath, getCanonicalFileName);
882+
const updatePath: (path: string) => string = path => isRootedDiskPath(path) ? path : combinePaths(relativeDifference, path);
883+
// Merge configs (copy the resolution stack so it is never reused between branches in potential diamond-problem scenarios)
884+
const result = parseJsonConfigFileContent(extendedResult.config, host, extendedDirname, /*existingOptions*/undefined, getBaseFileName(extendedConfigPath), resolutionStack.concat([resolvedPath]));
885+
errors.push(...result.errors);
886+
const [include, exclude, files] = map(["include", "exclude", "files"], key => {
887+
if (!json[key] && extendedResult.config[key]) {
888+
return map(extendedResult.config[key], updatePath);
889+
}
890+
});
891+
return [include, exclude, files, result.options];
892+
}
893+
828894
function getFileNames(errors: Diagnostic[]): ExpandResult {
829895
let fileNames: string[];
830896
if (hasProperty(json, "files")) {

src/compiler/core.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,20 @@ namespace ts {
377377
return result;
378378
}
379379

380+
export function mapObject<T, U>(object: MapLike<T>, f: (key: string, x: T) => [string, U]): MapLike<U> {
381+
let result: MapLike<U>;
382+
if (object) {
383+
result = {};
384+
for (const v of getOwnKeys(object)) {
385+
const [key, value]: [string, U] = f(v, object[v]) || [undefined, undefined];
386+
if (key !== undefined) {
387+
result[key] = value;
388+
}
389+
}
390+
}
391+
return result;
392+
}
393+
380394
export function concatenate<T>(array1: T[], array2: T[]): T[] {
381395
if (!array2 || !array2.length) return array1;
382396
if (!array1 || !array1.length) return array2;
@@ -639,6 +653,18 @@ namespace ts {
639653
}
640654
}
641655

656+
export function assign<T1 extends MapLike<{}>, T2, T3>(t: T1, arg1: T2, arg2: T3): T1 & T2 & T3;
657+
export function assign<T1 extends MapLike<{}>, T2>(t: T1, arg1: T2): T1 & T2;
658+
export function assign<T1 extends MapLike<{}>>(t: T1, ...args: any[]): any;
659+
export function assign<T1 extends MapLike<{}>>(t: T1, ...args: any[]) {
660+
for (const arg of args) {
661+
for (const p of getOwnKeys(arg)) {
662+
t[p] = arg[p];
663+
}
664+
}
665+
return t;
666+
}
667+
642668
/**
643669
* Reduce the properties of a map.
644670
*

src/compiler/diagnosticMessages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3047,5 +3047,14 @@
30473047
"Unknown typing option '{0}'.": {
30483048
"category": "Error",
30493049
"code": 17010
3050+
},
3051+
3052+
"Circularity detected while resolving configuration: {0}": {
3053+
"category": "Error",
3054+
"code": 18000
3055+
},
3056+
"The path in an 'extends' options must be relative or rooted.": {
3057+
"category": "Error",
3058+
"code": 18001
30503059
}
30513060
}

src/compiler/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,8 @@ namespace ts {
18071807
* @param path The path to test.
18081808
*/
18091809
fileExists(path: string): boolean;
1810+
1811+
readFile(path: string): string;
18101812
}
18111813

18121814
export interface WriteFileCallback {

src/harness/harness.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,8 @@ namespace Harness {
18441844
const parseConfigHost: ts.ParseConfigHost = {
18451845
useCaseSensitiveFileNames: false,
18461846
readDirectory: (name) => [],
1847-
fileExists: (name) => true
1847+
fileExists: (name) => true,
1848+
readFile: (name) => ts.forEach(testUnitData, data => data.name.toLowerCase() === name.toLowerCase() ? data.content : undefined)
18481849
};
18491850

18501851
// check if project has tsconfig.json in the list of files

src/harness/projectsRunner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ class ProjectRunner extends RunnerBase {
222222
useCaseSensitiveFileNames: Harness.IO.useCaseSensitiveFileNames(),
223223
fileExists,
224224
readDirectory,
225+
readFile
225226
};
226227
const configParseResult = ts.parseJsonConfigFileContent(configObject, configParseHost, ts.getDirectoryPath(configFileName), compilerOptions);
227228
if (configParseResult.errors.length > 0) {
@@ -292,6 +293,10 @@ class ProjectRunner extends RunnerBase {
292293
return Harness.IO.fileExists(getFileNameInTheProjectTest(fileName));
293294
}
294295

296+
function readFile(fileName: string): string {
297+
return Harness.IO.readFile(getFileNameInTheProjectTest(fileName));
298+
}
299+
295300
function getSourceFileText(fileName: string): string {
296301
let text: string = undefined;
297302
try {

src/harness/rwcRunner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ namespace RWC {
7979
useCaseSensitiveFileNames: Harness.IO.useCaseSensitiveFileNames(),
8080
fileExists: Harness.IO.fileExists,
8181
readDirectory: Harness.IO.readDirectory,
82+
readFile: Harness.IO.readFile
8283
};
8384
const configParseResult = ts.parseJsonConfigFileContent(parsedTsconfigFileContents.config, configParseHost, ts.getDirectoryPath(tsconfigFile.path));
8485
fileNames = configParseResult.fileNames;

src/harness/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"./unittests/moduleResolution.ts",
8787
"./unittests/tsconfigParsing.ts",
8888
"./unittests/commandLineParsing.ts",
89+
"./unittests/configurationExtension.ts",
8990
"./unittests/convertCompilerOptionsFromJson.ts",
9091
"./unittests/convertTypingOptionsFromJson.ts",
9192
"./unittests/tsserverProjectSystem.ts",
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/// <reference path="..\harness.ts" />
2+
/// <reference path="..\virtualFileSystem.ts" />
3+
4+
namespace ts {
5+
const testContents = {
6+
"/dev/tsconfig.json": `{
7+
"extends": "./configs/base",
8+
"files": [
9+
"main.ts",
10+
"supplemental.ts"
11+
]
12+
}`,
13+
"/dev/tsconfig.nostrictnull.json": `{
14+
"extends": "./tsconfig",
15+
"compilerOptions": {
16+
"strictNullChecks": false
17+
}
18+
}`,
19+
"/dev/configs/base.json": `{
20+
"compilerOptions": {
21+
"allowJs": true,
22+
"noImplicitAny": true,
23+
"strictNullChecks": true
24+
}
25+
}`,
26+
"/dev/configs/tests.json": `{
27+
"compilerOptions": {
28+
"preserveConstEnums": true,
29+
"removeComments": false,
30+
"sourceMap": true
31+
},
32+
"exclude": [
33+
"../tests/baselines",
34+
"../tests/scenarios"
35+
],
36+
"include": [
37+
"../tests/**/*.ts"
38+
]
39+
}`,
40+
"/dev/circular.json": `{
41+
"extends": "./circular2",
42+
"compilerOptions": {
43+
"module": "amd"
44+
}
45+
}`,
46+
"/dev/circular2.json": `{
47+
"extends": "./circular",
48+
"compilerOptions": {
49+
"module": "commonjs"
50+
}
51+
}`,
52+
"/dev/missing.json": `{
53+
"extends": "./missing2",
54+
"compilerOptions": {
55+
"types": []
56+
}
57+
}`,
58+
"/dev/failure.json": `{
59+
"extends": "./failure2.json",
60+
"compilerOptions": {
61+
"typeRoots": []
62+
}
63+
}`,
64+
"/dev/failure2.json": `{
65+
"excludes": ["*.js"]
66+
}`,
67+
"/dev/configs/first.json": `{
68+
"extends": "./base",
69+
"compilerOptions": {
70+
"module": "commonjs"
71+
},
72+
"files": ["../main.ts"]
73+
}`,
74+
"/dev/configs/second.json": `{
75+
"extends": "./base",
76+
"compilerOptions": {
77+
"module": "amd"
78+
},
79+
"include": ["../supplemental.*"]
80+
}`,
81+
"/dev/extends.json": `{ "extends": 42 }`,
82+
"/dev/extends2.json": `{ "extends": "configs/base" }`,
83+
"/dev/main.ts": "",
84+
"/dev/supplemental.ts": "",
85+
"/dev/tests/unit/spec.ts": "",
86+
"/dev/tests/utils.ts": "",
87+
"/dev/tests/scenarios/first.json": "",
88+
"/dev/tests/baselines/first/output.ts": ""
89+
};
90+
91+
const caseInsensitiveBasePath = "c:/dev/";
92+
const caseInsensitiveHost = new Utils.MockParseConfigHost(caseInsensitiveBasePath, /*useCaseSensitiveFileNames*/ false, mapObject(testContents, (key, content) => [`c:${key}`, content]));
93+
94+
const caseSensitiveBasePath = "/dev/";
95+
const caseSensitiveHost = new Utils.MockParseConfigHost(caseSensitiveBasePath, /*useCaseSensitiveFileNames*/ true, testContents);
96+
97+
function verifyDiagnostics(actual: Diagnostic[], expected: {code: number, category: DiagnosticCategory, messageText: string}[]) {
98+
assert.isTrue(expected.length === actual.length, `Expected error: ${JSON.stringify(expected)}. Actual error: ${JSON.stringify(actual)}.`);
99+
for (let i = 0; i < actual.length; i++) {
100+
const actualError = actual[i];
101+
const expectedError = expected[i];
102+
assert.equal(actualError.code, expectedError.code, "Error code mismatch");
103+
assert.equal(actualError.category, expectedError.category, "Category mismatch");
104+
assert.equal(flattenDiagnosticMessageText(actualError.messageText, "\n"), expectedError.messageText);
105+
}
106+
}
107+
108+
describe("Configuration Extension", () => {
109+
forEach<[string, string, Utils.MockParseConfigHost], void>([
110+
["under a case insensitive host", caseInsensitiveBasePath, caseInsensitiveHost],
111+
["under a case sensitive host", caseSensitiveBasePath, caseSensitiveHost]
112+
], ([testName, basePath, host]) => {
113+
function testSuccess(name: string, entry: string, expected: CompilerOptions, expectedFiles: string[]) {
114+
it(name, () => {
115+
const {config, error} = ts.readConfigFile(entry, name => host.readFile(name));
116+
assert(config && !error, flattenDiagnosticMessageText(error && error.messageText, "\n"));
117+
const parsed = ts.parseJsonConfigFileContent(config, host, basePath, {}, entry);
118+
assert(!parsed.errors.length, flattenDiagnosticMessageText(parsed.errors[0] && parsed.errors[0].messageText, "\n"));
119+
expected.configFilePath = entry;
120+
assert.deepEqual(parsed.options, expected);
121+
assert.deepEqual(parsed.fileNames, expectedFiles);
122+
});
123+
}
124+
125+
function testFailure(name: string, entry: string, expectedDiagnostics: {code: number, category: DiagnosticCategory, messageText: string}[]) {
126+
it(name, () => {
127+
const {config, error} = ts.readConfigFile(entry, name => host.readFile(name));
128+
assert(config && !error, flattenDiagnosticMessageText(error && error.messageText, "\n"));
129+
const parsed = ts.parseJsonConfigFileContent(config, host, basePath, {}, entry);
130+
verifyDiagnostics(parsed.errors, expectedDiagnostics);
131+
});
132+
}
133+
134+
describe(testName, () => {
135+
testSuccess("can resolve an extension with a base extension", "tsconfig.json", {
136+
allowJs: true,
137+
noImplicitAny: true,
138+
strictNullChecks: true,
139+
}, [
140+
combinePaths(basePath, "main.ts"),
141+
combinePaths(basePath, "supplemental.ts"),
142+
]);
143+
144+
testSuccess("can resolve an extension with a base extension that overrides options", "tsconfig.nostrictnull.json", {
145+
allowJs: true,
146+
noImplicitAny: true,
147+
strictNullChecks: false,
148+
}, [
149+
combinePaths(basePath, "main.ts"),
150+
combinePaths(basePath, "supplemental.ts"),
151+
]);
152+
153+
testFailure("can report errors on circular imports", "circular.json", [
154+
{
155+
code: 18000,
156+
category: DiagnosticCategory.Error,
157+
messageText: `Circularity detected while resolving configuration: ${[combinePaths(basePath, "circular.json"), combinePaths(basePath, "circular2.json"), combinePaths(basePath, "circular.json")].join(" -> ")}`
158+
}
159+
]);
160+
161+
testFailure("can report missing configurations", "missing.json", [{
162+
code: 6096,
163+
category: DiagnosticCategory.Message,
164+
messageText: `File './missing2' does not exist.`
165+
}]);
166+
167+
testFailure("can report errors in extended configs", "failure.json", [{
168+
code: 6114,
169+
category: DiagnosticCategory.Error,
170+
messageText: `Unknown option 'excludes'. Did you mean 'exclude'?`
171+
}]);
172+
173+
testFailure("can error when 'extends' is not a string", "extends.json", [{
174+
code: 5024,
175+
category: DiagnosticCategory.Error,
176+
messageText: `Compiler option 'extends' requires a value of type string.`
177+
}]);
178+
179+
testFailure("can error when 'extends' is neither relative nor rooted.", "extends2.json", [{
180+
code: 18001,
181+
category: DiagnosticCategory.Error,
182+
messageText: `The path in an 'extends' options must be relative or rooted.`
183+
}]);
184+
});
185+
});
186+
});
187+
}

0 commit comments

Comments
 (0)