mirror of
https://github.com/neovim/neovim
synced 2025-07-15 16:51:49 +00:00
feat(lsp): support annotated text edits (#34508)
This commit is contained in:
committed by
GitHub
parent
a5c55d200b
commit
835f11595f
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 })
|
||||
|
Reference in New Issue
Block a user