lsp: add incremental text synchronization

* Implementation derived from and validated by vim-lsc authored by Nate
  Bosch
This commit is contained in:
Michael Lingelbach 2021-03-07 17:18:32 -08:00
parent c12ea02e0b
commit e4e51c69d7
6 changed files with 203 additions and 31 deletions

View File

@ -751,8 +751,8 @@ start_client({config}) *vim.lsp.start_client()*
table.
>
-- In attach function for the client, you can do:
local custom_attach = function(client)
-- In init function for the client, you can do:
local custom_init = function(client)
if client.config.flags then
client.config.flags.allow_incremental_sync = true
end

View File

@ -262,6 +262,13 @@ end
--@param bufnr (Number) Number of the buffer, or 0 for current
--@param client Client object
local function text_document_did_open_handler(bufnr, client)
local allow_incremental_sync = if_nil(client.config.flags.allow_incremental_sync, false)
if allow_incremental_sync then
if not client._cached_buffers then
client._cached_buffers = {}
end
client._cached_buffers[bufnr] = nvim_buf_get_lines(bufnr, 0, -1, true)
end
if not client.resolved_capabilities.text_document_open_close then
return
end
@ -808,7 +815,6 @@ end
--- Notify all attached clients that a buffer has changed.
local text_document_did_change_handler
do
local encoding_index = { ["utf-8"] = 1; ["utf-16"] = 2; ["utf-32"] = 3; }
text_document_did_change_handler = function(_, bufnr, changedtick,
firstline, lastline, new_lastline, old_byte_size, old_utf32_size,
old_utf16_size)
@ -827,23 +833,12 @@ do
util.buf_versions[bufnr] = changedtick
-- Lazy initialize these because clients may not even need them.
local incremental_changes = once(function(client)
local size_index = encoding_index[client.offset_encoding]
local length = select(size_index, old_byte_size, old_utf16_size, old_utf32_size)
local lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true)
-- This is necessary because we are specifying the full line including the
-- newline in range. Therefore, we must replace the newline as well.
if #lines > 0 then
table.insert(lines, '')
end
return {
range = {
start = { line = firstline, character = 0 };
["end"] = { line = lastline, character = 0 };
};
rangeLength = length;
text = table.concat(lines, '\n');
};
local lines = nvim_buf_get_lines(bufnr, 0, -1, true)
local startline = math.min(firstline + 1, math.min(#client._cached_buffers[bufnr], #lines))
local endline = math.min(-(#lines - new_lastline), 0)
local incremental_change = vim.lsp.util.compute_diff(client._cached_buffers[bufnr], lines, startline, endline)
client._cached_buffers[bufnr] = lines
return incremental_change
end)
local full_changes = once(function()
return {
@ -931,6 +926,9 @@ function lsp.buf_attach_client(bufnr, client_id)
if client.resolved_capabilities.text_document_open_close then
client.notify('textDocument/didClose', params)
end
if client._cached_buffers then
client._cached_buffers[bufnr] = nil
end
end)
util.buf_versions[bufnr] = nil
all_buffer_active_clients[bufnr] = nil

View File

@ -226,6 +226,165 @@ end
-- function M.glob_to_regex(glob)
-- end
--@private
--- Finds the first line and column of the difference between old and new lines
--@param old_lines table list of lines
--@param new_lines table list of lines
--@returns (int, int) start_line_idx and start_col_idx of range
local function first_difference(old_lines, new_lines, start_line_idx)
local line_count = math.min(#old_lines, #new_lines)
if line_count == 0 then return 1, 1 end
if not start_line_idx then
for i = 1, line_count do
start_line_idx = i
if old_lines[start_line_idx] ~= new_lines[start_line_idx] then
break
end
end
end
local old_line = old_lines[start_line_idx]
local new_line = new_lines[start_line_idx]
local length = math.min(#old_line, #new_line)
local start_col_idx = 1
while start_col_idx <= length do
if string.sub(old_line, start_col_idx, start_col_idx) ~= string.sub(new_line, start_col_idx, start_col_idx) then
break
end
start_col_idx = start_col_idx + 1
end
return start_line_idx, start_col_idx
end
--@private
--- Finds the last line and column of the differences between old and new lines
--@param old_lines table list of lines
--@param new_lines table list of lines
--@param start_char integer First different character idx of range
--@returns (int, int) end_line_idx and end_col_idx of range
local function last_difference(old_lines, new_lines, start_char, end_line_idx)
local line_count = math.min(#old_lines, #new_lines)
if line_count == 0 then return 0,0 end
if not end_line_idx then
end_line_idx = -1
end
for i = end_line_idx, -line_count, -1 do
if old_lines[#old_lines + i + 1] ~= new_lines[#new_lines + i + 1] then
end_line_idx = i
break
end
end
local old_line
local new_line
if end_line_idx <= -line_count then
end_line_idx = -line_count
old_line = string.sub(old_lines[#old_lines + end_line_idx + 1], start_char)
new_line = string.sub(new_lines[#new_lines + end_line_idx + 1], start_char)
else
old_line = old_lines[#old_lines + end_line_idx + 1]
new_line = new_lines[#new_lines + end_line_idx + 1]
end
local old_line_length = #old_line
local new_line_length = #new_line
local length = math.min(old_line_length, new_line_length)
local end_col_idx = -1
while end_col_idx >= -length do
local old_char = string.sub(old_line, old_line_length + end_col_idx + 1, old_line_length + end_col_idx + 1)
local new_char = string.sub(new_line, new_line_length + end_col_idx + 1, new_line_length + end_col_idx + 1)
if old_char ~= new_char then
break
end
end_col_idx = end_col_idx - 1
end
return end_line_idx, end_col_idx
end
--@private
--- Get the text of the range defined by start and end line/column
--@param lines table list of lines
--@param start_char integer First different character idx of range
--@param end_char integer Last different character idx of range
--@param start_line integer First different line idx of range
--@param end_line integer Last different line idx of range
--@returns string text extracted from defined region
local function extract_text(lines, start_line, start_char, end_line, end_char)
if start_line == #lines + end_line + 1 then
if end_line == 0 then return '' end
local line = lines[start_line]
local length = #line + end_char - start_char
return string.sub(line, start_char, start_char + length + 1)
end
local result = string.sub(lines[start_line], start_char) .. '\n'
for line_idx = start_line + 1, #lines + end_line do
result = result .. lines[line_idx] .. '\n'
end
if end_line ~= 0 then
local line = lines[#lines + end_line + 1]
local length = #line + end_char + 1
result = result .. string.sub(line, 1, length)
end
return result
end
--@private
--- Compute the length of the substituted range
--@param lines table list of lines
--@param start_char integer First different character idx of range
--@param end_char integer Last different character idx of range
--@param start_line integer First different line idx of range
--@param end_line integer Last different line idx of range
--@returns (int, int) end_line_idx and end_col_idx of range
local function compute_length(lines, start_line, start_char, end_line, end_char)
local adj_end_line = #lines + end_line + 1
local adj_end_char
if adj_end_line > #lines then
adj_end_char = end_char - 1
else
adj_end_char = #lines[adj_end_line] + end_char
end
if start_line == adj_end_line then
return adj_end_char - start_char + 1
end
local result = #lines[start_line] - start_char + 1
for line = start_line + 1, adj_end_line -1 do
result = result + #lines[line] + 1
end
result = result + adj_end_char + 1
return result
end
--- Returns the range table for the difference between old and new lines
--@param old_lines table list of lines
--@param new_lines table list of lines
--@returns table start_line_idx and start_col_idx of range
function M.compute_diff(old_lines, new_lines, start_line_idx, end_line_idx)
local start_line, start_char = first_difference(old_lines, new_lines, start_line_idx)
local end_line, end_char = last_difference(vim.list_slice(old_lines, start_line, #old_lines),
vim.list_slice(new_lines, start_line, #new_lines), start_char, end_line_idx)
local text = extract_text(new_lines, start_line, start_char, end_line, end_char)
local length = compute_length(old_lines, start_line, start_char, end_line, end_char)
local adj_end_line = #old_lines + end_line
local adj_end_char
if end_line == 0 then
adj_end_char = 0
else
adj_end_char = #old_lines[#old_lines + end_line + 1] + end_char + 1
end
local result = {
range = {
start = { line = start_line - 1, character = start_char - 1},
["end"] = { line = adj_end_line, character = adj_end_char}
},
text = text,
rangeLength = length + 1,
}
return result
end
--- Can be used to extract the completion items from a
--- `textDocument/completion` request, which may return one of
--- `CompletionItem[]`, `CompletionList` or null.

View File

@ -400,6 +400,20 @@ function vim.tbl_count(t)
return count
end
--- Creates a copy of a table containing only elements from start to end (inclusive)
---
--@param list table table
--@param start integer Start range of slice
--@param finish integer End range of slice
--@returns Copy of table sliced from start to finish (inclusive)
function vim.list_slice(list, start, finish)
local new_list = {}
for i = start or 1, finish or #list do
new_list[#new_list+1] = list[i]
end
return new_list
end
--- Trim whitespace (Lua pattern "%s") from both sides of a string.
---
--@see https://www.lua.org/pil/20.2.html

View File

@ -402,11 +402,11 @@ function tests.basic_check_buffer_open_and_change_incremental()
contentChanges = {
{
range = {
start = { line = 1; character = 0; };
["end"] = { line = 2; character = 0; };
start = { line = 1; character = 3; };
["end"] = { line = 1; character = 3; };
};
rangeLength = 4;
text = "boop\n";
rangeLength = 0;
text = "boop";
};
}
})

View File

@ -27,10 +27,10 @@ teardown(function()
os.remove(fake_lsp_logfile)
end)
local function fake_lsp_server_setup(test_name, timeout_ms)
local function fake_lsp_server_setup(test_name, timeout_ms, options)
exec_lua([=[
lsp = require('vim.lsp')
local test_name, fixture_filename, logfile, timeout = ...
local test_name, fixture_filename, logfile, timeout, options = ...
TEST_RPC_CLIENT_ID = lsp.start_client {
cmd_env = {
NVIM_LOG_FILE = logfile;
@ -52,18 +52,19 @@ local function fake_lsp_server_setup(test_name, timeout_ms)
on_init = function(client, result)
TEST_RPC_CLIENT = client
vim.rpcrequest(1, "init", result)
client.config.flags.allow_incremental_sync = options.allow_incremental_sync or false
end;
on_exit = function(...)
vim.rpcnotify(1, "exit", ...)
end;
}
]=], test_name, fake_lsp_code, fake_lsp_logfile, timeout_ms or 1e3)
]=], test_name, fake_lsp_code, fake_lsp_logfile, timeout_ms or 1e3, options or {})
end
local function test_rpc_server(config)
if config.test_name then
clear()
fake_lsp_server_setup(config.test_name, config.timeout_ms or 1e3)
fake_lsp_server_setup(config.test_name, config.timeout_ms or 1e3, config.options)
end
local client = setmetatable({}, {
__index = function(_, name)
@ -680,8 +681,7 @@ describe('LSP', function()
}
end)
-- TODO(askhan) we don't support full for now, so we can disable these tests.
pending('should check the body and didChange incremental', function()
it('should check the body and didChange incremental', function()
local expected_callbacks = {
{NIL, "shutdown", {}, 1};
{NIL, "finish", {}, 1};
@ -690,6 +690,7 @@ describe('LSP', function()
local client
test_rpc_server {
test_name = "basic_check_buffer_open_and_change_incremental";
options = { allow_incremental_sync = true };
on_setup = function()
exec_lua [[
BUFFER = vim.api.nvim_create_buf(false, true)
@ -716,7 +717,7 @@ describe('LSP', function()
if method == 'start' then
exec_lua [[
vim.api.nvim_buf_set_lines(BUFFER, 1, 2, false, {
"boop";
"123boop";
})
]]
client.notify('finish')