diff --git a/src/compiler/core.ts b/src/compiler/core.ts index f0220fb8c93a5..a65298f72fee7 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1762,6 +1762,10 @@ namespace ts { return compareComparableValues(a, b); } + export function getStringComparer(ignoreCase?: boolean) { + return ignoreCase ? compareStringsCaseInsensitive : compareStringsCaseSensitive; + } + /** * Creates a string comparer for use with string collation in the UI. */ @@ -2274,7 +2278,7 @@ namespace ts { const aComponents = getNormalizedPathComponents(a, currentDirectory); const bComponents = getNormalizedPathComponents(b, currentDirectory); const sharedLength = Math.min(aComponents.length, bComponents.length); - const comparer = ignoreCase ? compareStringsCaseInsensitive : compareStringsCaseSensitive; + const comparer = getStringComparer(ignoreCase); for (let i = 0; i < sharedLength; i++) { const result = comparer(aComponents[i], bComponents[i]); if (result !== Comparison.EqualTo) { @@ -2615,7 +2619,7 @@ namespace ts { } // Sort the offsets array using either the literal or canonical path representations. - includeBasePaths.sort(useCaseSensitiveFileNames ? compareStringsCaseSensitive : compareStringsCaseInsensitive); + includeBasePaths.sort(getStringComparer(!useCaseSensitiveFileNames)); // Iterate over each include base path and include unique base paths that are not a // subpath of an existing base path diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index be95e06eb46cb..688400fc6d5c2 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -698,7 +698,7 @@ namespace ts { createWatchDirectoryUsing(dynamicPollingWatchFile || createDynamicPriorityPollingWatchFile({ getModifiedTime, setTimeout })) : watchDirectoryUsingFsWatch; const watchDirectoryRecursively = createRecursiveDirectoryWatcher({ - filePathComparer: useCaseSensitiveFileNames ? compareStringsCaseSensitive : compareStringsCaseInsensitive, + filePathComparer: getStringComparer(!useCaseSensitiveFileNames), directoryExists, getAccessibleSortedChildDirectories: path => getAccessibleFileSystemEntries(path).directories, watchDirectory, diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 1c0dd0beb5ce0..bfa999a3790d5 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -678,7 +678,10 @@ namespace ts.projectSystem { projectService.openClientFile(commonFile1.path); projectService.openClientFile(commonFile2.path); - checkNumberOfInferredProjects(projectService, 2); + projectService.checkNumberOfProjects({ inferredProjects: 2 }); + checkProjectActualFiles(projectService.inferredProjects[0], [commonFile1.path, libFile.path]); + checkProjectActualFiles(projectService.inferredProjects[1], [commonFile2.path, libFile.path]); + const configFileLocations = ["/", "/a/", "/a/b/"]; const watchedFiles = flatMap(configFileLocations, location => [location + "tsconfig.json", location + "jsconfig.json"]).concat(libFile.path); checkWatchedFiles(host, watchedFiles); @@ -686,18 +689,26 @@ namespace ts.projectSystem { // Add a tsconfig file host.reloadFS(filesWithConfig); host.checkTimeoutQueueLengthAndRun(1); - checkNumberOfInferredProjects(projectService, 1); - checkNumberOfConfiguredProjects(projectService, 1); + + projectService.checkNumberOfProjects({ inferredProjects: 2, configuredProjects: 1 }); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + checkProjectActualFiles(projectService.inferredProjects[1], [commonFile2.path, libFile.path]); + checkProjectActualFiles(projectService.configuredProjects.get(configFile.path), [libFile.path, commonFile1.path, configFile.path]); + checkWatchedFiles(host, watchedFiles); // remove the tsconfig file host.reloadFS(filesWithoutConfig); - checkNumberOfInferredProjects(projectService, 1); + projectService.checkNumberOfProjects({ inferredProjects: 2 }); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + checkProjectActualFiles(projectService.inferredProjects[1], [commonFile2.path, libFile.path]); + host.checkTimeoutQueueLengthAndRun(1); // Refresh inferred projects - checkNumberOfInferredProjects(projectService, 2); - checkNumberOfConfiguredProjects(projectService, 0); + projectService.checkNumberOfProjects({ inferredProjects: 2 }); + checkProjectActualFiles(projectService.inferredProjects[0], [commonFile1.path, libFile.path]); + checkProjectActualFiles(projectService.inferredProjects[1], [commonFile2.path, libFile.path]); checkWatchedFiles(host, watchedFiles); }); @@ -934,6 +945,10 @@ namespace ts.projectSystem { path: "/a/module1.ts", content: `export interface T {}` }; + const randomFile: FileOrFolder = { + path: "/a/file1.ts", + content: `export interface T {}` + }; const configFile: FileOrFolder = { path: "/a/b/tsconfig.json", content: `{ @@ -943,17 +958,18 @@ namespace ts.projectSystem { "files": ["${file1.path}"] }` }; - const files = [file1, nodeModuleFile, classicModuleFile, configFile]; + const files = [file1, nodeModuleFile, classicModuleFile, configFile, randomFile]; const host = createServerHost(files); const projectService = createProjectService(host); projectService.openClientFile(file1.path); projectService.openClientFile(nodeModuleFile.path); projectService.openClientFile(classicModuleFile.path); - checkNumberOfConfiguredProjects(projectService, 1); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 1 }); const project = configuredProjectAt(projectService, 0); + const inferredProject0 = projectService.inferredProjects[0]; checkProjectActualFiles(project, [file1.path, nodeModuleFile.path, configFile.path]); - checkNumberOfInferredProjects(projectService, 1); + checkProjectActualFiles(projectService.inferredProjects[0], [classicModuleFile.path]); configFile.content = `{ "compilerOptions": { @@ -963,8 +979,22 @@ namespace ts.projectSystem { }`; host.reloadFS(files); host.checkTimeoutQueueLengthAndRun(2); + + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 2 }); // will not remove project 1 checkProjectActualFiles(project, [file1.path, classicModuleFile.path, configFile.path]); - checkNumberOfInferredProjects(projectService, 1); + assert.strictEqual(projectService.inferredProjects[0], inferredProject0); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + const inferredProject1 = projectService.inferredProjects[1]; + checkProjectActualFiles(projectService.inferredProjects[1], [nodeModuleFile.path]); + + // Open random file and it will reuse first inferred project + projectService.openClientFile(randomFile.path); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 2 }); + checkProjectActualFiles(project, [file1.path, classicModuleFile.path, configFile.path]); + assert.strictEqual(projectService.inferredProjects[0], inferredProject0); + checkProjectActualFiles(projectService.inferredProjects[0], [randomFile.path]); // Reuses first inferred project + assert.strictEqual(projectService.inferredProjects[1], inferredProject1); + checkProjectActualFiles(projectService.inferredProjects[1], [nodeModuleFile.path]); }); it("should keep the configured project when the opened file is referenced by the project but not its root", () => { @@ -1304,20 +1334,21 @@ namespace ts.projectSystem { assert.isUndefined(projectService.configuredProjects.get(config2.path)); projectService.closeClientFile(file3.path); - checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 1 }); assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); assert.isUndefined(projectService.configuredProjects.get(config2.path)); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); projectService.closeClientFile(file1.path); - checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 1 }); assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); assert.isUndefined(projectService.configuredProjects.get(config2.path)); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); projectService.openClientFile(file2.path, file2.content); checkNumberOfProjects(projectService, { configuredProjects: 1 }); assert.isUndefined(projectService.configuredProjects.get(config1.path)); assert.isDefined(projectService.configuredProjects.get(config2.path)); - }); describe("ignoreConfigFiles", () => { @@ -1579,12 +1610,15 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(file1.path); - - checkNumberOfInferredProjects(projectService, 1); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + const inferredProject0 = projectService.inferredProjects[0]; checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, file2.path]); projectService.openClientFile(file3.path); - checkNumberOfInferredProjects(projectService, 2); + checkNumberOfProjects(projectService, { inferredProjects: 2 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProject0); + checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, file2.path]); + const inferredProject1 = projectService.inferredProjects[1]; checkProjectActualFiles(projectService.inferredProjects[1], [file3.path]); const modifiedFile2 = { @@ -1594,8 +1628,11 @@ namespace ts.projectSystem { host.reloadFS([file1, modifiedFile2, file3]); host.checkTimeoutQueueLengthAndRun(2); - checkNumberOfInferredProjects(projectService, 1); + checkNumberOfProjects(projectService, { inferredProjects: 2 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProject0); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, modifiedFile2.path, file3.path]); + assert.strictEqual(projectService.inferredProjects[1], inferredProject1); + assert.isTrue(inferredProject1.isOrphan()); }); it("deleted files affect project structure", () => { @@ -1767,8 +1804,10 @@ namespace ts.projectSystem { host.reloadFS([file1, file2, file3, configFile]); host.checkTimeoutQueueLengthAndRun(1); - checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 2 }); checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, file3.path, configFile.path]); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + assert.isTrue(projectService.inferredProjects[1].isOrphan()); }); it("correctly migrate files between projects", () => { @@ -1792,19 +1831,46 @@ namespace ts.projectSystem { projectService.openClientFile(file2.path); checkNumberOfProjects(projectService, { inferredProjects: 1 }); checkProjectActualFiles(projectService.inferredProjects[0], [file2.path]); + let inferredProjects = projectService.inferredProjects.slice(); projectService.openClientFile(file3.path); checkNumberOfProjects(projectService, { inferredProjects: 2 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProjects[0]); checkProjectActualFiles(projectService.inferredProjects[0], [file2.path]); checkProjectActualFiles(projectService.inferredProjects[1], [file3.path]); + inferredProjects = projectService.inferredProjects.slice(); projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { inferredProjects: 1 }); + assert.notStrictEqual(projectService.inferredProjects[0], inferredProjects[0]); + assert.notStrictEqual(projectService.inferredProjects[0], inferredProjects[1]); checkProjectRootFiles(projectService.inferredProjects[0], [file1.path]); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, file2.path, file3.path]); + inferredProjects = projectService.inferredProjects.slice(); projectService.closeClientFile(file1.path); + checkNumberOfProjects(projectService, { inferredProjects: 3 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProjects[0]); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + checkProjectActualFiles(projectService.inferredProjects[1], [file2.path]); + checkProjectActualFiles(projectService.inferredProjects[2], [file3.path]); + inferredProjects = projectService.inferredProjects.slice(); + + projectService.closeClientFile(file3.path); + checkNumberOfProjects(projectService, { inferredProjects: 3 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProjects[0]); + assert.strictEqual(projectService.inferredProjects[1], inferredProjects[1]); + assert.strictEqual(projectService.inferredProjects[2], inferredProjects[2]); + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + checkProjectActualFiles(projectService.inferredProjects[1], [file2.path]); + assert.isTrue(projectService.inferredProjects[2].isOrphan()); + + projectService.openClientFile(file3.path); checkNumberOfProjects(projectService, { inferredProjects: 2 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProjects[2]); + assert.strictEqual(projectService.inferredProjects[1], inferredProjects[1]); + checkProjectActualFiles(projectService.inferredProjects[0], [file3.path]); + checkProjectActualFiles(projectService.inferredProjects[1], [file2.path]); }); it("can correctly update configured project when set of root files has changed (new file on disk)", () => { @@ -2286,10 +2352,17 @@ namespace ts.projectSystem { projectService.openClientFile(modFile.path); checkNumberOfProjects(projectService, { inferredProjects: 2 }); + const inferredProjects = projectService.inferredProjects.slice(); + checkProjectActualFiles(inferredProjects[0], [file1.path]); + checkProjectActualFiles(inferredProjects[1], [modFile.path]); projectService.setCompilerOptionsForInferredProjects({ moduleResolution: ModuleResolutionKind.Classic }); host.checkTimeoutQueueLengthAndRun(3); - checkNumberOfProjects(projectService, { inferredProjects: 1 }); + checkNumberOfProjects(projectService, { inferredProjects: 2 }); + assert.strictEqual(projectService.inferredProjects[0], inferredProjects[0]); + assert.strictEqual(projectService.inferredProjects[1], inferredProjects[1]); + checkProjectActualFiles(inferredProjects[0], [file1.path, modFile.path]); + assert.isTrue(inferredProjects[1].isOrphan()); }); it("syntax tree cache handles changes in project settings", () => { @@ -2423,9 +2496,9 @@ namespace ts.projectSystem { verifyScriptInfos(); checkOpenFiles(projectService, files); - verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true); // file1, file2, file3 - checkNumberOfInferredProjects(projectService, 1); - const inferredProject3 = projectService.inferredProjects[0]; + verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true, 2); // file1, file2, file3 + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + const inferredProject3 = projectService.inferredProjects[1]; checkProjectActualFiles(inferredProject3, [file4.path]); assert.strictEqual(inferredProject3, inferredProject2); @@ -2435,22 +2508,21 @@ namespace ts.projectSystem { verifyScriptInfos(); checkOpenFiles(projectService, [file3]); - verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true); // file3 - checkNumberOfInferredProjects(projectService, 0); + verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true, 2); // file3 + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + assert.isTrue(projectService.inferredProjects[1].isOrphan()); projectService.openClientFile(file4.path); verifyScriptInfos(); checkOpenFiles(projectService, [file3, file4]); - verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true); // file3 - checkNumberOfInferredProjects(projectService, 1); + verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ true, 1); // file3 const inferredProject4 = projectService.inferredProjects[0]; checkProjectActualFiles(inferredProject4, [file4.path]); projectService.closeClientFile(file3.path); verifyScriptInfos(); checkOpenFiles(projectService, [file4]); - verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ false); // No open files - checkNumberOfInferredProjects(projectService, 1); + verifyConfiguredProjectStateAfterUpdate(/*hasOpenRef*/ false, 1); // No open files const inferredProject5 = projectService.inferredProjects[0]; checkProjectActualFiles(inferredProject4, [file4.path]); assert.strictEqual(inferredProject5, inferredProject4); @@ -2465,7 +2537,9 @@ namespace ts.projectSystem { assert.strictEqual(projectService.getScriptInfoForPath(file4.path as Path), find(infos, info => info.path === file4.path)); assert.isDefined(projectService.getScriptInfoForPath(file5.path as Path)); checkOpenFiles(projectService, [file4, file5]); - checkNumberOfConfiguredProjects(projectService, 0); + checkNumberOfProjects(projectService, { inferredProjects: 2 }); + checkProjectActualFiles(projectService.inferredProjects[0], [file4.path]); + checkProjectActualFiles(projectService.inferredProjects[1], [file5.path]); function verifyScriptInfos() { infos.forEach(info => assert.strictEqual(projectService.getScriptInfoForPath(info.path), info)); @@ -2477,8 +2551,8 @@ namespace ts.projectSystem { } } - function verifyConfiguredProjectStateAfterUpdate(hasOpenRef: boolean) { - checkNumberOfConfiguredProjects(projectService, 1); + function verifyConfiguredProjectStateAfterUpdate(hasOpenRef: boolean, inferredProjects: number) { + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects }); const configProject2 = projectService.configuredProjects.get(configFile.path); assert.strictEqual(configProject2, configProject1); checkProjectActualFiles(configProject2, [file1.path, file2.path, file3.path, configFile.path]); @@ -2543,11 +2617,13 @@ namespace ts.projectSystem { checkProjectActualFiles(inferredProject2, [file4.path]); host.runQueuedTimeoutCallbacks(); - checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 1 }); + checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 2 }); assert.strictEqual(projectService.configuredProjects.get(configFile.path), configuredProject); assert.isTrue(configuredProject.hasOpenRef()); // file2 checkProjectActualFiles(configuredProject, [file1.path, file2.path, file3.path, configFile.path]); - assert.strictEqual(projectService.inferredProjects[0], inferredProject2); + assert.strictEqual(projectService.inferredProjects[0], inferredProject1); + assert.isTrue(inferredProject1.isOrphan()); + assert.strictEqual(projectService.inferredProjects[1], inferredProject2); checkProjectActualFiles(inferredProject2, [file4.path]); }); @@ -4903,7 +4979,8 @@ namespace ts.projectSystem { command: server.protocol.CommandTypes.Close, arguments: { file: f1.path } }); - checkScriptInfoAndProjects(0, f1.content, "contents of closed file"); + checkScriptInfoAndProjects(f1.content, "contents of closed file"); + checkInferredProjectIsOrphan(); // Can reload contents of the file when its not open and has no project // reload from temp file @@ -4911,21 +4988,23 @@ namespace ts.projectSystem { command: server.protocol.CommandTypes.Reload, arguments: { file: f1.path, tmpfile: tmp.path } }); - checkScriptInfoAndProjects(0, tmp.content, "contents of temp file"); + checkScriptInfoAndProjects(tmp.content, "contents of temp file"); + checkInferredProjectIsOrphan(); // reload from own file session.executeCommandSeq({ command: server.protocol.CommandTypes.Reload, arguments: { file: f1.path } }); - checkScriptInfoAndProjects(0, f1.content, "contents of closed file"); + checkScriptInfoAndProjects(f1.content, "contents of closed file"); + checkInferredProjectIsOrphan(); // Open file again without setting its content session.executeCommandSeq({ command: server.protocol.CommandTypes.Open, arguments: { file: f1.path } }); - checkScriptInfoAndProjects(1, f1.content, "contents of file when opened without specifying contents"); + checkScriptInfoAndProjects(f1.content, "contents of file when opened without specifying contents"); const snap = info.getSnapshot(); // send close request @@ -4933,27 +5012,35 @@ namespace ts.projectSystem { command: server.protocol.CommandTypes.Close, arguments: { file: f1.path } }); - checkScriptInfoAndProjects(0, f1.content, "contents of closed file"); + checkScriptInfoAndProjects(f1.content, "contents of closed file"); assert.strictEqual(info.getSnapshot(), snap); + checkInferredProjectIsOrphan(); // reload from temp file session.executeCommandSeq({ command: server.protocol.CommandTypes.Reload, arguments: { file: f1.path, tmpfile: tmp.path } }); - checkScriptInfoAndProjects(0, tmp.content, "contents of temp file"); + checkScriptInfoAndProjects(tmp.content, "contents of temp file"); assert.notStrictEqual(info.getSnapshot(), snap); + checkInferredProjectIsOrphan(); // reload from own file session.executeCommandSeq({ command: server.protocol.CommandTypes.Reload, arguments: { file: f1.path } }); - checkScriptInfoAndProjects(0, f1.content, "contents of closed file"); + checkScriptInfoAndProjects(f1.content, "contents of closed file"); assert.notStrictEqual(info.getSnapshot(), snap); + checkInferredProjectIsOrphan(); - function checkScriptInfoAndProjects(inferredProjects: number, contentsOfInfo: string, captionForContents: string) { - checkNumberOfProjects(projectService, { inferredProjects }); + function checkInferredProjectIsOrphan() { + assert.isTrue(projectService.inferredProjects[0].isOrphan()); + assert.equal(info.containingProjects.length, 0); + } + + function checkScriptInfoAndProjects(contentsOfInfo: string, captionForContents: string) { + checkNumberOfProjects(projectService, { inferredProjects: 1 }); assert.strictEqual(projectService.getScriptInfo(f1.path), info); checkScriptInfoContents(contentsOfInfo, captionForContents); } diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index dfd5539cca3d6..ef82e6de30e03 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -708,17 +708,11 @@ namespace ts.server { this.handleDeletedFile(info); } else if (!info.isScriptOpen()) { - if (info.containingProjects.length === 0) { - // Orphan script info, remove it as we can always reload it on next open file request - this.stopWatchingScriptInfo(info); - this.deleteScriptInfo(info); - } - else { - // file has been changed which might affect the set of referenced files in projects that include - // this file and set of inferred projects - info.delayReloadNonMixedContentFile(); - this.delayUpdateProjectGraphs(info.containingProjects); - } + Debug.assert(info.containingProjects.length !== 0); + // file has been changed which might affect the set of referenced files in projects that include + // this file and set of inferred projects + info.delayReloadNonMixedContentFile(); + this.delayUpdateProjectGraphs(info.containingProjects); } } @@ -851,15 +845,23 @@ namespace ts.server { const project = this.getOrCreateInferredProjectForProjectRootPathIfEnabled(info, projectRootPath) || this.getOrCreateSingleInferredProjectIfEnabled() || - this.createInferredProject(info.isDynamic ? this.currentDirectory : getDirectoryPath(info.path)); + this.getOrCreateSingleInferredWithoutProjectRoot(info.isDynamic ? this.currentDirectory : getDirectoryPath(info.path)); project.addRoot(info); + if (info.containingProjects[0] !== project) { + // Ensure this is first project, we could be in this scenario because info could be part of orphan project + info.detachFromProject(project); + info.containingProjects.unshift(project); + } project.updateGraph(); if (!this.useSingleInferredProject && !project.projectRootPath) { // Note that we need to create a copy of the array since the list of project can change - for (const inferredProject of this.inferredProjects.slice(0, this.inferredProjects.length - 1)) { - Debug.assert(inferredProject !== project); + for (const inferredProject of this.inferredProjects) { + if (inferredProject === project || inferredProject.isOrphan()) { + continue; + } + // Remove the inferred project if the root of it is now part of newly created inferred project // e.g through references // Which means if any root of inferred project is part of more than 1 project can be removed @@ -870,8 +872,8 @@ namespace ts.server { // instead of scanning all open files const roots = inferredProject.getRootScriptInfos(); Debug.assert(roots.length === 1 || !!inferredProject.projectRootPath); - if (roots.length === 1 && roots[0].containingProjects.length > 1) { - this.removeProject(inferredProject); + if (roots.length === 1 && forEach(roots[0].containingProjects, p => p !== roots[0].containingProjects[0] && !p.isOrphan())) { + inferredProject.removeFile(roots[0], /*fileExists*/ true, /*detachFromProject*/ true); } } } @@ -891,15 +893,13 @@ namespace ts.server { info.close(fileExists); this.stopWatchingConfigFilesForClosedScriptInfo(info); - this.openFiles.delete(info.path); const canonicalFileName = this.toCanonicalFileName(info.fileName); if (this.openFilesWithNonRootedDiskPath.get(canonicalFileName) === info) { this.openFilesWithNonRootedDiskPath.delete(canonicalFileName); } - // collect all projects that should be removed - let projectsToRemove: Project[]; + let ensureProjectsForOpenFiles = false; for (const p of info.containingProjects) { if (p.projectKind === ProjectKind.Configured) { if (info.hasMixedContent) { @@ -909,15 +909,14 @@ namespace ts.server { // if it would need to be re-created with next file open } else if (p.projectKind === ProjectKind.Inferred && p.isRoot(info)) { - // If this was the open root file of inferred project + // If this was the last open root file of inferred project if ((p as InferredProject).isProjectWithSingleRoot()) { - // - when useSingleInferredProject is not set, we can guarantee that this will be the only root - // - other wise remove the project if it is the only root - (projectsToRemove || (projectsToRemove = [])).push(p); - } - else { - p.removeFile(info, fileExists, /*detachFromProject*/ true); + ensureProjectsForOpenFiles = true; } + + p.removeFile(info, fileExists, /*detachFromProject*/ true); + // Do not remove the project even if this was last root of the inferred project + // so that we can reuse this project, if it would need to be re-created with next file open } if (!p.languageServiceEnabled) { @@ -927,24 +926,24 @@ namespace ts.server { p.markAsDirty(); } } - if (projectsToRemove) { - for (const project of projectsToRemove) { - this.removeProject(project); - } + 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 f = this.getScriptInfoForPath(path as Path); - if (f.isOrphan()) { - this.assignOrphanScriptInfoToInferredProject(f, projectRootPath); + const info = this.getScriptInfoForPath(path as Path); + // collect all orphaned script infos from open files + if (info.isOrphan()) { + this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); } }); - - // Cleanup script infos that arent part of any project (eg. those could be closed script infos not referenced by any project) - // is postponed to next file open so that if file from same project is opened, - // we wont end up creating same script infos } + // Cleanup script infos that arent part of any project (eg. those could be closed script infos not referenced by any project) + // is postponed to next file open so that if file from same project is opened, + // we wont end up creating same script infos + // If the current info is being just closed - add the watcher file to track changes // But if file was deleted, handle that part if (fileExists) { @@ -955,16 +954,6 @@ namespace ts.server { } } - private deleteOrphanScriptInfoNotInAnyProject() { - this.filenameToScriptInfo.forEach(info => { - if (!info.isScriptOpen() && info.isOrphan()) { - // if there are not projects that include this script info - delete it - this.stopWatchingScriptInfo(info); - this.deleteScriptInfo(info); - } - }); - } - private deleteScriptInfo(info: ScriptInfo) { this.filenameToScriptInfo.delete(info.path); const realpath = info.getRealpathIfDifferent(); @@ -1141,7 +1130,7 @@ namespace ts.server { * This is called by inferred project whenever script info is added as a root */ /* @internal */ - startWatchingConfigFilesForInferredProjectRoot(info: ScriptInfo, projectRootPath: NormalizedPath | undefined) { + startWatchingConfigFilesForInferredProjectRoot(info: ScriptInfo) { Debug.assert(info.isScriptOpen()); this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) => { let configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); @@ -1163,7 +1152,7 @@ namespace ts.server { !this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath)) { this.createConfigFileWatcherOfConfigFileExistence(configFileName, canonicalConfigFilePath, configFileExistenceInfo); } - }, projectRootPath); + }); } /** @@ -1194,14 +1183,14 @@ namespace ts.server { * The server must start searching from the directory containing * the newly opened file. */ - private forEachConfigFileLocation(info: ScriptInfo, - action: (configFileName: NormalizedPath, canonicalConfigFilePath: string) => boolean | void, - projectRootPath?: NormalizedPath) { - + private forEachConfigFileLocation(info: ScriptInfo, action: (configFileName: NormalizedPath, canonicalConfigFilePath: string) => boolean | void) { if (this.syntaxOnly) { return undefined; } + Debug.assert(this.openFiles.has(info.path)); + const projectRootPath = this.openFiles.get(info.path); + let searchPath = asNormalizedPath(getDirectoryPath(info.fileName)); while (!projectRootPath || containsPath(projectRootPath, searchPath, this.currentDirectory, !this.host.useCaseSensitiveFileNames)) { @@ -1236,13 +1225,12 @@ namespace ts.server { * The server must start searching from the directory containing * the newly opened file. */ - private getConfigFileNameForFile(info: ScriptInfo, projectRootPath: NormalizedPath | undefined) { + private getConfigFileNameForFile(info: ScriptInfo) { Debug.assert(info.isScriptOpen()); this.logger.info(`Search path: ${getDirectoryPath(info.fileName)}`); const configFileName = this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) => this.configFileExists(configFileName, canonicalConfigFilePath, info), - projectRootPath ); if (configFileName) { this.logger.info(`For info: ${info.fileName} :: Config file name: ${configFileName}`); @@ -1670,6 +1658,21 @@ namespace ts.server { return this.createInferredProject(/*currentDirectory*/ undefined, /*isSingleInferredProject*/ true); } + private getOrCreateSingleInferredWithoutProjectRoot(currentDirectory: string | undefined): InferredProject { + Debug.assert(!this.useSingleInferredProject); + const expectedCurrentDirectory = this.toCanonicalFileName(this.getNormalizedAbsolutePath(currentDirectory || "")); + // Reuse the project with same current directory but no roots + for (const inferredProject of this.inferredProjects) { + if (!inferredProject.projectRootPath && + inferredProject.isOrphan() && + inferredProject.canonicalCurrentDirectory === expectedCurrentDirectory) { + return inferredProject; + } + } + + return this.createInferredProject(currentDirectory); + } + private createInferredProject(currentDirectory: string | undefined, isSingleInferredProject?: boolean, projectRootPath?: NormalizedPath): InferredProject { const compilerOptions = projectRootPath && this.compilerOptionsForInferredProjectsPerProjectRoot.get(projectRootPath) || this.compilerOptionsForInferredProjects; const project = new InferredProject(this, this.documentRegistry, compilerOptions, projectRootPath, currentDirectory); @@ -1716,6 +1719,7 @@ namespace ts.server { for (const project of toAddInfo.containingProjects) { // Add the projects only if they can use symLink targets and not already in the list if (project.languageServiceEnabled && + !project.isOrphan() && !project.getCompilerOptions().preserveSymlinks && !contains(info.containingProjects, project)) { if (!projects) { @@ -1905,7 +1909,7 @@ namespace ts.server { // we first detect if there is already a configured project created for it: if so, // we re- read the tsconfig file content and update the project only if we havent already done so // otherwise we create a new one. - const configFileName = this.getConfigFileNameForFile(info, this.openFiles.get(path)); + const configFileName = this.getConfigFileNameForFile(info); if (configFileName) { const project = this.findConfiguredProjectByProjectName(configFileName); if (!project) { @@ -1944,16 +1948,14 @@ namespace ts.server { // so it will be added to inferred project as a root. (for sake of this example assume single inferred project is false) // So at this poing a.ts is part of first inferred project and second inferred project (of which c.ts is root) // And hence it needs to be removed from the first inferred project. - if (info.containingProjects.length > 1 && - info.containingProjects[0].projectKind === ProjectKind.Inferred && - info.containingProjects[0].isRoot(info)) { - const inferredProject = info.containingProjects[0] as InferredProject; - if (inferredProject.isProjectWithSingleRoot()) { - this.removeProject(inferredProject); - } - else { - inferredProject.removeFile(info, /*fileExists*/ true, /*detachFromProject*/ true); - } + Debug.assert(info.containingProjects.length > 0); + const firstProject = info.containingProjects[0]; + + if (!firstProject.isOrphan() && + firstProject.projectKind === ProjectKind.Inferred && + firstProject.isRoot(info) && + forEach(info.containingProjects, p => p !== firstProject && !p.isOrphan())) { + firstProject.removeFile(info, /*fileExists*/ true, /*detachFromProject*/ true); } } @@ -2008,9 +2010,10 @@ namespace ts.server { let configFileErrors: ReadonlyArray; const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory, fileContent, scriptKind, hasMixedContent); + 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, projectRootPath); + configFileName = this.getConfigFileNameForFile(info); if (configFileName) { project = this.findConfiguredProjectByProjectName(configFileName); if (!project) { @@ -2046,9 +2049,8 @@ namespace ts.server { if (info.isOrphan()) { this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); } - Debug.assert(!info.isOrphan()); - this.openFiles.set(info.path, projectRootPath); + // Remove the configured projects that have zero references from open files. // This was postponed from closeOpenFile to after opening next file, @@ -2059,11 +2061,26 @@ namespace ts.server { } }); + // Remove orphan inferred projects now that we have reused projects + // We need to create a duplicate because we cant guarantee order after removal + for (const inferredProject of this.inferredProjects.slice()) { + if (inferredProject.isOrphan()) { + this.removeProject(inferredProject); + } + } + // Delete the orphan files here because there might be orphan script infos (which are not part of project) // when some file/s were closed which resulted in project removal. // It was then postponed to cleanup these script infos so that they can be reused if // the file from that old project is reopened because of opening file from here. - this.deleteOrphanScriptInfoNotInAnyProject(); + this.filenameToScriptInfo.forEach(info => { + if (!info.isScriptOpen() && info.isOrphan()) { + // if there are not projects that include this script info - delete it + this.stopWatchingScriptInfo(info); + this.deleteScriptInfo(info); + } + }); + this.printProjects(); return { configFileName, configFileErrors }; diff --git a/src/server/project.ts b/src/server/project.ts index 92645cfca5248..4a5ca9bffafa9 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -591,6 +591,11 @@ namespace ts.server { return this.rootFiles && this.rootFiles.length > 0; } + /*@internal*/ + isOrphan() { + return false; + } + getRootFiles() { return this.rootFiles && this.rootFiles.map(info => info.fileName); } @@ -834,6 +839,10 @@ namespace ts.server { /*@internal*/ updateTypingFiles(typingFiles: SortedReadonlyArray) { + enumerateInsertsAndDeletes(typingFiles, this.typingFiles, getStringComparer(!this.useCaseSensitiveFileNames()), + /*inserted*/ noop, + removed => this.detachScriptInfoFromProject(removed) + ); this.typingFiles = typingFiles; // Invalidate files with unresolved imports this.resolutionCache.setFilesWithInvalidatedNonRelativeUnresolvedImports(this.cachedUnresolvedImportsPerFile); @@ -894,7 +903,7 @@ namespace ts.server { const oldExternalFiles = this.externalFiles || emptyArray as SortedReadonlyArray; this.externalFiles = this.getExternalFiles(); - enumerateInsertsAndDeletes(this.externalFiles, oldExternalFiles, compareStringsCaseSensitive, + enumerateInsertsAndDeletes(this.externalFiles, oldExternalFiles, getStringComparer(!this.useCaseSensitiveFileNames()), // Ensure a ScriptInfo is created for new external files. This is performed indirectly // by the LSHost for files in the program when the program is retrieved above but // the program doesn't contain external files so this must be done explicitly. @@ -1169,6 +1178,10 @@ namespace ts.server { /** this is canonical project root path */ readonly projectRootPath: string | undefined; + /*@internal*/ + /** stored only if their is no projectRootPath and this isnt single inferred project */ + readonly canonicalCurrentDirectory: string | undefined; + /*@internal*/ constructor( projectService: ProjectService, @@ -1187,12 +1200,15 @@ namespace ts.server { projectService.host, currentDirectory); this.projectRootPath = projectRootPath && projectService.toCanonicalFileName(projectRootPath); + if (!projectRootPath && !projectService.useSingleInferredProject) { + this.canonicalCurrentDirectory = projectService.toCanonicalFileName(this.currentDirectory); + } this.enableGlobalPlugins(); } addRoot(info: ScriptInfo) { Debug.assert(info.isScriptOpen()); - this.projectService.startWatchingConfigFilesForInferredProjectRoot(info, this.projectService.openFiles.get(info.path)); + this.projectService.startWatchingConfigFilesForInferredProjectRoot(info); if (!this._isJsInferredProject && info.isJavaScript()) { this.toggleJsInferredProject(/*isJsInferredProject*/ true); } @@ -1209,6 +1225,11 @@ namespace ts.server { } } + /*@internal*/ + isOrphan() { + return !this.hasRoots(); + } + isProjectWithSingleRoot() { // - when useSingleInferredProject is not set and projectRootPath is not set, // we can guarantee that this will be the only root diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index 589975769b121..efae3a5faa68d 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -460,7 +460,7 @@ namespace ts.server { } isOrphan() { - return this.containingProjects.length === 0; + return !forEach(this.containingProjects, p => !p.isOrphan()); } /** diff --git a/src/server/session.ts b/src/server/session.ts index 19a9ee007726e..baed5a2dbe3e0 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -865,7 +865,7 @@ namespace ts.server { symLinkedProjects = this.projectService.getSymlinkedProjects(scriptInfo); } // filter handles case when 'projects' is undefined - projects = filter(projects, p => p.languageServiceEnabled); + projects = filter(projects, p => p.languageServiceEnabled && !p.isOrphan()); if ((!projects || !projects.length) && !symLinkedProjects) { return Errors.ThrowNoProject(); } @@ -1336,7 +1336,7 @@ namespace ts.server { symLinkedProjects ? { projects, symLinkedProjects } : projects, (project, info) => { let result: protocol.CompileOnSaveAffectedFileListSingleProject; - if (project.compileOnSaveEnabled && project.languageServiceEnabled && !project.getCompilationSettings().noEmit) { + if (project.compileOnSaveEnabled && project.languageServiceEnabled && !project.isOrphan() && !project.getCompilationSettings().noEmit) { result = { projectFileName: project.getProjectName(), fileNames: project.getCompileOnSaveAffectedFileList(info), diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 1f5757ff4ced7..60b5d7ffd9371 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8067,7 +8067,6 @@ declare namespace ts.server { * @param info The file that has been closed or newly configured */ private closeOpenFile; - private deleteOrphanScriptInfoNotInAnyProject; private deleteScriptInfo; private configFileExists; private setConfigFileExistenceByNewConfiguredProject; @@ -8124,6 +8123,7 @@ declare namespace ts.server { private sendConfigFileDiagEvent; private getOrCreateInferredProjectForProjectRootPathIfEnabled; private getOrCreateSingleInferredProjectIfEnabled; + private getOrCreateSingleInferredWithoutProjectRoot; private createInferredProject; getScriptInfo(uncheckedFileName: string): ScriptInfo; private watchClosedScriptInfo;