Riley Bruins
2025-07-19 10:54:49 -07:00
committed by GitHub
parent db1542af3d
commit c5167ffc18
8 changed files with 530 additions and 0 deletions

View File

@@ -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*

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -556,6 +556,9 @@ function protocol.make_client_capabilities()
selectionRange = {
dynamicRegistration = false,
},
linkedEditingRange = {
dynamicRegistration = false,
},
},
workspace = {
symbol = {

View File

@@ -283,6 +283,7 @@ local config = {
'tagfunc.lua',
'semantic_tokens.lua',
'document_color.lua',
'linked_editing_range.lua',
'handlers.lua',
'util.lua',
'log.lua',

View File

@@ -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)