diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 77e5b2ed8294d..f7c1d036f84c2 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -692,6 +692,13 @@ namespace ts.server { return this.findExternalProjectByProjectName(projectName) || this.findConfiguredProjectByProjectName(toNormalizedPath(projectName)); } + /* @internal */ + forEachProject(cb: (project: Project) => void) { + for (const p of this.inferredProjects) cb(p); + this.configuredProjects.forEach(cb); + this.externalProjects.forEach(cb); + } + getDefaultProjectForFile(fileName: NormalizedPath, ensureProject: boolean): Project | undefined { let scriptInfo = this.getScriptInfoForNormalizedPath(fileName); if (ensureProject && (!scriptInfo || scriptInfo.isOrphan())) { @@ -742,6 +749,14 @@ namespace ts.server { return info && info.getPreferences() || this.hostConfiguration.preferences; } + getHostFormatCodeOptions(): FormatCodeSettings { + return this.hostConfiguration.formatCodeOptions; + } + + getHostPreferences(): UserPreferences { + return this.hostConfiguration.preferences; + } + private onSourceFileChanged(fileName: string, eventKind: FileWatcherEventKind, path: Path) { const info = this.getScriptInfoForPath(path); if (!info) { diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 49fb54a6b10dd..5e1599549563f 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -627,9 +627,8 @@ namespace ts.server.protocol { arguments: GetEditsForFileRenameRequestArgs; } - // Note: The file from FileRequestArgs is just any file in the project. - // We will generate code changes for every file in that project, so the choice is arbitrary. - export interface GetEditsForFileRenameRequestArgs extends FileRequestArgs { + /** Note: Paths may also be directories. */ + export interface GetEditsForFileRenameRequestArgs { readonly oldFilePath: string; readonly newFilePath: string; } diff --git a/src/server/session.ts b/src/server/session.ts index c6934bb8d829c..8b9bb32ef40c4 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1142,7 +1142,7 @@ namespace ts.server { return this.getPosition(args, scriptInfo); } - private getFileAndProject(args: protocol.FileRequestArgs): { file: NormalizedPath, project: Project } { + private getFileAndProject(args: protocol.FileRequestArgs): FileAndProject { return this.getFileAndProjectWorker(args.file, args.projectFileName); } @@ -1738,9 +1738,23 @@ namespace ts.server { } private getEditsForFileRename(args: protocol.GetEditsForFileRenameRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { - const { file, project } = this.getFileAndProject(args); - const changes = project.getLanguageService().getEditsForFileRename(toNormalizedPath(args.oldFilePath), toNormalizedPath(args.newFilePath), this.getFormatOptions(file), this.getPreferences(file)); - return simplifiedResult ? this.mapTextChangesToCodeEdits(project, changes) : changes; + const oldPath = toNormalizedPath(args.oldFilePath); + 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; } private getCodeFixes(args: protocol.CodeFixRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray | undefined { @@ -1810,10 +1824,12 @@ namespace ts.server { } private mapTextChangesToCodeEdits(project: Project, textChanges: ReadonlyArray): protocol.FileCodeEdits[] { - return textChanges.map(change => { - const path = normalizedPathToPath(toNormalizedPath(change.fileName), this.host.getCurrentDirectory(), fileName => this.getCanonicalFileName(fileName)); - return mapTextChangesToCodeEdits(change, project.getSourceFileOrConfigFile(path)); - }); + return textChanges.map(change => this.mapTextChangeToCodeEdit(project, change)); + } + + 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)); } private convertTextChangeToCodeEdit(change: TextChange, scriptInfo: ScriptInfo): protocol.CodeEdit { @@ -2303,6 +2319,19 @@ namespace ts.server { private getPreferences(file: NormalizedPath): UserPreferences { return this.projectService.getPreferences(file); } + + private getHostFormatOptions(): FormatCodeSettings { + return this.projectService.getHostFormatCodeOptions(); + } + + private getHostPreferences(): UserPreferences { + return this.projectService.getHostPreferences(); + } + } + + interface FileAndProject { + readonly file: NormalizedPath; + readonly project: Project; } function mapTextChangesToCodeEdits(textChanges: FileTextChanges, sourceFile: SourceFile | undefined): protocol.FileCodeEdits { diff --git a/src/testRunner/unittests/tsserverProjectSystem.ts b/src/testRunner/unittests/tsserverProjectSystem.ts index feb7377a86852..62ecdf6334ab9 100644 --- a/src/testRunner/unittests/tsserverProjectSystem.ts +++ b/src/testRunner/unittests/tsserverProjectSystem.ts @@ -421,6 +421,18 @@ namespace ts.projectSystem { return createTextSpan(start, substring.length); } + function protocolTextSpanFromSubstring(str: string, substring: string): protocol.TextSpan { + const start = str.indexOf(substring); + Debug.assert(start !== -1); + const lineStarts = computeLineStarts(str); + const toLocation = (pos: number) => lineAndCharacterToLocation(computeLineAndCharacterOfPosition(lineStarts, pos)); + return { start: toLocation(start), end: toLocation(start + substring.length) }; + } + + function lineAndCharacterToLocation(lc: LineAndCharacter): protocol.Location { + return { line: lc.line + 1, offset: lc.character + 1 }; + } + /** * Test server cancellation token used to mock host token cancellation requests. * The cancelAfterRequest constructor param specifies how many isCancellationRequested() calls @@ -464,14 +476,13 @@ namespace ts.projectSystem { } } - export function makeSessionRequest(command: string, args: T) { - const newRequest: protocol.Request = { + export function makeSessionRequest(command: string, args: T): protocol.Request { + return { seq: 0, type: "request", command, arguments: args }; - return newRequest; } export function openFilesForSession(files: ReadonlyArray, session: server.Session) { @@ -8682,6 +8693,49 @@ export const x = 10;` }], }]); }); + + it("works with multiple projects", () => { + const aUserTs: File = { + path: "/a/user.ts", + content: 'import { x } from "./old";', + }; + const aOldTs: File = { + path: "/a/old.ts", + content: "export const x = 0;", + }; + const aTsconfig: File = { + path: "/a/tsconfig.json", + content: "{}", + }; + const bUserTs: File = { + path: "/b/user.ts", + content: 'import { x } from "../a/old";', + }; + const bTsconfig: File = { + path: "/b/tsconfig.json", + content: "{}", + }; + + const host = createServerHost([aUserTs, aOldTs, aTsconfig, bUserTs, bTsconfig]); + const session = createSession(host); + openFilesForSession([aUserTs, bUserTs], session); + + const renameRequest = makeSessionRequest(CommandNames.GetEditsForFileRename, { + oldFilePath: "/a/old.ts", + newFilePath: "/a/new.ts", + }); + const response = session.executeCommand(renameRequest).response as protocol.GetEditsForFileRenameResponse["body"]; + assert.deepEqual(response, [ + { + fileName: aUserTs.path, + textChanges: [{ ...protocolTextSpanFromSubstring(aUserTs.content, "./old"), newText: "./new" }], + }, + { + fileName: bUserTs.path, + textChanges: [{ ...protocolTextSpanFromSubstring(bUserTs.content, "../a/old"), newText: "../a/new" }], + }, + ]); + }); }); describe("tsserverProjectSystem document registry in project service", () => { diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 2bd0930e4db6c..ee604e331a2c4 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -12455,7 +12455,7 @@ declare namespace ts.server.protocol { command: CommandTypes.GetEditsForFileRename; arguments: GetEditsForFileRenameRequestArgs; } - interface GetEditsForFileRenameRequestArgs extends FileRequestArgs { + interface GetEditsForFileRenameRequestArgs { readonly oldFilePath: string; readonly newFilePath: string; } @@ -13827,11 +13827,14 @@ declare namespace ts.server { private delayUpdateProjectGraphs; setCompilerOptionsForInferredProjects(projectCompilerOptions: protocol.ExternalProjectCompilerOptions, projectRootPath?: string): void; findProject(projectName: string): Project | undefined; + forEachProject(cb: (project: Project) => void): void; getDefaultProjectForFile(fileName: NormalizedPath, ensureProject: boolean): Project | undefined; getScriptInfoEnsuringProjectsUptoDate(uncheckedFileName: string): ScriptInfo | undefined; private ensureProjectStructuresUptoDate; getFormatCodeOptions(file: NormalizedPath): FormatCodeSettings; getPreferences(file: NormalizedPath): UserPreferences; + getHostFormatCodeOptions(): FormatCodeSettings; + getHostPreferences(): UserPreferences; private onSourceFileChanged; private handleDeletedFile; watchWildcardDirectory(directory: Path, flags: WatchDirectoryFlags, project: ConfiguredProject): FileWatcher; @@ -14062,6 +14065,7 @@ declare namespace ts.server { private mapCodeAction; private mapCodeFixAction; private mapTextChangesToCodeEdits; + private mapTextChangeToCodeEdit; private convertTextChangeToCodeEdit; private getBraceMatching; private getDiagnosticsForProject; @@ -14078,6 +14082,8 @@ declare namespace ts.server { onMessage(message: string): void; private getFormatOptions; private getPreferences; + private getHostFormatOptions; + private getHostPreferences; } interface HandlerResponse { response?: {};