diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 562a1c6a206..095de4cecb0 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -3369,7 +3369,7 @@ Actual: ${stringify(fullActual)}`); this.languageServiceAdapterHost.renameFileOrDirectory(oldPath, newPath); this.languageService.cleanupSemanticCache(); - const pathUpdater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false)); + const pathUpdater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false), /*sourceMapper*/ undefined); test(renameKeys(newFileContents, key => pathUpdater(key) || key), "with file moved"); } diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 761be1ae999..fa9c88d3ffa 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -155,7 +155,7 @@ namespace Harness.LanguageService { this.vfs.mkdirpSync(ts.getDirectoryPath(newPath)); this.vfs.renameSync(oldPath, newPath); - const updater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(this.useCaseSensitiveFileNames())); + const updater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(this.useCaseSensitiveFileNames()), /*sourceMapper*/ undefined); this.scriptInfos.forEach((scriptInfo, key) => { const newFileName = updater(key); if (newFileName !== undefined) { diff --git a/src/server/session.ts b/src/server/session.ts index abb20371b95..8f32cc7c68b 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -286,6 +286,16 @@ namespace ts.server { : deduplicate(outputs, areEqual); } + function combineProjectOutputFromEveryProject(projectService: ProjectService, action: (project: Project) => ReadonlyArray, areEqual: (a: T, b: T) => boolean) { + const outputs: T[] = []; + projectService.forEachProject(project => { + if (project.isOrphan() || !project.languageServiceEnabled) return; + const theseOutputs = action(project); + outputs.push(...theseOutputs.filter(output => !outputs.some(o => areEqual(o, output)))); + }); + return outputs; + } + function combineProjectOutputWhileOpeningReferencedProjects( projects: Projects, projectService: ProjectService, @@ -1749,19 +1759,11 @@ namespace ts.server { const newPath = toNormalizedPath(args.newFilePath); const formatOptions = this.getHostFormatOptions(); const preferences = this.getHostPreferences(); - - const changes: (protocol.FileCodeEdits | FileTextChanges)[] = []; - this.projectService.forEachProject(project => { - if (project.isOrphan() || !project.languageServiceEnabled) return; - for (const fileTextChanges of project.getLanguageService().getEditsForFileRename(oldPath, newPath, formatOptions, preferences)) { - // Subsequent projects may make conflicting edits to the same file -- just go with the first. - if (!changes.some(f => f.fileName === fileTextChanges.fileName)) { - changes.push(simplifiedResult ? this.mapTextChangeToCodeEdit(project, fileTextChanges) : fileTextChanges); - } - } - }); - - return changes as ReadonlyArray | ReadonlyArray; + const changes = combineProjectOutputFromEveryProject( + this.projectService, + project => project.getLanguageService().getEditsForFileRename(oldPath, newPath, formatOptions, preferences), + (a, b) => a.fileName === b.fileName); + return simplifiedResult ? changes.map(c => this.mapTextChangeToCodeEditUsingScriptInfo(c)) : changes; } private getCodeFixes(args: protocol.CodeFixRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray | undefined { @@ -1835,8 +1837,15 @@ namespace ts.server { } private mapTextChangeToCodeEdit(project: Project, change: FileTextChanges): protocol.FileCodeEdits { - const path = normalizedPathToPath(toNormalizedPath(change.fileName), this.host.getCurrentDirectory(), fileName => this.getCanonicalFileName(fileName)); - return mapTextChangesToCodeEdits(change, project.getSourceFileOrConfigFile(path)); + return mapTextChangesToCodeEdits(change, project.getSourceFileOrConfigFile(this.normalizePath(change.fileName))); + } + + private mapTextChangeToCodeEditUsingScriptInfo(change: FileTextChanges): protocol.FileCodeEdits { + return mapTextChangesToCodeEditsUsingScriptInfo(change, this.projectService.getScriptInfo(this.normalizePath(change.fileName))); + } + + private normalizePath(fileName: string) { + return normalizedPathToPath(toNormalizedPath(fileName), this.host.getCurrentDirectory(), fileName => this.getCanonicalFileName(fileName)); } private convertTextChangeToCodeEdit(change: TextChange, scriptInfo: ScriptInfo): protocol.CodeEdit { @@ -2361,6 +2370,13 @@ namespace ts.server { } } + function mapTextChangesToCodeEditsUsingScriptInfo(textChanges: FileTextChanges, scriptInfo: ScriptInfo | undefined): protocol.FileCodeEdits { + Debug.assert(!!textChanges.isNewFile === !scriptInfo); + return scriptInfo + ? { fileName: textChanges.fileName, textChanges: textChanges.textChanges.map(textChange => convertTextChangeToCodeEditUsingScriptInfo(textChange, scriptInfo)) } + : convertNewFileTextChangeToCodeEdit(textChanges); + } + function convertTextChangeToCodeEdit(change: TextChange, sourceFile: SourceFile): protocol.CodeEdit { return { start: convertToLocation(sourceFile.getLineAndCharacterOfPosition(change.span.start)), @@ -2369,6 +2385,10 @@ namespace ts.server { }; } + function convertTextChangeToCodeEditUsingScriptInfo(change: TextChange, scriptInfo: ScriptInfo) { + return { start: scriptInfo.positionToLineOffset(change.span.start), end: scriptInfo.positionToLineOffset(textSpanEnd(change.span)), newText: change.newText }; + } + function convertNewFileTextChangeToCodeEdit(textChanges: FileTextChanges): protocol.FileCodeEdits { Debug.assert(textChanges.textChanges.length === 1); const change = first(textChanges.textChanges); diff --git a/src/services/getEditsForFileRename.ts b/src/services/getEditsForFileRename.ts index bfb737ef03c..932c777907e 100644 --- a/src/services/getEditsForFileRename.ts +++ b/src/services/getEditsForFileRename.ts @@ -1,10 +1,18 @@ /* @internal */ namespace ts { - export function getEditsForFileRename(program: Program, oldFileOrDirPath: string, newFileOrDirPath: string, host: LanguageServiceHost, formatContext: formatting.FormatContext, preferences: UserPreferences): ReadonlyArray { + export function getEditsForFileRename( + program: Program, + oldFileOrDirPath: string, + newFileOrDirPath: string, + host: LanguageServiceHost, + formatContext: formatting.FormatContext, + preferences: UserPreferences, + sourceMapper: SourceMapper, + ): ReadonlyArray { const useCaseSensitiveFileNames = hostUsesCaseSensitiveFileNames(host); const getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); - const oldToNew = getPathUpdater(oldFileOrDirPath, newFileOrDirPath, getCanonicalFileName); - const newToOld = getPathUpdater(newFileOrDirPath, oldFileOrDirPath, getCanonicalFileName); + const oldToNew = getPathUpdater(oldFileOrDirPath, newFileOrDirPath, getCanonicalFileName, sourceMapper); + const newToOld = getPathUpdater(newFileOrDirPath, oldFileOrDirPath, getCanonicalFileName, sourceMapper); return textChanges.ChangeTracker.with({ host, formatContext }, changeTracker => { updateTsconfigFiles(program, changeTracker, oldToNew, newFileOrDirPath, host.getCurrentDirectory(), useCaseSensitiveFileNames); updateImports(program, changeTracker, oldToNew, newToOld, host, getCanonicalFileName, preferences); @@ -14,13 +22,27 @@ namespace ts { /** If 'path' refers to an old directory, returns path in the new directory. */ type PathUpdater = (path: string) => string | undefined; // exported for tests - export function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName): PathUpdater { + export function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName, sourceMapper: SourceMapper | undefined): PathUpdater { const canonicalOldPath = getCanonicalFileName(oldFileOrDirPath); return path => { - if (getCanonicalFileName(path) === canonicalOldPath) return newFileOrDirPath; - const suffix = tryRemoveDirectoryPrefix(path, canonicalOldPath, getCanonicalFileName); - return suffix === undefined ? undefined : newFileOrDirPath + "/" + suffix; + const originalPath = sourceMapper && sourceMapper.tryGetOriginalLocation({ fileName: path, position: 0 }); + const updatedPath = getUpdatedPath(originalPath ? originalPath.fileName : path); + return originalPath + ? updatedPath === undefined ? undefined : makeCorrespondingRelativeChange(originalPath.fileName, updatedPath, path, getCanonicalFileName) + : updatedPath; }; + + function getUpdatedPath(pathToUpdate: string): string | undefined { + if (getCanonicalFileName(pathToUpdate) === canonicalOldPath) return newFileOrDirPath; + const suffix = tryRemoveDirectoryPrefix(pathToUpdate, canonicalOldPath, getCanonicalFileName); + return suffix === undefined ? undefined : newFileOrDirPath + "/" + suffix; + } + } + + // Relative path from a0 to b0 should be same as relative path from a1 to b1. Returns b1. + function makeCorrespondingRelativeChange(a0: string, b0: string, a1: string, getCanonicalFileName: GetCanonicalFileName): string { + const rel = getRelativePathFromFile(a0, b0, getCanonicalFileName); + return combinePathsSafe(getDirectoryPath(a1), rel); } function updateTsconfigFiles(program: Program, changeTracker: textChanges.ChangeTracker, oldToNew: PathUpdater, newFileOrDirPath: string, currentDirectory: string, useCaseSensitiveFileNames: boolean): void { diff --git a/src/services/services.ts b/src/services/services.ts index d294f53830c..cc195740aef 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -1812,7 +1812,7 @@ namespace ts { } function getEditsForFileRename(oldFilePath: string, newFilePath: string, formatOptions: FormatCodeSettings, preferences: UserPreferences = emptyOptions): ReadonlyArray { - return ts.getEditsForFileRename(getProgram()!, oldFilePath, newFilePath, host, formatting.getFormatContext(formatOptions), preferences); + return ts.getEditsForFileRename(getProgram()!, oldFilePath, newFilePath, host, formatting.getFormatContext(formatOptions), preferences, sourceMapper); } function applyCodeActionCommand(action: CodeActionCommand): Promise; diff --git a/src/testRunner/unittests/tsserverProjectSystem.ts b/src/testRunner/unittests/tsserverProjectSystem.ts index 70f4a9e37c7..7e09bae555b 100644 --- a/src/testRunner/unittests/tsserverProjectSystem.ts +++ b/src/testRunner/unittests/tsserverProjectSystem.ts @@ -9210,6 +9210,22 @@ export function Test2() { }); }); + + it("getEditsForFileRename", () => { + const { session, aTs, userTs } = makeSampleProjects(); + const response = executeSessionRequest(session, protocol.CommandTypes.GetEditsForFileRename, { + oldFilePath: aTs.path, + newFilePath: "/a/aNew.ts", + }); + assert.deepEqual>(response, [ + { + fileName: userTs.path, + textChanges: [ + { ...protocolTextSpanFromSubstring(userTs.content, "../a/bin/a"), newText: "../a/bin/aNew" }, + ], + }, + ]); + }); }); function makeReferenceItem(file: File, isDefinition: boolean, text: string, lineText: string, options?: SpanFromSubstringOptions): protocol.ReferencesResponseItem { diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index e38bf10b32b..fd2bdd559a9 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -11215,10 +11215,10 @@ declare namespace ts.FindAllReferences.Core { function getReferenceEntriesForShorthandPropertyAssignment(node: Node, checker: TypeChecker, addReference: (node: Node) => void): void; } declare namespace ts { - function getEditsForFileRename(program: Program, oldFileOrDirPath: string, newFileOrDirPath: string, host: LanguageServiceHost, formatContext: formatting.FormatContext, preferences: UserPreferences): ReadonlyArray; + function getEditsForFileRename(program: Program, oldFileOrDirPath: string, newFileOrDirPath: string, host: LanguageServiceHost, formatContext: formatting.FormatContext, preferences: UserPreferences, sourceMapper: SourceMapper): ReadonlyArray; /** If 'path' refers to an old directory, returns path in the new directory. */ type PathUpdater = (path: string) => string | undefined; - function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName): PathUpdater; + function getPathUpdater(oldFileOrDirPath: string, newFileOrDirPath: string, getCanonicalFileName: GetCanonicalFileName, sourceMapper: SourceMapper | undefined): PathUpdater; } declare namespace ts.GoToDefinition { function getDefinitionAtPosition(program: Program, sourceFile: SourceFile, position: number): DefinitionInfo[] | undefined; @@ -14253,6 +14253,8 @@ declare namespace ts.server { private mapCodeFixAction; private mapTextChangesToCodeEdits; private mapTextChangeToCodeEdit; + private mapTextChangeToCodeEditUsingScriptInfo; + private normalizePath; private convertTextChangeToCodeEdit; private getBraceMatching; private getDiagnosticsForProject;