diff --git a/Jakefile.js b/Jakefile.js index e425a8767c4..58779a3d8dc 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -138,6 +138,7 @@ var harnessSources = harnessCoreSources.concat([ "telemetry.ts", "transform.ts", "customTransforms.ts", + "programMissingFiles.ts", ].map(function (f) { return path.join(unittestsDirectory, f); })).concat([ diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 48e16ab16b4..2eae414db66 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1098,6 +1098,15 @@ namespace ts { return result; } + /** + * Creates a set from the elements of an array. + * + * @param array the array of input elements. + */ + export function arrayToSet(array: T[], makeKey: (value: T) => string): Map { + return arrayToMap(array, makeKey, () => true); + } + export function cloneMap(map: Map) { const clone = createMap(); copyEntries(map, clone); diff --git a/src/compiler/program.ts b/src/compiler/program.ts index 1d66eeaf34a..7b62fde8570 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -472,7 +472,7 @@ namespace ts { resolveTypeReferenceDirectiveNamesWorker = (typeReferenceDirectiveNames, containingFile) => loadWithLocalCache(typeReferenceDirectiveNames, containingFile, loader); } - const filesByName = createMap(); + const filesByName = createMap(); // stores 'filename -> file association' ignoring case // used to track cases when two file names differ only in casing const filesByNameIgnoreCase = host.useCaseSensitiveFileNames() ? createFileMap(fileName => fileName.toLowerCase()) : undefined; @@ -513,6 +513,8 @@ namespace ts { } } + const missingFilePaths = arrayFrom(filesByName.keys(), p => p).filter(p => !filesByName.get(p)); + // unconditionally set moduleResolutionCache to undefined to avoid unnecessary leaks moduleResolutionCache = undefined; @@ -524,6 +526,7 @@ namespace ts { getSourceFile, getSourceFileByPath, getSourceFiles: () => files, + getMissingFilePaths: () => missingFilePaths, getCompilerOptions: () => options, getSyntacticDiagnostics, getOptionsDiagnostics, @@ -862,6 +865,21 @@ namespace ts { return oldProgram.structureIsReused; } + // If a file has ceased to be missing, then we need to discard some of the old + // structure in order to pick it up. + // Caution: if the file has created and then deleted between since it was discovered to + // be missing, then the corresponding file watcher will have been closed and no new one + // will be created until we encounter a change that prevents complete structure reuse. + // During this interval, creation of the file will go unnoticed. We expect this to be + // both rare and low-impact. + if (oldProgram.getMissingFilePaths().some(missingFilePath => host.fileExists(missingFilePath))) { + return oldProgram.structureIsReused = StructureIsReused.SafeModules; + } + + for (const p of oldProgram.getMissingFilePaths()) { + filesByName.set(p, undefined); + } + // update fileName -> file mapping for (let i = 0; i < newSourceFiles.length; i++) { filesByName.set(filePaths[i], newSourceFiles[i]); diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 18ddba9c917..ddad013b6f0 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -4,7 +4,13 @@ declare function setTimeout(handler: (...args: any[]) => void, timeout: number): declare function clearTimeout(handle: any): void; namespace ts { - export type FileWatcherCallback = (fileName: string, removed?: boolean) => void; + export enum FileWatcherEventKind { + Created, + Changed, + Deleted + } + + export type FileWatcherCallback = (fileName: string, eventKind: FileWatcherEventKind) => void; export type DirectoryWatcherCallback = (fileName: string) => void; export interface WatchedFile { fileName: string; @@ -174,7 +180,7 @@ namespace ts { const callbacks = fileWatcherCallbacks.get(fileName); if (callbacks) { for (const fileCallback of callbacks) { - fileCallback(fileName); + fileCallback(fileName, FileWatcherEventKind.Changed); } } } @@ -340,11 +346,22 @@ namespace ts { } function fileChanged(curr: any, prev: any) { - if (+curr.mtime <= +prev.mtime) { + const isCurrZero = +curr.mtime === 0; + const isPrevZero = +prev.mtime === 0; + const created = !isCurrZero && isPrevZero; + const deleted = isCurrZero && !isPrevZero; + + const eventKind = created + ? FileWatcherEventKind.Created + : deleted + ? FileWatcherEventKind.Deleted + : FileWatcherEventKind.Changed; + + if (eventKind === FileWatcherEventKind.Changed && +curr.mtime <= +prev.mtime) { return; } - callback(fileName); + callback(fileName, eventKind); } }, watchDirectory: (directoryName, callback, recursive) => { diff --git a/src/compiler/tsc.ts b/src/compiler/tsc.ts index 3b2422bfe08..6e0aefd99a0 100644 --- a/src/compiler/tsc.ts +++ b/src/compiler/tsc.ts @@ -285,6 +285,16 @@ namespace ts { setCachedProgram(compileResult.program); reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.Compilation_complete_Watching_for_file_changes)); + + const missingPaths = compileResult.program.getMissingFilePaths(); + missingPaths.forEach(path => { + const fileWatcher = sys.watchFile(path, (_fileName, eventKind) => { + if (eventKind === FileWatcherEventKind.Created) { + fileWatcher.close(); + startTimerForRecompilation(); + } + }); + }); } function cachedFileExists(fileName: string): boolean { @@ -308,7 +318,7 @@ namespace ts { const sourceFile = hostGetSourceFile(fileName, languageVersion, onError); if (sourceFile && isWatchSet(compilerOptions) && sys.watchFile) { // Attach a file watcher - sourceFile.fileWatcher = sys.watchFile(sourceFile.fileName, (_fileName: string, removed?: boolean) => sourceFileChanged(sourceFile, removed)); + sourceFile.fileWatcher = sys.watchFile(sourceFile.fileName, (_fileName, eventKind) => sourceFileChanged(sourceFile, eventKind)); } return sourceFile; } @@ -330,10 +340,10 @@ namespace ts { } // If a source file changes, mark it as unwatched and start the recompilation timer - function sourceFileChanged(sourceFile: SourceFile, removed?: boolean) { + function sourceFileChanged(sourceFile: SourceFile, eventKind: FileWatcherEventKind) { sourceFile.fileWatcher.close(); sourceFile.fileWatcher = undefined; - if (removed) { + if (eventKind === FileWatcherEventKind.Deleted) { unorderedRemoveItem(rootFileNames, sourceFile.fileName); } startTimerForRecompilation(); diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 4b79c4e99ed..e2e14c9fd6b 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -2426,6 +2426,13 @@ namespace ts { */ getSourceFiles(): SourceFile[]; + /** + * Get a list of file names that were passed to 'createProgram' or referenced in a + * program source file but could not be located. + */ + /* @internal */ + getMissingFilePaths(): Path[]; + /** * Emits the JavaScript and declaration files. If targetSourceFile is not specified, then * the JavaScript and declaration files will be produced for all the files in this program. diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index eee6473f77f..8d52791f539 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -129,6 +129,7 @@ "./unittests/transform.ts", "./unittests/customTransforms.ts", "./unittests/textChanges.ts", - "./unittests/telemetry.ts" + "./unittests/telemetry.ts", + "./unittests/programMissingFiles.ts" ] } diff --git a/src/harness/unittests/cachingInServerLSHost.ts b/src/harness/unittests/cachingInServerLSHost.ts index 46d9aa462ce..eb2907e89de 100644 --- a/src/harness/unittests/cachingInServerLSHost.ts +++ b/src/harness/unittests/cachingInServerLSHost.ts @@ -75,7 +75,7 @@ namespace ts { const rootScriptInfo = projectService.getOrCreateScriptInfo(rootFile, /* openedByClient */ true, /*containingProject*/ undefined); const project = projectService.createInferredProjectWithRootFileIfNecessary(rootScriptInfo); - project.setCompilerOptions({ module: ts.ModuleKind.AMD } ); + project.setCompilerOptions({ module: ts.ModuleKind.AMD, noLib: true } ); return { project, rootScriptInfo diff --git a/src/harness/unittests/compileOnSave.ts b/src/harness/unittests/compileOnSave.ts index 7e262a1b257..79e1f921dcb 100644 --- a/src/harness/unittests/compileOnSave.ts +++ b/src/harness/unittests/compileOnSave.ts @@ -208,7 +208,7 @@ namespace ts.projectSystem { file1Consumer1.content = `let y = 10;`; host.reloadFS([moduleFile1, file1Consumer1, file1Consumer2, configFile, libFile]); - host.triggerFileWatcherCallback(file1Consumer1.path, /*removed*/ false); + host.triggerFileWatcherCallback(file1Consumer1.path, FileWatcherEventKind.Changed); session.executeCommand(changeModuleFile1ShapeRequest1); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, [{ projectFileName: configFile.path, files: [moduleFile1, file1Consumer2] }]); @@ -225,7 +225,7 @@ namespace ts.projectSystem { session.executeCommand(changeModuleFile1ShapeRequest1); // Delete file1Consumer2 host.reloadFS([moduleFile1, file1Consumer1, configFile, libFile]); - host.triggerFileWatcherCallback(file1Consumer2.path, /*removed*/ true); + host.triggerFileWatcherCallback(file1Consumer2.path, FileWatcherEventKind.Deleted); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, [{ projectFileName: configFile.path, files: [moduleFile1, file1Consumer1] }]); }); @@ -475,7 +475,7 @@ namespace ts.projectSystem { openFilesForSession([referenceFile1], session); host.reloadFS([referenceFile1, configFile]); - host.triggerFileWatcherCallback(moduleFile1.path, /*removed*/ true); + host.triggerFileWatcherCallback(moduleFile1.path, FileWatcherEventKind.Deleted); const request = makeSessionRequest(CommandNames.CompileOnSaveAffectedFileList, { file: referenceFile1.path }); sendAffectedFileRequestAndCheckResult(session, request, [ diff --git a/src/harness/unittests/programMissingFiles.ts b/src/harness/unittests/programMissingFiles.ts new file mode 100644 index 00000000000..668b684a250 --- /dev/null +++ b/src/harness/unittests/programMissingFiles.ts @@ -0,0 +1,103 @@ +/// + +namespace ts { + describe("Program.getMissingFilePaths", () => { + + const options: CompilerOptions = { + noLib: true, + }; + + const emptyFileName = "empty.ts"; + const emptyFileRelativePath = "./" + emptyFileName; + + const emptyFile: Harness.Compiler.TestFile = { + unitName: emptyFileName, + content: "" + }; + + const referenceFileName = "reference.ts"; + const referenceFileRelativePath = "./" + referenceFileName; + + const referenceFile: Harness.Compiler.TestFile = { + unitName: referenceFileName, + content: + "/// \n" + // Absolute + "/// \n" + // Relative + "/// \n" + // Unqualified + "/// \n" // No extension + }; + + const testCompilerHost = Harness.Compiler.createCompilerHost( + /*inputFiles*/ [emptyFile, referenceFile], + /*writeFile*/ undefined, + /*scriptTarget*/ undefined, + /*useCaseSensitiveFileNames*/ false, + /*currentDirectory*/ "d:\\pretend\\", + /*newLineKind*/ NewLineKind.LineFeed, + /*libFiles*/ undefined + ); + + it("handles no missing root files", () => { + const program = createProgram([emptyFileRelativePath], options, testCompilerHost); + const missing = program.getMissingFilePaths(); + assert.isDefined(missing); + assert.deepEqual(missing, []); + }); + + it("handles missing root file", () => { + const program = createProgram(["./nonexistent.ts"], options, testCompilerHost); + const missing = program.getMissingFilePaths(); + assert.isDefined(missing); + assert.deepEqual(missing, ["d:/pretend/nonexistent.ts"]); // Absolute path + }); + + it("handles multiple missing root files", () => { + const program = createProgram(["./nonexistent0.ts", "./nonexistent1.ts"], options, testCompilerHost); + const missing = program.getMissingFilePaths().sort(); + assert.deepEqual(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); + }); + + it("handles a mix of present and missing root files", () => { + const program = createProgram(["./nonexistent0.ts", emptyFileRelativePath, "./nonexistent1.ts"], options, testCompilerHost); + const missing = program.getMissingFilePaths().sort(); + assert.deepEqual(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); + }); + + it("handles repeatedly specified root files", () => { + const program = createProgram(["./nonexistent.ts", "./nonexistent.ts"], options, testCompilerHost); + const missing = program.getMissingFilePaths(); + assert.isDefined(missing); + assert.deepEqual(missing, ["d:/pretend/nonexistent.ts"]); + }); + + it("normalizes file paths", () => { + const program0 = createProgram(["./nonexistent.ts", "./NONEXISTENT.ts"], options, testCompilerHost); + const program1 = createProgram(["./NONEXISTENT.ts", "./nonexistent.ts"], options, testCompilerHost); + const missing0 = program0.getMissingFilePaths(); + const missing1 = program1.getMissingFilePaths(); + assert.equal(missing0.length, 1); + assert.deepEqual(missing0, missing1); + }); + + it("handles missing triple slash references", () => { + const program = createProgram([referenceFileRelativePath], options, testCompilerHost); + const missing = program.getMissingFilePaths().sort(); + assert.isDefined(missing); + assert.deepEqual(missing, [ + // From absolute reference + "d:/imaginary/nonexistent1.ts", + + // From relative reference + "d:/pretend/nonexistent2.ts", + + // From unqualified reference + "d:/pretend/nonexistent3.ts", + + // From no-extension reference + "d:/pretend/nonexistent4.d.ts", + "d:/pretend/nonexistent4.ts", + "d:/pretend/nonexistent4.tsx" + ]); + }); + }); +} \ No newline at end of file diff --git a/src/harness/unittests/projectErrors.ts b/src/harness/unittests/projectErrors.ts index 30942fb2fdc..f250d4d9b36 100644 --- a/src/harness/unittests/projectErrors.ts +++ b/src/harness/unittests/projectErrors.ts @@ -135,7 +135,7 @@ namespace ts.projectSystem { } // fix config and trigger watcher host.reloadFS([file1, file2, correctConfig]); - host.triggerFileWatcherCallback(correctConfig.path, /*false*/); + host.triggerFileWatcherCallback(correctConfig.path, FileWatcherEventKind.Changed); { projectService.checkNumberOfProjects({ configuredProjects: 1 }); const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); @@ -177,7 +177,7 @@ namespace ts.projectSystem { } // break config and trigger watcher host.reloadFS([file1, file2, corruptedConfig]); - host.triggerFileWatcherCallback(corruptedConfig.path, /*false*/); + host.triggerFileWatcherCallback(corruptedConfig.path, FileWatcherEventKind.Changed); { projectService.checkNumberOfProjects({ configuredProjects: 1 }); const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); diff --git a/src/harness/unittests/reuseProgramStructure.ts b/src/harness/unittests/reuseProgramStructure.ts index 2eb3d1f0890..de5f5a756d8 100644 --- a/src/harness/unittests/reuseProgramStructure.ts +++ b/src/harness/unittests/reuseProgramStructure.ts @@ -316,6 +316,31 @@ namespace ts { assert.isTrue(program_1.structureIsReused === StructureIsReused.Not); }); + it("succeeds if missing files remain missing", () => { + const options: CompilerOptions = { target, noLib: true }; + + const program_1 = newProgram(files, ["a.ts"], options); + assert.notDeepEqual(emptyArray, program_1.getMissingFilePaths()); + + const program_2 = updateProgram(program_1, ["a.ts"], options, noop); + assert.deepEqual(program_1.getMissingFilePaths(), program_2.getMissingFilePaths()); + + assert.equal(StructureIsReused.Completely, program_1.structureIsReused); + }); + + it("fails if missing file is created", () => { + const options: CompilerOptions = { target, noLib: true }; + + const program_1 = newProgram(files, ["a.ts"], options); + assert.notDeepEqual(emptyArray, program_1.getMissingFilePaths()); + + const newTexts: NamedSourceText[] = files.concat([{ name: "non-existing-file.ts", text: SourceText.New("", "", `var x = 1`) }]); + const program_2 = updateProgram(program_1, ["a.ts"], options, noop, newTexts); + assert.deepEqual(emptyArray, program_2.getMissingFilePaths()); + + assert.equal(StructureIsReused.SafeModules, program_1.structureIsReused); + }); + it("resolution cache follows imports", () => { (Error).stackTraceLimit = Infinity; diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index c0a0d21f854..92903b66872 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -484,12 +484,12 @@ namespace ts.projectSystem { } } - triggerFileWatcherCallback(fileName: string, removed?: boolean): void { + triggerFileWatcherCallback(fileName: string, eventKind: FileWatcherEventKind): void { const path = this.toPath(fileName); const callbacks = this.watchedFiles.get(path); if (callbacks) { for (const callback of callbacks) { - callback(path, removed); + callback(path, eventKind); } } } @@ -770,7 +770,7 @@ namespace ts.projectSystem { // remove the tsconfig file host.reloadFS(filesWithoutConfig); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfInferredProjects(projectService, 2); checkNumberOfConfiguredProjects(projectService, 0); @@ -927,7 +927,7 @@ namespace ts.projectSystem { "files": ["${commonFile1.path}"] }`; host.reloadFS(files); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfConfiguredProjects(projectService, 1); checkProjectRootFiles(project, [commonFile1.path]); @@ -1001,7 +1001,7 @@ namespace ts.projectSystem { "files": ["${file1.path}"] }`; host.reloadFS(files); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkProjectActualFiles(project, [file1.path, classicModuleFile.path, configFile.path]); checkNumberOfInferredProjects(projectService, 1); }); @@ -1432,7 +1432,7 @@ namespace ts.projectSystem { }; host.reloadFS([file1, modifiedFile2, file3]); - host.triggerFileWatcherCallback(modifiedFile2.path, /*removed*/ false); + host.triggerFileWatcherCallback(modifiedFile2.path, FileWatcherEventKind.Changed); checkNumberOfInferredProjects(projectService, 1); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, modifiedFile2.path, file3.path]); @@ -1464,7 +1464,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { inferredProjects: 1 }); host.reloadFS([file1, file3]); - host.triggerFileWatcherCallback(file2.path, /*removed*/ true); + host.triggerFileWatcherCallback(file2.path, FileWatcherEventKind.Deleted); checkNumberOfProjects(projectService, { inferredProjects: 2 }); @@ -1662,7 +1662,7 @@ namespace ts.projectSystem { }; host.reloadFS([file1, file2, modifiedConfigFile]); - host.triggerFileWatcherCallback(configFile.path, /*removed*/ false); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfProjects(projectService, { configuredProjects: 1 }); checkProjectRootFiles(projectService.configuredProjects[0], [file1.path, file2.path]); @@ -1695,7 +1695,7 @@ namespace ts.projectSystem { }; host.reloadFS([file1, file2, modifiedConfigFile]); - host.triggerFileWatcherCallback(configFile.path, /*removed*/ false); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfProjects(projectService, { configuredProjects: 1 }); checkProjectRootFiles(projectService.configuredProjects[0], [file1.path, file2.path]); @@ -1775,7 +1775,7 @@ namespace ts.projectSystem { checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, config.path]); host.reloadFS([file1, file2]); - host.triggerFileWatcherCallback(config.path, /*removed*/ true); + host.triggerFileWatcherCallback(config.path, FileWatcherEventKind.Deleted); checkNumberOfProjects(projectService, { inferredProjects: 2 }); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path]); @@ -2027,7 +2027,7 @@ namespace ts.projectSystem { projectService.openExternalProject({ projectFileName, options: {}, rootFiles: [{ fileName: file1.path, scriptKind: ScriptKind.JS, hasMixedContent: true }] }); checkNumberOfProjects(projectService, { externalProjects: 1 }); - checkWatchedFiles(host, []); + checkWatchedFiles(host, [libFile.path]); // watching the "missing" lib file const project = projectService.externalProjects[0]; @@ -2257,7 +2257,7 @@ namespace ts.projectSystem { assert.isFalse(lastEvent.data.languageServiceEnabled, "Language service state"); host.reloadFS([f1, f2, configWithExclude]); - host.triggerFileWatcherCallback(config.path, /*removed*/ false); + host.triggerFileWatcherCallback(config.path, FileWatcherEventKind.Changed); checkNumberOfProjects(projectService, { configuredProjects: 1 }); assert.isTrue(project.languageServiceEnabled, "Language service enabled"); @@ -2491,7 +2491,7 @@ namespace ts.projectSystem { // rename tsconfig.json back to lib.ts host.reloadFS([f1, f2]); - host.triggerFileWatcherCallback(tsconfig.path, /*removed*/ true); + host.triggerFileWatcherCallback(tsconfig.path, FileWatcherEventKind.Deleted); projectService.openExternalProject({ projectFileName: projectName, rootFiles: toExternalFiles([f1.path, f2.path]), @@ -2636,7 +2636,7 @@ namespace ts.projectSystem { checkProjectActualFiles(projectService.configuredProjects[0], [libES5.path, app.path, config1.path]); host.reloadFS([libES5, libES2015Promise, app, config2]); - host.triggerFileWatcherCallback(config1.path); + host.triggerFileWatcherCallback(config1.path, FileWatcherEventKind.Changed); projectService.checkNumberOfProjects({ configuredProjects: 1 }); checkProjectActualFiles(projectService.configuredProjects[0], [libES5.path, libES2015Promise.path, app.path, config2.path]); @@ -2859,7 +2859,7 @@ namespace ts.projectSystem { const moduleFileNewPath = "/a/b/moduleFile1.ts"; moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1]); - host.triggerFileWatcherCallback(moduleFileOldPath); + host.triggerFileWatcherCallback(moduleFileOldPath, FileWatcherEventKind.Changed); host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); diags = session.executeCommand(getErrRequest).response; @@ -2867,7 +2867,7 @@ namespace ts.projectSystem { moduleFile.path = moduleFileOldPath; host.reloadFS([moduleFile, file1]); - host.triggerFileWatcherCallback(moduleFileNewPath); + host.triggerFileWatcherCallback(moduleFileNewPath, FileWatcherEventKind.Changed); host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); @@ -2911,7 +2911,7 @@ namespace ts.projectSystem { const moduleFileNewPath = "/a/b/moduleFile1.ts"; moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1, configFile]); - host.triggerFileWatcherCallback(moduleFileOldPath); + host.triggerFileWatcherCallback(moduleFileOldPath, FileWatcherEventKind.Changed); host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); diags = session.executeCommand(getErrRequest).response; @@ -2919,7 +2919,7 @@ namespace ts.projectSystem { moduleFile.path = moduleFileOldPath; host.reloadFS([moduleFile, file1, configFile]); - host.triggerFileWatcherCallback(moduleFileNewPath); + host.triggerFileWatcherCallback(moduleFileNewPath, FileWatcherEventKind.Changed); host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); diags = session.executeCommand(getErrRequest).response; @@ -3084,7 +3084,7 @@ namespace ts.projectSystem { } }`; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); host.runQueuedTimeoutCallbacks(); serverEventManager.checkEventCountOfType("configFileDiag", 2); @@ -3092,7 +3092,7 @@ namespace ts.projectSystem { "compilerOptions": {} }`; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); host.runQueuedTimeoutCallbacks(); serverEventManager.checkEventCountOfType("configFileDiag", 3); }); @@ -4020,7 +4020,7 @@ namespace ts.projectSystem { configFile.content = configFileContentWithoutCommentLine; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path); + host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); const diagsAfterEdit = session.executeCommand({ type: "request", diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index 7479c532c08..c23e1529984 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -737,7 +737,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); const p = projectService.configuredProjects[0]; checkProjectActualFiles(p, [app.path, jsconfig.path]); - checkWatchedFiles(host, [jsconfig.path, "/bower_components", "/node_modules"]); + checkWatchedFiles(host, [jsconfig.path, "/bower_components", "/node_modules", libFile.path]); installer.installAll(/*expectedCount*/ 1); @@ -826,7 +826,7 @@ namespace ts.projectSystem { installer.checkPendingCommands(/*expectedCount*/ 0); host.reloadFS([f, fixedPackageJson]); - host.triggerFileWatcherCallback(fixedPackageJson.path, /*removed*/ false); + host.triggerFileWatcherCallback(fixedPackageJson.path, FileWatcherEventKind.Changed); // expected install request installer.installAll(/*expectedCount*/ 1); diff --git a/src/server/lsHost.ts b/src/server/lsHost.ts index fa972a0c930..55513f1a285 100644 --- a/src/server/lsHost.ts +++ b/src/server/lsHost.ts @@ -210,8 +210,11 @@ namespace ts.server { return this.host.resolvePath(path); } - fileExists(path: string): boolean { - return this.host.fileExists(path); + fileExists(file: string): boolean { + // As an optimization, don't hit the disks for files we already know don't exist + // (because we're watching for their creation). + const path = toPath(file, this.host.getCurrentDirectory(), this.getCanonicalFileName); + return !this.project.isWatchedMissingFile(path) && this.host.fileExists(file); } readFile(fileName: string): string { diff --git a/src/server/project.ts b/src/server/project.ts index d4544af5e59..25573216b1c 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -107,6 +107,7 @@ namespace ts.server { private rootFilesMap: Map = createMap(); private program: ts.Program; private externalFiles: SortedReadonlyArray; + private missingFilesMap: Map = createMap(); private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap(); private lastCachedUnresolvedImportsList: SortedReadonlyArray; @@ -310,6 +311,10 @@ namespace ts.server { this.lsHost.dispose(); this.lsHost = undefined; + // Clean up file watchers waiting for missing files + this.missingFilesMap.forEach(fileWatcher => fileWatcher.close()); + this.missingFilesMap = undefined; + // signal language service to release source files acquired from document registry this.languageService.dispose(); this.languageService = undefined; @@ -586,12 +591,12 @@ namespace ts.server { const oldProgram = this.program; this.program = this.languageService.getProgram(); - let hasChanges = false; // bump up the version if // - oldProgram is not set - this is a first time updateGraph is called // - newProgram is different from the old program and structure of the old program was not reused. - if (!oldProgram || (this.program !== oldProgram && !(oldProgram.structureIsReused & StructureIsReused.Completely))) { - hasChanges = true; + const hasChanges = !oldProgram || (this.program !== oldProgram && !(oldProgram.structureIsReused & StructureIsReused.Completely)); + + if (hasChanges) { if (oldProgram) { for (const f of oldProgram.getSourceFiles()) { if (this.program.getSourceFileByPath(f.path)) { @@ -604,6 +609,35 @@ namespace ts.server { } } } + + const missingFilePaths = this.program.getMissingFilePaths(); + const missingFilePathsSet = arrayToSet(missingFilePaths, p => p); + + // Files that are no longer missing (e.g. because they are no longer required) + // should no longer be watched. + this.missingFilesMap.forEach((fileWatcher, missingFilePath) => { + if (!missingFilePathsSet.has(missingFilePath)) { + this.missingFilesMap.delete(missingFilePath); + fileWatcher.close(); + } + }); + + // Missing files that are not yet watched should be added to the map. + for (const missingFilePath of missingFilePaths) { + if (!this.missingFilesMap.has(missingFilePath)) { + const fileWatcher = this.projectService.host.watchFile(missingFilePath, (_filename: string, eventKind: FileWatcherEventKind) => { + if (eventKind === FileWatcherEventKind.Created && this.missingFilesMap.has(missingFilePath)) { + fileWatcher.close(); + this.missingFilesMap.delete(missingFilePath); + + // When a missing file is created, we should update the graph. + this.markAsDirty(); + this.updateGraph(); + } + }); + this.missingFilesMap.set(missingFilePath, fileWatcher); + } + } } const oldExternalFiles = this.externalFiles || emptyArray as SortedReadonlyArray; @@ -626,6 +660,10 @@ namespace ts.server { return hasChanges; } + isWatchedMissingFile(path: Path) { + return this.missingFilesMap.has(path); + } + getScriptInfoLSHost(fileName: string) { const scriptInfo = this.projectService.getOrCreateScriptInfo(fileName, /*openedByClient*/ false); if (scriptInfo) { diff --git a/src/server/server.ts b/src/server/server.ts index a7063fb1ad3..c339a4fe249 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -523,11 +523,14 @@ namespace ts.server { fs.stat(watchedFile.fileName, (err: any, stats: any) => { if (err) { - watchedFile.callback(watchedFile.fileName); + watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Changed); } else if (watchedFile.mtime.getTime() !== stats.mtime.getTime()) { watchedFile.mtime = stats.mtime; - watchedFile.callback(watchedFile.fileName, watchedFile.mtime.getTime() === 0); + const eventKind = watchedFile.mtime.getTime() === 0 + ? FileWatcherEventKind.Deleted + : FileWatcherEventKind.Changed; + watchedFile.callback(watchedFile.fileName, eventKind); } }); } @@ -559,7 +562,9 @@ namespace ts.server { const file: WatchedFile = { fileName, callback, - mtime: getModifiedTime(fileName) + mtime: sys.fileExists(fileName) + ? getModifiedTime(fileName) + : new Date(0) // Any subsequent modification will occur after this time }; watchedFiles.push(file); diff --git a/src/services/documentHighlights.ts b/src/services/documentHighlights.ts index 7ea8c519646..fc84b60cc1f 100644 --- a/src/services/documentHighlights.ts +++ b/src/services/documentHighlights.ts @@ -2,7 +2,10 @@ namespace ts.DocumentHighlights { export function getDocumentHighlights(program: Program, cancellationToken: CancellationToken, sourceFile: SourceFile, position: number, sourceFilesToSearch: SourceFile[]): DocumentHighlights[] | undefined { const node = getTouchingWord(sourceFile, position, /*includeJsDocComment*/ true); - if (!node) return undefined; + // Note that getTouchingWord indicates failure by returning the sourceFile node. + if (node === sourceFile) return undefined; + + Debug.assert(node.parent !== undefined); if (isJsxOpeningElement(node.parent) && node.parent.tagName === node || isJsxClosingElement(node.parent)) { // For a JSX element, just highlight the matching tag, not all references.