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

Configuration Inheritance #9941

Merged
merged 3 commits into from
Sep 13, 2016
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Jakefile.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ var harnessSources = harnessCoreSources.concat([
"moduleResolution.ts",
"tsconfigParsing.ts",
"commandLineParsing.ts",
"configurationExtension.ts",
"convertCompilerOptionsFromJson.ts",
"convertTypingOptionsFromJson.ts",
"tsserverProjectSystem.ts",
Expand Down
72 changes: 69 additions & 3 deletions src/compiler/commandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,12 +799,45 @@ namespace ts {
* @param basePath A root directory to resolve relative path entries in the config
* file to. e.g. outDir
*/
export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string): ParsedCommandLine {
export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string, resolutionStack: Path[] = []): ParsedCommandLine {
const errors: Diagnostic[] = [];
const compilerOptions: CompilerOptions = convertCompilerOptionsFromJsonWorker(json["compilerOptions"], basePath, errors, configFileName);
const options = extend(existingOptions, compilerOptions);
const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames);
const resolvedPath = toPath(configFileName || "", basePath, getCanonicalFileName);
if (resolutionStack.indexOf(resolvedPath) >= 0) {
return {
options: {},
fileNames: [],
typingOptions: {},
raw: json,
errors: [createCompilerDiagnostic(Diagnostics.Circularity_detected_while_resolving_configuration_Colon_0, [...resolutionStack, resolvedPath].join(" -> "))],
wildcardDirectories: {}
};
}

let options: CompilerOptions = convertCompilerOptionsFromJsonWorker(json["compilerOptions"], basePath, errors, configFileName);
const typingOptions: TypingOptions = convertTypingOptionsFromJsonWorker(json["typingOptions"], basePath, errors, configFileName);

if (json["extends"]) {
let [include, exclude, files, baseOptions]: [string[], string[], string[], CompilerOptions] = [undefined, undefined, undefined, {}];
if (typeof json["extends"] === "string") {
[include, exclude, files, baseOptions] = (tryExtendsName(json["extends"]) || [include, exclude, files, baseOptions]);
}
else {
errors.push(createCompilerDiagnostic(Diagnostics.Compiler_option_0_requires_a_value_of_type_1, "extends", "string"));
}
if (include && !json["include"]) {
json["include"] = include;
}
if (exclude && !json["exclude"]) {
json["exclude"] = exclude;
}
if (files && !json["files"]) {
json["files"] = files;
}
options = assign({}, baseOptions, options);
}

options = extend(existingOptions, options);
options.configFilePath = configFileName;

const { fileNames, wildcardDirectories } = getFileNames(errors);
Expand All @@ -818,6 +851,39 @@ namespace ts {
wildcardDirectories
};

function tryExtendsName(extendedConfig: string): [string[], string[], string[], CompilerOptions] {
// 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(normalizeSlashes(extendedConfig), "./") || startsWith(normalizeSlashes(extendedConfig), "../"))) {
errors.push(createCompilerDiagnostic(Diagnostics.The_path_in_an_extends_options_must_be_relative_or_rooted));
return;
}
let extendedConfigPath = toPath(extendedConfig, basePath, getCanonicalFileName);
if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, ".json")) {
Copy link
Contributor

Choose a reason for hiding this comment

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

kinda strange that you force them to write .\ but not the extension. i would say either be pedantic all the way or not. so i would just try to load the file, if it exists fine, if not error.

Copy link
Member Author

Choose a reason for hiding this comment

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

The reasoning for forbidding modules identifier like paths is, as described in the original issue, to reserve the form for potentially loading configs from node_modules in the future (to align with module resolution) (this way adding that isn't a breaking change) (eslint does this). If you'd like, you can spec that behavior out; but without that I think it'd be better to just reserve the form with an error for now.

extendedConfigPath = `${extendedConfigPath}.json` as Path;
if (!host.fileExists(extendedConfigPath)) {
errors.push(createCompilerDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig));
return;
}
}
const extendedResult = readConfigFile(extendedConfigPath, path => host.readFile(path));
if (extendedResult.error) {
errors.push(extendedResult.error);
return;
}
const extendedDirname = getDirectoryPath(extendedConfigPath);
const relativeDifference = convertToRelativePath(extendedDirname, basePath, getCanonicalFileName);
const updatePath: (path: string) => string = path => isRootedDiskPath(path) ? path : combinePaths(relativeDifference, path);
// Merge configs (copy the resolution stack so it is never reused between branches in potential diamond-problem scenarios)
const result = parseJsonConfigFileContent(extendedResult.config, host, extendedDirname, /*existingOptions*/undefined, getBaseFileName(extendedConfigPath), resolutionStack.concat([resolvedPath]));
errors.push(...result.errors);
const [include, exclude, files] = map(["include", "exclude", "files"], key => {
if (!json[key] && extendedResult.config[key]) {
return map(extendedResult.config[key], updatePath);
}
});
return [include, exclude, files, result.options];
}

function getFileNames(errors: Diagnostic[]): ExpandResult {
let fileNames: string[];
if (hasProperty(json, "files")) {
Expand Down
26 changes: 26 additions & 0 deletions src/compiler/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,20 @@ namespace ts {
return result;
}

export function mapObject<T, U>(object: MapLike<T>, f: (key: string, x: T) => [string, U]): MapLike<U> {
let result: MapLike<U>;
if (object) {
result = {};
for (const v of getOwnKeys(object)) {
const [key, value]: [string, U] = f(v, object[v]) || [undefined, undefined];
if (key !== undefined) {
result[key] = value;
}
}
}
return result;
}

export function concatenate<T>(array1: T[], array2: T[]): T[] {
if (!array2 || !array2.length) return array1;
if (!array1 || !array1.length) return array2;
Expand Down Expand Up @@ -464,6 +478,18 @@ namespace ts {
}
}

export function assign<T1 extends MapLike<{}>, T2, T3>(t: T1, arg1: T2, arg2: T3): T1 & T2 & T3;
export function assign<T1 extends MapLike<{}>, T2>(t: T1, arg1: T2): T1 & T2;
export function assign<T1 extends MapLike<{}>>(t: T1, ...args: any[]): any;
export function assign<T1 extends MapLike<{}>>(t: T1, ...args: any[]) {
for (const arg of args) {
for (const p of getOwnKeys(arg)) {
t[p] = arg[p];
}
}
return t;
}

/**
* Reduce the properties of a map.
*
Expand Down
9 changes: 9 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3043,5 +3043,14 @@
"Unknown typing option '{0}'.": {
"category": "Error",
"code": 17010
},

"Circularity detected while resolving configuration: {0}": {
"category": "Error",
"code": 18000
},
"The path in an 'extends' options must be relative or rooted.": {
"category": "Error",
"code": 18001
}
}
2 changes: 2 additions & 0 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,8 @@ namespace ts {
* @param path The path to test.
*/
fileExists(path: string): boolean;

readFile(path: string): string;
Copy link
Contributor

Choose a reason for hiding this comment

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

we need to document this as an API breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

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

@alexeagle, @chuckjaz, @basarat, @adidahiya, @ivogabe, @jbrantly, and @chancancode this is a breaking change in the API, it adds a requirement for a new method ParseConfigHost.readFile to handle config file inheritance. We would like to merge this in soon. Please let us know if you have any reservations about this change.

Copy link
Contributor

Choose a reason for hiding this comment

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

I've been just passing in ts.sys and it works fine and probably will continue to be fine 🌹

Copy link
Contributor

Choose a reason for hiding this comment

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

❤️ @blakeembrey (ts-node / tsconfig) @cartant / @smrq (tsify) @johnnyreilly (also on ts-loader) @sebastian-lenz (typedoc)

Sorry for the mention, thought I'd let you know. Feel free to ignore 💟 🌹

Copy link
Contributor

Choose a reason for hiding this comment

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

This breaks us in at least one place:

https://github.com/angular/angular/blob/master/tools/@angular/tsc-wrapped/src/tsc.ts#L70

What is the timing of this change? Will it be part of 2.0? If so this will be a challenge for us.

Copy link
Member Author

Choose a reason for hiding this comment

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

@chuckjaz It looks like you have a readfile method available, you just dont pass it in as part of the host, so it looks easy to update for future versions. Actually, is there a reason you capture the methods on a new object, rather than just passing in this?

Copy link
Contributor

Choose a reason for hiding this comment

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

What is the timing of this change? Will it be part of 2.0? If so this will be a challenge for us.

no, this is a TS 2.1 feature. but once checked in it will start showing up in typescript@next. This is why i wanted to make sure that all partners are aware of it. I would recommend adding the readFile method now. in TS 2.0 it will not be used; this way users can mix and match tool versions without breakage.

Copy link
Contributor

Choose a reason for hiding this comment

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

@mhegazy If it is part of 2.1 on typescript@next we will have plenty of time to react and this should not be significant for us.

@weswigham This can be better but we do this, in general, to make testing easier.

}

export interface WriteFileCallback {
Expand Down
3 changes: 2 additions & 1 deletion src/harness/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,8 @@ namespace Harness {
const parseConfigHost: ts.ParseConfigHost = {
useCaseSensitiveFileNames: false,
readDirectory: (name) => [],
fileExists: (name) => true
fileExists: (name) => true,
readFile: (name) => ts.forEach(testUnitData, data => data.name.toLowerCase() === name.toLowerCase() ? data.content : undefined)
};

// check if project has tsconfig.json in the list of files
Expand Down
5 changes: 5 additions & 0 deletions src/harness/projectsRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ class ProjectRunner extends RunnerBase {
useCaseSensitiveFileNames: Harness.IO.useCaseSensitiveFileNames(),
fileExists,
readDirectory,
readFile
};
const configParseResult = ts.parseJsonConfigFileContent(configObject, configParseHost, ts.getDirectoryPath(configFileName), compilerOptions);
if (configParseResult.errors.length > 0) {
Expand Down Expand Up @@ -292,6 +293,10 @@ class ProjectRunner extends RunnerBase {
return Harness.IO.fileExists(getFileNameInTheProjectTest(fileName));
}

function readFile(fileName: string): string {
return Harness.IO.readFile(getFileNameInTheProjectTest(fileName));
}

function getSourceFileText(fileName: string): string {
let text: string = undefined;
try {
Expand Down
1 change: 1 addition & 0 deletions src/harness/rwcRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ namespace RWC {
useCaseSensitiveFileNames: Harness.IO.useCaseSensitiveFileNames(),
fileExists: Harness.IO.fileExists,
readDirectory: Harness.IO.readDirectory,
readFile: Harness.IO.readFile
};
const configParseResult = ts.parseJsonConfigFileContent(parsedTsconfigFileContents.config, configParseHost, ts.getDirectoryPath(tsconfigFile.path));
fileNames = configParseResult.fileNames;
Expand Down
1 change: 1 addition & 0 deletions src/harness/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"./unittests/moduleResolution.ts",
"./unittests/tsconfigParsing.ts",
"./unittests/commandLineParsing.ts",
"./unittests/configurationExtension.ts",
"./unittests/convertCompilerOptionsFromJson.ts",
"./unittests/convertTypingOptionsFromJson.ts",
"./unittests/tsserverProjectSystem.ts",
Expand Down
187 changes: 187 additions & 0 deletions src/harness/unittests/configurationExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/// <reference path="..\harness.ts" />
/// <reference path="..\virtualFileSystem.ts" />

namespace ts {
const testContents = {
"/dev/tsconfig.json": `{
"extends": "./configs/base",
"files": [
"main.ts",
"supplemental.ts"
]
}`,
"/dev/tsconfig.nostrictnull.json": `{
"extends": "./tsconfig",
"compilerOptions": {
"strictNullChecks": false
}
}`,
"/dev/configs/base.json": `{
"compilerOptions": {
"allowJs": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}`,
"/dev/configs/tests.json": `{
"compilerOptions": {
"preserveConstEnums": true,
"removeComments": false,
"sourceMap": true
},
"exclude": [
"../tests/baselines",
"../tests/scenarios"
],
"include": [
"../tests/**/*.ts"
]
}`,
"/dev/circular.json": `{
"extends": "./circular2",
"compilerOptions": {
"module": "amd"
}
}`,
"/dev/circular2.json": `{
"extends": "./circular",
"compilerOptions": {
"module": "commonjs"
}
}`,
"/dev/missing.json": `{
"extends": "./missing2",
"compilerOptions": {
"types": []
}
}`,
"/dev/failure.json": `{
"extends": "./failure2.json",
"compilerOptions": {
"typeRoots": []
}
}`,
"/dev/failure2.json": `{
"excludes": ["*.js"]
}`,
"/dev/configs/first.json": `{
"extends": "./base",
"compilerOptions": {
"module": "commonjs"
},
"files": ["../main.ts"]
}`,
"/dev/configs/second.json": `{
"extends": "./base",
"compilerOptions": {
"module": "amd"
},
"include": ["../supplemental.*"]
}`,
"/dev/extends.json": `{ "extends": 42 }`,
"/dev/extends2.json": `{ "extends": "configs/base" }`,
"/dev/main.ts": "",
"/dev/supplemental.ts": "",
"/dev/tests/unit/spec.ts": "",
"/dev/tests/utils.ts": "",
"/dev/tests/scenarios/first.json": "",
"/dev/tests/baselines/first/output.ts": ""
};

const caseInsensitiveBasePath = "c:/dev/";
const caseInsensitiveHost = new Utils.MockParseConfigHost(caseInsensitiveBasePath, /*useCaseSensitiveFileNames*/ false, mapObject(testContents, (key, content) => [`c:${key}`, content]));

const caseSensitiveBasePath = "/dev/";
const caseSensitiveHost = new Utils.MockParseConfigHost(caseSensitiveBasePath, /*useCaseSensitiveFileNames*/ true, testContents);

function verifyDiagnostics(actual: Diagnostic[], expected: {code: number, category: DiagnosticCategory, messageText: string}[]) {
assert.isTrue(expected.length === actual.length, `Expected error: ${JSON.stringify(expected)}. Actual error: ${JSON.stringify(actual)}.`);
for (let i = 0; i < actual.length; i++) {
const actualError = actual[i];
const expectedError = expected[i];
assert.equal(actualError.code, expectedError.code, "Error code mismatch");
assert.equal(actualError.category, expectedError.category, "Category mismatch");
assert.equal(flattenDiagnosticMessageText(actualError.messageText, "\n"), expectedError.messageText);
}
}

describe("Configuration Extension", () => {
forEach<[string, string, Utils.MockParseConfigHost], void>([
["under a case insensitive host", caseInsensitiveBasePath, caseInsensitiveHost],
["under a case sensitive host", caseSensitiveBasePath, caseSensitiveHost]
], ([testName, basePath, host]) => {
function testSuccess(name: string, entry: string, expected: CompilerOptions, expectedFiles: string[]) {
it(name, () => {
const {config, error} = ts.readConfigFile(entry, name => host.readFile(name));
assert(config && !error, flattenDiagnosticMessageText(error && error.messageText, "\n"));
const parsed = ts.parseJsonConfigFileContent(config, host, basePath, {}, entry);
assert(!parsed.errors.length, flattenDiagnosticMessageText(parsed.errors[0] && parsed.errors[0].messageText, "\n"));
expected.configFilePath = entry;
assert.deepEqual(parsed.options, expected);
assert.deepEqual(parsed.fileNames, expectedFiles);
});
}

function testFailure(name: string, entry: string, expectedDiagnostics: {code: number, category: DiagnosticCategory, messageText: string}[]) {
it(name, () => {
const {config, error} = ts.readConfigFile(entry, name => host.readFile(name));
assert(config && !error, flattenDiagnosticMessageText(error && error.messageText, "\n"));
const parsed = ts.parseJsonConfigFileContent(config, host, basePath, {}, entry);
verifyDiagnostics(parsed.errors, expectedDiagnostics);
});
}

describe(testName, () => {
testSuccess("can resolve an extension with a base extension", "tsconfig.json", {
allowJs: true,
noImplicitAny: true,
strictNullChecks: true,
}, [
combinePaths(basePath, "main.ts"),
combinePaths(basePath, "supplemental.ts"),
]);

testSuccess("can resolve an extension with a base extension that overrides options", "tsconfig.nostrictnull.json", {
allowJs: true,
noImplicitAny: true,
strictNullChecks: false,
}, [
combinePaths(basePath, "main.ts"),
combinePaths(basePath, "supplemental.ts"),
]);

testFailure("can report errors on circular imports", "circular.json", [
{
code: 18000,
category: DiagnosticCategory.Error,
messageText: `Circularity detected while resolving configuration: ${[combinePaths(basePath, "circular.json"), combinePaths(basePath, "circular2.json"), combinePaths(basePath, "circular.json")].join(" -> ")}`
}
]);

testFailure("can report missing configurations", "missing.json", [{
code: 6096,
category: DiagnosticCategory.Message,
messageText: `File './missing2' does not exist.`
}]);

testFailure("can report errors in extended configs", "failure.json", [{
code: 6114,
category: DiagnosticCategory.Error,
messageText: `Unknown option 'excludes'. Did you mean 'exclude'?`
}]);

testFailure("can error when 'extends' is not a string", "extends.json", [{
code: 5024,
category: DiagnosticCategory.Error,
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: `The path in an 'extends' options must be relative or rooted.`
}]);
});
});
});
}
Loading