feat(lsp): improve vim.lsp.util.apply_text_edits (#15561)

- Fix the cursor position after applying TextEdits
- Support reversed range of TextEdit
- Invoke nvim_buf_set_text one by one
This commit is contained in:
hrsh7th 2021-09-19 05:19:21 +09:00 committed by GitHub
parent 340f77e78e
commit 41cfba63cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 207 additions and 41 deletions

View File

@ -146,10 +146,6 @@ local function sort_by_key(fn)
return false
end
end
---@private
local edit_sort_key = sort_by_key(function(e)
return {e.A[1], e.A[2], e.i}
end)
---@private
--- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position
@ -174,6 +170,7 @@ local function get_line_byte_from_position(bufnr, position)
if ok then
return result
end
return math.min(#lines[1], col)
end
end
return col
@ -237,8 +234,8 @@ function M.get_progress_messages()
end
--- Applies a list of text edits to a buffer.
---@param text_edits (table) list of `TextEdit` objects
---@param buf_nr (number) Buffer id
---@param text_edits table list of `TextEdit` objects
---@param bufnr number Buffer id
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
function M.apply_text_edits(text_edits, bufnr)
if not next(text_edits) then return end
@ -246,45 +243,110 @@ function M.apply_text_edits(text_edits, bufnr)
vim.fn.bufload(bufnr)
end
api.nvim_buf_set_option(bufnr, 'buflisted', true)
local start_line, finish_line = math.huge, -1
local cleaned = {}
for i, e in ipairs(text_edits) do
-- adjust start and end column for UTF-16 encoding of non-ASCII characters
local start_row = e.range.start.line
local start_col = get_line_byte_from_position(bufnr, e.range.start)
local end_row = e.range["end"].line
local end_col = get_line_byte_from_position(bufnr, e.range['end'])
start_line = math.min(e.range.start.line, start_line)
finish_line = math.max(e.range["end"].line, finish_line)
-- TODO(ashkan) sanity check ranges for overlap.
table.insert(cleaned, {
i = i;
A = {start_row; start_col};
B = {end_row; end_col};
lines = vim.split(e.newText, '\n', true);
-- Fix reversed range and indexing each text_edits
local index = 0
text_edits = vim.tbl_map(function(text_edit)
index = index + 1
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
return text_edit
end, text_edits)
-- Sort text_edits
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
if a._index ~= b._index then
return a._index > b._index
end
end)
-- 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.
local has_eol_text_edit = false
local max = vim.api.nvim_buf_line_count(bufnr)
local len = vim.str_utfindex(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '')
text_edits = vim.tbl_map(function(text_edit)
if max <= text_edit.range.start.line then
text_edit.range.start.line = max - 1
text_edit.range.start.character = len
text_edit.newText = '\n' .. text_edit.newText
has_eol_text_edit = true
end
if max <= text_edit.range['end'].line then
text_edit.range['end'].line = max - 1
text_edit.range['end'].character = len
has_eol_text_edit = true
end
return text_edit
end, text_edits)
-- Some LSP servers are depending on the VSCode behavior.
-- The VSCode will re-locate the cursor position after applying TextEdit so we also do it.
local is_current_buf = vim.api.nvim_get_current_buf() == bufnr
local cursor = (function()
if not is_current_buf then
return {
row = -1,
col = -1,
}
end
local cursor = vim.api.nvim_win_get_cursor(0)
return {
row = cursor[1] - 1,
col = cursor[2],
}
end)()
-- Apply text edits.
local is_cursor_fixed = false
for _, text_edit in ipairs(text_edits) do
local e = {
start_row = text_edit.range.start.line,
start_col = get_line_byte_from_position(bufnr, text_edit.range.start),
end_row = text_edit.range['end'].line,
end_col = get_line_byte_from_position(bufnr, text_edit.range['end']),
text = vim.split(text_edit.newText, '\n', true),
}
vim.api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text)
local row_count = (e.end_row - e.start_row) + 1
if e.end_row < cursor.row then
cursor.row = cursor.row + (#e.text - row_count)
is_cursor_fixed = true
elseif e.end_row == cursor.row and e.end_col <= cursor.col then
cursor.row = cursor.row + (#e.text - row_count)
cursor.col = #e.text[#e.text] + (cursor.col - e.end_col)
if #e.text == 1 then
cursor.col = cursor.col + e.start_col
end
is_cursor_fixed = true
end
end
if is_cursor_fixed then
vim.api.nvim_win_set_cursor(0, {
cursor.row + 1,
math.min(cursor.col, #(vim.api.nvim_buf_get_lines(bufnr, cursor.row, cursor.row + 1, false)[1] or ''))
})
end
-- Reverse sort the orders so we can apply them without interfering with
-- eachother. Also add i as a sort key to mimic a stable sort.
table.sort(cleaned, edit_sort_key)
local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false)
local fix_eol = api.nvim_buf_get_option(bufnr, 'fixeol')
local set_eol = fix_eol and api.nvim_buf_line_count(bufnr) <= finish_line + 1
if set_eol and (#lines == 0 or #lines[#lines] ~= 0) then
table.insert(lines, '')
-- Remove final line if needed
local fix_eol = has_eol_text_edit
fix_eol = fix_eol and api.nvim_buf_get_option(bufnr, 'fixeol')
fix_eol = fix_eol and (vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') == ''
if fix_eol then
vim.api.nvim_buf_set_lines(bufnr, -2, -1, false, {})
end
for i = #cleaned, 1, -1 do
local e = cleaned[i]
local A = {e.A[1] - start_line, e.A[2]}
local B = {e.B[1] - start_line, e.B[2]}
lines = M.set_lines(lines, A, B, e.lines)
end
if set_eol and #lines[#lines] == 0 then
table.remove(lines)
end
api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines)
end
-- local valid_windows_path_characters = "[^<>:\"/\\|?*]"

View File

@ -1066,6 +1066,30 @@ describe('LSP', function()
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
end)
it('applies complex edits (reversed range)', function()
local edits = {
make_edit(0, 0, 0, 0, {"", "12"});
make_edit(0, 0, 0, 0, {"3", "foo"});
make_edit(0, 1, 0, 1, {"bar", "123"});
make_edit(0, #"First line of text", 0, #"First ", {"guy"});
make_edit(1, #'Second', 1, 0, {"baz"});
make_edit(2, #"Third", 2, #'Th', {"e next"});
make_edit(3, #"Fourth", 3, #'', {"another line of text", "before this"});
make_edit(3, #"Fourth line of text", 3, #'Fourth', {"!"});
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'';
'123';
'fooFbar';
'123irst guy';
'baz line of text';
'The next line of text';
'another line of text';
'before this!';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
end)
it('applies non-ASCII characters edits', function()
local edits = {
make_edit(4, 3, 4, 4, {"ä"});
@ -1094,6 +1118,86 @@ describe('LSP', function()
}, buf_lines(1))
end)
describe('cursor position', function()
it('don\'t fix the cursor if the range contains the cursor', function()
funcs.nvim_win_set_cursor(0, { 2, 6 })
local edits = {
make_edit(1, 0, 1, 19, 'Second line of text')
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'First line of text';
'Second line of text';
'Third line of text';
'Fourth line of text';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
eq({ 2, 6 }, funcs.nvim_win_get_cursor(0))
end)
it('fix the cursor to the valid column if the content was removed', function()
funcs.nvim_win_set_cursor(0, { 2, 6 })
local edits = {
make_edit(1, 0, 1, 19, '')
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'First line of text';
'';
'Third line of text';
'Fourth line of text';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
eq({ 2, 0 }, funcs.nvim_win_get_cursor(0))
end)
it('fix the cursor row', function()
funcs.nvim_win_set_cursor(0, { 3, 0 })
local edits = {
make_edit(1, 0, 2, 0, '')
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'First line of text';
'Third line of text';
'Fourth line of text';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
eq({ 2, 0 }, funcs.nvim_win_get_cursor(0))
end)
it('fix the cursor col', function()
funcs.nvim_win_set_cursor(0, { 2, 11 })
local edits = {
make_edit(1, 7, 1, 11, '')
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'First line of text';
'Second of text';
'Third line of text';
'Fourth line of text';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
eq({ 2, 7 }, funcs.nvim_win_get_cursor(0))
end)
it('fix the cursor row and col', function()
funcs.nvim_win_set_cursor(0, { 2, 12 })
local edits = {
make_edit(0, 11, 1, 12, '')
}
exec_lua('vim.lsp.util.apply_text_edits(...)', edits, 1)
eq({
'First line of text';
'Third line of text';
'Fourth line of text';
'å å ɧ 汉语 ↥ 🤦 🦄';
}, buf_lines(1))
eq({ 1, 11 }, funcs.nvim_win_get_cursor(0))
end)
end)
describe('with LSP end line after what Vim considers to be the end line', function()
it('applies edits when the last linebreak is considered a new line', function()
local edits = {