diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index aa398138f4..df7452679e 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2285,6 +2285,38 @@ is_enabled({bufnr}) *vim.lsp.document_color.is_enabled()* (`boolean`) +============================================================================== +Lua module: vim.lsp.linked_editing_range *lsp-linked_editing_range* + +The `vim.lsp.linked_editing_range` module enables "linked editing" via a +language server's `textDocument/linkedEditingRange` request. Linked editing +ranges are synchronized text regions, meaning changes in one range are +mirrored in all the others. This is helpful in HTML files for example, where +the language server can update the text of a closing tag if its opening tag +was changed. + +LSP spec: +https://microsoft.github.io/language-server-protocol/specification/#textDocument_linkedEditingRange + + +enable({enable}, {filter}) *vim.lsp.linked_editing_range.enable()* + Enable or disable a linked editing session globally or for a specific + client. The following is a practical usage example: >lua + vim.lsp.start({ + name = 'html', + cmd = '…', + on_attach = function(client) + vim.lsp.linked_editing_range.enable(true, { client_id = client.id }) + end, + }) +< + + Parameters: ~ + • {enable} (`boolean?`) `true` or `nil` to enable, `false` to disable. + • {filter} (`table?`) Optional filters |kwargs|: + • {client_id} (`integer?`) Client ID, or `nil` for all. + + ============================================================================== Lua module: vim.lsp.util *lsp-util* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 5df4795e9f..220c5fcb38 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -212,6 +212,8 @@ LSP • When inside the float created by |vim.diagnostic.open_float()| and the cursor is on a line with `DiagnosticRelatedInformation`, |gf| can be used to jump to the problematic location. +• Support for `textDocument/linkedEditingRange`: |lsp-linked_editing_range| + https://microsoft.github.io/language-server-protocol/specification/#textDocument_linkedEditingRange LUA diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index beaa8f8e8e..556634b5ec 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -16,6 +16,7 @@ local lsp = vim._defer_require('vim.lsp', { document_color = ..., --- @module 'vim.lsp.document_color' handlers = ..., --- @module 'vim.lsp.handlers' inlay_hint = ..., --- @module 'vim.lsp.inlay_hint' + linked_editing_range = ..., --- @module 'vim.lsp.linked_editing_range' log = ..., --- @module 'vim.lsp.log' protocol = ..., --- @module 'vim.lsp.protocol' rpc = ..., --- @module 'vim.lsp.rpc' diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index 9a1b78c948..bdc9b9d67c 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -205,6 +205,8 @@ local validate = vim.validate --- See [vim.lsp.ClientConfig]. --- @field workspace_folders lsp.WorkspaceFolder[]? --- +--- Whether linked editing ranges are enabled for this client. +--- @field _linked_editing_enabled boolean? --- --- Track this so that we can escalate automatically if we've already tried a --- graceful shutdown diff --git a/runtime/lua/vim/lsp/linked_editing_range.lua b/runtime/lua/vim/lsp/linked_editing_range.lua new file mode 100644 index 0000000000..ad18a9180e --- /dev/null +++ b/runtime/lua/vim/lsp/linked_editing_range.lua @@ -0,0 +1,352 @@ +--- @brief +--- The `vim.lsp.linked_editing_range` module enables "linked editing" via a language server's +--- `textDocument/linkedEditingRange` request. Linked editing ranges are synchronized text regions, +--- meaning changes in one range are mirrored in all the others. This is helpful in HTML files for +--- example, where the language server can update the text of a closing tag if its opening tag was +--- changed. +--- +--- LSP spec: https://microsoft.github.io/language-server-protocol/specification/#textDocument_linkedEditingRange + +local util = require('vim.lsp.util') +local log = require('vim.lsp.log') +local lsp = vim.lsp +local method = require('vim.lsp.protocol').Methods.textDocument_linkedEditingRange +local Range = require('vim.treesitter._range') +local api = vim.api +local M = {} + +---@class (private) vim.lsp.linked_editing_range.state Global state for linked editing ranges +---An optional word pattern (regular expression) that describes valid contents for the given ranges. +---@field word_pattern string +---@field range_index? integer The index of the range that the cursor is on. +---@field namespace integer namespace for range extmarks + +---@class (private) vim.lsp.linked_editing_range.LinkedEditor +---@field active table +---@field bufnr integer +---@field augroup integer augroup for buffer events +---@field client_states table +local LinkedEditor = { active = {} } + +---@package +---@param client_id integer +function LinkedEditor:attach(client_id) + if self.client_states[client_id] then + return + end + self.client_states[client_id] = { + namespace = api.nvim_create_namespace('nvim.lsp.linked_editing_range:' .. client_id), + word_pattern = '^[%w%-_]*$', + } +end + +---@package +---@param bufnr integer +---@param client_state vim.lsp.linked_editing_range.state +local function clear_ranges(bufnr, client_state) + api.nvim_buf_clear_namespace(bufnr, client_state.namespace, 0, -1) + client_state.range_index = nil +end + +---@package +---@param client_id integer +function LinkedEditor:detach(client_id) + local client_state = self.client_states[client_id] + if not client_state then + return + end + + --TODO: delete namespace if/when that becomes possible + clear_ranges(self.bufnr, client_state) + self.client_states[client_id] = nil + + -- Destroy the LinkedEditor instance if we are detaching the last client + if vim.tbl_isempty(self.client_states) then + api.nvim_del_augroup_by_id(self.augroup) + LinkedEditor.active[self.bufnr] = nil + end +end + +---Syncs the text of each linked editing range after a range has been edited. +--- +---@package +---@param bufnr integer +---@param client_state vim.lsp.linked_editing_range.state +local function update_ranges(bufnr, client_state) + if not client_state.range_index then + return + end + + local ns = client_state.namespace + local ranges = api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + if #ranges <= 1 then + return + end + + local r = assert(ranges[client_state.range_index]) + local replacement = api.nvim_buf_get_text(bufnr, r[2], r[3], r[4].end_row, r[4].end_col, {}) + + if not string.match(table.concat(replacement, '\n'), client_state.word_pattern) then + clear_ranges(bufnr, client_state) + return + end + + -- Join text update changes into one undo chunk. If we came here from an undo, then return. + local success = pcall(vim.cmd.undojoin) + if not success then + return + end + + for i, range in ipairs(ranges) do + if i ~= client_state.range_index then + api.nvim_buf_set_text( + bufnr, + range[2], + range[3], + range[4].end_row, + range[4].end_col, + replacement + ) + end + end +end + +---|lsp-handler| for the `textDocument/linkedEditingRange` request. Sets marks for the given ranges +---(if present) and tracks which range the cursor is currently inside. +--- +---@package +---@param err lsp.ResponseError? +---@param result lsp.LinkedEditingRanges? +---@param ctx lsp.HandlerContext +function LinkedEditor:handler(err, result, ctx) + if err then + log.error('linkededitingrange', err) + return + end + + local client_id = ctx.client_id + local client_state = self.client_states[client_id] + if not client_state then + return + end + + local bufnr = assert(ctx.bufnr) + if not api.nvim_buf_is_loaded(bufnr) or util.buf_versions[bufnr] ~= ctx.version then + return + end + + clear_ranges(bufnr, client_state) + + if not result then + return + end + + local client = assert(lsp.get_client_by_id(client_id)) + + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + local curpos = api.nvim_win_get_cursor(0) + local cursor_range = { curpos[1] - 1, curpos[2], curpos[1] - 1, curpos[2] } + for i, range in ipairs(result.ranges) do + local start_line = range.start.line + local line = lines and lines[start_line + 1] or '' + local start_col = vim.str_byteindex(line, client.offset_encoding, range.start.character, false) + local end_line = range['end'].line + line = lines and lines[end_line + 1] or '' + local end_col = vim.str_byteindex(line, client.offset_encoding, range['end'].character, false) + + api.nvim_buf_set_extmark(bufnr, client_state.namespace, start_line, start_col, { + end_line = end_line, + end_col = end_col, + hl_group = 'LspReferenceTarget', + right_gravity = false, + end_right_gravity = true, + }) + + local range_tuple = { start_line, start_col, end_line, end_col } + if Range.contains(range_tuple, cursor_range) then + client_state.range_index = i + end + end + + -- TODO: Apply the client's own word pattern, if it exists +end + +---Refreshes the linked editing ranges by issuing a new request. +---@package +function LinkedEditor:refresh() + local bufnr = self.bufnr + + util._cancel_requests({ + bufnr = bufnr, + method = method, + type = 'pending', + }) + lsp.buf_request(bufnr, method, function(client) + return util.make_position_params(0, client.offset_encoding) + end, function(...) + self:handler(...) + end) +end + +---Construct a new LinkedEditor for the buffer. +--- +---@private +---@param bufnr integer +---@return vim.lsp.linked_editing_range.LinkedEditor +function LinkedEditor.new(bufnr) + local self = setmetatable({}, { __index = LinkedEditor }) + + self.bufnr = bufnr + local augroup = + api.nvim_create_augroup('nvim.lsp.linked_editing_range:' .. bufnr, { clear = true }) + self.augroup = augroup + self.client_states = {} + + api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + buffer = bufnr, + group = augroup, + callback = function() + for _, client_state in pairs(self.client_states) do + update_ranges(bufnr, client_state) + end + self:refresh() + end, + }) + api.nvim_create_autocmd('CursorMoved', { + group = augroup, + buffer = bufnr, + callback = function() + self:refresh() + end, + }) + api.nvim_create_autocmd('LspDetach', { + group = augroup, + buffer = bufnr, + callback = function(args) + self:detach(args.data.client_id) + end, + }) + + LinkedEditor.active[bufnr] = self + return self +end + +---@param bufnr integer +---@param client vim.lsp.Client +local function attach_linked_editor(bufnr, client) + local client_id = client.id + if not lsp.buf_is_attached(bufnr, client_id) then + vim.notify( + '[LSP] Client with id ' .. client_id .. ' not attached to buffer ' .. bufnr, + vim.log.levels.WARN + ) + return + end + + if not vim.tbl_get(client.server_capabilities, 'linkedEditingRangeProvider') then + vim.notify('[LSP] Server does not support linked editing ranges', vim.log.levels.WARN) + return + end + + local linked_editor = LinkedEditor.active[bufnr] or LinkedEditor.new(bufnr) + linked_editor:attach(client_id) + linked_editor:refresh() +end + +---@param bufnr integer +---@param client vim.lsp.Client +local function detach_linked_editor(bufnr, client) + local linked_editor = LinkedEditor.active[bufnr] + if not linked_editor then + return + end + + linked_editor:detach(client.id) +end + +api.nvim_create_autocmd('LspAttach', { + desc = 'Enable linked editing ranges for all buffers this client attaches to, if enabled', + callback = function(ev) + local client = assert(lsp.get_client_by_id(ev.data.client_id)) + if not client._linked_editing_enabled or not client:supports_method(method, ev.buf) then + return + end + + attach_linked_editor(ev.buf, client) + end, +}) + +---@param enable boolean +---@param client vim.lsp.Client +local function toggle_linked_editing_for_client(enable, client) + local handler = enable and attach_linked_editor or detach_linked_editor + + -- Toggle for buffers already attached. + for bufnr, _ in pairs(client.attached_buffers) do + handler(bufnr, client) + end + + client._linked_editing_enabled = enable +end + +---@param enable boolean +local function toggle_linked_editing_globally(enable) + -- Toggle for clients that have already attached. + local clients = lsp.get_clients({ method = method }) + for _, client in ipairs(clients) do + toggle_linked_editing_for_client(enable, client) + end + + -- If disabling, only clear the attachment autocmd. If enabling, create it. + local group = api.nvim_create_augroup('nvim.lsp.linked_editing_range', { clear = true }) + if enable then + api.nvim_create_autocmd('LspAttach', { + group = group, + desc = 'Enable linked editing ranges for all clients', + callback = function(ev) + local client = assert(lsp.get_client_by_id(ev.data.client_id)) + if client:supports_method(method, ev.buf) then + attach_linked_editor(ev.buf, client) + end + end, + }) + end +end + +--- Optional filters |kwargs|: +--- @inlinedoc +--- @class vim.lsp.linked_editing_range.enable.Filter +--- @field client_id integer? Client ID, or `nil` for all. + +--- Enable or disable a linked editing session globally or for a specific client. The following is a +--- practical usage example: +--- +--- ```lua +--- vim.lsp.start({ +--- name = 'html', +--- cmd = '…', +--- on_attach = function(client) +--- vim.lsp.linked_editing_range.enable(true, { client_id = client.id }) +--- end, +--- }) +--- ``` +--- +---@param enable boolean? `true` or `nil` to enable, `false` to disable. +---@param filter vim.lsp.linked_editing_range.enable.Filter? +function M.enable(enable, filter) + vim.validate('enable', enable, 'boolean', true) + vim.validate('filter', filter, 'table', true) + + enable = enable ~= false + filter = filter or {} + + if filter.client_id then + local client = + assert(lsp.get_client_by_id(filter.client_id), 'Client not found for id ' .. filter.client_id) + toggle_linked_editing_for_client(enable, client) + else + toggle_linked_editing_globally(enable) + end +end + +return M diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 85c39fce92..314717037a 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -556,6 +556,9 @@ function protocol.make_client_capabilities() selectionRange = { dynamicRegistration = false, }, + linkedEditingRange = { + dynamicRegistration = false, + }, }, workspace = { symbol = { diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua index 7568cc3788..b46c687406 100755 --- a/src/gen/gen_vimdoc.lua +++ b/src/gen/gen_vimdoc.lua @@ -283,6 +283,7 @@ local config = { 'tagfunc.lua', 'semantic_tokens.lua', 'document_color.lua', + 'linked_editing_range.lua', 'handlers.lua', 'util.lua', 'log.lua', diff --git a/test/functional/plugin/lsp/linked_editing_range_spec.lua b/test/functional/plugin/lsp/linked_editing_range_spec.lua new file mode 100644 index 0000000000..601df123e0 --- /dev/null +++ b/test/functional/plugin/lsp/linked_editing_range_spec.lua @@ -0,0 +1,137 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local t_lsp = require('test.functional.plugin.lsp.testutil') + +local eq = t.eq +local exec_lua = n.exec_lua +local insert = n.insert +local feed = n.feed + +local clear_notrace = t_lsp.clear_notrace +local create_server_definition = t_lsp.create_server_definition + +describe('vim.lsp.linked_editing_range', function() + before_each(function() + clear_notrace() + + insert([[ + hello + hello + hello]]) + + exec_lua(create_server_definition) + exec_lua(function() + vim.lsp.linked_editing_range.enable() + + _G.server = _G._create_server({ + capabilities = { + linkedEditingRangeProvider = true, + }, + handlers = { + ['textDocument/linkedEditingRange'] = function(_, _, callback) + callback(nil, { + ranges = { + { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } }, + { start = { line = 1, character = 0 }, ['end'] = { line = 1, character = 5 } }, + { start = { line = 2, character = 0 }, ['end'] = { line = 2, character = 5 } }, + }, + }) + end, + }, + }) + + _G.server_id = vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd }) + end) + end) + + it('initiates linked editing', function() + exec_lua(function() + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(win, { 1, 0 }) + end) + -- Deletion + feed('ldw') + eq( + { + 'h', + 'h', + 'h', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + -- Insertion + feed('Apt') + eq( + { + 'hpt', + 'hpt', + 'hpt', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + -- Undo/redo + feed('0xx') + eq( + { + 't', + 't', + 't', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + feed('u') + eq( + { + 'pt', + 'pt', + 'pt', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + feed('u') + eq( + { + 'hpt', + 'hpt', + 'hpt', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + feed('') + eq( + { + 't', + 't', + 't', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + -- Disabling + exec_lua(function() + vim.lsp.linked_editing_range.enable(false, { client_id = _G.server_id }) + end) + feed('Ipp') + eq( + { + 'ppt', + 't', + 't', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + end) +end)