commit c5167ffc185a94da6e227601b28fe2935c6ca1dc
parent db1542af3d03d304738fa22c1c848c2f02c37156
Author: Riley Bruins <ribru17@hotmail.com>
Date: Sat, 19 Jul 2025 10:54:49 -0700
feat(lsp): support linked editing ranges #34388
ref: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_linkedEditingRange
Diffstat:
8 files changed, 530 insertions(+), 0 deletions(-)
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -2286,6 +2286,38 @@ is_enabled({bufnr}) *vim.lsp.document_color.is_enabled()*
==============================================================================
+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*
*vim.lsp.util.open_floating_preview.Opts*
diff --git 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
@@ -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
@@ -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
@@ -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<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('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
@@ -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
@@ -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
@@ -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<Esc>')
+ 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('<C-r><C-r>')
+ 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<Esc>')
+ eq(
+ {
+ 'ppt',
+ 't',
+ 't',
+ },
+ exec_lua(function()
+ return vim.api.nvim_buf_get_lines(0, 0, -1, false)
+ end)
+ )
+ end)
+end)