diff --git a/lib/adapters/rename-adapter.ts b/lib/adapters/rename-adapter.ts index d87d6fc9..255e1866 100644 --- a/lib/adapters/rename-adapter.ts +++ b/lib/adapters/rename-adapter.ts @@ -3,15 +3,27 @@ import Convert from "../convert" import { Point, TextEditor } from "atom" import { LanguageClientConnection, + PrepareRenameParams, RenameParams, ServerCapabilities, TextDocumentEdit, + ApplyWorkspaceEditResponse, TextEdit, + Range, } from "../languageclient" +import ApplyEditAdapter from "./apply-edit-adapter" export default class RenameAdapter { public static canAdapt(serverCapabilities: ServerCapabilities): boolean { - return serverCapabilities.renameProvider === true + return serverCapabilities.renameProvider !== false + } + + public static canPrepare(serverCapabilities: ServerCapabilities): boolean { + if (serverCapabilities.renameProvider === undefined || typeof serverCapabilities.renameProvider === "boolean") { + return false + } + + return serverCapabilities.renameProvider.prepareProvider || false } public static async getRename( @@ -34,6 +46,36 @@ export default class RenameAdapter { } } + public static async rename( + connection: LanguageClientConnection, + editor: TextEditor, + point: Point, + newName: string + ): Promise { + const edit = await connection.rename(RenameAdapter.createRenameParams(editor, point, newName)) + return ApplyEditAdapter.onApplyEdit({ edit }) + } + + public static async prepareRename( + connection: LanguageClientConnection, + editor: TextEditor, + point: Point + ): Promise<{ possible: boolean; range?: Range; label?: string | null }> { + const result = await connection.prepareRename(RenameAdapter.createPrepareRenameParams(editor, point)) + + if (!result) { + return { possible: false } + } + if ("defaultBehavior" in result) { + return { possible: result.defaultBehavior } + } + return { + possible: true, + range: "range" in result ? result.range : result, + label: "range" in result ? result.placeholder : null, + } + } + public static createRenameParams(editor: TextEditor, point: Point, newName: string): RenameParams { return { textDocument: Convert.editorToTextDocumentIdentifier(editor), @@ -42,6 +84,13 @@ export default class RenameAdapter { } } + public static createPrepareRenameParams(editor: TextEditor, point: Point): PrepareRenameParams { + return { + textDocument: Convert.editorToTextDocumentIdentifier(editor), + position: Convert.pointToPosition(point), + } + } + public static convertChanges(changes: { [uri: string]: TextEdit[] }): Map { const result = new Map() Object.keys(changes).forEach((uri) => { diff --git a/lib/auto-languageclient.ts b/lib/auto-languageclient.ts index 189db358..416baf47 100644 --- a/lib/auto-languageclient.ts +++ b/lib/auto-languageclient.ts @@ -34,8 +34,9 @@ import { normalizePath, considerAdditionalPath, } from "./server-manager.js" -import { Disposable, CompositeDisposable, Point, Range, TextEditor } from "atom" +import { Disposable, CompositeDisposable, Point, Range, TextEditor, CommandEvent, TextEditorElement } from "atom" import * as ac from "atom/autocomplete-plus" +import Dialog from "./views/dialog" import { basename } from "path" export { ActiveServer, LanguageClientConnection, LanguageServerProcess } @@ -234,6 +235,7 @@ export default class AutoLanguageClient { dynamicRegistration: false, }, rename: { + prepareSupport: true, dynamicRegistration: false, }, moniker: { @@ -350,6 +352,7 @@ export default class AutoLanguageClient { this.shutdownGracefully ) this._serverManager.startListening() + this.registerRenameCommands() process.on("exit", () => this.exitCleanup.bind(this)) } @@ -1033,6 +1036,104 @@ export default class AutoLanguageClient { } } + public async registerRenameCommands() { + this._disposable.add( + atom.commands.add("atom-text-editor", "IDE:Rename", async (event: CommandEvent) => { + const textEditorElement = event.currentTarget + const textEditor = textEditorElement.getModel() + const bufferPosition = textEditor.getCursorBufferPosition() + const server = await this._serverManager.getServer(textEditor) + + if (!server) { + return + } + + if (!RenameAdapter.canAdapt(server.capabilities)) { + atom.notifications.addInfo(`Rename is not supported by ${this.getServerName()}`) + } + + const outcome = { possible: true, label: "Rename" } + if (RenameAdapter.canPrepare(server.capabilities)) { + const { possible } = await RenameAdapter.prepareRename(server.connection, textEditor, bufferPosition) + outcome.possible = possible + } + + if (!outcome.possible) { + atom.notifications.addWarning( + `Nothing to rename at position at row ${bufferPosition.row + 1} and column ${bufferPosition.column + 1}` + ) + return + } + const newName = await Dialog.prompt("Enter new name") + RenameAdapter.rename(server.connection, textEditor, bufferPosition, newName) + return + }) + ) + + this._disposable.add( + atom.contextMenu.add({ + "atom-text-editor": [ + { + label: "Refactor", + submenu: [{ label: "Rename", command: "IDE:Rename" }], + created: function (event: MouseEvent) { + const textEditor = atom.workspace.getActiveTextEditor() + if (!textEditor) { + return + } + + const screenPosition = atom.views.getView(textEditor).getComponent().screenPositionForMouseEvent(event) + const bufferPosition = textEditor.bufferPositionForScreenPosition(screenPosition) + + textEditor.setCursorBufferPosition(bufferPosition) + }, + }, + ], + }) + ) + } + + public provideIntentions() { + return { + grammarScopes: this.getGrammarScopes(), // [*] would also work + getIntentions: async ({ textEditor, bufferPosition }: { textEditor: TextEditor; bufferPosition: Point }) => { + const intentions: { title: string; selected: () => void }[] = [] + const server = await this._serverManager.getServer(textEditor) + + if (server == null) { + return intentions + } + + if (RenameAdapter.canAdapt(server.capabilities)) { + const outcome = { possible: true, label: "Rename" } + if (RenameAdapter.canPrepare(server.capabilities)) { + const { possible } = await RenameAdapter.prepareRename(server.connection, textEditor, bufferPosition) + outcome.possible = possible + } + + if (outcome.possible) { + intentions.push({ + title: outcome.label, + selected: async () => { + const newName = await Dialog.prompt("Enter new name") + return RenameAdapter.rename(server.connection, textEditor, bufferPosition, newName) + }, + }) + } + } + + intentions.push({ + title: "Some dummy intention", + selected: async () => { + console.log("selected") + }, + }) + + return intentions + }, + } + } + protected async getRename( editor: TextEditor, position: Point, diff --git a/lib/languageclient.ts b/lib/languageclient.ts index 63ba1343..34808a13 100644 --- a/lib/languageclient.ts +++ b/lib/languageclient.ts @@ -510,6 +510,24 @@ export class LanguageClientConnection extends EventEmitter { return this._sendRequest(lsp.DocumentOnTypeFormattingRequest.type, params) } + /** + * Public: Send a `textDocument/prepareRename` request. + * + * @param params The {PrepareRenameParams} identifying the document containing the symbol to be renamed, as well as + * the position. + * @returns A {Promise} containing either: + * + * - A {Range} of the string to rename and optionally a `placeholder` text of the string content to be renamed. + * - `{ defaultBehavior: boolean }` is returned (since 3.16) if the rename position is valid and the client should use + * its default behavior to compute the rename range. + * - `null` is returned when it is deemed that a ‘textDocument/rename’ request is not valid at the given position + */ + public prepareRename( + params: lsp.PrepareRenameParams + ): Promise { + return this._sendRequest("textDocument/prepareRename", params) + } + /** * Public: Send a `textDocument/rename` request. * diff --git a/lib/main.ts b/lib/main.ts index 3b16d2e1..16f4f801 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -9,6 +9,7 @@ import { Logger, ConsoleLogger, FilteredLogger } from "./logger" import DownloadFile from "./download-file" import LinterPushV2Adapter from "./adapters/linter-push-v2-adapter" import CommandExecutionAdapter from "./adapters/command-execution-adapter" +import RenameAdapter from "./adapters/rename-adapter" export { getExePath } from "./utils" export * from "./auto-languageclient" @@ -21,4 +22,5 @@ export { DownloadFile, LinterPushV2Adapter, CommandExecutionAdapter, + RenameAdapter, } diff --git a/lib/views/dialog.ts b/lib/views/dialog.ts new file mode 100644 index 00000000..7a28bc94 --- /dev/null +++ b/lib/views/dialog.ts @@ -0,0 +1,35 @@ +import { TextEditor } from "atom" + +export default class Dialog { + public static async prompt(message: string): Promise { + const miniEditor = new TextEditor({ mini: true }) + const editorElement = atom.views.getView(miniEditor) + + const messageElement = document.createElement("div") + messageElement.classList.add("message") + messageElement.textContent = message + + const element = document.createElement("div") + element.classList.add("prompt") + element.appendChild(editorElement) + element.appendChild(messageElement) + + const panel = atom.workspace.addModalPanel({ + item: element, + visible: true, + }) + + editorElement.focus() + + return new Promise((resolve, reject) => { + atom.commands.add(editorElement, "core:confirm", () => { + resolve(miniEditor.getText()) + panel.destroy() + }) + atom.commands.add(editorElement, "core:cancel", () => { + reject() + panel.destroy() + }) + }) + } +}