diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index c5bc372e179cd..92041c885b2a6 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -1110,7 +1110,14 @@ namespace ts.server { // don't trigger callback on open, existing files if (project.fileIsOpen(fileOrDirectoryPath)) { if (project.pendingReload !== ConfigFileProgramReloadLevel.Full) { - project.openFileWatchTriggered.set(fileOrDirectoryPath, true); + const info = Debug.assertDefined(this.getScriptInfoForPath(fileOrDirectoryPath)); + if (info.isAttached(project)) { + project.openFileWatchTriggered.set(fileOrDirectoryPath, true); + } + else { + project.pendingReload = ConfigFileProgramReloadLevel.Partial; + this.delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(project); + } } return; } diff --git a/src/server/session.ts b/src/server/session.ts index b256dfe139cef..2733ca5141214 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -719,10 +719,8 @@ namespace ts.server { this.projectService.logger.info(`got projects updated in background, updating diagnostics for ${openFiles}`); if (openFiles.length) { if (!this.suppressDiagnosticEvents && !this.noGetErrOnBackgroundUpdate) { - const checkList = this.createCheckList(openFiles); - // For now only queue error checking for open files. We can change this to include non open files as well - this.errorCheck.startNew(next => this.updateErrorCheck(next, checkList, 100, /*requireOpen*/ true)); + this.errorCheck.startNew(next => this.updateErrorCheck(next, openFiles, 100, /*requireOpen*/ true)); } // Send project changed event @@ -870,20 +868,37 @@ namespace ts.server { } /** It is the caller's responsibility to verify that `!this.suppressDiagnosticEvents`. */ - private updateErrorCheck(next: NextStep, checkList: PendingErrorCheck[], ms: number, requireOpen = true) { + private updateErrorCheck(next: NextStep, checkList: readonly string[] | readonly PendingErrorCheck[], ms: number, requireOpen = true) { Debug.assert(!this.suppressDiagnosticEvents); // Caller's responsibility const seq = this.changeSeq; const followMs = Math.min(ms, 200); let index = 0; + const goNext = () => { + index++; + if (checkList.length > index) { + next.delay(followMs, checkOne); + } + }; const checkOne = () => { if (this.changeSeq !== seq) { return; } - const { fileName, project } = checkList[index]; - index++; + let item: string | PendingErrorCheck | undefined = checkList[index]; + if (isString(item)) { + // Find out project for the file name + item = this.toPendingErrorCheck(item); + if (!item) { + // Ignore file if there is no project for the file + goNext(); + return; + } + } + + const { fileName, project } = item; + // Ensure the project is upto date before checking if this file is present in the project updateProjectIfDirty(project); if (!project.containsFile(fileName, requireOpen)) { @@ -901,11 +916,6 @@ namespace ts.server { return; } - const goNext = () => { - if (checkList.length > index) { - next.delay(followMs, checkOne); - } - }; if (this.getPreferences(fileName).disableSuggestions) { goNext(); } @@ -1727,12 +1737,10 @@ namespace ts.server { } } - private createCheckList(fileNames: string[]): PendingErrorCheck[] { - return mapDefined(fileNames, uncheckedFileName => { - const fileName = toNormalizedPath(uncheckedFileName); - const project = this.projectService.tryGetDefaultProjectForFile(fileName); - return project && { fileName, project }; - }); + private toPendingErrorCheck(uncheckedFileName: string): PendingErrorCheck | undefined { + const fileName = toNormalizedPath(uncheckedFileName); + const project = this.projectService.tryGetDefaultProjectForFile(fileName); + return project && { fileName, project }; } private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { @@ -1740,9 +1748,8 @@ namespace ts.server { return; } - const checkList = this.createCheckList(fileNames); - if (checkList.length > 0) { - this.updateErrorCheck(next, checkList, delay); + if (fileNames.length > 0) { + this.updateErrorCheck(next, fileNames, delay); } } diff --git a/src/testRunner/unittests/tsserver/cancellationToken.ts b/src/testRunner/unittests/tsserver/cancellationToken.ts index 71fb4bb4b228d..4e5d8ab00f983 100644 --- a/src/testRunner/unittests/tsserver/cancellationToken.ts +++ b/src/testRunner/unittests/tsserver/cancellationToken.ts @@ -84,7 +84,10 @@ namespace ts.projectSystem { command: "geterr", arguments: { files: ["/a/missing"] } }); - // no files - expect 'completed' event + // Queued files + assert.equal(host.getOutput().length, 0, "expected 0 message"); + host.checkTimeoutQueueLengthAndRun(1); + // Completed event since file is missing assert.equal(host.getOutput().length, 1, "expect 1 message"); verifyRequestCompleted(session.getSeq(), 0); } diff --git a/src/testRunner/unittests/tsserver/configuredProjects.ts b/src/testRunner/unittests/tsserver/configuredProjects.ts index 7bc09a955c4ff..06bd87fa0dd20 100644 --- a/src/testRunner/unittests/tsserver/configuredProjects.ts +++ b/src/testRunner/unittests/tsserver/configuredProjects.ts @@ -923,6 +923,146 @@ declare var console: { const service = createProjectService(host); service.openClientFile(file.path); }); + + describe("when creating new file", () => { + const foo: File = { + path: `${tscWatch.projectRoot}/src/foo.ts`, + content: "export function foo() { }" + }; + const bar: File = { + path: `${tscWatch.projectRoot}/src/bar.ts`, + content: "export function bar() { }" + }; + const config: File = { + path: `${tscWatch.projectRoot}/tsconfig.json`, + content: JSON.stringify({ + include: ["./src"] + }) + }; + const fooBar: File = { + path: `${tscWatch.projectRoot}/src/sub/fooBar.ts`, + content: "export function fooBar() { }" + }; + function verifySessionWorker({ withExclude, openFileBeforeCreating, checkProjectBeforeError, checkProjectAfterError, }: VerifySession, errorOnNewFileBeforeOldFile: boolean) { + const host = createServerHost([ + foo, bar, libFile, { path: `${tscWatch.projectRoot}/src/sub` }, + withExclude ? + { + path: config.path, + content: JSON.stringify({ + include: ["./src"], + exclude: ["./src/sub"] + }) + } : + config + ]); + const session = createSession(host, { + canUseEvents: true + }); + session.executeCommandSeq({ + command: protocol.CommandTypes.Open, + arguments: { + file: foo.path, + fileContent: foo.content, + projectRootPath: tscWatch.projectRoot + } + }); + if (!openFileBeforeCreating) { + host.writeFile(fooBar.path, fooBar.content); + } + session.executeCommandSeq({ + command: protocol.CommandTypes.Open, + arguments: { + file: fooBar.path, + fileContent: fooBar.content, + projectRootPath: tscWatch.projectRoot + } + }); + if (openFileBeforeCreating) { + host.writeFile(fooBar.path, fooBar.content); + } + const service = session.getProjectService(); + checkProjectBeforeError(service); + verifyGetErrRequest({ + session, + host, + expected: errorOnNewFileBeforeOldFile ? + [ + { file: fooBar, syntax: [], semantic: [], suggestion: [] }, + { file: foo, syntax: [], semantic: [], suggestion: [] }, + ] : + [ + { file: foo, syntax: [], semantic: [], suggestion: [] }, + { file: fooBar, syntax: [], semantic: [], suggestion: [] }, + ], + existingTimeouts: 2 + }); + checkProjectAfterError(service); + } + interface VerifySession { + withExclude?: boolean; + openFileBeforeCreating: boolean; + checkProjectBeforeError: (service: server.ProjectService) => void; + checkProjectAfterError: (service: server.ProjectService) => void; + } + function verifySession(input: VerifySession) { + it("when error on new file are asked before old one", () => { + verifySessionWorker(input, /*errorOnNewFileBeforeOldFile*/ true); + }); + + it("when error on new file are asked after old one", () => { + verifySessionWorker(input, /*errorOnNewFileBeforeOldFile*/ false); + }); + } + function checkFooBarInInferredProject(service: server.ProjectService) { + checkNumberOfProjects(service, { configuredProjects: 1, inferredProjects: 1 }); + checkProjectActualFiles(service.configuredProjects.get(config.path)!, [foo.path, bar.path, libFile.path, config.path]); + checkProjectActualFiles(service.inferredProjects[0], [fooBar.path, libFile.path]); + } + function checkFooBarInConfiguredProject(service: server.ProjectService) { + checkNumberOfProjects(service, { configuredProjects: 1 }); + checkProjectActualFiles(service.configuredProjects.get(config.path)!, [foo.path, bar.path, fooBar.path, libFile.path, config.path]); + } + describe("when new file creation directory watcher is invoked before file is opened in editor", () => { + verifySession({ + openFileBeforeCreating: false, + checkProjectBeforeError: checkFooBarInConfiguredProject, + checkProjectAfterError: checkFooBarInConfiguredProject + }); + describe("when new file is excluded from config", () => { + verifySession({ + withExclude: true, + openFileBeforeCreating: false, + checkProjectBeforeError: checkFooBarInInferredProject, + checkProjectAfterError: checkFooBarInInferredProject + }); + }); + }); + + describe("when new file creation directory watcher is invoked after file is opened in editor", () => { + verifySession({ + openFileBeforeCreating: true, + checkProjectBeforeError: checkFooBarInInferredProject, + checkProjectAfterError: service => { + // Both projects exist but fooBar is in configured project after the update + // Inferred project is yet to be updated so still has fooBar + checkNumberOfProjects(service, { configuredProjects: 1, inferredProjects: 1 }); + checkProjectActualFiles(service.configuredProjects.get(config.path)!, [foo.path, bar.path, fooBar.path, libFile.path, config.path]); + checkProjectActualFiles(service.inferredProjects[0], [fooBar.path, libFile.path]); + assert.isTrue(service.inferredProjects[0].dirty); + assert.equal(service.inferredProjects[0].getRootFilesMap().size, 0); + } + }); + describe("when new file is excluded from config", () => { + verifySession({ + withExclude: true, + openFileBeforeCreating: true, + checkProjectBeforeError: checkFooBarInInferredProject, + checkProjectAfterError: checkFooBarInInferredProject + }); + }); + }); + }); }); describe("unittests:: tsserver:: ConfiguredProjects:: non-existing directories listed in config file input array", () => { diff --git a/src/testRunner/unittests/tsserver/projectErrors.ts b/src/testRunner/unittests/tsserver/projectErrors.ts index 1c71628acff36..37ae521302ff9 100644 --- a/src/testRunner/unittests/tsserver/projectErrors.ts +++ b/src/testRunner/unittests/tsserver/projectErrors.ts @@ -382,7 +382,7 @@ namespace ts.projectSystem { } }); - host.runQueuedImmediateCallbacks(); + host.checkTimeoutQueueLengthAndRun(1); assert.isFalse(hasError()); checkCompleteEvent(session, 1, expectedSequenceId); session.clearMessages(); diff --git a/src/testRunner/unittests/tsserver/projects.ts b/src/testRunner/unittests/tsserver/projects.ts index 7c4c5ef522cc0..9fb3da6490ee5 100644 --- a/src/testRunner/unittests/tsserver/projects.ts +++ b/src/testRunner/unittests/tsserver/projects.ts @@ -1508,7 +1508,7 @@ var x = 10;` host, expected: [ { file: fileB, syntax: [], semantic: [], suggestion: [] }, - { file: fileSubA }, + { file: fileSubA, syntax: [], semantic: [], suggestion: [] }, ], existingTimeouts: 2, onErrEvent: () => assert.isFalse(hasErrorMsg()) diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 1855fae39b672..240b237e024ce 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -9482,7 +9482,7 @@ declare namespace ts.server { private getCompileOnSaveAffectedFileList; private emitFile; private getSignatureHelpItems; - private createCheckList; + private toPendingErrorCheck; private getDiagnostics; private change; private reload;