linked_editing_range.lua (10741B)
1 --- @brief 2 --- The `vim.lsp.linked_editing_range` module enables "linked editing" via a language server's 3 --- `textDocument/linkedEditingRange` request. Linked editing ranges are synchronized text regions, 4 --- meaning changes in one range are mirrored in all the others. This is helpful in HTML files for 5 --- example, where the language server can update the text of a closing tag if its opening tag was 6 --- changed. 7 --- 8 --- LSP spec: https://microsoft.github.io/language-server-protocol/specification/#textDocument_linkedEditingRange 9 10 local util = require('vim.lsp.util') 11 local log = require('vim.lsp.log') 12 local lsp = vim.lsp 13 local method = 'textDocument/linkedEditingRange' 14 local Range = require('vim.treesitter._range') 15 local api = vim.api 16 local M = {} 17 18 ---@class (private) vim.lsp.linked_editing_range.state Global state for linked editing ranges 19 ---An optional word pattern (regular expression) that describes valid contents for the given ranges. 20 ---@field word_pattern string 21 ---@field range_index? integer The index of the range that the cursor is on. 22 ---@field namespace integer namespace for range extmarks 23 24 ---@class (private) vim.lsp.linked_editing_range.LinkedEditor 25 ---@field active table<integer, vim.lsp.linked_editing_range.LinkedEditor> 26 ---@field bufnr integer 27 ---@field augroup integer augroup for buffer events 28 ---@field client_states table<integer, vim.lsp.linked_editing_range.state> 29 local LinkedEditor = { active = {} } 30 31 ---@package 32 ---@param client_id integer 33 function LinkedEditor:attach(client_id) 34 if self.client_states[client_id] then 35 return 36 end 37 self.client_states[client_id] = { 38 namespace = api.nvim_create_namespace('nvim.lsp.linked_editing_range:' .. client_id), 39 word_pattern = '^[%w%-_]*$', 40 } 41 end 42 43 ---@package 44 ---@param bufnr integer 45 ---@param client_state vim.lsp.linked_editing_range.state 46 local function clear_ranges(bufnr, client_state) 47 api.nvim_buf_clear_namespace(bufnr, client_state.namespace, 0, -1) 48 client_state.range_index = nil 49 end 50 51 ---@package 52 ---@param client_id integer 53 function LinkedEditor:detach(client_id) 54 local client_state = self.client_states[client_id] 55 if not client_state then 56 return 57 end 58 59 --TODO: delete namespace if/when that becomes possible 60 clear_ranges(self.bufnr, client_state) 61 self.client_states[client_id] = nil 62 63 -- Destroy the LinkedEditor instance if we are detaching the last client 64 if vim.tbl_isempty(self.client_states) then 65 api.nvim_del_augroup_by_id(self.augroup) 66 LinkedEditor.active[self.bufnr] = nil 67 end 68 end 69 70 ---Syncs the text of each linked editing range after a range has been edited. 71 --- 72 ---@package 73 ---@param bufnr integer 74 ---@param client_state vim.lsp.linked_editing_range.state 75 local function update_ranges(bufnr, client_state) 76 if not client_state.range_index then 77 return 78 end 79 80 local ns = client_state.namespace 81 local ranges = api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) 82 if #ranges <= 1 then 83 return 84 end 85 86 local r = assert(ranges[client_state.range_index]) 87 local replacement = api.nvim_buf_get_text(bufnr, r[2], r[3], r[4].end_row, r[4].end_col, {}) 88 89 if not string.match(table.concat(replacement, '\n'), client_state.word_pattern) then 90 clear_ranges(bufnr, client_state) 91 return 92 end 93 94 -- Join text update changes into one undo chunk. If we came here from an undo, then return. 95 local success = pcall(vim.cmd.undojoin) 96 if not success then 97 return 98 end 99 100 for i, range in ipairs(ranges) do 101 if i ~= client_state.range_index then 102 api.nvim_buf_set_text( 103 bufnr, 104 range[2], 105 range[3], 106 range[4].end_row, 107 range[4].end_col, 108 replacement 109 ) 110 end 111 end 112 end 113 114 ---|lsp-handler| for the `textDocument/linkedEditingRange` request. Sets marks for the given ranges 115 ---(if present) and tracks which range the cursor is currently inside. 116 --- 117 ---@package 118 ---@param err lsp.ResponseError? 119 ---@param result lsp.LinkedEditingRanges? 120 ---@param ctx lsp.HandlerContext 121 function LinkedEditor:handler(err, result, ctx) 122 if err then 123 log.error('linkededitingrange', err) 124 return 125 end 126 127 local client_id = ctx.client_id 128 local client_state = self.client_states[client_id] 129 if not client_state then 130 return 131 end 132 133 local bufnr = assert(ctx.bufnr) 134 if not api.nvim_buf_is_loaded(bufnr) or util.buf_versions[bufnr] ~= ctx.version then 135 return 136 end 137 138 clear_ranges(bufnr, client_state) 139 140 if not result then 141 return 142 end 143 144 local client = assert(lsp.get_client_by_id(client_id)) 145 146 local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) 147 local curpos = api.nvim_win_get_cursor(0) 148 local cursor_range = { curpos[1] - 1, curpos[2], curpos[1] - 1, curpos[2] } 149 for i, range in ipairs(result.ranges) do 150 local start_line = range.start.line 151 local line = lines and lines[start_line + 1] or '' 152 local start_col = vim.str_byteindex(line, client.offset_encoding, range.start.character, false) 153 local end_line = range['end'].line 154 line = lines and lines[end_line + 1] or '' 155 local end_col = vim.str_byteindex(line, client.offset_encoding, range['end'].character, false) 156 157 api.nvim_buf_set_extmark(bufnr, client_state.namespace, start_line, start_col, { 158 end_line = end_line, 159 end_col = end_col, 160 hl_group = 'LspReferenceTarget', 161 right_gravity = false, 162 end_right_gravity = true, 163 }) 164 165 local range_tuple = { start_line, start_col, end_line, end_col } 166 if Range.contains(range_tuple, cursor_range) then 167 client_state.range_index = i 168 end 169 end 170 171 -- TODO: Apply the client's own word pattern, if it exists 172 end 173 174 ---Refreshes the linked editing ranges by issuing a new request. 175 ---@package 176 function LinkedEditor:refresh() 177 local bufnr = self.bufnr 178 179 util._cancel_requests({ 180 bufnr = bufnr, 181 method = method, 182 type = 'pending', 183 }) 184 lsp.buf_request(bufnr, method, function(client) 185 return util.make_position_params(0, client.offset_encoding) 186 end, function(...) 187 self:handler(...) 188 end) 189 end 190 191 ---Construct a new LinkedEditor for the buffer. 192 --- 193 ---@private 194 ---@param bufnr integer 195 ---@return vim.lsp.linked_editing_range.LinkedEditor 196 function LinkedEditor.new(bufnr) 197 local self = setmetatable({}, { __index = LinkedEditor }) 198 199 self.bufnr = bufnr 200 local augroup = 201 api.nvim_create_augroup('nvim.lsp.linked_editing_range:' .. bufnr, { clear = true }) 202 self.augroup = augroup 203 self.client_states = {} 204 205 api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { 206 buffer = bufnr, 207 group = augroup, 208 callback = function() 209 for _, client_state in pairs(self.client_states) do 210 update_ranges(bufnr, client_state) 211 end 212 self:refresh() 213 end, 214 }) 215 api.nvim_create_autocmd('CursorMoved', { 216 group = augroup, 217 buffer = bufnr, 218 callback = function() 219 self:refresh() 220 end, 221 }) 222 api.nvim_create_autocmd('LspDetach', { 223 group = augroup, 224 buffer = bufnr, 225 callback = function(args) 226 self:detach(args.data.client_id) 227 end, 228 }) 229 230 LinkedEditor.active[bufnr] = self 231 return self 232 end 233 234 ---@param bufnr integer 235 ---@param client vim.lsp.Client 236 local function attach_linked_editor(bufnr, client) 237 local client_id = client.id 238 if not lsp.buf_is_attached(bufnr, client_id) then 239 vim.notify( 240 '[LSP] Client with id ' .. client_id .. ' not attached to buffer ' .. bufnr, 241 vim.log.levels.WARN 242 ) 243 return 244 end 245 246 if not vim.tbl_get(client.server_capabilities, 'linkedEditingRangeProvider') then 247 vim.notify('[LSP] Server does not support linked editing ranges', vim.log.levels.WARN) 248 return 249 end 250 251 local linked_editor = LinkedEditor.active[bufnr] or LinkedEditor.new(bufnr) 252 linked_editor:attach(client_id) 253 linked_editor:refresh() 254 end 255 256 ---@param bufnr integer 257 ---@param client vim.lsp.Client 258 local function detach_linked_editor(bufnr, client) 259 local linked_editor = LinkedEditor.active[bufnr] 260 if not linked_editor then 261 return 262 end 263 264 linked_editor:detach(client.id) 265 end 266 267 api.nvim_create_autocmd('LspAttach', { 268 desc = 'Enable linked editing ranges for all buffers this client attaches to, if enabled', 269 callback = function(ev) 270 local client = assert(lsp.get_client_by_id(ev.data.client_id)) 271 if 272 not client._enabled_capabilities['linked_editing_range'] 273 or not client:supports_method(method, ev.buf) 274 then 275 return 276 end 277 278 attach_linked_editor(ev.buf, client) 279 end, 280 }) 281 282 ---@param enable boolean 283 ---@param client vim.lsp.Client 284 local function toggle_linked_editing_for_client(enable, client) 285 local handler = enable and attach_linked_editor or detach_linked_editor 286 287 -- Toggle for buffers already attached. 288 for bufnr, _ in pairs(client.attached_buffers) do 289 handler(bufnr, client) 290 end 291 292 client._enabled_capabilities['linked_editing_range'] = enable 293 end 294 295 ---@param enable boolean 296 local function toggle_linked_editing_globally(enable) 297 -- Toggle for clients that have already attached. 298 local clients = lsp.get_clients({ method = method }) 299 for _, client in ipairs(clients) do 300 toggle_linked_editing_for_client(enable, client) 301 end 302 303 -- If disabling, only clear the attachment autocmd. If enabling, create it. 304 local group = api.nvim_create_augroup('nvim.lsp.linked_editing_range', { clear = true }) 305 if enable then 306 api.nvim_create_autocmd('LspAttach', { 307 group = group, 308 desc = 'Enable linked editing ranges for all clients', 309 callback = function(ev) 310 local client = assert(lsp.get_client_by_id(ev.data.client_id)) 311 if client:supports_method(method, ev.buf) then 312 attach_linked_editor(ev.buf, client) 313 end 314 end, 315 }) 316 end 317 end 318 319 --- Optional filters |kwargs|: 320 --- @inlinedoc 321 --- @class vim.lsp.linked_editing_range.enable.Filter 322 --- @field client_id integer? Client ID, or `nil` for all. 323 324 --- Enable or disable a linked editing session globally or for a specific client. The following is a 325 --- practical usage example: 326 --- 327 --- ```lua 328 --- vim.lsp.start({ 329 --- name = 'html', 330 --- cmd = '…', 331 --- on_attach = function(client) 332 --- vim.lsp.linked_editing_range.enable(true, { client_id = client.id }) 333 --- end, 334 --- }) 335 --- ``` 336 --- 337 ---@param enable boolean? `true` or `nil` to enable, `false` to disable. 338 ---@param filter vim.lsp.linked_editing_range.enable.Filter? 339 function M.enable(enable, filter) 340 vim.validate('enable', enable, 'boolean', true) 341 vim.validate('filter', filter, 'table', true) 342 343 enable = enable ~= false 344 filter = filter or {} 345 346 if filter.client_id then 347 local client = 348 assert(lsp.get_client_by_id(filter.client_id), 'Client not found for id ' .. filter.client_id) 349 toggle_linked_editing_for_client(enable, client) 350 else 351 toggle_linked_editing_globally(enable) 352 end 353 end 354 355 return M