diff --git a/src/compiler/builder.ts b/src/compiler/builder.ts index 04d520cb2286c..4fb90ca7143f5 100644 --- a/src/compiler/builder.ts +++ b/src/compiler/builder.ts @@ -18,6 +18,13 @@ namespace ts { text: string; } + export interface ChangedProgramFiles { + /** Minimal set of list of files that require emit */ + readonly filesToEmit: ReadonlyArray; + /** File paths of source files changed/added/removed or affected by changed files */ + readonly changedFiles: ReadonlyArray; + } + export interface Builder { /** * This is the callback when file infos in the builder are updated @@ -25,8 +32,15 @@ namespace ts { onProgramUpdateGraph(program: Program, hasInvalidatedResolution: HasInvalidatedResolution): void; getFilesAffectedBy(program: Program, path: Path): string[]; emitFile(program: Program, path: Path): EmitOutput; + + /** Emit the changed files and clear the cache of the changed files */ emitChangedFiles(program: Program): EmitOutputDetailed[]; + /** Get the changed files since last query and then clear the cache of changed files */ + getChangedProgramFiles(program: Program): ChangedProgramFiles; + /** When called gets the semantic diagnostics for the program. It also caches the diagnostics and manage them */ getSemanticDiagnostics(program: Program, cancellationToken?: CancellationToken): Diagnostic[]; + + /** Called to reset the status of the builder */ clear(): void; } @@ -73,16 +87,18 @@ namespace ts { ): Builder { let isModuleEmit: boolean | undefined; // Last checked shape signature for the file info - type FileInfo = { version: string; signature: string; }; - let fileInfos: Map; + type FileInfo = { fileName: string; version: string; signature: string; }; + const fileInfos = createMap(); const semanticDiagnosticsPerFile = createMap(); - let changedFilesSinceLastEmit: Map; + /** The map has key by source file's path that has been changed */ + const changedFileNames = createMap(); let emitHandler: EmitHandler; return { onProgramUpdateGraph, getFilesAffectedBy, emitFile, emitChangedFiles, + getChangedProgramFiles, getSemanticDiagnostics, clear }; @@ -92,13 +108,11 @@ namespace ts { if (isModuleEmit !== currentIsModuleEmit) { isModuleEmit = currentIsModuleEmit; emitHandler = isModuleEmit ? getModuleEmitHandler() : getNonModuleEmitHandler(); - fileInfos = undefined; + fileInfos.clear(); semanticDiagnosticsPerFile.clear(); } - - changedFilesSinceLastEmit = changedFilesSinceLastEmit || createMap(); mutateMap( - fileInfos || (fileInfos = createMap()), + fileInfos, arrayToMap(program.getSourceFiles(), sourceFile => sourceFile.path), { // Add new file info @@ -111,27 +125,26 @@ namespace ts { ); } - function registerChangedFile(path: Path) { - changedFilesSinceLastEmit.set(path, true); + function registerChangedFile(path: Path, fileName: string) { + changedFileNames.set(path, fileName); // All changed files need to re-evaluate its semantic diagnostics semanticDiagnosticsPerFile.delete(path); } function addNewFileInfo(program: Program, sourceFile: SourceFile): FileInfo { - registerChangedFile(sourceFile.path); + registerChangedFile(sourceFile.path, sourceFile.fileName); emitHandler.addScriptInfo(program, sourceFile); - return { version: sourceFile.version, signature: undefined }; + return { fileName: sourceFile.fileName, version: sourceFile.version, signature: undefined }; } - function removeExistingFileInfo(path: Path, _existingFileInfo: FileInfo) { - registerChangedFile(path); + function removeExistingFileInfo(path: Path, existingFileInfo: FileInfo) { + registerChangedFile(path, existingFileInfo.fileName); emitHandler.removeScriptInfo(path); } function updateExistingFileInfo(program: Program, existingInfo: FileInfo, sourceFile: SourceFile, hasInvalidatedResolution: HasInvalidatedResolution) { if (existingInfo.version !== sourceFile.version || hasInvalidatedResolution(sourceFile.path)) { - registerChangedFile(sourceFile.path); - semanticDiagnosticsPerFile.delete(sourceFile.path); + registerChangedFile(sourceFile.path, sourceFile.fileName); existingInfo.version = sourceFile.version; emitHandler.updateScriptInfo(program, sourceFile); } @@ -154,7 +167,7 @@ namespace ts { const sourceFile = program.getSourceFile(path); const singleFileResult = sourceFile && shouldEmitFile(sourceFile) ? [sourceFile.fileName] : []; - const info = fileInfos && fileInfos.get(path); + const info = fileInfos.get(path); if (!info || !updateShapeSignature(program, sourceFile, info)) { return singleFileResult; } @@ -165,44 +178,61 @@ namespace ts { function emitFile(program: Program, path: Path) { ensureProgramGraph(program); - if (!fileInfos || !fileInfos.has(path)) { + if (!fileInfos.has(path)) { return { outputFiles: [], emitSkipped: true }; } return getEmitOutput(program, program.getSourceFileByPath(path), /*emitOnlyDtsFiles*/ false, /*isDetailed*/ false); } - function emitChangedFiles(program: Program): EmitOutputDetailed[] { - ensureProgramGraph(program); - const result: EmitOutputDetailed[] = []; - if (changedFilesSinceLastEmit) { - const seenFiles = createMap(); - changedFilesSinceLastEmit.forEach((__value, path: Path) => { - const affectedFiles = getFilesAffectedBy(program, path); - for (const file of affectedFiles) { - if (!seenFiles.has(file)) { - const sourceFile = program.getSourceFile(file); - seenFiles.set(file, sourceFile); - if (sourceFile) { - // Any affected file shouldnt have the cached diagnostics - semanticDiagnosticsPerFile.delete(sourceFile.path); - - const emitOutput = getEmitOutput(program, sourceFile, /*emitOnlyDtsFiles*/ false, /*isDetailed*/ true) as EmitOutputDetailed; - result.push(emitOutput); - - // mark all the emitted source files as seen - if (emitOutput.emittedSourceFiles) { - for (const file of emitOutput.emittedSourceFiles) { - seenFiles.set(file.fileName, file); - } - } + function enumerateChangedFilesSet( + program: Program, + onChangedFile: (fileName: string, path: Path) => void, + onAffectedFile: (fileName: string, sourceFile: SourceFile) => void + ) { + changedFileNames.forEach((fileName, path) => { + onChangedFile(fileName, path as Path); + const affectedFiles = getFilesAffectedBy(program, path as Path); + for (const file of affectedFiles) { + onAffectedFile(file, program.getSourceFile(file)); + } + }); + } + + function enumerateChangedFilesEmitOutput( + program: Program, + emitOnlyDtsFiles: boolean, + onChangedFile: (fileName: string, path: Path) => void, + onEmitOutput: (emitOutput: EmitOutputDetailed, sourceFile: SourceFile) => void + ) { + const seenFiles = createMap(); + enumerateChangedFilesSet(program, onChangedFile, (fileName, sourceFile) => { + if (!seenFiles.has(fileName)) { + seenFiles.set(fileName, sourceFile); + if (sourceFile) { + // Any affected file shouldnt have the cached diagnostics + semanticDiagnosticsPerFile.delete(sourceFile.path); + + const emitOutput = getEmitOutput(program, sourceFile, emitOnlyDtsFiles, /*isDetailed*/ true) as EmitOutputDetailed; + onEmitOutput(emitOutput, sourceFile); + + // mark all the emitted source files as seen + if (emitOutput.emittedSourceFiles) { + for (const file of emitOutput.emittedSourceFiles) { + seenFiles.set(file.fileName, file); } } } - }); + } + }); + } - changedFilesSinceLastEmit = undefined; - } + function emitChangedFiles(program: Program): EmitOutputDetailed[] { + ensureProgramGraph(program); + const result: EmitOutputDetailed[] = []; + enumerateChangedFilesEmitOutput(program, /*emitOnlyDtsFiles*/ false, + /*onChangedFile*/ noop, emitOutput => result.push(emitOutput)); + changedFileNames.clear(); return result; } @@ -210,17 +240,11 @@ namespace ts { ensureProgramGraph(program); // Ensure that changed files have cleared their respective - if (changedFilesSinceLastEmit) { - changedFilesSinceLastEmit.forEach((__value, path: Path) => { - const affectedFiles = getFilesAffectedBy(program, path); - for (const file of affectedFiles) { - const sourceFile = program.getSourceFile(file); - if (sourceFile) { - semanticDiagnosticsPerFile.delete(sourceFile.path); - } - } - }); - } + enumerateChangedFilesSet(program, /*onChangedFile*/ noop, (_affectedFileName, sourceFile) => { + if (sourceFile) { + semanticDiagnosticsPerFile.delete(sourceFile.path); + } + }); let diagnostics: Diagnostic[]; for (const sourceFile of program.getSourceFiles()) { @@ -240,11 +264,36 @@ namespace ts { return diagnostics || emptyArray; } + function getChangedProgramFiles(program: Program): ChangedProgramFiles { + ensureProgramGraph(program); + + let filesToEmit: string[]; + const changedFiles = createMap(); + enumerateChangedFilesEmitOutput(program, /*emitOnlyDtsFiles*/ true, + // All the changed files are required to get diagnostics + (changedFileName, changedFilePath) => addFileForDiagnostics(changedFileName, changedFilePath), + // Emitted file is for emit as well as diagnostic + (_emitOutput, sourceFile) => { + (filesToEmit || (filesToEmit = [])).push(sourceFile.fileName); + addFileForDiagnostics(sourceFile.fileName, sourceFile.path); + }); + changedFileNames.clear(); + return { + filesToEmit: filesToEmit || emptyArray, + changedFiles: arrayFrom(changedFiles.values()) + }; + + function addFileForDiagnostics(fileName: string, path: Path) { + changedFiles.set(path, fileName); + } + } + function clear() { isModuleEmit = undefined; emitHandler = undefined; - fileInfos = undefined; + fileInfos.clear(); semanticDiagnosticsPerFile.clear(); + changedFileNames.clear(); } /** @@ -287,9 +336,7 @@ namespace ts { } /** - * Gets the referenced files for a file from the program - * @param program - * @param path + * Gets the referenced files for a file from the program with values for the keys as referenced file's path to be true */ function getReferencedFiles(program: Program, sourceFile: SourceFile): Map { const referencedFiles = createMap(); @@ -371,55 +418,36 @@ namespace ts { function getModuleEmitHandler(): EmitHandler { const references = createMap>(); - const referencedBy = createMultiMap(); return { - addScriptInfo: (program, sourceFile) => { - const refs = createMap(); - references.set(sourceFile.path, refs); - setReferences(program, sourceFile, refs); - }, + addScriptInfo: setReferences, removeScriptInfo, - updateScriptInfo: (program, sourceFile) => setReferences(program, sourceFile, references.get(sourceFile.path)), + updateScriptInfo: setReferences, getFilesAffectedByUpdatedShape }; - function setReferences(program: Program, sourceFile: SourceFile, existingReferences: Map) { - const path = sourceFile.path; - mutateMap( - // Existing references - existingReferences, - // Updated references - getReferencedFiles(program, sourceFile), - { - // Creating new Reference: as sourceFile references file with path 'key' - // in other words source file (path) is referenced by 'key' - createNewValue: (key): true => { referencedBy.add(key, path); return true; }, - // Remove existing reference by entry: source file doesnt reference file 'key' any more - // in other words source file (path) is not referenced by 'key' - onDeleteValue: (key, _existingValue) => { referencedBy.remove(key, path); } - } - ); + function setReferences(program: Program, sourceFile: SourceFile) { + references.set(sourceFile.path, getReferencedFiles(program, sourceFile)); } - function removeScriptInfo(path: Path) { + function removeScriptInfo(removedFilePath: Path) { // Remove existing references - references.forEach((_value, key) => { - referencedBy.remove(key, path); - }); - references.delete(path); - - // Delete the entry and add files referencing this file, as chagned files too - const referencedByPaths = referencedBy.get(path); - if (referencedByPaths) { - for (const path of referencedByPaths) { - registerChangedFile(path); + references.forEach((referencesInFile, filePath) => { + if (referencesInFile.has(removedFilePath)) { + // add files referencing the removedFilePath, as changed files too + const referencedByInfo = fileInfos.get(filePath); + if (referencedByInfo) { + registerChangedFile(filePath as Path, referencedByInfo.fileName); + } } - referencedBy.delete(path); - } + }); + // Delete the entry for the removed file path + references.delete(removedFilePath); } - function getReferencedByPaths(path: Path) { - return referencedBy.get(path) || []; + function getReferencedByPaths(referencedFilePath: Path) { + return mapDefinedIter(references.entries(), ([filePath, referencesInFile]) => + referencesInFile.has(referencedFilePath) ? filePath as Path : undefined + ); } function getFilesAffectedByUpdatedShape(program: Program, sourceFile: SourceFile, singleFileResult: string[]): string[] { @@ -444,7 +472,7 @@ namespace ts { // Start with the paths this file was referenced by const path = sourceFile.path; setSeenFileName(path, sourceFile); - const queue = getReferencedByPaths(path).slice(); + const queue = getReferencedByPaths(path); while (queue.length > 0) { const currentPath = queue.pop(); if (!seenFileNamesMap.has(currentPath)) { diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 58c2b3a614d7f..3c394da4e3167 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -125,7 +125,7 @@ namespace ts.projectSystem { this.events.push(event); } - checkEventCountOfType(eventType: "context" | "configFileDiag", expectedCount: number) { + checkEventCountOfType(eventType: "configFileDiag", expectedCount: number) { const eventsOfType = filter(this.events, e => e.eventName === eventType); assert.equal(eventsOfType.length, expectedCount, `The actual event counts of type ${eventType} is ${eventsOfType.length}, while expected ${expectedCount}`); } @@ -2001,7 +2001,7 @@ namespace ts.projectSystem { const session = createSession(host, { canUseEvents: true, eventHandler: e => { - if (e.eventName === server.ConfigFileDiagEvent || e.eventName === server.ContextEvent || e.eventName === server.ProjectInfoTelemetryEvent) { + if (e.eventName === server.ConfigFileDiagEvent || e.eventName === server.ProjectChangedEvent || e.eventName === server.ProjectInfoTelemetryEvent) { return; } assert.equal(e.eventName, server.ProjectLanguageServiceStateEvent); @@ -4467,4 +4467,537 @@ namespace ts.projectSystem { }); }); }); + + describe("ProjectChangedEvent", () => { + function verifyFiles(caption: string, actual: ReadonlyArray, expected: ReadonlyArray) { + assert.equal(actual.length, expected.length, `Incorrect number of ${caption}. Actual: ${actual} Expected: ${expected}`); + const seen = createMap(); + forEach(actual, f => { + assert.isFalse(seen.has(f), `${caption}: Found duplicate ${f}. Actual: ${actual} Expected: ${expected}`); + seen.set(f, true); + assert.isTrue(contains(expected, f), `${caption}: Expected not to contain ${f}. Actual: ${actual} Expected: ${expected}`); + }); + } + + function createVerifyInitialOpen(session: TestSession, verifyProjectChangedEventHandler: (events: server.ProjectChangedEvent[]) => void) { + return (file: FileOrFolder) => { + session.executeCommandSeq({ + command: server.CommandNames.Open, + arguments: { + file: file.path + } + }); + verifyProjectChangedEventHandler([]); + }; + } + + interface ProjectChangeEventVerifier { + session: TestSession; + verifyProjectChangedEventHandler(events: server.ProjectChangedEvent[]): void; + verifyInitialOpen(file: FileOrFolder): void; + } + + function verifyProjectChangedEvent(createSession: (host: TestServerHost) => ProjectChangeEventVerifier) { + it("when adding new file", () => { + const commonFile1: FileOrFolder = { + path: "/a/b/file1.ts", + content: "export var x = 10;" + }; + const commonFile2: FileOrFolder = { + path: "/a/b/file2.ts", + content: "export var y = 10;" + }; + const commonFile3: FileOrFolder = { + path: "/a/b/file3.ts", + content: "export var z = 10;" + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{}` + }; + const host = createServerHost([commonFile1, libFile, configFile]); + const { session, verifyProjectChangedEventHandler, verifyInitialOpen } = createSession(host, ); + const projectService = session.getProjectService(); + verifyInitialOpen(commonFile1); + + host.reloadFS([commonFile1, libFile, configFile, commonFile2]); + host.runQueuedTimeoutCallbacks(); + // Since this is first event + const project = projectService.configuredProjects.get(configFile.path); + verifyProjectChangedEventHandler([{ + eventName: server.ProjectChangedEvent, + data: { + project, + changedFiles: [libFile.path, commonFile1.path, commonFile2.path], + filesToEmit: [commonFile1.path, commonFile2.path] + } + }]); + + host.reloadFS([commonFile1, commonFile2, libFile, configFile, commonFile3]); + host.runQueuedTimeoutCallbacks(); + verifyProjectChangedEventHandler([{ + eventName: server.ProjectChangedEvent, + data: { + project, + changedFiles: [commonFile3.path], + filesToEmit: [commonFile3.path] + } + }]); + }); + + describe("with --out or --outFile setting", () => { + function verifyEventWithOutSettings(compilerOptions: CompilerOptions = {}) { + const config: FileOrFolder = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compilerOptions + }) + }; + + const f1: FileOrFolder = { + path: "/a/a.ts", + content: "export let x = 1" + }; + const f2: FileOrFolder = { + path: "/a/b.ts", + content: "export let y = 1" + }; + + const files = [f1, config, libFile]; + const host = createServerHost(files); + const { session, verifyInitialOpen, verifyProjectChangedEventHandler } = createSession(host); + const projectService = session.getProjectService(); + verifyInitialOpen(f1); + + files.push(f2); + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + + // Since this is first event + const project = projectService.configuredProjects.get(config.path); + verifyProjectChangedEventHandler([{ + eventName: server.ProjectChangedEvent, + data: { + project, + changedFiles: [libFile.path, f1.path, f2.path], + filesToEmit: [f1.path, f2.path] + } + }]); + + f2.content = "export let x = 11"; + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + verifyProjectChangedEventHandler([{ + eventName: server.ProjectChangedEvent, + data: { + project, + changedFiles: [f2.path], + filesToEmit: [f2.path] + } + }]); + } + + it("when both options are not set", () => { + verifyEventWithOutSettings(); + }); + + it("when --out is set", () => { + const outJs = "/a/out.js"; + verifyEventWithOutSettings({ out: outJs }); + }); + + it("when --outFile is set", () => { + const outJs = "/a/out.js"; + verifyEventWithOutSettings({ outFile: outJs }); + }); + }); + + describe("with modules and configured project", () => { + const file1Consumer1Path = "/a/b/file1Consumer1.ts"; + const moduleFile1Path = "/a/b/moduleFile1.ts"; + const configFilePath = "/a/b/tsconfig.json"; + type InitialStateParams = { + /** custom config file options */ + configObj?: any; + /** list of files emitted/changed on first update graph */ + firstCompilationEmitFiles?: string[]; + /** Additional files and folders to add */ + getAdditionalFileOrFolder?(): FileOrFolder[]; + /** initial list of files to reload in fs and first file in this list being the file to open */ + firstReloadFileList?: string[]; + }; + function getInitialState({ configObj = {}, getAdditionalFileOrFolder, firstReloadFileList, firstCompilationEmitFiles }: InitialStateParams = {}) { + const moduleFile1: FileOrFolder = { + path: moduleFile1Path, + content: "export function Foo() { };", + }; + + const file1Consumer1: FileOrFolder = { + path: file1Consumer1Path, + content: `import {Foo} from "./moduleFile1"; export var y = 10;`, + }; + + const file1Consumer2: FileOrFolder = { + path: "/a/b/file1Consumer2.ts", + content: `import {Foo} from "./moduleFile1"; let z = 10;`, + }; + + const moduleFile2: FileOrFolder = { + path: "/a/b/moduleFile2.ts", + content: `export var Foo4 = 10;`, + }; + + const globalFile3: FileOrFolder = { + path: "/a/b/globalFile3.ts", + content: `interface GlobalFoo { age: number }` + }; + + const additionalFiles = getAdditionalFileOrFolder ? getAdditionalFileOrFolder() : []; + const configFile = { + path: configFilePath, + content: JSON.stringify(configObj || { compilerOptions: {} }) + }; + + const files = [file1Consumer1, moduleFile1, file1Consumer2, moduleFile2, ...additionalFiles, globalFile3, libFile, configFile]; + + const filesToReload = firstReloadFileList && getFiles(firstReloadFileList) || files; + const host = createServerHost([filesToReload[0], configFile]); + + // Initial project creation + const { session, verifyProjectChangedEventHandler, verifyInitialOpen } = createSession(host); + const projectService = session.getProjectService(); + verifyInitialOpen(filesToReload[0]); + + // Since this is first event, it will have all the files + const firstFilesExpected = firstCompilationEmitFiles && getFiles(firstCompilationEmitFiles) || filesToReload; + verifyProjectChangedEvent(firstFilesExpected, filesToReload); + + return { + moduleFile1, file1Consumer1, file1Consumer2, moduleFile2, globalFile3, configFile, + files, + updateContentOfOpenFile, + verifyProjectChangedEvent, + verifyAffectedAllFiles, + }; + + function getFiles(filelist: string[]) { + return map(filelist, getFile); + } + + function getFile(fileName: string) { + return find(files, file => file.path === fileName); + } + + function verifyAffectedAllFiles() { + verifyProjectChangedEvent(filter(files, f => f !== libFile)); + } + + function verifyProjectChangedEvent(filesToEmit: FileOrFolder[], filesToReload?: FileOrFolder[], additionalChangedFiles?: FileOrFolder[]) { + const changedFiles = mapDefined(additionalChangedFiles ? filesToEmit.concat(additionalChangedFiles) : filesToEmit, f => f !== configFile ? f.path : undefined); + host.reloadFS(filesToReload || files); + host.runQueuedTimeoutCallbacks(); + const project = projectService.configuredProjects.get(configFile.path); + verifyProjectChangedEventHandler([{ + eventName: server.ProjectChangedEvent, + data: { + project, + changedFiles, + filesToEmit: mapDefined(filesToEmit, f => f !== libFile && f !== configFile ? f.path : undefined) + } + }]); + } + + function updateContentOfOpenFile(file: FileOrFolder, newContent: string) { + session.executeCommandSeq({ + command: server.CommandNames.Change, + arguments: { + file: file.path, + insertString: newContent, + endLine: 1, + endOffset: file.content.length, + line: 1, + offset: 1 + } + }); + file.content = newContent; + } + } + + it("should contains only itself if a module file's shape didn't change, and all files referencing it if its shape changed", () => { + const { moduleFile1, file1Consumer1, file1Consumer2, verifyProjectChangedEvent } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1, file1Consumer2]); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { console.log('hi'); };` + moduleFile1.content = `export var T: number;export function Foo() { console.log('hi'); };`; + verifyProjectChangedEvent([moduleFile1]); + }); + + it("should be up-to-date with the reference map changes", () => { + const { moduleFile1, file1Consumer1, file1Consumer2, updateContentOfOpenFile, verifyProjectChangedEvent } = getInitialState(); + + // Change file1Consumer1 content to `export let y = Foo();` + updateContentOfOpenFile(file1Consumer1, "export let y = Foo();"); + verifyProjectChangedEvent([file1Consumer1]); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer2]); + + // Add the import statements back to file1Consumer1 + updateContentOfOpenFile(file1Consumer1, `import {Foo} from "./moduleFile1";let y = Foo();`); + verifyProjectChangedEvent([file1Consumer1]); + + // Change the content of moduleFile1 to `export var T: number;export var T2: string;export function Foo() { };` + moduleFile1.content = `export var T: number;export var T2: string;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer2, file1Consumer1]); + + // Multiple file edits in one go: + + // Change file1Consumer1 content to `export let y = Foo();` + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + updateContentOfOpenFile(file1Consumer1, `export let y = Foo();`); + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1, file1Consumer2]); + }); + + it("should be up-to-date with deleted files", () => { + const { moduleFile1, file1Consumer1, file1Consumer2, files, verifyProjectChangedEvent } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + + // Delete file1Consumer2 + const filesToLoad = filter(files, file => file !== file1Consumer2); + verifyProjectChangedEvent([moduleFile1, file1Consumer1], filesToLoad, [file1Consumer2]); + }); + + it("should be up-to-date with newly created files", () => { + const { moduleFile1, file1Consumer1, file1Consumer2, files, verifyProjectChangedEvent, } = getInitialState(); + + const file1Consumer3: FileOrFolder = { + path: "/a/b/file1Consumer3.ts", + content: `import {Foo} from "./moduleFile1"; let y = Foo();` + }; + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1, file1Consumer3, file1Consumer2], files.concat(file1Consumer3)); + }); + + it("should detect changes in non-root files", () => { + const { moduleFile1, file1Consumer1, verifyProjectChangedEvent } = getInitialState({ + configObj: { files: [file1Consumer1Path] }, + firstCompilationEmitFiles: [file1Consumer1Path, moduleFile1Path, libFile.path] + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1]); + + // change file1 internal, and verify only file1 is affected + moduleFile1.content += "var T1: number;"; + verifyProjectChangedEvent([moduleFile1]); + }); + + it("should return all files if a global file changed shape", () => { + const { globalFile3, verifyAffectedAllFiles } = getInitialState(); + + globalFile3.content += "var T2: string;"; + verifyAffectedAllFiles(); + }); + + it("should always return the file itself if '--isolatedModules' is specified", () => { + const { moduleFile1, verifyProjectChangedEvent } = getInitialState({ + configObj: { compilerOptions: { isolatedModules: true } } + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1]); + }); + + it("should always return the file itself if '--out' or '--outFile' is specified", () => { + const outFilePath = "/a/b/out.js"; + const { moduleFile1, verifyProjectChangedEvent } = getInitialState({ + configObj: { compilerOptions: { module: "system", outFile: outFilePath } } + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1]); + }); + + it("should return cascaded affected file list", () => { + const file1Consumer1Consumer1: FileOrFolder = { + path: "/a/b/file1Consumer1Consumer1.ts", + content: `import {y} from "./file1Consumer1";` + }; + const { moduleFile1, file1Consumer1, file1Consumer2, updateContentOfOpenFile, verifyProjectChangedEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [file1Consumer1Consumer1] + }); + + updateContentOfOpenFile(file1Consumer1, file1Consumer1.content + "export var T: number;"); + verifyProjectChangedEvent([file1Consumer1, file1Consumer1Consumer1]); + + // Doesnt change the shape of file1Consumer1 + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1, file1Consumer2]); + + // Change both files before the timeout + updateContentOfOpenFile(file1Consumer1, file1Consumer1.content + "export var T2: number;"); + moduleFile1.content = `export var T2: number;export function Foo() { };`; + verifyProjectChangedEvent([moduleFile1, file1Consumer1, file1Consumer2, file1Consumer1Consumer1]); + }); + + it("should work fine for files with circular references", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: ` + /// + export var t1 = 10;` + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: ` + /// + export var t2 = 10;` + }; + const { configFile, verifyProjectChangedEvent, updateContentOfOpenFile } = getInitialState({ + getAdditionalFileOrFolder: () => [file1, file2], + firstReloadFileList: [file1.path, libFile.path, file2.path, configFilePath] + }); + + updateContentOfOpenFile(file1, file1.content + "export var t3 = 10;"); + verifyProjectChangedEvent([file1, file2], [file1, file2, libFile, configFile]); + }); + + it("should detect removed code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { configFile, verifyProjectChangedEvent, moduleFile1 } = getInitialState({ + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [referenceFile1.path, libFile.path, moduleFile1Path, configFilePath] + }); + + verifyProjectChangedEvent([referenceFile1], [libFile, referenceFile1, configFile], [moduleFile1]); + }); + + it("should detect non-existing code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { configFile, moduleFile2, updateContentOfOpenFile, verifyProjectChangedEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [referenceFile1.path, libFile.path, configFilePath] + }); + + updateContentOfOpenFile(referenceFile1, referenceFile1.content + "export var yy = Foo();"); + verifyProjectChangedEvent([referenceFile1], [libFile, referenceFile1, configFile]); + + // Create module File2 and see both files are saved + verifyProjectChangedEvent([referenceFile1, moduleFile2], [libFile, moduleFile2, referenceFile1, configFile]); + }); + }); + } + + describe("when event handler is set in the session", () => { + verifyProjectChangedEvent(createSessionWithProjectChangedEventHandler); + + function createSessionWithProjectChangedEventHandler(host: TestServerHost): ProjectChangeEventVerifier { + const projectChangedEvents: server.ProjectChangedEvent[] = []; + const session = createSession(host, { + eventHandler: e => { + if (e.eventName === server.ProjectChangedEvent) { + projectChangedEvents.push(e); + } + } + }); + + return { + session, + verifyProjectChangedEventHandler, + verifyInitialOpen: createVerifyInitialOpen(session, verifyProjectChangedEventHandler) + }; + + function eventToString(event: server.ProjectChangedEvent) { + const eventToModify = event && { + eventName: event.eventName, + data: { + project: event.data.project.getProjectName(), + changedFiles: event.data.changedFiles, + filesToEmit: event.data.filesToEmit + } + }; + return JSON.stringify(eventToModify); + } + + function eventsToString(events: ReadonlyArray) { + return "[" + map(events, eventToString).join(",") + "]"; + } + + function verifyProjectChangedEventHandler(expectedEvents: ReadonlyArray) { + assert.equal(projectChangedEvents.length, expectedEvents.length, `Incorrect number of events Actual: ${eventsToString(projectChangedEvents)} Expected: ${eventsToString(expectedEvents)}`); + forEach(projectChangedEvents, (actualEvent, i) => { + const expectedEvent = expectedEvents[i]; + assert.strictEqual(actualEvent.eventName, expectedEvent.eventName); + assert.strictEqual(actualEvent.data.project, expectedEvent.data.project); + verifyFiles("changedFiles", actualEvent.data.changedFiles, expectedEvent.data.changedFiles); + verifyFiles("filesToEmit", actualEvent.data.filesToEmit, expectedEvent.data.filesToEmit); + }); + + // Verified the events, reset them + projectChangedEvents.length = 0; + } + } + }); + + describe("when event handler is not set but session is created with canUseEvents = true", () => { + verifyProjectChangedEvent(createSessionThatUsesEvents); + + function createSessionThatUsesEvents(host: TestServerHost): ProjectChangeEventVerifier { + const session = createSession(host, { canUseEvents: true }); + + return { + session, + verifyProjectChangedEventHandler, + verifyInitialOpen: createVerifyInitialOpen(session, verifyProjectChangedEventHandler) + }; + + function verifyProjectChangedEventHandler(expected: ReadonlyArray) { + const expectedEvents: protocol.ProjectChangedEventBody[] = map(expected, e => { + return { + projectName: e.data.project.getProjectName(), + changedFiles: e.data.changedFiles, + fileNamesToEmit: e.data.filesToEmit + }; + }); + const outputEventRegex = /Content\-Length: [\d]+\r\n\r\n/; + const events: protocol.ProjectStructureChangedEvent[] = filter( + map( + host.getOutput(), s => convertToObject( + ts.parseJsonText("json.json", s.replace(outputEventRegex, "")), + [] + ) + ), + e => e.event === server.ProjectChangedEvent + ); + assert.equal(events.length, expectedEvents.length, `Incorrect number of events Actual: ${map(events, e => e.body)} Expected: ${expectedEvents}`); + forEach(events, (actualEvent, i) => { + const expectedEvent = expectedEvents[i]; + assert.strictEqual(actualEvent.body.projectName, expectedEvent.projectName); + verifyFiles("changedFiles", actualEvent.body.changedFiles, expectedEvent.changedFiles); + verifyFiles("fileNamesToEmit", actualEvent.body.fileNamesToEmit, expectedEvent.fileNamesToEmit); + }); + + // Verified the events, reset them + host.clearOutput(); + } + } + }); + }); } diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 884ee518a30b9..385ade0b81644 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -10,14 +10,14 @@ namespace ts.server { export const maxProgramSizeForNonTsFiles = 20 * 1024 * 1024; - export const ContextEvent = "context"; + export const ProjectChangedEvent = "projectChanged"; export const ConfigFileDiagEvent = "configFileDiag"; export const ProjectLanguageServiceStateEvent = "projectLanguageServiceState"; export const ProjectInfoTelemetryEvent = "projectInfo"; - export interface ContextEvent { - eventName: typeof ContextEvent; - data: { project: Project; fileName: NormalizedPath }; + export interface ProjectChangedEvent { + eventName: typeof ProjectChangedEvent; + data: { project: Project; filesToEmit: string[]; changedFiles: string[] }; } export interface ConfigFileDiagEvent { @@ -77,7 +77,7 @@ namespace ts.server { readonly dts: number; } - export type ProjectServiceEvent = ContextEvent | ConfigFileDiagEvent | ProjectLanguageServiceStateEvent | ProjectInfoTelemetryEvent; + export type ProjectServiceEvent = ProjectChangedEvent | ConfigFileDiagEvent | ProjectLanguageServiceStateEvent | ProjectInfoTelemetryEvent; export interface ProjectServiceEventHandler { (event: ProjectServiceEvent): void; @@ -492,9 +492,32 @@ namespace ts.server { if (this.pendingProjectUpdates.delete(projectName)) { project.updateGraph(); } + // Send the update event to notify about the project changes + this.sendProjectChangedEvent(project); }); } + private sendProjectChangedEvent(project: Project) { + if (project.isClosed() || !this.eventHandler || !project.languageServiceEnabled) { + return; + } + + const { filesToEmit, changedFiles } = project.getChangedFiles(); + if (changedFiles.length === 0) { + return; + } + + const event: ProjectChangedEvent = { + eventName: ProjectChangedEvent, + data: { + project, + filesToEmit: filesToEmit as string[], + changedFiles: changedFiles as string[] + } + }; + this.eventHandler(event); + } + /* @internal */ delayUpdateProjectGraphAndInferredProjectsRefresh(project: Project) { this.delayUpdateProjectGraph(project); @@ -678,19 +701,6 @@ namespace ts.server { // update projects to make sure that set of referenced files is correct this.delayUpdateProjectGraphs(containingProjects); - - // TODO: (sheetalkamat) Someway to send this event so that error checks are updated? - // if (!this.eventHandler) { - // return; - // } - - // for (const openFile of this.openFiles) { - // const event: ContextEvent = { - // eventName: ContextEvent, - // data: { project: openFile.getDefaultProject(), fileName: openFile.fileName } - // }; - // this.eventHandler(event); - // } } } diff --git a/src/server/project.ts b/src/server/project.ts index b35298b293867..cce0fc5b5103a 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -133,7 +133,7 @@ namespace ts.server { /*@internal*/ lsHost: LSHost; - builder: Builder; + private builder: Builder; /** * Set of files names that were updated since the last call to getChangesSinceVersion. */ @@ -307,6 +307,12 @@ namespace ts.server { return !emitSkipped; } + getChangedFiles() { + Debug.assert(this.languageServiceEnabled); + this.ensureBuilder(); + return this.builder.getChangedProgramFiles(this.program); + } + getProjectVersion() { return this.projectStateVersion.toString(); } @@ -392,6 +398,10 @@ namespace ts.server { this.languageService = undefined; } + isClosed() { + return this.lsHost === undefined; + } + getCompilerOptions() { return this.compilerOptions; } @@ -635,8 +645,9 @@ namespace ts.server { // update builder only if language service is enabled // otherwise tell it to drop its internal state + // Note we are retaining builder so we can send events for project change if (this.builder) { - if (this.languageServiceEnabled && this.compileOnSaveEnabled) { + if (this.languageServiceEnabled) { this.builder.onProgramUpdateGraph(this.program, this.lsHost.hasInvalidatedResolution); } else { diff --git a/src/server/protocol.ts b/src/server/protocol.ts index f50c873112671..3cc8555032fb1 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2040,6 +2040,29 @@ namespace ts.server.protocol { languageServiceEnabled: boolean; } + export type ProjectChangedEventName = "projectChanged"; + export interface ProjectStructureChangedEvent extends Event { + event: ProjectChangedEventName; + body: ProjectChangedEventBody; + } + + export interface ProjectChangedEventBody { + /** + * Project name that has changes + */ + projectName: string; + + /** + * Minimum set of file names to emit + */ + fileNamesToEmit: string[]; + + /** + * List of files that have changed/added/removed or could have been affected by the changed files + */ + changedFiles: string[]; + } + /** * Arguments for reload request. */ diff --git a/src/server/session.ts b/src/server/session.ts index d4fc22425a065..4b42aaba96a2d 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -332,14 +332,18 @@ namespace ts.server { private defaultEventHandler(event: ProjectServiceEvent) { switch (event.eventName) { - case ContextEvent: - const { project, fileName } = event.data; - this.projectService.logger.info(`got context event, updating diagnostics for ${fileName}`); - this.errorCheck.startNew(next => this.updateErrorCheck(next, [{ fileName, project }], 100)); + case ProjectChangedEvent: + const { project, filesToEmit, changedFiles } = event.data; + this.projectChangedEvent(project, filesToEmit, changedFiles); break; case ConfigFileDiagEvent: - const { triggerFile, configFileName, diagnostics } = event.data; - this.configFileDiagnosticEvent(triggerFile, configFileName, diagnostics); + const { triggerFile, configFileName: configFile, diagnostics } = event.data; + const bakedDiags = map(diagnostics, diagnostic => formatConfigFileDiag(diagnostic, /*includeFileName*/ true)); + this.event({ + triggerFile, + configFile, + diagnostics: bakedDiags + }, "configFileDiag"); break; case ProjectLanguageServiceStateEvent: { const eventName: protocol.ProjectLanguageServiceStateEventName = "projectLanguageServiceState"; @@ -360,6 +364,24 @@ namespace ts.server { } } + private projectChangedEvent(project: Project, fileNamesToEmit: string[], changedFiles: string[]): void { + this.projectService.logger.info(`got project changed event, updating diagnostics for ${changedFiles}`); + if (changedFiles.length) { + const checkList = this.createCheckList(changedFiles, project); + + // For now only queue error checking for open files. We can change this to include non open files as well + this.errorCheck.startNew(next => this.updateErrorCheck(next, checkList, 100, /*requireOpen*/ true)); + + + // Send project changed event + this.event({ + projectName: project.getProjectName(), + changedFiles, + fileNamesToEmit + }, "projectChanged"); + } + } + public logError(err: Error, cmd: string) { let msg = "Exception on executing command " + cmd; if (err.message) { @@ -381,21 +403,6 @@ namespace ts.server { this.host.write(formatMessage(msg, this.logger, this.byteLength, this.host.newLine)); } - public configFileDiagnosticEvent(triggerFile: string, configFile: string, diagnostics: ReadonlyArray) { - const bakedDiags = map(diagnostics, diagnostic => formatConfigFileDiag(diagnostic, /*includeFileName*/ true)); - const ev: protocol.ConfigFileDiagnosticEvent = { - seq: 0, - type: "event", - event: "configFileDiag", - body: { - triggerFile, - configFile, - diagnostics: bakedDiags - } - }; - this.send(ev); - } - public event(info: T, eventName: string) { const ev: protocol.Event = { seq: 0, @@ -1257,13 +1264,16 @@ namespace ts.server { } } - private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { - const checkList = mapDefined(fileNames, uncheckedFileName => { + private createCheckList(fileNames: string[], defaultProject?: Project): PendingErrorCheck[] { + return mapDefined(fileNames, uncheckedFileName => { const fileName = toNormalizedPath(uncheckedFileName); - const project = this.projectService.getDefaultProjectForFile(fileName, /*refreshInferredProjects*/ true); + const project = defaultProject || this.projectService.getDefaultProjectForFile(fileName, /*refreshInferredProjects*/ true); return project && { fileName, project }; }); + } + private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { + const checkList = this.createCheckList(fileNames); if (checkList.length > 0) { this.updateErrorCheck(next, checkList, delay); }