diff --git a/src/compiler/tsbuild.ts b/src/compiler/tsbuild.ts index 4bd6c13914d..8e50800fff6 100644 --- a/src/compiler/tsbuild.ts +++ b/src/compiler/tsbuild.ts @@ -4,7 +4,7 @@ namespace ts { * specified like "./blah" to an absolute path to an actual * tsconfig file, e.g. "/root/blah/tsconfig.json" */ - type ResolvedConfigFileName = string & { _isResolvedConfigFileName: never }; + export type ResolvedConfigFileName = string & { _isResolvedConfigFileName: never }; const minimumDate = new Date(-8640000000000000); const maximumDate = new Date(8640000000000000); @@ -42,7 +42,7 @@ namespace ts { type Mapper = ReturnType; interface DependencyGraph { - buildQueue: ResolvedConfigFileName[][]; + buildQueue: ResolvedConfigFileName[]; dependencyMap: Mapper; } @@ -409,7 +409,8 @@ namespace ts { getUpToDateStatusOfFile, buildProjects, cleanProjects, - resetBuildContext + resetBuildContext, + getBuildGraph }; function resetBuildContext(opts = defaultOptions) { @@ -420,6 +421,13 @@ namespace ts { return getUpToDateStatus(configFileCache.parseConfigFile(configFileName)); } + function getBuildGraph(configFileNames: string[]) { + const resolvedNames: ResolvedConfigFileName[] | undefined = resolveProjectNames(configFileNames); + if (resolvedNames === undefined) return; + + return createDependencyGraph(resolvedNames); + } + function getUpToDateStatus(project: ParsedCommandLine | undefined): UpToDateStatus { if (project === undefined) { return { type: UpToDateStatusType.Unbuildable, reason: "File deleted mid-build" }; @@ -583,66 +591,62 @@ namespace ts { }; } - // TODO: Use the better algorithm - function createDependencyGraph(roots: ResolvedConfigFileName[]): DependencyGraph { - // This is a list of list of projects that need to be built. - // The ordering here is "backwards", i.e. the first entry in the array is the last set of projects that need to be built; - // and the last entry is the first set of projects to be built. - // Each subarray is effectively unordered. - // We traverse the reference graph from each root, then "clean" the list by removing - // any entry that is duplicated to its right. - const buildQueue: ResolvedConfigFileName[][] = []; - const dependencyMap = createDependencyMapper(); - let buildQueuePosition = 0; + function createDependencyGraph(roots: ResolvedConfigFileName[]): DependencyGraph | undefined { + const temporaryMarks: { [path: string]: true } = {}; + const permanentMarks: { [path: string]: true } = {}; + const circularityReportStack: string[] = []; + const buildOrder: ResolvedConfigFileName[] = []; + const graph = createDependencyMapper(); + + let hadError = false; + for (const root of roots) { - const config = configFileCache.parseConfigFile(root); - if (config === undefined) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.File_0_does_not_exist, root)); - continue; - } - enumerateReferences(normalizePath(root) as ResolvedConfigFileName, config); + visit(root); + } + + if (hadError) { + return undefined; } - removeDuplicatesFromBuildQueue(buildQueue); return { - buildQueue, - dependencyMap + buildQueue: buildOrder, + dependencyMap: graph }; - function enumerateReferences(fileName: ResolvedConfigFileName, root: ParsedCommandLine): void { - const myBuildLevel = buildQueue[buildQueuePosition] = buildQueue[buildQueuePosition] || []; - if (myBuildLevel.indexOf(fileName) < 0) { - myBuildLevel.push(fileName); - } - - const refs = root.projectReferences; - if (refs === undefined) return; - buildQueuePosition++; - for (const ref of refs) { - const actualPath = resolveProjectReferencePath(host, ref) as ResolvedConfigFileName; - dependencyMap.addReference(fileName, actualPath); - const resolvedRef = configFileCache.parseConfigFile(actualPath); - if (resolvedRef === undefined) continue; - enumerateReferences(normalizePath(actualPath) as ResolvedConfigFileName, resolvedRef); - } - buildQueuePosition--; - } - - /** - * Removes entries from arrays which appear in later arrays. - */ - function removeDuplicatesFromBuildQueue(queue: string[][]): void { - // No need to check the last array - for (let i = 0; i < queue.length - 1; i++) { - queue[i] = queue[i].filter(fn => !occursAfter(fn, i + 1)); - } - - function occursAfter(s: string, start: number) { - for (let i = start; i < queue.length; i++) { - if (queue[i].indexOf(s) >= 0) return true; + function visit(projPath: ResolvedConfigFileName, inCircularContext = false) { + // Already visited + if (permanentMarks[projPath]) return; + // Circular + if (temporaryMarks[projPath]) { + if (!inCircularContext) { + hadError = true; + reportDiagnostic(createCompilerDiagnostic(Diagnostics.Project_references_may_not_form_a_circular_graph_Cycle_detected_Colon_0, circularityReportStack.join("\r\n"))); + return; } - return false; } + + temporaryMarks[projPath] = true; + circularityReportStack.push(projPath); + const parsed = configFileCache.parseConfigFile(projPath); + if (parsed === undefined) { + hadError = true; + return; + } + if (parsed.projectReferences) { + for (const ref of parsed.projectReferences) { + const resolvedRefPath = resolveProjectName(ref.path); + if (resolvedRefPath === undefined) { + hadError = true; + break; + } + visit(resolvedRefPath, inCircularContext || ref.circular); + graph.addReference(projPath, resolvedRefPath); + } + } + + circularityReportStack.pop(); + permanentMarks[projPath] = true; + buildOrder.push(projPath); } } @@ -762,20 +766,19 @@ namespace ts { // Get the same graph for cleaning we'd use for building const graph = createDependencyGraph(resolvedNames); + if (graph === undefined) return undefined; const filesToDelete: string[] = []; - for (const level of graph.buildQueue) { - for (const proj of level) { - const parsed = configFileCache.parseConfigFile(proj); - if (parsed === undefined) { - // File has gone missing; fine to ignore here - continue; - } - const outputs = getAllProjectOutputs(parsed); - for (const output of outputs) { - if (host.fileExists(output)) { - filesToDelete.push(output); - } + for (const proj of graph.buildQueue) { + const parsed = configFileCache.parseConfigFile(proj); + if (parsed === undefined) { + // File has gone missing; fine to ignore here + continue; + } + const outputs = getAllProjectOutputs(parsed); + for (const output of outputs) { + if (host.fileExists(output)) { + filesToDelete.push(output); } } } @@ -805,21 +808,27 @@ namespace ts { } } + function resolveProjectName(name: string): ResolvedConfigFileName | undefined { + let fullPath = resolvePath(host.getCurrentDirectory(), name); + if (host.fileExists(fullPath)) { + return fullPath as ResolvedConfigFileName; + } + fullPath = combinePaths(fullPath, "tsconfig.json"); + if (host.fileExists(fullPath)) { + return fullPath as ResolvedConfigFileName; + } + reportDiagnostic(createCompilerDiagnostic(Diagnostics.File_0_not_found, fullPath)); + return undefined; + } + function resolveProjectNames(configFileNames: string[]): ResolvedConfigFileName[] | undefined { const resolvedNames: ResolvedConfigFileName[] = []; for (const name of configFileNames) { - let fullPath = resolvePath(host.getCurrentDirectory(), name); - if (host.fileExists(fullPath)) { - resolvedNames.push(fullPath as ResolvedConfigFileName); - continue; + const resolved = resolveProjectName(name); + if (resolved === undefined) { + return undefined; } - fullPath = combinePaths(fullPath, "tsconfig.json"); - if (host.fileExists(fullPath)) { - resolvedNames.push(fullPath as ResolvedConfigFileName); - continue; - } - reportDiagnostic(createCompilerDiagnostic(Diagnostics.File_0_not_found, fullPath)); - return undefined; + resolvedNames.push(resolved); } return resolvedNames; } @@ -830,12 +839,12 @@ namespace ts { // Establish what needs to be built const graph = createDependencyGraph(resolvedNames); + if (graph === undefined) return; const queue = graph.buildQueue; reportBuildQueue(graph); - let next: ResolvedConfigFileName | undefined; - while (next = getNext()) { + for (const next of queue) { const proj = configFileCache.parseConfigFile(next); if (proj === undefined) { break; @@ -866,21 +875,6 @@ namespace ts { buildSingleProject(next); } - - function getNext(): ResolvedConfigFileName | undefined { - if (queue.length === 0) { - return undefined; - } - while (queue.length > 0) { - const last = queue[queue.length - 1]; - if (last.length === 0) { - queue.pop(); - continue; - } - return last.pop()!; - } - return undefined; - } } /** @@ -890,12 +884,9 @@ namespace ts { if (!context.options.verbose) return; const names: string[] = []; - for (const level of graph.buildQueue) { - for (const el of level) { - names.push(el); - } + for (const name of graph.buildQueue) { + names.push(name); } - names.reverse(); context.verbose(Diagnostics.Sorted_list_of_input_projects_Colon_0, names.map(s => "\r\n * " + s).join("")); } diff --git a/src/harness/unittests/tsbuild.ts b/src/harness/unittests/tsbuild.ts index 4d2e5febdef..08818d6ac3f 100644 --- a/src/harness/unittests/tsbuild.ts +++ b/src/harness/unittests/tsbuild.ts @@ -207,25 +207,49 @@ namespace ts { }); describe("tsbuild - graph-ordering", () => { - it("orders the graph correctly", () => { - const fs = new vfs.FileSystem(false); - const host = new fakes.CompilerHost(fs); - const deps: [string, string][] = [ - ["A", "B"], - ["B", "C"], - ["A", "C"], - ["B", "D"], - ["C", "D"], - ["C", "E"], - ["F", "E"] - ]; + const fs = new vfs.FileSystem(false); + const host = new fakes.CompilerHost(fs); + const deps: [string, string][] = [ + ["A", "B"], + ["B", "C"], + ["A", "C"], + ["B", "D"], + ["C", "D"], + ["C", "E"], + ["F", "E"] + ]; - writeProjects(fs, ["A", "B", "C", "D", "E", "F", "G"], deps); - const builder = createSolutionBuilder(host, reportDiagnostic, { dry: true, force: false, verbose: false }); - builder.buildProjects(["/project/A", "/project/G"]); - printDiagnostics(); + writeProjects(fs, ["A", "B", "C", "D", "E", "F", "G"], deps); + + const builder = createSolutionBuilder(host, reportDiagnostic, { dry: true, force: false, verbose: false }); + + it("orders the graph correctly - specify two roots", () => { + checkGraphOrdering(["A", "G"], ["A", "B", "C", "D", "E", "G"]); }); + it("orders the graph correctly - multiple parts of the same graph in various orders", () => { + // TODO add cases here + }); + + function checkGraphOrdering(rootNames: string[], expectedBuildSet: string[]) { + const projFileNames = rootNames.map(getProjectFileName); + const graph = builder.getBuildGraph(projFileNames); + if (graph === undefined) throw new Error("Graph shouldn't be undefined"); + + assert.sameMembers(graph.buildQueue, expectedBuildSet.map(getProjectFileName)); + + for (const dep of deps) { + const child = getProjectFileName(dep[0]); + if (graph.buildQueue.indexOf(child) < 0) continue; + const parent = getProjectFileName(dep[1]); + assert.isAbove(graph.buildQueue.indexOf(child), graph.buildQueue.indexOf(parent), `Expecting child ${child} to be built after parent ${parent}`); + } + } + + function getProjectFileName(proj: string) { + return `/project/${proj}/tsconfig.json` as ResolvedConfigFileName; + } + function writeProjects(fileSystem: vfs.FileSystem, projectNames: string[], deps: [string, string][]): string[] { const projFileNames: string[] = []; for (const dep of deps) { @@ -235,7 +259,7 @@ namespace ts { for (const proj of projectNames) { fileSystem.mkdirpSync(`/project/${proj}`); fileSystem.writeFileSync(`/project/${proj}/${proj}.ts`, "export {}"); - const configFileName = `/project/${proj}/tsconfig.json`; + const configFileName = getProjectFileName(proj); const configContent = JSON.stringify({ compilerOptions: { composite: true }, files: [`./${proj}.ts`],