neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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