diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 4e5435feaba..10a7b667009 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -2234,7 +2234,13 @@ namespace ts.server { getDocumentPositionMapper(project: Project, generatedFileName: string, sourceFileName?: string): DocumentPositionMapper | undefined { // Since declaration info and map file watches arent updating project's directory structure host (which can cache file structure) use host const declarationInfo = this.getOrCreateScriptInfoNotOpenedByClient(generatedFileName, project.currentDirectory, this.host); - if (!declarationInfo) return undefined; + if (!declarationInfo) { + if (sourceFileName) { + // Project contains source file and it generates the generated file name + project.addGeneratedFileWatch(generatedFileName, sourceFileName); + } + return undefined; + } // Try to get from cache declarationInfo.getSnapshot(); // Ensure synchronized diff --git a/src/server/project.ts b/src/server/project.ts index d1605e22d1d..32a1d8f53b2 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -109,12 +109,22 @@ namespace ts.server { return value instanceof ScriptInfo; } + interface GeneratedFileWatcher { + generatedFilePath: Path; + watcher: FileWatcher; + } + type GeneratedFileWatcherMap = GeneratedFileWatcher | Map; + function isGeneratedFileWatcher(watch: GeneratedFileWatcherMap): watch is GeneratedFileWatcher { + return (watch as GeneratedFileWatcher).generatedFilePath !== undefined; + } + export abstract class Project implements LanguageServiceHost, ModuleResolutionHost { private rootFiles: ScriptInfo[] = []; private rootFilesMap: Map = createMap(); private program: Program | undefined; private externalFiles: SortedReadonlyArray | undefined; private missingFilesMap: Map | undefined; + private generatedFilesMap: GeneratedFileWatcherMap | undefined; private plugins: PluginModuleWithName[] = []; /*@internal*/ @@ -568,6 +578,7 @@ namespace ts.server { this.lastFileExceededProgramSize = lastFileExceededProgramSize; this.builderState = undefined; this.resolutionCache.closeTypeRootsWatch(); + this.clearGeneratedFileWatch(); this.projectService.onUpdateLanguageServiceStateForProject(this, /*languageServiceEnabled*/ false); } @@ -649,6 +660,7 @@ namespace ts.server { clearMap(this.missingFilesMap, closeFileWatcher); this.missingFilesMap = undefined!; } + this.clearGeneratedFileWatch(); // signal language service to release source files acquired from document registry this.languageService.dispose(); @@ -942,6 +954,39 @@ namespace ts.server { missingFilePath => this.addMissingFileWatcher(missingFilePath) ); + if (this.generatedFilesMap) { + const outPath = this.compilerOptions.outFile && this.compilerOptions.out; + if (isGeneratedFileWatcher(this.generatedFilesMap)) { + // --out + if (!outPath || !this.isValidGeneratedFileWatcher( + removeFileExtension(outPath) + Extension.Dts, + this.generatedFilesMap, + )) { + this.clearGeneratedFileWatch(); + } + } + else { + // MultiFile + if (outPath) { + this.clearGeneratedFileWatch(); + } + else { + this.generatedFilesMap.forEach((watcher, source) => { + const sourceFile = this.program!.getSourceFileByPath(source as Path); + if (!sourceFile || + sourceFile.resolvedPath !== source || + !this.isValidGeneratedFileWatcher( + getDeclarationEmitOutputFilePathWorker(sourceFile.fileName, this.compilerOptions, this.currentDirectory, this.program!.getCommonSourceDirectory(), this.getCanonicalFileName), + watcher + )) { + closeFileWatcherOf(watcher); + (this.generatedFilesMap as Map).delete(source); + } + }); + } + } + } + // Watch the type locations that would be added to program as part of automatic type resolutions if (this.languageServiceEnabled) { this.resolutionCache.updateTypeRootsWatch(); @@ -1006,6 +1051,61 @@ namespace ts.server { return !!this.missingFilesMap && this.missingFilesMap.has(path); } + /* @internal */ + addGeneratedFileWatch(generatedFile: string, sourceFile: string) { + if (this.compilerOptions.outFile || this.compilerOptions.out) { + // Single watcher + if (!this.generatedFilesMap) { + this.generatedFilesMap = this.createGeneratedFileWatcher(generatedFile); + } + } + else { + // Map + const path = this.toPath(sourceFile); + if (this.generatedFilesMap) { + if (isGeneratedFileWatcher(this.generatedFilesMap)) { + Debug.fail(`${this.projectName} Expected not to have --out watcher for generated file with options: ${JSON.stringify(this.compilerOptions)}`); + return; + } + if (this.generatedFilesMap.has(path)) return; + } + else { + this.generatedFilesMap = createMap(); + } + this.generatedFilesMap.set(path, this.createGeneratedFileWatcher(generatedFile)); + } + } + + private createGeneratedFileWatcher(generatedFile: string): GeneratedFileWatcher { + return { + generatedFilePath: this.toPath(generatedFile), + watcher: this.projectService.watchFactory.watchFile( + this.projectService.host, + generatedFile, + () => this.projectService.delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(this), + PollingInterval.High, + WatchType.MissingGeneratedFile, + this + ) + }; + } + + private isValidGeneratedFileWatcher(generateFile: string, watcher: GeneratedFileWatcher) { + return this.toPath(generateFile) === watcher.generatedFilePath; + } + + private clearGeneratedFileWatch() { + if (this.generatedFilesMap) { + if (isGeneratedFileWatcher(this.generatedFilesMap)) { + closeFileWatcherOf(this.generatedFilesMap); + } + else { + clearMap(this.generatedFilesMap, closeFileWatcherOf); + } + this.generatedFilesMap = undefined; + } + } + getScriptInfoForNormalizedPath(fileName: NormalizedPath): ScriptInfo | undefined { const scriptInfo = this.projectService.getScriptInfoForPath(this.toPath(fileName)); if (scriptInfo && !scriptInfo.isAttached(this)) { diff --git a/src/server/utilities.ts b/src/server/utilities.ts index 91880ccfdbe..fd38c6a4326 100644 --- a/src/server/utilities.ts +++ b/src/server/utilities.ts @@ -227,5 +227,6 @@ namespace ts { NodeModulesForClosedScriptInfo = "node_modules for closed script infos in them", MissingSourceMapFile = "Missing source map file", NoopConfigFileForInferredRoot = "Noop Config file for the inferred project root", + MissingGeneratedFile = "Missing generated file" } } diff --git a/src/testRunner/unittests/tsserver/projectReferences.ts b/src/testRunner/unittests/tsserver/projectReferences.ts index 1b2e90bcd4f..79e8ad5ebb0 100644 --- a/src/testRunner/unittests/tsserver/projectReferences.ts +++ b/src/testRunner/unittests/tsserver/projectReferences.ts @@ -94,6 +94,7 @@ namespace ts.projectSystem { describe("with main and depedency project", () => { const projectLocation = "/user/username/projects/myproject"; const dependecyLocation = `${projectLocation}/dependency`; + const dependecyDeclsLocation = `${projectLocation}/decls`; const mainLocation = `${projectLocation}/main`; const dependencyTs: File = { path: `${dependecyLocation}/FnS.ts`, @@ -106,7 +107,7 @@ export function fn5() { } }; const dependencyConfig: File = { path: `${dependecyLocation}/tsconfig.json`, - content: JSON.stringify({ compilerOptions: { composite: true, declarationMap: true } }) + content: JSON.stringify({ compilerOptions: { composite: true, declarationMap: true, declarationDir: "../decls" } }) }; const mainTs: File = { @@ -117,7 +118,7 @@ export function fn5() { } fn3, fn4, fn5 -} from '../dependency/fns' +} from '../decls/fns' fn1(); fn2(); @@ -142,9 +143,9 @@ fn5(); path: `${projectLocation}/random/tsconfig.json`, content: "{}" }; - const dtsLocation = `${dependecyLocation}/FnS.d.ts`; + const dtsLocation = `${dependecyDeclsLocation}/FnS.d.ts`; const dtsPath = dtsLocation.toLowerCase() as Path; - const dtsMapLocation = `${dtsLocation}.map`; + const dtsMapLocation = `${dependecyDeclsLocation}/FnS.d.ts.map`; const dtsMapPath = dtsMapLocation.toLowerCase() as Path; const files = [dependencyTs, dependencyConfig, mainTs, mainConfig, libFile, randomFile, randomConfig]; @@ -217,7 +218,7 @@ fn5(); start: { line: fn + 1, offset: 5 }, end: { line: fn + 1, offset: 8 }, contextStart: { line: 1, offset: 1 }, - contextEnd: { line: 7, offset: 27 } + contextEnd: { line: 7, offset: 22 } }; } function usageSpan(fn: number): protocol.TextSpan { @@ -287,19 +288,25 @@ fn5(); function verifyDocumentPositionMapperUpdates( mainScenario: string, verifier: ReadonlyArray, - closedInfos: ReadonlyArray) { + closedInfos: ReadonlyArray, + withRefs: boolean) { const openFiles = verifier.map(v => v.openFile); const expectedProjectActualFiles = verifier.map(v => v.expectedProjectActualFiles); - const actionGetters = verifier.map(v => v.actionGetter); const openFileLastLines = verifier.map(v => v.openFileLastLine); const configFiles = openFiles.map(openFile => `${getDirectoryPath(openFile.path)}/tsconfig.json`); const openInfos = openFiles.map(f => f.path); // When usage and dependency are used, dependency config is part of closedInfo so ignore - const otherWatchedFiles = verifier.length > 1 ? [configFiles[0]] : configFiles; + const otherWatchedFiles = withRefs && verifier.length > 1 ? [configFiles[0]] : configFiles; function openTsFile(onHostCreate?: (host: TestServerHost) => void) { const host = createHost(files, [mainConfig.path]); + if (!withRefs) { + // Erase project reference + host.writeFile(mainConfig.path, JSON.stringify({ + compilerOptions: { composite: true, declarationMap: true } + })); + } if (onHostCreate) { onHostCreate(host); } @@ -336,7 +343,7 @@ fn5(); ); } - function verifyInfosWhenNoDtsFile(session: TestSession, host: TestServerHost, dependencyTsAndMapOk?: true) { + function verifyInfosWhenNoDtsFile(session: TestSession, host: TestServerHost, watchDts: boolean, dependencyTsAndMapOk?: true) { const dtsMapClosedInfo = firstDefined(closedInfos, f => f.toLowerCase() === dtsMapPath ? f : undefined); const dtsClosedInfo = firstDefined(closedInfos, f => f.toLowerCase() === dtsPath ? f : undefined); verifyInfosWithRandom( @@ -344,8 +351,7 @@ fn5(); host, openInfos, closedInfos.filter(f => (dependencyTsAndMapOk || f !== dtsMapClosedInfo) && f !== dtsClosedInfo && (dependencyTsAndMapOk || f !== dependencyTs.path)), - // When project actual file contains dts, it needs to be watched - dtsClosedInfo && expectedProjectActualFiles.some(expectedProjectActualFiles => expectedProjectActualFiles.some(f => f.toLowerCase() === dtsPath)) ? + dtsClosedInfo && watchDts ? otherWatchedFiles.concat(dtsClosedInfo) : otherWatchedFiles ); @@ -361,22 +367,22 @@ fn5(); } } - function action(actionGetter: SessionActionGetter, fn: number, session: TestSession) { - const { reqName, request, expectedResponse, expectedResponseNoMap, expectedResponseNoDts } = actionGetter(fn); + function action(verifier: DocumentPositionMapperVerifier, fn: number, session: TestSession) { + const { reqName, request, expectedResponse, expectedResponseNoMap, expectedResponseNoDts } = verifier.actionGetter(fn); const { response } = session.executeCommandSeq(request); - return { reqName, response, expectedResponse, expectedResponseNoMap, expectedResponseNoDts }; + return { reqName, response, expectedResponse, expectedResponseNoMap, expectedResponseNoDts, verifier }; } function firstAction(session: TestSession) { - actionGetters.forEach(actionGetter => action(actionGetter, 1, session)); + verifier.forEach(v => action(v, 1, session)); } function verifyAllFnActionWorker(session: TestSession, verifyAction: (result: ReturnType, dtsInfo: server.ScriptInfo | undefined, isFirst: boolean) => void, dtsAbsent?: true) { // action let isFirst = true; - for (const actionGetter of actionGetters) { + for (const v of verifier) { for (let fn = 1; fn <= 5; fn++) { - const result = action(actionGetter, fn, session); + const result = action(v, fn, session); const dtsInfo = session.getProjectService().filenameToScriptInfo.get(dtsPath); if (dtsAbsent) { assert.isUndefined(dtsInfo); @@ -449,9 +455,17 @@ fn5(); dependencyTsAndMapOk?: true ) { // action - verifyAllFnActionWorker(session, ({ reqName, response, expectedResponse, expectedResponseNoDts }) => { + verifyAllFnActionWorker(session, ({ reqName, response, expectedResponse, expectedResponseNoDts, verifier }) => { assert.deepEqual(response, expectedResponseNoDts || expectedResponse, `Failed on ${reqName}`); - verifyInfosWhenNoDtsFile(session, host, dependencyTsAndMapOk); + verifyInfosWhenNoDtsFile( + session, + host, + // Even when project actual file contains dts, its not watched because the dts is in another folder and module resolution just fails + // instead of succeeding to source file and then mapping using project reference (When using usage location) + // But watched if sourcemapper is in source project since we need to keep track of dts to update the source mapper for any potential usages + verifier.expectedProjectActualFiles.every(f => f.toLowerCase() !== dtsPath), + dependencyTsAndMapOk, + ); }, /*dtsAbsent*/ true); } @@ -535,7 +549,11 @@ fn5(); // Collecting at this point retains dependency.d.ts and map watcher closeFilesForSession([randomFile], session); openFilesForSession([randomFile], session); - verifyInfosWhenNoDtsFile(session, host); + verifyInfosWhenNoDtsFile( + session, + host, + !!forEach(verifier, v => v.expectedProjectActualFiles.every(f => f.toLowerCase() !== dtsPath)) + ); // Closing open file, removes dependencies too closeFilesForSession([...openFiles, randomFile], session); @@ -616,7 +634,7 @@ fn5(); "when dependency file's map changes", host => host.writeFile( dtsMapLocation, - `{"version":3,"file":"FnS.d.ts","sourceRoot":"","sources":["FnS.ts"],"names":[],"mappings":"AAAA,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,eAAO,MAAM,CAAC,KAAK,CAAC"}` + `{"version":3,"file":"FnS.d.ts","sourceRoot":"","sources":["../dependency/FnS.ts"],"names":[],"mappings":"AAAA,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,wBAAgB,GAAG,SAAM;AACzB,eAAO,MAAM,CAAC,KAAK,CAAC"}` ), /*afterActionDocumentPositionMapperNotEquals*/ true ); @@ -635,44 +653,58 @@ fn5(); ); } - const usageVerifier: DocumentPositionMapperVerifier = { - openFile: mainTs, - expectedProjectActualFiles: [mainTs.path, libFile.path, mainConfig.path, dtsPath], - actionGetter: gotoDefintinionFromMainTs, - openFileLastLine: 14 - }; - describe("from project that uses dependency", () => { - const closedInfos = [dependencyTs.path, dependencyConfig.path, libFile.path, dtsPath, dtsMapLocation]; - verifyDocumentPositionMapperUpdates( - "can go to definition correctly", - [usageVerifier], - closedInfos - ); - }); + function verifyScenarios(withRefs: boolean) { + describe(withRefs ? "when main tsconfig has project reference" : "when main tsconfig doesnt have project reference", () => { + const usageVerifier: DocumentPositionMapperVerifier = { + openFile: mainTs, + expectedProjectActualFiles: [mainTs.path, libFile.path, mainConfig.path, dtsPath], + actionGetter: gotoDefintinionFromMainTs, + openFileLastLine: 14 + }; + describe("from project that uses dependency", () => { + const closedInfos = withRefs ? + [dependencyTs.path, dependencyConfig.path, libFile.path, dtsPath, dtsMapLocation] : + [dependencyTs.path, libFile.path, dtsPath, dtsMapLocation]; + verifyDocumentPositionMapperUpdates( + "can go to definition correctly", + [usageVerifier], + closedInfos, + withRefs + ); + }); - const definingVerifier: DocumentPositionMapperVerifier = { - openFile: dependencyTs, - expectedProjectActualFiles: [dependencyTs.path, libFile.path, dependencyConfig.path], - actionGetter: renameFromDependencyTs, - openFileLastLine: 6 - }; - describe("from defining project", () => { - const closedInfos = [libFile.path, dtsLocation, dtsMapLocation]; - verifyDocumentPositionMapperUpdates( - "rename locations from dependency", - [definingVerifier], - closedInfos - ); - }); + const definingVerifier: DocumentPositionMapperVerifier = { + openFile: dependencyTs, + expectedProjectActualFiles: [dependencyTs.path, libFile.path, dependencyConfig.path], + actionGetter: renameFromDependencyTs, + openFileLastLine: 6, + }; + describe("from defining project", () => { + const closedInfos = [libFile.path, dtsLocation, dtsMapLocation]; + verifyDocumentPositionMapperUpdates( + "rename locations from dependency", + [definingVerifier], + closedInfos, + withRefs + ); + }); - describe("when opening depedency and usage project", () => { - const closedInfos = [libFile.path, dtsPath, dtsMapLocation, dependencyConfig.path]; - verifyDocumentPositionMapperUpdates( - "goto Definition in usage and rename locations from defining project", - [usageVerifier, { ...definingVerifier, actionGetter: renameFromDependencyTsWithBothProjectsOpen }], - closedInfos - ); - }); + describe("when opening depedency and usage project", () => { + const closedInfos = withRefs ? + [libFile.path, dtsPath, dtsMapLocation, dependencyConfig.path] : + [libFile.path, dtsPath, dtsMapLocation]; + verifyDocumentPositionMapperUpdates( + "goto Definition in usage and rename locations from defining project", + [usageVerifier, { ...definingVerifier, actionGetter: renameFromDependencyTsWithBothProjectsOpen }], + closedInfos, + withRefs + ); + }); + }); + } + + verifyScenarios(/*withRefs*/ false); + verifyScenarios(/*withRefs*/ true); }); }); } diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 82ab70b6e6f..e0f55481c5b 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8397,6 +8397,7 @@ declare namespace ts.server { private program; private externalFiles; private missingFilesMap; + private generatedFilesMap; private plugins; private lastFileExceededProgramSize; protected languageService: LanguageService; @@ -8509,6 +8510,9 @@ declare namespace ts.server { private detachScriptInfoFromProject; private addMissingFileWatcher; private isWatchedMissingFile; + private createGeneratedFileWatcher; + private isValidGeneratedFileWatcher; + private clearGeneratedFileWatch; getScriptInfoForNormalizedPath(fileName: NormalizedPath): ScriptInfo | undefined; getScriptInfo(uncheckedFileName: string): ScriptInfo | undefined; filesToString(writeProjectFileNames: boolean): string;