diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index ed51028392e..10939b68114 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -1143,11 +1143,22 @@ namespace ts.server { return project; } + private assignOrphanScriptInfosToInferredProject() { + // collect orphaned files and assign them to inferred project just like we treat open of a file + this.openFiles.forEach((projectRootPath, path) => { + const info = this.getScriptInfoForPath(path as Path)!; + // collect all orphaned script infos from open files + if (info.isOrphan()) { + this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); + } + }); + } + /** * Remove this file from the set of open, non-configured files. * @param info The file that has been closed or newly configured */ - private closeOpenFile(info: ScriptInfo): void { + private closeOpenFile(info: ScriptInfo, skipAssignOrphanScriptInfosToInferredProject?: true) { // Closing file should trigger re-reading the file content from disk. This is // because the user may chose to discard the buffer content before saving // to the disk, and the server's version of the file can be out of sync. @@ -1191,15 +1202,8 @@ namespace ts.server { this.openFiles.delete(info.path); - if (ensureProjectsForOpenFiles) { - // collect orphaned files and assign them to inferred project just like we treat open of a file - this.openFiles.forEach((projectRootPath, path) => { - const info = this.getScriptInfoForPath(path as Path)!; - // collect all orphaned script infos from open files - if (info.isOrphan()) { - this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); - } - }); + if (!skipAssignOrphanScriptInfosToInferredProject && ensureProjectsForOpenFiles) { + this.assignOrphanScriptInfosToInferredProject(); } // Cleanup script infos that arent part of any project (eg. those could be closed script infos not referenced by any project) @@ -1214,6 +1218,8 @@ namespace ts.server { else { this.handleDeletedFile(info); } + + return ensureProjectsForOpenFiles; } private deleteScriptInfo(info: ScriptInfo) { @@ -2585,20 +2591,22 @@ namespace ts.server { }); } - openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult { + private getOrCreateOpenScriptInfo(fileName: NormalizedPath, fileContent: string | undefined, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, projectRootPath: NormalizedPath | undefined) { + const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory, fileContent, scriptKind, hasMixedContent)!; // TODO: GH#18217 + this.openFiles.set(info.path, projectRootPath); + return info; + } + + private assignProjectToOpenedScriptInfo(info: ScriptInfo): OpenConfiguredProjectResult { let configFileName: NormalizedPath | undefined; let configFileErrors: ReadonlyArray | undefined; - - const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory, fileContent, scriptKind, hasMixedContent)!; // TODO: GH#18217 - - this.openFiles.set(info.path, projectRootPath); let project: ConfiguredProject | ExternalProject | undefined = this.findExternalProjectContainingOpenScriptInfo(info); if (!project && !this.syntaxOnly) { // Checking syntaxOnly is an optimization configFileName = this.getConfigFileNameForFile(info); if (configFileName) { project = this.findConfiguredProjectByProjectName(configFileName); if (!project) { - project = this.createLoadAndUpdateConfiguredProject(configFileName, `Creating possible configured project for ${fileName} to open`); + project = this.createLoadAndUpdateConfiguredProject(configFileName, `Creating possible configured project for ${info.fileName} to open`); // Send the event only if the project got created as part of this open request and info is part of the project if (info.isOrphan()) { // Since the file isnt part of configured project, do not send config file info @@ -2606,7 +2614,7 @@ namespace ts.server { } else { configFileErrors = project.getAllProjectErrors(); - this.sendConfigFileDiagEvent(project, fileName); + this.sendConfigFileDiagEvent(project, info.fileName); } } else { @@ -2628,10 +2636,14 @@ namespace ts.server { // At this point if file is part of any any configured or external project, then it would be present in the containing projects // So if it still doesnt have any containing projects, it needs to be part of inferred project if (info.isOrphan()) { - this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); + Debug.assert(this.openFiles.has(info.path)); + this.assignOrphanScriptInfoToInferredProject(info, this.openFiles.get(info.path)); } Debug.assert(!info.isOrphan()); + return { configFileName, configFileErrors }; + } + private cleanupAfterOpeningFile() { // This was postponed from closeOpenFile to after opening next file, // so that we can reuse the project if we need to right away this.removeOrphanConfiguredProjects(); @@ -2651,9 +2663,14 @@ namespace ts.server { this.removeOrphanScriptInfos(); this.printProjects(); + } + openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult { + const info = this.getOrCreateOpenScriptInfo(fileName, fileContent, scriptKind, hasMixedContent, projectRootPath); + const result = this.assignProjectToOpenedScriptInfo(info); + this.cleanupAfterOpeningFile(); this.telemetryOnOpenFile(info); - return { configFileName, configFileErrors }; + return result; } private removeOrphanConfiguredProjects() { @@ -2760,12 +2777,16 @@ namespace ts.server { * Close file whose contents is managed by the client * @param filename is absolute pathname */ - closeClientFile(uncheckedFileName: string) { + closeClientFile(uncheckedFileName: string): void; + /*@internal*/ + closeClientFile(uncheckedFileName: string, skipAssignOrphanScriptInfosToInferredProject: true): boolean; + closeClientFile(uncheckedFileName: string, skipAssignOrphanScriptInfosToInferredProject?: true) { const info = this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName)); - if (info) { - this.closeOpenFile(info); + const result = info ? this.closeOpenFile(info, skipAssignOrphanScriptInfosToInferredProject) : false; + if (!skipAssignOrphanScriptInfosToInferredProject) { + this.printProjects(); } - this.printProjects(); + return result; } private collectChanges(lastKnownProjectVersions: protocol.ProjectVersionInfo[], currentProjects: Project[], result: ProjectFilesWithTSDiagnostics[]): void { @@ -2786,14 +2807,23 @@ namespace ts.server { /* @internal */ applyChangesInOpenFiles(openFiles: Iterator | undefined, changedFiles?: Iterator, closedFiles?: string[]): void { + let openScriptInfos: ScriptInfo[] | undefined; + let assignOrphanScriptInfosToInferredProject = false; if (openFiles) { while (true) { const { value: file, done } = openFiles.next(); if (done) break; const scriptInfo = this.getScriptInfo(file.fileName); Debug.assert(!scriptInfo || !scriptInfo.isScriptOpen(), "Script should not exist and not be open already"); - const normalizedPath = scriptInfo ? scriptInfo.fileName : toNormalizedPath(file.fileName); - this.openClientFileWithNormalizedPath(normalizedPath, file.content, tryConvertScriptKindName(file.scriptKind!), file.hasMixedContent, file.projectRootPath ? toNormalizedPath(file.projectRootPath) : undefined); // TODO: GH#18217 + // Create script infos so we have the new content for all the open files before we do any updates to projects + const info = this.getOrCreateOpenScriptInfo( + scriptInfo ? scriptInfo.fileName : toNormalizedPath(file.fileName), + file.content, + tryConvertScriptKindName(file.scriptKind!), + file.hasMixedContent, + file.projectRootPath ? toNormalizedPath(file.projectRootPath) : undefined + ); + (openScriptInfos || (openScriptInfos = [])).push(info); } } @@ -2803,15 +2833,34 @@ namespace ts.server { if (done) break; const scriptInfo = this.getScriptInfo(file.fileName)!; Debug.assert(!!scriptInfo); + // Make edits to script infos and marks containing project as dirty this.applyChangesToFile(scriptInfo, file.changes); } } if (closedFiles) { for (const file of closedFiles) { - this.closeClientFile(file); + // Close files, but dont assign projects to orphan open script infos, that part comes later + assignOrphanScriptInfosToInferredProject = this.closeClientFile(file, /*skipAssignOrphanScriptInfosToInferredProject*/ true) || assignOrphanScriptInfosToInferredProject; } } + + // All the script infos now exist, so ok to go update projects for open files + if (openScriptInfos) { + openScriptInfos.forEach(info => this.assignProjectToOpenedScriptInfo(info)); + } + + // While closing files there could be open files that needed assigning new inferred projects, do it now + if (assignOrphanScriptInfosToInferredProject) { + this.assignOrphanScriptInfosToInferredProject(); + } + + // Cleanup projects + this.cleanupAfterOpeningFile(); + + // Telemetry + forEach(openScriptInfos, info => this.telemetryOnOpenFile(info)); + this.printProjects(); } /* @internal */ diff --git a/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts b/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts index e5dd2e58754..3c0da365bb5 100644 --- a/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts +++ b/src/testRunner/unittests/tsserver/applyChangesToOpenFiles.ts @@ -59,7 +59,7 @@ ${file.content}`; applyChangesToOpen(session); // Verify again - verifyProjectVersion(project, 5); + verifyProjectVersion(project, 3); // Open file contents verifyText(service, commonFile1.path, fileContentWithComment(commonFile1)); verifyText(service, commonFile2.path, fileContentWithComment(commonFile2)); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index eb522125ddd..f3fdd5158b4 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8652,6 +8652,7 @@ declare namespace ts.server { */ private onConfigFileChangeForOpenScriptInfo; private removeProject; + private assignOrphanScriptInfosToInferredProject; /** * Remove this file from the set of open, non-configured files. * @param info The file that has been closed or newly configured @@ -8770,6 +8771,9 @@ declare namespace ts.server { */ openClientFile(fileName: string, fileContent?: string, scriptKind?: ScriptKind, projectRootPath?: string): OpenConfiguredProjectResult; private findExternalProjectContainingOpenScriptInfo; + private getOrCreateOpenScriptInfo; + private assignProjectToOpenedScriptInfo; + private cleanupAfterOpeningFile; openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult; private removeOrphanConfiguredProjects; private removeOrphanScriptInfos;