diff --git a/Gulpfile.js b/Gulpfile.js index 68b1391cf52..253ddac31c1 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -530,12 +530,17 @@ gulp.task( "Runs 'local'", ["local"]); -// TODO(rbuckton): Investigate restoring gulp.watch() functionality. -// gulp.task( -// "watch", -// "Watches the src/ directory for changes and executes runtests-parallel.", -// [], -// () => gulp.watch("src/**/*.*", ["runtests-parallel"])); +gulp.task( + "watch-tsc", + "Watches for changes to the build inputs for built/local/tsc.js", + [typescriptServicesJs], + () => project.watch(tscProject, { typescript: "built" })); + +gulp.task( + "watch", + "Watches for changes to the build inputs for built/local/run.js executes runtests-parallel.", + [typescriptServicesJs], + () => project.watch(testRunnerProject, { typescript: "built" }, ["runtests-parallel"])); gulp.task("clean-built", /*help*/ false, ["clean:" + diagnosticInformationMapTs], () => del(["built"])); gulp.task( diff --git a/scripts/build/project.js b/scripts/build/project.js index dcda9d63102..badeb606d3b 100644 --- a/scripts/build/project.js +++ b/scripts/build/project.js @@ -16,16 +16,16 @@ const { reportDiagnostics } = require("./diagnostics"); class CompilationGulp extends gulp.Gulp { /** - * @param {boolean} [verbose] + * @param {boolean} [verbose] */ fork(verbose) { const child = new ForkedGulp(this.tasks); if (verbose) { - this.on("task_start", e => gulp.emit("task_start", e)); - this.on("task_stop", e => gulp.emit("task_stop", e)); - this.on("task_err", e => gulp.emit("task_err", e)); - this.on("task_not_found", e => gulp.emit("task_not_found", e)); - this.on("task_recursion", e => gulp.emit("task_recursion", e)); + child.on("task_start", e => gulp.emit("task_start", e)); + child.on("task_stop", e => gulp.emit("task_stop", e)); + child.on("task_err", e => gulp.emit("task_err", e)); + child.on("task_not_found", e => gulp.emit("task_not_found", e)); + child.on("task_recursion", e => gulp.emit("task_recursion", e)); } return child; } @@ -58,13 +58,15 @@ const typescriptAliasMap = new Map(); /** * Defines a gulp orchestration for a TypeScript project, returning a callback that can be used to trigger compilation. * @param {string} projectSpec The path to a tsconfig.json file or its containing directory. - * @param {ProjectOptions} [options] Project compilation options. + * @param {CompileOptions} [options] Project compilation options. * @returns {() => Promise} */ function createCompiler(projectSpec, options) { - const resolvedOptions = resolveProjectOptions(options); + const resolvedOptions = resolveCompileOptions(options); const resolvedProjectSpec = resolveProjectSpec(projectSpec, resolvedOptions.paths, /*referrer*/ undefined); - const taskName = compileTaskName(ensureCompileTask(getOrCreateProjectGraph(resolvedProjectSpec, resolvedOptions.paths), resolvedOptions), resolvedOptions.typescript); + const projectGraph = getOrCreateProjectGraph(resolvedProjectSpec, resolvedOptions.paths); + projectGraph.isRoot = true; + const taskName = compileTaskName(ensureCompileTask(projectGraph, resolvedOptions), resolvedOptions.typescript); return () => new Promise((resolve, reject) => compilationGulp .fork(resolvedOptions.verbose) .start(taskName, err => err ? reject(err) : resolve())); @@ -74,10 +76,10 @@ exports.createCompiler = createCompiler; /** * Defines and executes a gulp orchestration for a TypeScript project. * @param {string} projectSpec The path to a tsconfig.json file or its containing directory. - * @param {ProjectOptions} [options] Project compilation options. + * @param {CompileOptions} [options] Project compilation options. * @returns {Promise} - * - * @typedef ProjectOptions + * + * @typedef CompileOptions * @property {string} [cwd] The path to use for the current working directory. Defaults to `process.cwd()`. * @property {string} [base] The path to use as the base for relative paths. Defaults to `cwd`. * @property {string} [typescript] A module specifier or path (relative to gulpfile.js) to the version of TypeScript to use. @@ -86,7 +88,7 @@ exports.createCompiler = createCompiler; * @property {boolean} [verbose] Indicates whether verbose logging is enabled. * @property {boolean} [force] Force recompilation (no up-to-date check). * @property {boolean} [inProcess] Indicates whether to run gulp-typescript in-process or out-of-process (default). - * + * * @typedef {(stream: NodeJS.ReadableStream) => NodeJS.ReadWriteStream} Hook */ function compile(projectSpec, options) { @@ -103,7 +105,9 @@ exports.compile = compile; function createCleaner(projectSpec, options) { const paths = resolvePathOptions(options); const resolvedProjectSpec = resolveProjectSpec(projectSpec, paths, /*referrer*/ undefined); - const taskName = cleanTaskName(ensureCleanTask(getOrCreateProjectGraph(resolvedProjectSpec, paths))); + const projectGraph = getOrCreateProjectGraph(resolvedProjectSpec, paths); + projectGraph.isRoot = true; + const taskName = cleanTaskName(ensureCleanTask(projectGraph)); return () => new Promise((resolve, reject) => compilationGulp .fork() .start(taskName, err => err ? reject(err) : resolve())); @@ -121,6 +125,25 @@ function clean(projectSpec, options) { } exports.clean = clean; +/** + * Defines a watcher to execute a gulp orchestration to recompile a TypeScript project. + * @param {string} projectSpec + * @param {WatchCallback | string[] | CompileOptions} [options] + * @param {WatchCallback | string[]} [tasks] + * @param {WatchCallback} [callback] + */ +function watch(projectSpec, options, tasks, callback) { + if (typeof tasks === "function") callback = tasks, tasks = /**@type {string[] | undefined}*/(undefined); + if (typeof options === "function") callback = options, tasks = /**@type {string[] | undefined}*/(undefined), options = /**@type {CompileOptions | undefined}*/(undefined); + if (Array.isArray(options)) tasks = options, options = /**@type {CompileOptions | undefined}*/(undefined); + const resolvedOptions = resolveCompileOptions(options); + const resolvedProjectSpec = resolveProjectSpec(projectSpec, resolvedOptions.paths, /*referrer*/ undefined); + const projectGraph = getOrCreateProjectGraph(resolvedProjectSpec, resolvedOptions.paths); + projectGraph.isRoot = true; + ensureWatcher(projectGraph, resolvedOptions, tasks, callback); +} +exports.watch = watch; + /** * Adds a named alias for a TypeScript language service path * @param {string} alias An alias for a TypeScript version. @@ -138,7 +161,7 @@ exports.addTypeScript = addTypeScript; * @param {string} projectSpec The path to a tsconfig.json file or its containing directory. * @param {string} flattenedProjectSpec The output path for the flattened tsconfig.json file. * @param {FlattenOptions} [options] Options used to flatten a project hierarchy. - * + * * @typedef FlattenOptions * @property {string} [cwd] The path to use for the current working directory. Defaults to `process.cwd()`. * @property {CompilerOptions} [compilerOptions] Compiler option overrides. @@ -167,7 +190,7 @@ function flatten(projectSpec, flattenedProjectSpec, options = {}) { } /** - * @param {ProjectGraph} projectGraph + * @param {ProjectGraph} projectGraph */ function recur(projectGraph) { if (skipProjects.has(projectGraph)) return; @@ -190,14 +213,14 @@ exports.flatten = flatten; * @param {string} typescript An unresolved module specifier to a TypeScript version. * @param {ResolvedPathOptions} paths Paths used to resolve `typescript`. * @returns {ResolvedTypeScript} - * + * * @typedef {string & {_isResolvedTypeScript: never}} ResolvedTypeScriptSpec - * + * * @typedef ResolvedTypeScript * @property {ResolvedTypeScriptSpec} typescript * @property {string} [alias] */ -function resolveTypeScript(typescript, paths) { +function resolveTypeScript(typescript = "default", paths) { let alias; while (typescriptAliasMap.has(typescript)) { ({ typescript, alias, paths } = typescriptAliasMap.get(typescript)); @@ -226,31 +249,34 @@ function getTaskNameSuffix(typescript, paths) { } /** @type {ResolvedPathOptions} */ -const defaultPaths = { cwd: process.cwd(), base: process.cwd() }; +const defaultPaths = (() => { + const cwd = /**@type {AbsolutePath}*/(normalizeSlashes(process.cwd())); + return { cwd, base: cwd }; +})(); /** * @param {PathOptions | undefined} options Path options to resolve and normalize. * @returns {ResolvedPathOptions} - * + * * @typedef PathOptions * @property {string} [cwd] The path to use for the current working directory. Defaults to `process.cwd()`. * @property {string} [base] The path to use as the base for relative paths. Defaults to `cwd`. - * + * * @typedef ResolvedPathOptions - * @property {string} cwd The path to use for the current working directory. Defaults to `process.cwd()`. - * @property {string} base The path to use as the base for relative paths. Defaults to `cwd`. + * @property {AbsolutePath} cwd The path to use for the current working directory. Defaults to `process.cwd()`. + * @property {AbsolutePath} base The path to use as the base for relative paths. Defaults to `cwd`. */ function resolvePathOptions(options) { - const cwd = options && options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); - const base = options && options.base ? path.resolve(cwd, options.base) : cwd; + const cwd = options && options.cwd ? resolvePath(defaultPaths.cwd, options.cwd) : defaultPaths.cwd; + const base = options && options.base ? resolvePath(cwd, options.base) : cwd; return cwd === defaultPaths.cwd && base === defaultPaths.base ? defaultPaths : { cwd, base }; } /** - * @param {ProjectOptions} [options] - * @returns {ResolvedProjectOptions} - * - * @typedef ResolvedProjectOptions + * @param {CompileOptions} [options] + * @returns {ResolvedCompileOptions} + * + * @typedef ResolvedCompileOptions * @property {ResolvedPathOptions} paths * @property {ResolvedTypeScript} typescript A resolved reference to a TypeScript implementation. * @property {Hook} [js] Pipeline hook for .js file outputs. @@ -259,9 +285,9 @@ function resolvePathOptions(options) { * @property {boolean} [force] Force recompilation (no up-to-date check). * @property {boolean} [inProcess] Indicates whether to run gulp-typescript in-process or out-of-process (default). */ -function resolveProjectOptions(options = {}) { +function resolveCompileOptions(options = {}) { const paths = resolvePathOptions(options); - const typescript = resolveTypeScript(options.typescript || "default", paths); + const typescript = resolveTypeScript(options.typescript, paths); return { paths, typescript, @@ -274,13 +300,13 @@ function resolveProjectOptions(options = {}) { } /** - * @param {ResolvedProjectOptions} left - * @param {ResolvedProjectOptions} right - * @returns {ResolvedProjectOptions} + * @param {ResolvedCompileOptions} left + * @param {ResolvedCompileOptions} right + * @returns {ResolvedCompileOptions} */ -function mergeProjectOptions(left, right) { +function mergeCompileOptions(left, right) { if (left.typescript !== right.typescript) throw new Error("Cannot merge project options targeting different TypeScript packages"); - if (tryReuseProjectOptions(left, right)) return left; + if (tryReuseCompileOptions(left, right)) return left; return { paths: left.paths, typescript: left.typescript, @@ -293,10 +319,10 @@ function mergeProjectOptions(left, right) { } /** - * @param {ResolvedProjectOptions} left - * @param {ResolvedProjectOptions} right + * @param {ResolvedCompileOptions} left + * @param {ResolvedCompileOptions} right */ -function tryReuseProjectOptions(left, right) { +function tryReuseCompileOptions(left, right) { return left === right || left.js === (right.js || left.js) && left.dts === (right.dts || left.dts) @@ -309,11 +335,13 @@ function tryReuseProjectOptions(left, right) { * @param {ResolvedProjectSpec} projectSpec * @param {ResolvedPathOptions} paths * @returns {UnqualifiedProjectName} - * + * * @typedef {string & {_isUnqualifiedProjectName:never}} UnqualifiedProjectName */ function getUnqualifiedProjectName(projectSpec, paths) { - return /**@type {UnqualifiedProjectName}*/(normalizeSlashes(path.relative(paths.base, projectSpec))); + let projectName = path.relative(paths.base, projectSpec); + if (path.basename(projectName) === "tsconfig.json") projectName = path.dirname(projectName); + return /**@type {UnqualifiedProjectName}*/(normalizeSlashes(projectName)); } /** @@ -321,16 +349,16 @@ function getUnqualifiedProjectName(projectSpec, paths) { * @param {ResolvedPathOptions} paths * @param {ResolvedTypeScript} typescript * @returns {QualifiedProjectName} - * + * * @typedef {string & {_isQualifiedProjectName:never}} QualifiedProjectName */ function getQualifiedProjectName(projectName, paths, typescript) { return /**@type {QualifiedProjectName}*/(projectName + getTaskNameSuffix(typescript, paths)); } -/** +/** * @typedef {import("../../lib/typescript").ParseConfigFileHost} ParseConfigFileHost - * @type {ParseConfigFileHost} + * @type {ParseConfigFileHost} */ const parseConfigFileHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, @@ -342,11 +370,11 @@ const parseConfigFileHost = { }; /** - * @param {string} [cwd] + * @param {AbsolutePath} [cwd] * @returns {ParseConfigFileHost} */ function getParseConfigFileHost(cwd) { - if (!cwd || cwd === process.cwd()) return parseConfigFileHost; + if (!cwd || cwd === defaultPaths.cwd) return parseConfigFileHost; return { useCaseSensitiveFileNames: parseConfigFileHost.useCaseSensitiveFileNames, fileExists: parseConfigFileHost.fileExists, @@ -363,14 +391,21 @@ function getParseConfigFileHost(cwd) { * @returns {ProjectGraph} * * @typedef ProjectGraph + * @property {ResolvedPathOptions} paths * @property {ResolvedProjectSpec} projectSpec The fully qualified path to the tsconfig.json of the project * @property {UnqualifiedProjectName} projectName The relative project name, excluding any TypeScript suffix. - * @property {string} projectDirectory The fully qualified path to the project directory. + * @property {AbsolutePath} projectDirectory The fully qualified path to the project directory. * @property {ParsedCommandLine} project The parsed tsconfig.json file. * @property {ProjectGraphReference[]} references An array of project references. + * @property {Set} referrers An array of referring projects. + * @property {Set} inputs A set of compilation inputs. + * @property {Set} outputs A set of compilation outputs. * @property {Map} configurations TypeScript-specific configurations for the project. * @property {boolean} cleanTaskCreated A value indicating whether a `clean:` task has been created for this project (not dependent on TypeScript version). - * + * @property {boolean} watcherCreated A value indicating whether a watcher has been created for this project. + * @property {boolean} isRoot The project graph is a root project reference. + * @property {Set} [allWatchers] Tasks to execute when the compilation has completed after being triggered by a watcher. + * * @typedef ProjectGraphReference * @property {ProjectGraph} source The referring project. * @property {ProjectGraph} target The referenced project. @@ -378,15 +413,22 @@ function getParseConfigFileHost(cwd) { function getOrCreateProjectGraph(projectSpec, paths) { let projectGraph = projectGraphCache.get(projectSpec); if (!projectGraph) { - const project = ts.getParsedCommandLineOfConfigFile(projectSpec, {}, getParseConfigFileHost(paths.cwd)); + const project = parseProject(projectSpec, paths); + const projectDirectory = parentDirectory(projectSpec); projectGraph = { + paths, projectSpec, projectName: getUnqualifiedProjectName(projectSpec, paths), - projectDirectory: path.dirname(projectSpec), + projectDirectory, project, references: [], + referrers: new Set(), + inputs: new Set(project.fileNames.map(file => resolvePath(projectDirectory, file))), + outputs: new Set(ts.getAllProjectOutputs(project).map(file => resolvePath(projectDirectory, file))), configurations: new Map(), - cleanTaskCreated: false + cleanTaskCreated: false, + watcherCreated: false, + isRoot: false }; projectGraphCache.set(projectSpec, projectGraph); if (project.projectReferences) { @@ -395,6 +437,7 @@ function getOrCreateProjectGraph(projectSpec, paths) { const referencedProject = getOrCreateProjectGraph(resolvedProjectSpec, paths); const reference = { source: projectGraph, target: referencedProject }; projectGraph.references.push(reference); + referencedProject.referrers.add(projectGraph); } } } @@ -418,13 +461,56 @@ function createParseProject(paths) { /** * @param {ProjectGraph} projectGraph - * @param {ResolvedProjectOptions} resolvedOptions + * @param {ParsedCommandLine} parsedProject + */ +function updateProjectGraph(projectGraph, parsedProject) { + projectGraph.project = parsedProject; + projectGraph.inputs = new Set(projectGraph.project.fileNames.map(file => resolvePath(projectGraph.projectDirectory, file))); + projectGraph.outputs = new Set(ts.getAllProjectOutputs(projectGraph.project).map(file => resolvePath(projectGraph.projectDirectory, file))); + + // Update project references. + const oldReferences = new Set(projectGraph.references.map(ref => ref.target)); + projectGraph.references = []; + if (projectGraph.project.projectReferences) { + for (const projectReference of projectGraph.project.projectReferences) { + const resolvedProjectSpec = resolveProjectSpec(projectReference.path, projectGraph.paths, projectGraph); + const referencedProject = getOrCreateProjectGraph(resolvedProjectSpec, projectGraph.paths); + const reference = { source: projectGraph, target: referencedProject }; + projectGraph.references.push(reference); + referencedProject.referrers.add(projectGraph); + oldReferences.delete(referencedProject); + } + } + + // Remove project references that have been removed from the project + for (const referencedProject of oldReferences) { + referencedProject.referrers.delete(projectGraph); + // If there are no more references to this project and the project was not directly requested, + // remove it from the cache. + if (referencedProject.referrers.size === 0 && !referencedProject.isRoot) { + projectGraphCache.delete(referencedProject.projectSpec); + } + } +} + +/** + * @param {ResolvedProjectSpec} projectSpec + * @param {ResolvedPathOptions} paths + */ +function parseProject(projectSpec, paths) { + return ts.getParsedCommandLineOfConfigFile(projectSpec, {}, getParseConfigFileHost(paths.cwd)); +} + +/** + * @param {ProjectGraph} projectGraph + * @param {ResolvedCompileOptions} resolvedOptions * @returns {ProjectGraphConfiguration} * * @typedef ProjectGraphConfiguration * @property {QualifiedProjectName} projectName - * @property {ResolvedProjectOptions} resolvedOptions - * @property {boolean} compileTaskCreated + * @property {ResolvedCompileOptions} resolvedOptions + * @property {boolean} compileTaskCreated A value indicating whether a `compile:` task has been created for this project. + * @property {Set} [watchers] Tasks to execute when the compilation has completed after being triggered by a watcher. */ function getOrCreateProjectGraphConfiguration(projectGraph, resolvedOptions) { let projectGraphConfig = projectGraph.configurations.get(resolvedOptions.typescript.typescript); @@ -439,18 +525,56 @@ function getOrCreateProjectGraphConfiguration(projectGraph, resolvedOptions) { return projectGraphConfig; } +/** + * Resolves a series of path steps as a normalized, canonical, and absolute path. + * @param {AbsolutePath} basePath + * @param {...string} paths + * @returns {AbsolutePath} + * + * @typedef {string & {_isResolvedPath:never}} AbsolutePath + */ +function resolvePath(basePath, ...paths) { + return /**@type {AbsolutePath}*/(normalizeSlashes(path.resolve(basePath, ...paths))); +} + +/** + * @param {AbsolutePath} from + * @param {AbsolutePath} to + * @returns {Path} + * + * @typedef {string & {_isRelativePath:never}} RelativePath + * @typedef {RelativePath | AbsolutePath} Path + */ +function relativePath(from, to) { + let relativePath = normalizeSlashes(path.relative(from, to)); + if (!relativePath) relativePath = "."; + if (path.isAbsolute(relativePath)) return /**@type {AbsolutePath}*/(relativePath); + if (relativePath.charAt(0) !== ".") relativePath = "./" + relativePath; + return /**@type {RelativePath}*/(relativePath); +} + +/** + * @param {AbsolutePath} file + * @returns {AbsolutePath} + */ +function parentDirectory(file) { + const dirname = path.dirname(file); + if (!dirname || dirname === file) return file; + return /**@type {AbsolutePath}*/(normalizeSlashes(dirname)); +} + /** * @param {string} projectSpec * @param {ResolvedPathOptions} paths * @param {ProjectGraph | undefined} referrer * @returns {ResolvedProjectSpec} - * - * @typedef {string & {_isResolvedProjectSpec: never}} ResolvedProjectSpec + * + * @typedef {AbsolutePath & {_isResolvedProjectSpec: never}} ResolvedProjectSpec */ function resolveProjectSpec(projectSpec, paths, referrer) { - projectSpec = path.resolve(paths.cwd, referrer && referrer.projectDirectory || "", projectSpec); - if (!ts.sys.fileExists(projectSpec)) projectSpec = path.join(projectSpec, "tsconfig.json"); - return /**@type {ResolvedProjectSpec}*/(normalizeSlashes(projectSpec)); + let projectPath = resolvePath(paths.cwd, referrer && referrer.projectDirectory || "", projectSpec); + if (!ts.sys.fileExists(projectPath)) projectPath = resolvePath(paths.cwd, projectPath, "tsconfig.json"); + return /**@type {ResolvedProjectSpec}*/(normalizeSlashes(projectPath)); } /** @@ -458,24 +582,24 @@ function resolveProjectSpec(projectSpec, paths, referrer) { * @param {ResolvedPathOptions} paths */ function resolveDestPath(projectGraph, paths) { - /** @type {string} */ + /** @type {AbsolutePath} */ let destPath = projectGraph.projectDirectory; if (projectGraph.project.options.outDir) { - destPath = path.resolve(paths.cwd, destPath, projectGraph.project.options.outDir); + destPath = resolvePath(paths.cwd, destPath, projectGraph.project.options.outDir); } else if (projectGraph.project.options.outFile || projectGraph.project.options.out) { - destPath = path.dirname(path.resolve(paths.cwd, destPath, projectGraph.project.options.outFile || projectGraph.project.options.out)); + destPath = parentDirectory(resolvePath(paths.cwd, destPath, projectGraph.project.options.outFile || projectGraph.project.options.out)); } - return normalizeSlashes(path.relative(paths.base, destPath)); + return relativePath(paths.base, destPath); } /** * @param {ProjectGraph} projectGraph - * @param {ResolvedProjectOptions} options + * @param {ResolvedCompileOptions} options */ function ensureCompileTask(projectGraph, options) { const projectGraphConfig = getOrCreateProjectGraphConfiguration(projectGraph, options); - projectGraphConfig.resolvedOptions = options = mergeProjectOptions(options, options); + projectGraphConfig.resolvedOptions = options = mergeCompileOptions(options, options); if (!projectGraphConfig.compileTaskCreated) { const deps = makeProjectReferenceCompileTasks(projectGraph, options.typescript, options.paths); compilationGulp.task(compileTaskName(projectGraph, options.typescript), deps, () => { @@ -543,7 +667,300 @@ function makeProjectReferenceCleanTasks(projectGraph) { } /** - * @param {ProjectGraph} projectGraph + * @param {ProjectGraph} projectGraph + * @param {ResolvedCompileOptions} options + * @param {string[]} [tasks] + * @param {(err?: any) => void} [callback] + * + * @typedef Watcher + * @property {string[]} [tasks] + * @property {(err?: any) => void} [callback] + * + * @typedef WatcherRegistration + * @property {() => void} end + */ +function ensureWatcher(projectGraph, options, tasks, callback) { + ensureCompileTask(projectGraph, options); + if (!projectGraph.watcherCreated) { + projectGraph.watcherCreated = true; + makeProjectReferenceWatchers(projectGraph, options.typescript, options.paths); + createWatcher(projectGraph, options, () => { + for (const config of projectGraph.configurations.values()) { + const taskName = compileTaskName(projectGraph, config.resolvedOptions.typescript); + const task = compilationGulp.tasks[taskName]; + if (!task) continue; + possiblyTriggerRecompilation(config, task); + } + }); + } + if ((tasks && tasks.length) || callback) { + const projectGraphConfig = getOrCreateProjectGraphConfiguration(projectGraph, options); + if (!projectGraphConfig.watchers) projectGraphConfig.watchers = new Set(); + if (!projectGraph.allWatchers) projectGraph.allWatchers = new Set(); + + /** @type {Watcher} */ + const watcher = { tasks, callback }; + projectGraphConfig.watchers.add(watcher); + projectGraph.allWatchers.add(watcher); + + /** @type {WatcherRegistration} */ + const registration = { + end() { + projectGraphConfig.watchers.delete(watcher); + projectGraph.allWatchers.delete(watcher); + } + }; + return registration; + } +} + +/** + * @param {ProjectGraphConfiguration} config + * @param {import("orchestrator").Task} task + */ +function possiblyTriggerRecompilation(config, task) { + // if any of the task's dependencies are still running, wait until they are complete. + for (const dep of task.dep) { + if (compilationGulp.tasks[dep].running) { + setTimeout(possiblyTriggerRecompilation, 50, config, task); + return; + } + } + + triggerRecompilation(task, config); +} + +/** + * @param {import("orchestrator").Task} task + * @param {ProjectGraphConfiguration} config + */ +function triggerRecompilation(task, config) { + compilationGulp._resetTask(task); + if (config.watchers && config.watchers.size) { + compilationGulp.fork().start(task.name, () => { + /** @type {Set} */ + const taskNames = new Set(); + /** @type {((err?: any) => void)[]} */ + const callbacks = []; + for (const { tasks, callback } of config.watchers) { + if (tasks) for (const task of tasks) taskNames.add(task); + if (callback) callbacks.push(callback); + } + if (taskNames.size) { + gulp.start([...taskNames], error => { + for (const callback of callbacks) callback(error); + }); + } + else { + for (const callback of callbacks) callback(); + } + }); + } + else { + compilationGulp.fork(/*verbose*/ true).start(task.name); + } +} + +/** + * @param {ProjectGraph} projectGraph + * @param {ResolvedTypeScript} typescript + * @param {ResolvedPathOptions} paths + */ +function makeProjectReferenceWatchers(projectGraph, typescript, paths) { + for (const { target } of projectGraph.references) { + ensureWatcher(target, { paths, typescript }); + } +} + +/** + * @param {ProjectGraph} projectGraph + * @param {ResolvedCompileOptions} options + * @param {() => void} callback + */ +function createWatcher(projectGraph, options, callback) { + let projectRemoved = false; + let patterns = collectWatcherPatterns(projectGraph.projectSpec, projectGraph.project, projectGraph); + let watcher = /**@type {GulpWatcher}*/ (gulp.watch(patterns, { cwd: projectGraph.projectDirectory }, onWatchEvent)); + + /** + * @param {WatchEvent} event + */ + function onWatchEvent(event) { + const file = resolvePath(options.paths.cwd, event.path); + if (file === projectGraph.projectSpec) { + onProjectWatchEvent(event); + } + else { + onInputOrOutputChanged(file); + } + } + + /** + * @param {WatchEvent} event + */ + function onProjectWatchEvent(event) { + if (event.type === "renamed" || event.type === "deleted") { + onProjectRenamedOrDeleted(); + } + else { + onProjectCreatedOrModified(); + } + } + + function onProjectRenamedOrDeleted() { + // stop listening for file changes and wait for the project to be created again + projectRemoved = true; + watcher.end(); + watcher = /**@type {GulpWatcher}*/ (gulp.watch([projectGraph.projectSpec], onWatchEvent)); + } + + function onProjectCreatedOrModified() { + const newParsedProject = parseProject(projectGraph.projectSpec, options.paths); + const newPatterns = collectWatcherPatterns(projectGraph.projectSpec, newParsedProject, projectGraph); + if (projectRemoved || !sameValues(patterns, newPatterns)) { + projectRemoved = false; + watcher.end(); + updateProjectGraph(projectGraph, newParsedProject); + // Ensure we catch up with any added projects + for (const config of projectGraph.configurations.values()) { + if (config.watchers) { + makeProjectReferenceWatchers(projectGraph, config.resolvedOptions.typescript, config.resolvedOptions.paths); + } + } + patterns = newPatterns; + watcher = /**@type {GulpWatcher}*/ (gulp.watch(patterns, onWatchEvent)); + } + onProjectInvalidated(); + } + + function onProjectInvalidated() { + callback(); + } + + /** + * @param {AbsolutePath} file + */ + function onInputOrOutputChanged(file) { + if (projectGraph.inputs.has(file) || + projectGraph.references.some(ref => ref.target.outputs.has(file))) { + onProjectInvalidated(); + } + } +} + +/** + * @param {ResolvedProjectSpec} projectSpec + * @param {ParsedCommandLine} parsedProject + * @param {ProjectGraph} projectGraph + */ +function collectWatcherPatterns(projectSpec, parsedProject, projectGraph) { + const configFileSpecs = parsedProject.configFileSpecs; + + // NOTE: we do not currently handle files from `/// ` tags + const patterns = /**@type {string[]} */([]); + + // Add the project contents. + if (configFileSpecs) { + addIncludeSpecs(patterns, configFileSpecs.validatedIncludeSpecs); + addExcludeSpecs(patterns, configFileSpecs.validatedExcludeSpecs); + addIncludeSpecs(patterns, configFileSpecs.filesSpecs); + } + else { + addWildcardDirectories(patterns, parsedProject.wildcardDirectories); + addIncludeSpecs(patterns, parsedProject.fileNames); + } + + // Add the project itself. + addIncludeSpec(patterns, projectSpec); + + // TODO: Add the project base. + // addExtendsSpec(patterns, project.raw && project.raw.extends); + + // Add project reference outputs. + addProjectReferences(patterns, parsedProject.projectReferences); + + return patterns; + + /** + * @param {string[]} patterns + * @param {string | undefined} includeSpec + */ + function addIncludeSpec(patterns, includeSpec) { + if (!includeSpec) return; + patterns.push(includeSpec); + } + + /** + * @param {string[]} patterns + * @param {ReadonlyArray | undefined} includeSpecs + */ + function addIncludeSpecs(patterns, includeSpecs) { + if (!includeSpecs) return; + for (const includeSpec of includeSpecs) { + addIncludeSpec(patterns, includeSpec); + } + } + + /** + * @param {string[]} patterns + * @param {string | undefined} excludeSpec + */ + function addExcludeSpec(patterns, excludeSpec) { + if (!excludeSpec) return; + patterns.push("!" + excludeSpec); + } + + /** + * @param {string[]} patterns + * @param {ReadonlyArray | undefined} excludeSpecs + */ + function addExcludeSpecs(patterns, excludeSpecs) { + if (!excludeSpecs) return; + for (const excludeSpec of excludeSpecs) { + addExcludeSpec(patterns, excludeSpec); + } + } + + /** + * @param {string[]} patterns + * @param {ts.MapLike | undefined} wildcardDirectories + */ + function addWildcardDirectories(patterns, wildcardDirectories) { + if (!wildcardDirectories) return; + for (const dirname of Object.keys(wildcardDirectories)) { + const flags = wildcardDirectories[dirname]; + patterns.push(path.join(dirname, flags & ts.WatchDirectoryFlags.Recursive ? "**" : "", "*")); + } + } + + // TODO: Add the project base + // /** + // * @param {string[]} patterns + // * @param {string | undefined} base + // */ + // function addExtendsSpec(patterns, base) { + // if (!base) return; + // addIncludeSpec(patterns, base); + // } + + /** + * @param {string[]} patterns + * @param {ReadonlyArray} projectReferences + */ + function addProjectReferences(patterns, projectReferences) { + if (!projectReferences) return; + for (const projectReference of projectReferences) { + const resolvedProjectSpec = resolveProjectSpec(projectReference.path, projectGraph.paths, projectGraph); + const referencedProject = getOrCreateProjectGraph(resolvedProjectSpec, projectGraph.paths); + for (const output of referencedProject.outputs) { + patterns.push(output); + } + } + } +} + +/** + * @param {ProjectGraph} projectGraph * @param {ResolvedTypeScript} typescript */ function compileTaskName(projectGraph, typescript) { @@ -551,7 +968,7 @@ function compileTaskName(projectGraph, typescript) { } /** - * @param {ProjectGraph} projectGraph + * @param {ProjectGraph} projectGraph */ function cleanTaskName(projectGraph) { return `clean:${projectGraph.projectName}`; @@ -573,7 +990,32 @@ function isPath(moduleSpec) { } /** - * @typedef {import("../../lib/typescript").ParsedCommandLine & { options: CompilerOptions }} ParsedCommandLine + * @template T + * @param {ReadonlyArray} left + * @param {ReadonlyArray} right + */ +function sameValues(left, right) { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false; + } + return true; +} + +/** + * @typedef {import("../../lib/typescript").ParsedCommandLine & { options: CompilerOptions, configFileSpecs?: ConfigFileSpecs }} ParsedCommandLine * @typedef {import("../../lib/typescript").CompilerOptions & { configFilePath?: string }} CompilerOptions + * @typedef {import("../../lib/typescript").ProjectReference} ProjectReference + * @typedef {import("gulp").WatchEvent} WatchEvent + * @typedef {import("gulp").WatchCallback} WatchCallback + * @typedef {NodeJS.EventEmitter & { end(): void, add(files: string | string[], done?: () => void): void, remove(file: string): void }} GulpWatcher + * + * @typedef ConfigFileSpecs + * @property {ReadonlyArray | undefined} filesSpecs + * @property {ReadonlyArray | undefined} referenceSpecs + * @property {ReadonlyArray | undefined} validatedIncludeSpecs + * @property {ReadonlyArray | undefined} validatedExcludeSpecs + * @property {ts.MapLike} wildcardDirectories */ void 0; \ No newline at end of file