diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 5abc19cdbc4..b7eca0ee51c 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -472,6 +472,33 @@ namespace ts { } } + function recursiveCreateDirectory(directoryPath: string, sys: System) { + const basePath = getDirectoryPath(directoryPath); + const shouldCreateParent = basePath !== "" && directoryPath !== basePath && !sys.directoryExists(basePath); + if (shouldCreateParent) { + recursiveCreateDirectory(basePath, sys); + } + if (shouldCreateParent || !sys.directoryExists(directoryPath)) { + sys.createDirectory(directoryPath); + } + } + + /** + * patch writefile to create folder before writing the file + */ + /*@internal*/ + export function patchWriteFileEnsuringDirectory(sys: System) { + // patch writefile to create folder before writing the file + const originalWriteFile = sys.writeFile; + sys.writeFile = (path, data, writeBom) => { + const directoryPath = getDirectoryPath(normalizeSlashes(path)); + if (directoryPath && !sys.directoryExists(directoryPath)) { + recursiveCreateDirectory(directoryPath, sys); + } + originalWriteFile.call(sys, path, data, writeBom); + }; + } + /*@internal*/ interface NodeBuffer extends Uint8Array { write(str: string, offset?: number, length?: number, encoding?: string): number; @@ -1259,17 +1286,6 @@ namespace ts { }; } - function recursiveCreateDirectory(directoryPath: string, sys: System) { - const basePath = getDirectoryPath(directoryPath); - const shouldCreateParent = basePath !== "" && directoryPath !== basePath && !sys.directoryExists(basePath); - if (shouldCreateParent) { - recursiveCreateDirectory(basePath, sys); - } - if (shouldCreateParent || !sys.directoryExists(directoryPath)) { - sys.createDirectory(directoryPath); - } - } - let sys: System | undefined; if (typeof ChakraHost !== "undefined") { sys = getChakraSystem(); @@ -1281,14 +1297,7 @@ namespace ts { } if (sys) { // patch writefile to create folder before writing the file - const originalWriteFile = sys.writeFile; - sys.writeFile = (path, data, writeBom) => { - const directoryPath = getDirectoryPath(normalizeSlashes(path)); - if (directoryPath && !sys!.directoryExists(directoryPath)) { - recursiveCreateDirectory(directoryPath, sys!); - } - originalWriteFile.call(sys, path, data, writeBom); - }; + patchWriteFileEnsuringDirectory(sys); } return sys!; })(); diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts index 9e615a02638..23127f49aad 100644 --- a/src/harness/virtualFileSystemWithWatch.ts +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -66,6 +66,8 @@ interface Array {}` params.newLine, params.useWindowsStylePaths, params.environmentVariables); + // Just like sys, patch the host to use writeFile + patchWriteFileEnsuringDirectory(host); return host; } @@ -990,6 +992,19 @@ interface Array {}` } } + export type TestServerHostTrackingWrittenFiles = TestServerHost & { writtenFiles: Map; }; + + export function changeToHostTrackingWrittenFiles(inputHost: TestServerHost) { + const host = inputHost as TestServerHostTrackingWrittenFiles; + const originalWriteFile = host.writeFile; + host.writtenFiles = createMap(); + host.writeFile = (fileName, content) => { + originalWriteFile.call(host, fileName, content); + const path = host.toFullPath(fileName); + host.writtenFiles.set(path, true); + }; + return host; + } export const tsbuildProjectsLocation = "/user/username/projects"; export function getTsBuildProjectFilePath(project: string, file: string) { return `${tsbuildProjectsLocation}/${project}/${file}`; diff --git a/src/server/project.ts b/src/server/project.ts index cd5a3fec855..2811b04c413 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -537,8 +537,11 @@ namespace ts.server { return this.projectService.getSourceFileLike(fileName, this); } - private shouldEmitFile(scriptInfo: ScriptInfo) { - return scriptInfo && !scriptInfo.isDynamicOrHasMixedContent(); + /*@internal*/ + shouldEmitFile(scriptInfo: ScriptInfo | undefined) { + return scriptInfo && + !scriptInfo.isDynamicOrHasMixedContent() && + !this.program!.isSourceOfProjectReferenceRedirect(scriptInfo.path); } getCompileOnSaveAffectedFileList(scriptInfo: ScriptInfo): string[] { @@ -548,7 +551,7 @@ namespace ts.server { updateProjectIfDirty(this); this.builderState = BuilderState.create(this.program!, this.projectService.toCanonicalFileName, this.builderState); return mapDefined(BuilderState.getFilesAffectedBy(this.builderState, this.program!, scriptInfo.path, this.cancellationToken, data => this.projectService.host.createHash!(data)), // TODO: GH#18217 - sourceFile => this.shouldEmitFile(this.projectService.getScriptInfoForPath(sourceFile.path)!) ? sourceFile.fileName : undefined); + sourceFile => this.shouldEmitFile(this.projectService.getScriptInfoForPath(sourceFile.path)) ? sourceFile.fileName : undefined); } /** diff --git a/src/server/session.ts b/src/server/session.ts index a5c13574805..ff080998552 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1030,7 +1030,9 @@ namespace ts.server { private getEmitOutput(args: protocol.FileRequestArgs): EmitOutput { const { file, project } = this.getFileAndProject(args); - return project.getLanguageService().getEmitOutput(file); + return project.shouldEmitFile(project.getScriptInfo(file)) ? + project.getLanguageService().getEmitOutput(file) : + { emitSkipped: true, outputFiles: [] }; } private mapDefinitionInfo(definitions: ReadonlyArray, project: Project): ReadonlyArray { diff --git a/src/testRunner/tsconfig.json b/src/testRunner/tsconfig.json index 9d67215ffd9..ee2aba6275f 100644 --- a/src/testRunner/tsconfig.json +++ b/src/testRunner/tsconfig.json @@ -137,6 +137,7 @@ "unittests/tsserver/occurences.ts", "unittests/tsserver/openFile.ts", "unittests/tsserver/projectErrors.ts", + "unittests/tsserver/projectReferenceCompileOnSave.ts", "unittests/tsserver/projectReferenceErrors.ts", "unittests/tsserver/projectReferences.ts", "unittests/tsserver/projects.ts", diff --git a/src/testRunner/unittests/tsbuildWatchMode.ts b/src/testRunner/unittests/tsbuildWatchMode.ts index 6a577888c7e..dcc2fdbb1ac 100644 --- a/src/testRunner/unittests/tsbuildWatchMode.ts +++ b/src/testRunner/unittests/tsbuildWatchMode.ts @@ -2,18 +2,12 @@ namespace ts.tscWatch { import projectsLocation = TestFSWithWatch.tsbuildProjectsLocation; import getFilePathInProject = TestFSWithWatch.getTsBuildProjectFilePath; import getFileFromProject = TestFSWithWatch.getTsBuildProjectFile; - type TsBuildWatchSystem = WatchedSystem & { writtenFiles: Map; }; + type TsBuildWatchSystem = TestFSWithWatch.TestServerHostTrackingWrittenFiles; function createTsBuildWatchSystem(fileOrFolderList: ReadonlyArray, params?: TestFSWithWatch.TestServerHostCreationParameters) { - const host = createWatchedSystem(fileOrFolderList, params) as TsBuildWatchSystem; - const originalWriteFile = host.writeFile; - host.writtenFiles = createMap(); - host.writeFile = (fileName, content) => { - originalWriteFile.call(host, fileName, content); - const path = host.toFullPath(fileName); - host.writtenFiles.set(path, true); - }; - return host; + return TestFSWithWatch.changeToHostTrackingWrittenFiles( + createWatchedSystem(fileOrFolderList, params) + ); } export function createSolutionBuilder(system: WatchedSystem, rootNames: ReadonlyArray, defaultOptions?: BuildOptions) { diff --git a/src/testRunner/unittests/tsserver/helpers.ts b/src/testRunner/unittests/tsserver/helpers.ts index 1c6bd4e9f59..12611f79266 100644 --- a/src/testRunner/unittests/tsserver/helpers.ts +++ b/src/testRunner/unittests/tsserver/helpers.ts @@ -498,7 +498,7 @@ namespace ts.projectSystem { return protocolToLocation(str)(start); } - function protocolToLocation(text: string): (pos: number) => protocol.Location { + export function protocolToLocation(text: string): (pos: number) => protocol.Location { const lineStarts = computeLineStarts(text); return pos => { const x = computeLineAndCharacterOfPosition(lineStarts, pos); diff --git a/src/testRunner/unittests/tsserver/projectReferenceCompileOnSave.ts b/src/testRunner/unittests/tsserver/projectReferenceCompileOnSave.ts new file mode 100644 index 00000000000..9602ef6360e --- /dev/null +++ b/src/testRunner/unittests/tsserver/projectReferenceCompileOnSave.ts @@ -0,0 +1,410 @@ +namespace ts.projectSystem { + describe("unittests:: tsserver:: with project references and compile on save", () => { + const projectLocation = "/user/username/projects/myproject"; + const dependecyLocation = `${projectLocation}/dependency`; + const usageLocation = `${projectLocation}/usage`; + const dependencyTs: File = { + path: `${dependecyLocation}/fns.ts`, + content: `export function fn1() { } +export function fn2() { } +` + }; + const dependencyConfig: File = { + path: `${dependecyLocation}/tsconfig.json`, + content: JSON.stringify({ + compilerOptions: { composite: true, declarationDir: "../decls" }, + compileOnSave: true + }) + }; + const usageTs: File = { + path: `${usageLocation}/usage.ts`, + content: `import { + fn1, + fn2, +} from '../decls/fns' +fn1(); +fn2(); +` + }; + const usageConfig: File = { + path: `${usageLocation}/tsconfig.json`, + content: JSON.stringify({ + compileOnSave: true, + references: [{ path: "../dependency" }] + }) + }; + + interface VerifySingleScenarioWorker extends VerifySingleScenario { + withProject: boolean; + } + function verifySingleScenarioWorker({ + withProject, scenario, openFiles, requestArgs, change, expectedResult + }: VerifySingleScenarioWorker) { + it(scenario, () => { + const host = TestFSWithWatch.changeToHostTrackingWrittenFiles( + createServerHost([dependencyTs, dependencyConfig, usageTs, usageConfig, libFile]) + ); + const session = createSession(host); + openFilesForSession(openFiles(), session); + const reqArgs = requestArgs(); + const { + expectedAffected, + expectedEmit: { expectedEmitSuccess, expectedFiles }, + expectedEmitOutput + } = expectedResult(withProject); + + if (change) { + session.executeCommandSeq({ + command: protocol.CommandTypes.CompileOnSaveAffectedFileList, + arguments: { file: dependencyTs.path } + }); + const { file, insertString } = change(); + if (session.getProjectService().openFiles.has(file.path)) { + const toLocation = protocolToLocation(file.content); + const location = toLocation(file.content.length); + session.executeCommandSeq({ + command: protocol.CommandTypes.Change, + arguments: { + file: file.path, + ...location, + endLine: location.line, + endOffset: location.offset, + insertString + } + }); + } + else { + host.writeFile(file.path, `${file.content}${insertString}`); + } + host.writtenFiles.clear(); + } + + const args = withProject ? reqArgs : { file: reqArgs.file }; + // Verify CompileOnSaveAffectedFileList + const actualAffectedFiles = session.executeCommandSeq({ + command: protocol.CommandTypes.CompileOnSaveAffectedFileList, + arguments: args + }).response as protocol.CompileOnSaveAffectedFileListSingleProject[]; + assert.deepEqual(actualAffectedFiles, expectedAffected, "Affected files"); + + // Verify CompileOnSaveEmit + const actualEmit = session.executeCommandSeq({ + command: protocol.CommandTypes.CompileOnSaveEmitFile, + arguments: args + }).response; + assert.deepEqual(actualEmit, expectedEmitSuccess, "Emit files"); + assert.equal(host.writtenFiles.size, expectedFiles.length); + for (const file of expectedFiles) { + assert.equal(host.readFile(file.path), file.content, `Expected to write ${file.path}`); + assert.isTrue(host.writtenFiles.has(file.path), `${file.path} is newly written`); + } + + // Verify EmitOutput + const { exportedModulesFromDeclarationEmit: _1, ...actualEmitOutput } = session.executeCommandSeq({ + command: protocol.CommandTypes.EmitOutput, + arguments: args + }).response as EmitOutput; + assert.deepEqual(actualEmitOutput, expectedEmitOutput, "Emit output"); + }); + } + + interface VerifySingleScenario { + scenario: string; + openFiles: () => readonly File[]; + requestArgs: () => protocol.FileRequestArgs; + skipWithoutProject?: boolean; + change?: () => SingleScenarioChange; + expectedResult: GetSingleScenarioResult; + } + function verifySingleScenario(scenario: VerifySingleScenario) { + if (!scenario.skipWithoutProject) { + describe("without specifying project file", () => { + verifySingleScenarioWorker({ + withProject: false, + ...scenario + }); + }); + } + describe("with specifying project file", () => { + verifySingleScenarioWorker({ + withProject: true, + ...scenario + }); + }); + } + + interface SingleScenarioExpectedEmit { + expectedEmitSuccess: boolean; + expectedFiles: readonly File[]; + } + interface SingleScenarioResult { + expectedAffected: protocol.CompileOnSaveAffectedFileListSingleProject[]; + expectedEmit: SingleScenarioExpectedEmit; + expectedEmitOutput: EmitOutput; + } + type GetSingleScenarioResult = (withProject: boolean) => SingleScenarioResult; + interface SingleScenarioChange { + file: File; + insertString: string; + } + interface ScenarioDetails { + scenarioName: string; + requestArgs: () => protocol.FileRequestArgs; + skipWithoutProject?: boolean; + initial: GetSingleScenarioResult; + localChangeToDependency: GetSingleScenarioResult; + localChangeToUsage: GetSingleScenarioResult; + changeToDependency: GetSingleScenarioResult; + changeToUsage: GetSingleScenarioResult; + } + interface VerifyScenario { + openFiles: () => readonly File[]; + scenarios: readonly ScenarioDetails[]; + } + + const localChange = "function fn3() { }"; + const change = `export ${localChange}`; + const changeJs = `function fn3() { } +exports.fn3 = fn3;`; + const changeDts = "export declare function fn3(): void;"; + function verifyScenario({ openFiles, scenarios }: VerifyScenario) { + for (const { + scenarioName, requestArgs, skipWithoutProject, initial, + localChangeToDependency, localChangeToUsage, + changeToDependency, changeToUsage + } of scenarios) { + describe(scenarioName, () => { + verifySingleScenario({ + scenario: "with initial file open", + openFiles, + requestArgs, + skipWithoutProject, + expectedResult: initial + }); + + verifySingleScenario({ + scenario: "with local change to dependency", + openFiles, + requestArgs, + skipWithoutProject, + change: () => ({ file: dependencyTs, insertString: localChange }), + expectedResult: localChangeToDependency + }); + + verifySingleScenario({ + scenario: "with local change to usage", + openFiles, + requestArgs, + skipWithoutProject, + change: () => ({ file: usageTs, insertString: localChange }), + expectedResult: localChangeToUsage + }); + + verifySingleScenario({ + scenario: "with change to dependency", + openFiles, + requestArgs, + skipWithoutProject, + change: () => ({ file: dependencyTs, insertString: change }), + expectedResult: changeToDependency + }); + + verifySingleScenario({ + scenario: "with change to usage", + openFiles, + requestArgs, + skipWithoutProject, + change: () => ({ file: usageTs, insertString: change }), + expectedResult: changeToUsage + }); + }); + } + } + + function expectedAffectedFiles(config: File, fileNames: File[]): protocol.CompileOnSaveAffectedFileListSingleProject { + return { + projectFileName: config.path, + fileNames: fileNames.map(f => f.path), + projectUsesOutFile: false + }; + } + + function expectedUsageEmit(appendJsText?: string): SingleScenarioExpectedEmit { + const appendJs = appendJsText ? `${appendJsText} +` : ""; + return { + expectedEmitSuccess: true, + expectedFiles: [{ + path: `${usageLocation}/usage.js`, + content: `"use strict"; +exports.__esModule = true; +var fns_1 = require("../decls/fns"); +fns_1.fn1(); +fns_1.fn2(); +${appendJs}` + }] + }; + } + + function expectedEmitOutput({ expectedFiles }: SingleScenarioExpectedEmit): EmitOutput { + return { + outputFiles: expectedFiles.map(({ path, content }) => ({ + name: path, + text: content, + writeByteOrderMark: false + })), + emitSkipped: false + }; + } + + function expectedUsageEmitOutput(appendJsText?: string): EmitOutput { + return expectedEmitOutput(expectedUsageEmit(appendJsText)); + } + + function noEmit(): SingleScenarioExpectedEmit { + return { + expectedEmitSuccess: false, + expectedFiles: emptyArray + }; + } + + function noEmitOutput(): EmitOutput { + return { + emitSkipped: true, + outputFiles: [] + }; + } + + function expectedDependencyEmit(appendJsText?: string, appendDtsText?: string): SingleScenarioExpectedEmit { + const appendJs = appendJsText ? `${appendJsText} +` : ""; + const appendDts = appendDtsText ? `${appendDtsText} +` : ""; + return { + expectedEmitSuccess: true, + expectedFiles: [ + { + path: `${dependecyLocation}/fns.js`, + content: `"use strict"; +exports.__esModule = true; +function fn1() { } +exports.fn1 = fn1; +function fn2() { } +exports.fn2 = fn2; +${appendJs}` + }, + { + path: `${projectLocation}/decls/fns.d.ts`, + content: `export declare function fn1(): void; +export declare function fn2(): void; +${appendDts}` + } + ] + }; + } + + function expectedDependencyEmitOutput(appendJsText?: string, appendDtsText?: string): EmitOutput { + return expectedEmitOutput(expectedDependencyEmit(appendJsText, appendDtsText)); + } + + function scenarioDetailsOfUsage(isDependencyOpen?: boolean): ScenarioDetails[] { + return [ + { + scenarioName: "Of usageTs", + requestArgs: () => ({ file: usageTs.path, projectFileName: usageConfig.path }), + initial: () => initialUsageTs(), + // no change to usage so same as initial only usage file + localChangeToDependency: () => initialUsageTs(), + localChangeToUsage: () => initialUsageTs(localChange), + changeToDependency: () => initialUsageTs(), + changeToUsage: () => initialUsageTs(changeJs) + }, + { + scenarioName: "Of dependencyTs in usage project", + requestArgs: () => ({ file: dependencyTs.path, projectFileName: usageConfig.path }), + skipWithoutProject: !!isDependencyOpen, + initial: () => initialDependencyTs(), + localChangeToDependency: () => initialDependencyTs(/*noUsageFiles*/ true), + localChangeToUsage: () => initialDependencyTs(/*noUsageFiles*/ true), + changeToDependency: () => initialDependencyTs(), + changeToUsage: () => initialDependencyTs(/*noUsageFiles*/ true) + } + ]; + + function initialUsageTs(jsText?: string) { + return { + expectedAffected: [ + expectedAffectedFiles(usageConfig, [usageTs]) + ], + expectedEmit: expectedUsageEmit(jsText), + expectedEmitOutput: expectedUsageEmitOutput(jsText) + }; + } + + function initialDependencyTs(noUsageFiles?: true) { + return { + expectedAffected: [ + expectedAffectedFiles(usageConfig, noUsageFiles ? [] : [usageTs]) + ], + expectedEmit: noEmit(), + expectedEmitOutput: noEmitOutput() + }; + } + } + + function scenarioDetailsOfDependencyWhenOpen(): ScenarioDetails { + return { + scenarioName: "Of dependencyTs", + requestArgs: () => ({ file: dependencyTs.path, projectFileName: dependencyConfig.path }), + initial, + localChangeToDependency: withProject => ({ + expectedAffected: withProject ? + [ + expectedAffectedFiles(dependencyConfig, [dependencyTs]) + ] : + [ + expectedAffectedFiles(usageConfig, []), + expectedAffectedFiles(dependencyConfig, [dependencyTs]) + ], + expectedEmit: expectedDependencyEmit(localChange), + expectedEmitOutput: expectedDependencyEmitOutput(localChange) + }), + localChangeToUsage: withProject => initial(withProject, /*noUsageFiles*/ true), + changeToDependency: withProject => initial(withProject, /*noUsageFiles*/ undefined, changeJs, changeDts), + changeToUsage: withProject => initial(withProject, /*noUsageFiles*/ true) + }; + + function initial(withProject: boolean, noUsageFiles?: true, appendJs?: string, appendDts?: string): SingleScenarioResult { + return { + expectedAffected: withProject ? + [ + expectedAffectedFiles(dependencyConfig, [dependencyTs]) + ] : + [ + expectedAffectedFiles(usageConfig, noUsageFiles ? [] : [usageTs]), + expectedAffectedFiles(dependencyConfig, [dependencyTs]) + ], + expectedEmit: expectedDependencyEmit(appendJs, appendDts), + expectedEmitOutput: expectedDependencyEmitOutput(appendJs, appendDts) + }; + } + } + + describe("when dependency project is not open", () => { + verifyScenario({ + openFiles: () => [usageTs], + scenarios: scenarioDetailsOfUsage() + }); + }); + + describe("when the depedency file is open", () => { + verifyScenario({ + openFiles: () => [usageTs, dependencyTs], + scenarios: [ + ...scenarioDetailsOfUsage(/*isDependencyOpen*/ true), + scenarioDetailsOfDependencyWhenOpen(), + ] + }); + }); + }); +} diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index ea7003e471f..0b728ac95d8 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -8463,7 +8463,6 @@ declare namespace ts.server { getGlobalProjectErrors(): ReadonlyArray; getAllProjectErrors(): ReadonlyArray; getLanguageService(ensureSynchronized?: boolean): LanguageService; - private shouldEmitFile; getCompileOnSaveAffectedFileList(scriptInfo: ScriptInfo): string[]; /** * Returns true if emit was conducted