diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 9fd15333cc9c6..ab29a2bca15a6 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -382,7 +382,7 @@ namespace ts.server { syntaxOnly?: boolean; } - interface OriginalFileInfo { fileName: NormalizedPath; path: Path; } + interface OriginalFileInfo { fileName: NormalizedPath; path: Path; openInfoPathForConfigFile?: Path; } type OpenScriptInfoOrClosedFileInfo = ScriptInfo | OriginalFileInfo; function isOpenScriptInfo(infoOrFileName: OpenScriptInfoOrClosedFileInfo): infoOrFileName is ScriptInfo { @@ -407,8 +407,8 @@ namespace ts.server { } function setProjectOptionsUsed(project: ConfiguredProject | ExternalProject) { - if (project.projectKind === ProjectKind.Configured) { - (project as ConfiguredProject).projectOptions = true; + if (isConfiguredProject(project)) { + project.projectOptions = true; } } @@ -1058,6 +1058,10 @@ namespace ts.server { } else { this.logConfigFileWatchUpdate(project.getConfigFilePath(), project.canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.ReloadingInferredRootFiles); + if (project.isInitialLoadPending()) { + return; + } + project.pendingReload = ConfigFileProgramReloadLevel.Full; project.pendingReloadReason = "Change in config file detected"; this.delayUpdateProjectGraph(project); @@ -1496,25 +1500,30 @@ namespace ts.server { } Debug.assert(!isOpenScriptInfo(info) || this.openFiles.has(info.path)); - const projectRootPath = this.openFiles.get(info.path); + const openInfoPathForConfigFile = !isOpenScriptInfo(info) ? info.openInfoPathForConfigFile : undefined; + const projectRootPath = this.openFiles.get(openInfoPathForConfigFile || info.path); let searchPath = asNormalizedPath(getDirectoryPath(info.fileName)); const isSearchPathInProjectRoot = () => containsPath(projectRootPath!, searchPath, this.currentDirectory, !this.host.useCaseSensitiveFileNames); // If projectRootPath doesn't contain info.path, then do normal search for config file const anySearchPathOk = !projectRootPath || !isSearchPathInProjectRoot(); + // For config files always ignore its directory since that would just result to same config file + let ignoreDirectory = !!openInfoPathForConfigFile; do { - const canonicalSearchPath = normalizedPathToPath(searchPath, this.currentDirectory, this.toCanonicalFileName); - const tsconfigFileName = asNormalizedPath(combinePaths(searchPath, "tsconfig.json")); - let result = action(tsconfigFileName, combinePaths(canonicalSearchPath, "tsconfig.json")); - if (result) { - return tsconfigFileName; - } + if (!ignoreDirectory) { + const canonicalSearchPath = normalizedPathToPath(searchPath, this.currentDirectory, this.toCanonicalFileName); + const tsconfigFileName = asNormalizedPath(combinePaths(searchPath, "tsconfig.json")); + let result = action(tsconfigFileName, combinePaths(canonicalSearchPath, "tsconfig.json")); + if (result) { + return tsconfigFileName; + } - const jsconfigFileName = asNormalizedPath(combinePaths(searchPath, "jsconfig.json")); - result = action(jsconfigFileName, combinePaths(canonicalSearchPath, "jsconfig.json")); - if (result) { - return jsconfigFileName; + const jsconfigFileName = asNormalizedPath(combinePaths(searchPath, "jsconfig.json")); + result = action(jsconfigFileName, combinePaths(canonicalSearchPath, "jsconfig.json")); + if (result) { + return jsconfigFileName; + } } const parentPath = asNormalizedPath(getDirectoryPath(searchPath)); @@ -1522,6 +1531,7 @@ namespace ts.server { break; } searchPath = parentPath; + ignoreDirectory = false; } while (anySearchPathOk || isSearchPathInProjectRoot()); return undefined; @@ -1663,7 +1673,7 @@ namespace ts.server { return; } - const projectOptions = project.projectKind === ProjectKind.Configured ? (project as ConfiguredProject).projectOptions as ProjectOptions : undefined; + const projectOptions = isConfiguredProject(project) ? project.projectOptions as ProjectOptions : undefined; setProjectOptionsUsed(project); const data: ProjectInfoTelemetryEventData = { projectId: this.host.createSHA256Hash(project.projectName), @@ -2606,8 +2616,8 @@ namespace ts.server { // Add configured projects as referenced originalScriptInfo.containingProjects.forEach(project => { - if (project.projectKind === ProjectKind.Configured) { - addOriginalConfiguredProject(project as ConfiguredProject); + if (isConfiguredProject(project)) { + addOriginalConfiguredProject(project); } }); return originalLocation; @@ -2651,7 +2661,7 @@ namespace ts.server { if (!project) { 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()) { + if (!project.containsScriptInfo(info)) { // Since the file isnt part of configured project, do not send config file info configFileName = undefined; } @@ -2665,6 +2675,8 @@ namespace ts.server { updateProjectIfDirty(project); } defaultConfigProject = project; + // Traverse till project Root and create those configured projects + this.createAncestorConfiguredProjects(info, project); } } @@ -2687,6 +2699,63 @@ namespace ts.server { return { configFileName, configFileErrors, defaultConfigProject }; } + /** + * Traverse till project Root and create those configured projects + */ + private createAncestorConfiguredProjects(info: ScriptInfo, project: ConfiguredProject) { + if (!project.containsScriptInfo(info) || !project.getCompilerOptions().composite) { + return; + } + + const configPath = this.toPath(project.canonicalConfigFilePath); + const configInfo: OriginalFileInfo = { + fileName: project.getConfigFilePath(), + path: configPath, + openInfoPathForConfigFile: info.path + }; + + // Go create all configured projects till project root + while (true) { + const configFileName = this.getConfigFileNameForFile(configInfo); + if (!configFileName) return; + + const ancestor = this.findConfiguredProjectByProjectName(configFileName) || + this.createConfiguredProjectWithDelayLoad(configFileName, `Project possibly referencing default composite project ${project.getProjectName()} of open file ${info.fileName}`); + if (ancestor.isInitialLoadPending()) { + ancestor.setPotentialProjectRefence(configPath); + } + else if (!project.getCompilerOptions().composite) { + return; + } + + configInfo.fileName = configFileName; + configInfo.path = this.toPath(configFileName); + } + } + + /*@internal*/ + loadAncestorAndReferenceConfiguredProjects(forProjects: ReadonlyMap) { + // Load all the projects ancestor projects for seen projects + // Because the configured projects can update in the callback, get the copy to iterate + const currentConfigProjects = arrayFrom(this.configuredProjects.values()); + currentConfigProjects.forEach(project => { + if (project.isInitialLoadPending() && + project.forEachProjectReference(returnFalse, returnFalse, path => forProjects.has(path))) { + // Load the project + project.updateGraph(); + // We want to also load the referenced projects + project.forEachProjectReference(ref => { + if (ref) { + const configFileName = toNormalizedPath(ref.sourceFile.fileName); + const configuredProject = this.findConfiguredProjectByProjectName(configFileName) || + this.createAndLoadConfiguredProject(toNormalizedPath(configFileName), `Creating project for transitive reference of ancestor project: ${project.projectName}`); + updateProjectIfDirty(configuredProject); + } + }, noop, noop); + } + }); + } + private cleanupAfterOpeningFile(toRetainConfigProjects: ConfiguredProject[] | ConfiguredProject | undefined) { // This was postponed from closeOpenFile to after opening next file, // so that we can reuse the project if we need to right away @@ -2718,6 +2787,17 @@ namespace ts.server { private removeOrphanConfiguredProjects(toRetainConfiguredProjects: ConfiguredProject[] | ConfiguredProject | undefined) { const toRemoveConfiguredProjects = cloneMap(this.configuredProjects); + const markOriginalProjectsAsUsed = (project: Project) => { + if (!project.isOrphan() && project.originalConfiguredProjects) { + project.originalConfiguredProjects.forEach( + (_value, configuredProjectPath) => retainConfiguredProject( + this.configuredProjects.get(configuredProjectPath) + ) + ); + } + }; + + // Retain pinned projects if (toRetainConfiguredProjects) { if (isArray(toRetainConfiguredProjects)) { toRetainConfiguredProjects.forEach(retainConfiguredProject); @@ -2734,32 +2814,28 @@ namespace ts.server { // If project has open ref (there are more than zero references from external project/open file), keep it alive as well as any project it references if (project.hasOpenRef()) { retainConfiguredProject(project); - markOriginalProjectsAsUsed(project); } - else { + else if (toRemoveConfiguredProjects.has(project.canonicalConfigFilePath)) { // If the configured project for project reference has more than zero references, keep it alive - project.forEachResolvedProjectReference(ref => { - if (ref) { - const refProject = this.configuredProjects.get(ref.sourceFile.path); - if (refProject && refProject.hasOpenRef()) { - retainConfiguredProject(project); - } - } - }); + project.forEachReferencedConfiguredProject(markProjectAsUsedIfReferencedConfigWithOpenRef); } }); // Remove all the non marked projects toRemoveConfiguredProjects.forEach(project => this.removeProject(project)); - function markOriginalProjectsAsUsed(project: Project) { - if (!project.isOrphan() && project.originalConfiguredProjects) { - project.originalConfiguredProjects.forEach((_value, configuredProjectPath) => toRemoveConfiguredProjects.delete(configuredProjectPath)); + function markProjectAsUsedIfReferencedConfigWithOpenRef(refProject: ConfiguredProject | undefined, project: ConfiguredProject) { + if (refProject && refProject.hasOpenRef()) { + retainConfiguredProject(project); + return true; } } - function retainConfiguredProject(project: ConfiguredProject) { - toRemoveConfiguredProjects.delete(project.canonicalConfigFilePath); + function retainConfiguredProject(project: ConfiguredProject | undefined) { + if (project && toRemoveConfiguredProjects.delete(project.canonicalConfigFilePath)) { + markOriginalProjectsAsUsed(project); + project.forEachReferencedConfiguredProject(retainConfiguredProject); + } } } diff --git a/src/server/project.ts b/src/server/project.ts index f9465d8b24294..425a4c8bc6451 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -109,6 +109,11 @@ namespace ts.server { return value instanceof ScriptInfo; } + /* @internal */ + export function isConfiguredProject(p: Project): p is ConfiguredProject { + return p.projectKind === ProjectKind.Configured; + } + interface GeneratedFileWatcher { generatedFilePath: Path; watcher: FileWatcher; @@ -1123,6 +1128,7 @@ namespace ts.server { return this.projectService.getScriptInfo(uncheckedFileName); } + /* @internal */ filesToString(writeProjectFileNames: boolean) { if (!this.program) { return "\tFiles (0)\n"; @@ -1493,10 +1499,14 @@ namespace ts.server { private projectReferences: ReadonlyArray | undefined; + /** Portentual project references before the project is actually loaded (read config file) */ + private potentialProjectReferences: Map | undefined; + /*@internal*/ projectOptions?: ProjectOptions | true; - protected isInitialLoadPending: () => boolean = returnTrue; + /*@internal*/ + isInitialLoadPending: () => boolean = returnTrue; /*@internal*/ sendLoadingProjectFinish = false; @@ -1519,6 +1529,14 @@ namespace ts.server { this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName)); } + /* @internal */ + filesToString(writeProjectFileNames: boolean) { + if (this.isInitialLoadPending()) { + return "\tFiles (0) InitialLoadPending\n"; + } + return super.filesToString(writeProjectFileNames); + } + /** * If the project has reload from disk pending, it reloads (and then updates graph as part of that) instead of just updating the graph * @returns: true if set of files in the project stays the same and false - otherwise. @@ -1561,12 +1579,39 @@ namespace ts.server { updateReferences(refs: ReadonlyArray | undefined) { this.projectReferences = refs; + this.potentialProjectReferences = undefined; } /*@internal*/ - forEachResolvedProjectReference(cb: (resolvedProjectReference: ResolvedProjectReference | undefined, resolvedProjectReferencePath: Path) => T | undefined): T | undefined { + setPotentialProjectRefence(path: Path) { + // We know the composites if we have read the config file + Debug.assert(this.isInitialLoadPending()); + (this.potentialProjectReferences || (this.potentialProjectReferences = createMap())).set(path, true); + } + + /*@internal*/ + forEachProjectReference( + cb: (resolvedProjectReference: ResolvedProjectReference | undefined, resolvedProjectReferencePath: Path) => T | undefined, + cbProjectRef: (projectReference: ProjectReference) => T | undefined, + cbPotentialProjectRef: (path: Path) => T | undefined + ): T | undefined { const program = this.getCurrentProgram(); - return program && program.forEachResolvedProjectReference(cb); + if (program) { + return program.forEachResolvedProjectReference(cb); + } + if (this.isInitialLoadPending()) { + return this.potentialProjectReferences && forEachKey(this.potentialProjectReferences, cbPotentialProjectRef); + } + return forEach(this.projectReferences, cbProjectRef); + } + + /*@internal*/ + forEachReferencedConfiguredProject(cb: (refProject: ConfiguredProject | undefined, thisProject: ConfiguredProject) => T | undefined) { + return this.forEachProjectReference( + resolvedRef => cb(resolvedRef && this.projectService.configuredProjects.get(resolvedRef.sourceFile.path), this), + projectRef => cb(this.projectService.configuredProjects.get(this.toPath(projectRef.path)), this), + potentialProjectRef => cb(this.projectService.configuredProjects.get(potentialProjectRef), this) + ); } /*@internal*/ diff --git a/src/server/session.ts b/src/server/session.ts index f7d6e4a747a07..f7166fdfb7dce 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -422,7 +422,7 @@ namespace ts.server { ): void { const projectService = defaultProject.projectService; let toDo: ProjectAndLocation[] | undefined; - const seenProjects = createMap(); + const seenProjects = createMap(); forEachProjectInProjects(projects, initialLocation && initialLocation.fileName, (project, path) => { // TLocation shoud be either `DocumentPosition` or `undefined`. Since `initialLocation` is `TLocation` this cast should be valid. const location = (initialLocation ? { fileName: path, pos: initialLocation.pos } : undefined) as TLocation; @@ -432,8 +432,9 @@ namespace ts.server { // After initial references are collected, go over every other project and see if it has a reference for the symbol definition. if (getDefinition) { const memGetDefinition = memoize(getDefinition); + projectService.loadAncestorAndReferenceConfiguredProjects(seenProjects); projectService.forEachEnabledProject(project => { - if (!addToSeen(seenProjects, project.projectName)) return; + if (!addToSeen(seenProjects, project)) return; const definition = getDefinitionInProject(memGetDefinition(), defaultProject, project); if (definition) { toDo = callbackProjectAndLocation({ project, location: definition as TLocation }, projectService, toDo, seenProjects, cb); @@ -452,16 +453,24 @@ namespace ts.server { return mappedDefinition && project.containsFile(toNormalizedPath(mappedDefinition.fileName)) ? mappedDefinition : undefined; } + function addToSeen(seenProjects: Map, project: Project) { + return ts.addToSeen(seenProjects, getProjectKey(project), project); + } + + function getProjectKey(project: Project) { + return isConfiguredProject(project) ? project.canonicalConfigFilePath : project.projectName; + } + function callbackProjectAndLocation( projectAndLocation: ProjectAndLocation, projectService: ProjectService, toDo: ProjectAndLocation[] | undefined, - seenProjects: Map, + seenProjects: Map, cb: CombineProjectOutputCallback, ): ProjectAndLocation[] | undefined { if (projectAndLocation.project.getCancellationToken().isCancellationRequested()) return undefined; // Skip rest of toDo if cancelled cb(projectAndLocation, (project, location) => { - seenProjects.set(projectAndLocation.project.projectName, true); + seenProjects.set(getProjectKey(projectAndLocation.project), projectAndLocation.project); const originalLocation = projectService.getOriginalLocationEnsuringConfiguredProject(project, location); if (!originalLocation) return undefined; @@ -482,8 +491,8 @@ namespace ts.server { return toDo; } - function addToTodo(projectAndLocation: ProjectAndLocation, toDo: Push>, seenProjects: Map): void { - if (addToSeen(seenProjects, projectAndLocation.project.projectName)) toDo.push(projectAndLocation); + function addToTodo(projectAndLocation: ProjectAndLocation, toDo: Push>, seenProjects: Map): void { + if (addToSeen(seenProjects, projectAndLocation.project)) toDo.push(projectAndLocation); } function documentSpanLocation({ fileName, textSpan }: DocumentSpan): DocumentPosition { diff --git a/src/testRunner/unittests/tsserver/declarationFileMaps.ts b/src/testRunner/unittests/tsserver/declarationFileMaps.ts index 734af2a68fe29..328991bef1fd8 100644 --- a/src/testRunner/unittests/tsserver/declarationFileMaps.ts +++ b/src/testRunner/unittests/tsserver/declarationFileMaps.ts @@ -126,7 +126,8 @@ namespace ts.projectSystem { openFilesForSession([userTs], session); const service = session.getProjectService(); - checkNumberOfProjects(service, addUserTsConfig ? { configuredProjects: 1 } : { inferredProjects: 1 }); + // If config file then userConfig project and bConfig project since it is referenced + checkNumberOfProjects(service, addUserTsConfig ? { configuredProjects: 2 } : { inferredProjects: 1 }); return session; } @@ -224,7 +225,7 @@ namespace ts.projectSystem { }) ], }); - checkNumberOfProjects(session.getProjectService(), { configuredProjects: 1 }); + checkNumberOfProjects(session.getProjectService(), { configuredProjects: 2 }); verifyUserTsConfigProject(session); // Navigate to the definition @@ -232,7 +233,7 @@ namespace ts.projectSystem { openFilesForSession([aTs], session); // UserTs configured project should be alive - checkNumberOfProjects(session.getProjectService(), { configuredProjects: 2 }); + checkNumberOfProjects(session.getProjectService(), { configuredProjects: 3 }); verifyUserTsConfigProject(session); verifyATsConfigProject(session); @@ -421,7 +422,7 @@ namespace ts.projectSystem { const session = createSession(createServerHost([aTs, aTsconfig, bTs, bTsconfig, aDts, aDtsMap])); checkDeclarationFiles(aTs, session, [aDtsMap, aDts]); openFilesForSession([bTs], session); - checkNumberOfProjects(session.getProjectService(), { configuredProjects: 1 }); + checkNumberOfProjects(session.getProjectService(), { configuredProjects: 2 }); // configured project of b is alive since a references b const responseFull = executeSessionRequest(session, protocol.CommandTypes.ReferencesFull, protocolFileLocationFromSubstring(bTs, "f()")); diff --git a/src/testRunner/unittests/tsserver/projectReferences.ts b/src/testRunner/unittests/tsserver/projectReferences.ts index 4941c650d99e0..e17e4791a3711 100644 --- a/src/testRunner/unittests/tsserver/projectReferences.ts +++ b/src/testRunner/unittests/tsserver/projectReferences.ts @@ -68,7 +68,9 @@ namespace ts.projectSystem { const session = createSession(host); openFilesForSession([containerCompositeExec[1]], session); const service = session.getProjectService(); - checkNumberOfProjects(service, { configuredProjects: 1 }); + checkNumberOfProjects(service, { configuredProjects: 2 }); // compositeExec and solution + const solutionProject = service.configuredProjects.get(containerConfig.path)!; + assert.isTrue(solutionProject.isInitialLoadPending()); const { file: myConstFile, start: myConstStart, end: myConstEnd } = protocolFileSpanFromSubstring({ file: containerCompositeExec[1], text: "myConst", @@ -84,10 +86,61 @@ namespace ts.projectSystem { contextText: "export const myConst = 30;" }); const { file: _, ...renameTextOfMyConstInLib } = locationOfMyConstInLib; + const locationOfMyConstInExec = protocolFileSpanWithContextFromSubstring({ + file: containerExec[1], + text: "myConst" + }); + const { file: myConstInExecFile, ...renameTextOfMyConstInExec } = locationOfMyConstInExec; assert.deepEqual(response.locs, [ { file: myConstFile, locs: [{ start: myConstStart, end: myConstEnd }] }, + { file: myConstInExecFile, locs: [renameTextOfMyConstInExec] }, { file: locationOfMyConstInLib.file, locs: [renameTextOfMyConstInLib] } ]); + checkNumberOfProjects(service, { configuredProjects: 4 }); + assert.isFalse(solutionProject.isInitialLoadPending()); + }); + + it("ancestor and project ref management", () => { + const tempFile: File = { + path: `/user/username/projects/temp/temp.ts`, + content: "let x = 10" + }; + const host = createHost(files.concat([tempFile]), [containerConfig.path]); + const session = createSession(host); + openFilesForSession([containerCompositeExec[1]], session); + const service = session.getProjectService(); + checkNumberOfProjects(service, { configuredProjects: 2 }); // compositeExec and solution + const solutionProject = service.configuredProjects.get(containerConfig.path)!; + assert.isTrue(solutionProject.isInitialLoadPending()); + + // Open temp file and verify all projects alive + openFilesForSession([tempFile], session); + checkNumberOfProjects(service, { configuredProjects: 2, inferredProjects: 1 }); + assert.isTrue(solutionProject.isInitialLoadPending()); + + const locationOfMyConst = protocolLocationFromSubstring(containerCompositeExec[1].content, "myConst"); + session.executeCommandSeq({ + command: protocol.CommandTypes.Rename, + arguments: { + file: containerCompositeExec[1].path, + ...locationOfMyConst + } + }); + + // Ref projects are loaded + checkNumberOfProjects(service, { configuredProjects: 4, inferredProjects: 1 }); + assert.isFalse(solutionProject.isInitialLoadPending()); + + // Open temp file and verify all projects alive + service.closeClientFile(tempFile.path); + openFilesForSession([tempFile], session); + checkNumberOfProjects(service, { configuredProjects: 4, inferredProjects: 1 }); + + // Close all files and open temp file, only inferred project should be alive + service.closeClientFile(containerCompositeExec[1].path); + service.closeClientFile(tempFile.path); + openFilesForSession([tempFile], session); + checkNumberOfProjects(service, { inferredProjects: 1 }); }); }); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 67d6a98e11ec5..b834ffee5810d 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8534,7 +8534,6 @@ declare namespace ts.server { private clearGeneratedFileWatch; getScriptInfoForNormalizedPath(fileName: NormalizedPath): ScriptInfo | undefined; getScriptInfo(uncheckedFileName: string): ScriptInfo | undefined; - filesToString(writeProjectFileNames: boolean): string; setCompilerOptions(compilerOptions: CompilerOptions): void; protected removeRoot(info: ScriptInfo): void; protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map | undefined): void; @@ -8573,7 +8572,8 @@ declare namespace ts.server { private externalProjectRefCount; private projectErrors; private projectReferences; - protected isInitialLoadPending: () => boolean; + /** Portentual project references before the project is actually loaded (read config file) */ + private potentialProjectReferences; /** * If the project has reload from disk pending, it reloads (and then updates graph as part of that) instead of just updating the graph * @returns: true if set of files in the project stays the same and false - otherwise. @@ -8999,6 +8999,10 @@ declare namespace ts.server { private findExternalProjectContainingOpenScriptInfo; private getOrCreateOpenScriptInfo; private assignProjectToOpenedScriptInfo; + /** + * Traverse till project Root and create those configured projects + */ + private createAncestorConfiguredProjects; private cleanupAfterOpeningFile; openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult; private removeOrphanConfiguredProjects;