summaryrefslogtreecommitdiffstatshomepage
path: root/runtime/lua/vim/lsp/linked_editing_range.lua
blob: 07cc6fb7404b82de73eb929a413b86de77ca85c1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
--- @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 = '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<integer, vim.lsp.linked_editing_range.LinkedEditor>
---@field bufnr integer
---@field augroup integer augroup for buffer events
---@field client_states table<integer, vim.lsp.linked_editing_range.state>
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('linked_editing_range', 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' }, {
    buf = 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,
    buf = bufnr,
    callback = function()
      self:refresh()
    end,
  })
  api.nvim_create_autocmd('LspDetach', {
    group = augroup,
    buf = bufnr,
    callback = function(ev)
      self:detach(ev.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._enabled_capabilities['linked_editing_range']
      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._enabled_capabilities['linked_editing_range'] = 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