feat(lsp): support annotated text edits (#34508)

This commit is contained in:
Maria José Solano
2025-06-23 06:30:49 -07:00
committed by GitHub
parent a5c55d200b
commit 835f11595f
5 changed files with 202 additions and 94 deletions

View File

@ -2334,7 +2334,8 @@ Lua module: vim.lsp.util *lsp-util*
*vim.lsp.util.apply_text_document_edit()*
apply_text_document_edit({text_document_edit}, {index}, {position_encoding})
apply_text_document_edit({text_document_edit}, {index}, {position_encoding},
{change_annotations})
Applies a `TextDocumentEdit`, which is a list of changes to a single
document.
@ -2343,18 +2344,21 @@ apply_text_document_edit({text_document_edit}, {index}, {position_encoding})
• {index} (`integer?`) Optional index of the edit, if from
a list of edits (or nil, if not from a list)
• {position_encoding} (`'utf-8'|'utf-16'|'utf-32'?`)
• {change_annotations} (`table<string, lsp.ChangeAnnotation>?`)
See also: ~
• https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
*vim.lsp.util.apply_text_edits()*
apply_text_edits({text_edits}, {bufnr}, {position_encoding})
apply_text_edits({text_edits}, {bufnr}, {position_encoding},
{change_annotations})
Applies a list of text edits to a buffer.
Parameters: ~
• {text_edits} (`lsp.TextEdit[]`)
• {bufnr} (`integer`) Buffer id
• {position_encoding} (`'utf-8'|'utf-16'|'utf-32'`)
• {text_edits} (`(lsp.TextEdit|lsp.AnnotatedTextEdit)[]`)
• {bufnr} (`integer`) Buffer id
• {position_encoding} (`'utf-8'|'utf-16'|'utf-32'`)
• {change_annotations} (`table<string, lsp.ChangeAnnotation>?`)
See also: ~
• https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit

View File

@ -182,6 +182,7 @@ LSP
• Support for the `disabled` field on code actions.
• The function form of `cmd` in a vim.lsp.Config or vim.lsp.ClientConfig
receives the resolved config as the second arg: `cmd(dispatchers, config)`.
• Support for annotated text edits.
LUA

View File

@ -431,6 +431,7 @@ function protocol.make_client_capabilities()
properties = { 'edit', 'command' },
},
disabledSupport = true,
honorsChangeAnnotations = true,
},
codeLens = {
dynamicRegistration = false,
@ -529,6 +530,7 @@ function protocol.make_client_capabilities()
rename = {
dynamicRegistration = true,
prepareSupport = true,
honorsChangeAnnotations = true,
},
publishDiagnostics = {
tagSupport = {
@ -562,6 +564,7 @@ function protocol.make_client_capabilities()
workspaceEdit = {
resourceOperations = { 'rename', 'create', 'delete' },
normalizesLineEndings = true,
changeAnnotationSupport = { groupsOnLabel = true },
},
semanticTokens = {
refreshSupport = true,

View File

@ -287,14 +287,16 @@ local function get_line_byte_from_position(bufnr, position, position_encoding)
end
--- Applies a list of text edits to a buffer.
---@param text_edits lsp.TextEdit[]
---@param text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit)[]
---@param bufnr integer Buffer id
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@param change_annotations? table<string, lsp.ChangeAnnotation>
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
function M.apply_text_edits(text_edits, bufnr, position_encoding)
function M.apply_text_edits(text_edits, bufnr, position_encoding, change_annotations)
validate('text_edits', text_edits, 'table', false)
validate('bufnr', bufnr, 'number', false)
validate('position_encoding', position_encoding, 'string', false)
validate('change_annotations', change_annotations, 'table', true)
if not next(text_edits) then
return
@ -307,95 +309,156 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding)
end
vim.bo[bufnr].buflisted = true
-- Fix reversed range and indexing each text_edits
for index, text_edit in ipairs(text_edits) do
--- @cast text_edit lsp.TextEdit|{_index: integer}
text_edit._index = index
if
text_edit.range.start.line > text_edit.range['end'].line
or text_edit.range.start.line == text_edit.range['end'].line
and text_edit.range.start.character > text_edit.range['end'].character
then
local start = text_edit.range.start
text_edit.range.start = text_edit.range['end']
text_edit.range['end'] = start
end
end
--- @cast text_edits (lsp.TextEdit|{_index: integer})[]
-- Sort text_edits
---@param a lsp.TextEdit | { _index: integer }
---@param b lsp.TextEdit | { _index: integer }
---@return boolean
table.sort(text_edits, function(a, b)
if a.range.start.line ~= b.range.start.line then
return a.range.start.line > b.range.start.line
end
if a.range.start.character ~= b.range.start.character then
return a.range.start.character > b.range.start.character
end
return a._index > b._index
end)
-- save and restore local marks since they get deleted by nvim_buf_set_lines
local marks = {} --- @type table<string,[integer,integer]>
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
if m.mark:match("^'[a-z]$") then
marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed
local has_eol_text_edit = false
local function apply_text_edits()
-- Fix reversed range and indexing each text_edits
for index, text_edit in ipairs(text_edits) do
--- @cast text_edit lsp.TextEdit|{_index: integer}
text_edit._index = index
if
text_edit.range.start.line > text_edit.range['end'].line
or text_edit.range.start.line == text_edit.range['end'].line
and text_edit.range.start.character > text_edit.range['end'].character
then
local start = text_edit.range.start
text_edit.range.start = text_edit.range['end']
text_edit.range['end'] = start
end
end
--- @cast text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})[]
-- Sort text_edits
---@param a (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
---@param b (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
---@return boolean
table.sort(text_edits, function(a, b)
if a.range.start.line ~= b.range.start.line then
return a.range.start.line > b.range.start.line
end
if a.range.start.character ~= b.range.start.character then
return a.range.start.character > b.range.start.character
end
return a._index > b._index
end)
-- save and restore local marks since they get deleted by nvim_buf_set_lines
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
if m.mark:match("^'[a-z]$") then
marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed
end
end
for _, text_edit in ipairs(text_edits) do
-- Normalize line ending
text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n')
-- Convert from LSP style ranges to Neovim style ranges.
local start_row = text_edit.range.start.line
local start_col = get_line_byte_from_position(bufnr, text_edit.range.start, position_encoding)
local end_row = text_edit.range['end'].line
local end_col = get_line_byte_from_position(bufnr, text_edit.range['end'], position_encoding)
local text = vim.split(text_edit.newText, '\n', { plain = true })
local max = api.nvim_buf_line_count(bufnr)
-- If the whole edit is after the lines in the buffer we can simply add the new text to the end
-- of the buffer.
if max <= start_row then
api.nvim_buf_set_lines(bufnr, max, max, false, text)
else
local last_line_len = #(get_line(bufnr, math.min(end_row, max - 1)) or '')
-- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't
-- accept it so we should fix it here.
if max <= end_row then
end_row = max - 1
end_col = last_line_len
has_eol_text_edit = true
else
-- If the replacement is over the end of a line (i.e. end_col is equal to the line length and the
-- replacement text ends with a newline We can likely assume that the replacement is assumed
-- to be meant to replace the newline with another newline and we need to make sure this
-- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r'
-- in the file some servers (clangd on windows) will include that character in the line
-- while nvim_buf_set_text doesn't count it as part of the line.
if
end_col >= last_line_len
and text_edit.range['end'].character > end_col
and #text_edit.newText > 0
and string.sub(text_edit.newText, -1) == '\n'
then
table.remove(text, #text)
end
end
-- Make sure we don't go out of bounds for end_col
end_col = math.min(last_line_len, end_col)
api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text)
end
end
end
-- Apply text edits.
local has_eol_text_edit = false
--- Track how many times each change annotation is applied to build up the final description.
---@type table<string, integer>
local change_count = {}
-- If there are any annotated text edits, we need to confirm them before applying the edits.
local confirmations = {} ---@type table<string, integer>
for _, text_edit in ipairs(text_edits) do
-- Normalize line ending
text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n')
if text_edit.annotationId then
assert(
change_annotations ~= nil,
'change_annotations must be provided for annotated text edits'
)
-- Convert from LSP style ranges to Neovim style ranges.
local start_row = text_edit.range.start.line
local start_col = get_line_byte_from_position(bufnr, text_edit.range.start, position_encoding)
local end_row = text_edit.range['end'].line
local end_col = get_line_byte_from_position(bufnr, text_edit.range['end'], position_encoding)
local text = vim.split(text_edit.newText, '\n', { plain = true })
local annotation = assert(
change_annotations[text_edit.annotationId],
string.format('No change annotation found for ID: %s', text_edit.annotationId)
)
local max = api.nvim_buf_line_count(bufnr)
-- If the whole edit is after the lines in the buffer we can simply add the new text to the end
-- of the buffer.
if max <= start_row then
api.nvim_buf_set_lines(bufnr, max, max, false, text)
else
local last_line_len = #(get_line(bufnr, math.min(end_row, max - 1)) or '')
-- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't
-- accept it so we should fix it here.
if max <= end_row then
end_row = max - 1
end_col = last_line_len
has_eol_text_edit = true
else
-- If the replacement is over the end of a line (i.e. end_col is equal to the line length and the
-- replacement text ends with a newline We can likely assume that the replacement is assumed
-- to be meant to replace the newline with another newline and we need to make sure this
-- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r'
-- in the file some servers (clangd on windows) will include that character in the line
-- while nvim_buf_set_text doesn't count it as part of the line.
if
end_col >= last_line_len
and text_edit.range['end'].character > end_col
and #text_edit.newText > 0
and string.sub(text_edit.newText, -1) == '\n'
then
table.remove(text, #text)
end
if annotation.needsConfirmation then
confirmations[text_edit.annotationId] = (confirmations[text_edit.annotationId] or 0) + 1
end
-- Make sure we don't go out of bounds for end_col
end_col = math.min(last_line_len, end_col)
api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text)
change_count[text_edit.annotationId] = (change_count[text_edit.annotationId] or 0) + 1
end
end
if next(confirmations) then
local message = { 'Apply all changes?' }
for id, count in pairs(confirmations) do
local annotation = assert(change_annotations)[id]
message[#message + 1] = annotation.label
.. (annotation.description and (string.format(': %s', annotation.description)) or '')
.. (count > 1 and string.format(' (%d)', count) or '')
end
local response = vim.fn.confirm(table.concat(message, '\n'), '&Yes\n&No', 1, 'Question')
if response == 1 then
-- Proceed with applying text edits.
apply_text_edits()
else
-- Don't apply any text edits.
return
end
else
-- No confirmations needed, apply text edits directly.
apply_text_edits()
end
if change_annotations ~= nil and next(change_count) then
local change_message = { 'Applied changes:' }
for id, count in pairs(change_count) do
local annotation = change_annotations[id]
change_message[#change_message + 1] = annotation.label
.. (annotation.description and (': ' .. annotation.description) or '')
.. (count > 1 and string.format(' (%d)', count) or '')
end
vim.notify(table.concat(change_message, '\n'), vim.log.levels.INFO)
end
local max = api.nvim_buf_line_count(bufnr)
-- no need to restore marks that still exist
@ -427,8 +490,14 @@ end
---@param text_document_edit lsp.TextDocumentEdit
---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list)
---@param position_encoding? 'utf-8'|'utf-16'|'utf-32'
---@param change_annotations? table<string, lsp.ChangeAnnotation>
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
function M.apply_text_document_edit(text_document_edit, index, position_encoding)
function M.apply_text_document_edit(
text_document_edit,
index,
position_encoding,
change_annotations
)
local text_document = text_document_edit.textDocument
local bufnr = vim.uri_to_bufnr(text_document.uri)
if position_encoding == nil then
@ -455,7 +524,7 @@ function M.apply_text_document_edit(text_document_edit, index, position_encoding
return
end
M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding)
M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding, change_annotations)
end
local function path_components(path)
@ -637,7 +706,7 @@ function M.apply_workspace_edit(workspace_edit, position_encoding)
elseif change.kind then --- @diagnostic disable-line:undefined-field
error(string.format('Unsupported change: %q', vim.inspect(change)))
else
M.apply_text_document_edit(change, idx, position_encoding)
M.apply_text_document_edit(change, idx, position_encoding, workspace_edit.changeAnnotations)
end
end
return
@ -650,7 +719,7 @@ function M.apply_workspace_edit(workspace_edit, position_encoding)
for uri, changes in pairs(all_changes) do
local bufnr = vim.uri_to_bufnr(uri)
M.apply_text_edits(changes, bufnr, position_encoding)
M.apply_text_edits(changes, bufnr, position_encoding, workspace_edit.changeAnnotations)
end
end

View File

@ -2069,13 +2069,16 @@ describe('LSP', function()
end)
describe('apply_text_edits', function()
local buffer_text = {
'First line of text',
'Second line of text',
'Third line of text',
'Fourth line of text',
'å å ɧ 汉语 ↥ 🤦 🦄',
}
before_each(function()
insert(dedent([[
First line of text
Second line of text
Third line of text
Fourth line of text
å å ɧ 汉语 ↥ 🤦 🦄]]))
insert(dedent(table.concat(buffer_text, '\n')))
end)
it('applies simple edits', function()
@ -2226,6 +2229,34 @@ describe('LSP', function()
eq({ 2, 1 }, api.nvim_buf_get_mark(1, 'a'))
end)
it('applies edit based on confirmation response', function()
--- @type lsp.AnnotatedTextEdit
local edit = make_edit(0, 0, 5, 0, 'foo')
edit.annotationId = 'annotation-id'
local function test(response)
exec_lua(function()
---@diagnostic disable-next-line: duplicate-set-field
vim.fn.confirm = function()
return response
end
vim.lsp.util.apply_text_edits(
{ edit },
1,
'utf-16',
{ ['annotation-id'] = { label = 'Insert "foo"', needsConfirmation = true } }
)
end, { response })
end
test(2) -- 2 = No
eq(buffer_text, buf_lines(1))
test(1) -- 1 = Yes
eq({ 'foo' }, buf_lines(1))
end)
describe('cursor position', function()
it("don't fix the cursor if the range contains the cursor", function()
api.nvim_win_set_cursor(0, { 2, 6 })