diff options
Diffstat (limited to 'runtime/lua/vim/lsp.lua')
-rw-r--r-- | runtime/lua/vim/lsp.lua | 460 |
1 files changed, 296 insertions, 164 deletions
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index bf2201d9c8..fd64c1a495 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -338,54 +338,166 @@ end local changetracking = {} do - --@private - --- client_id → state + ---@private + --- + --- LSP has 3 different sync modes: + --- - None (Servers will read the files themselves when needed) + --- - Full (Client sends the full buffer content on updates) + --- - Incremental (Client sends only the changed parts) + --- + --- Changes are tracked per buffer. + --- A buffer can have multiple clients attached and each client needs to send the changes + --- To minimize the amount of changesets to compute, computation is grouped: + --- + --- None: One group for all clients + --- Full: One group for all clients + --- Incremental: One group per `offset_encoding` --- - --- state - --- use_incremental_sync: bool - --- buffers: bufnr -> buffer_state + --- Sending changes can be debounced per buffer. To simplify the implementation the + --- smallest debounce interval is used and we don't group clients by different intervals. --- - --- buffer_state - --- pending_change?: function that the timer starts to trigger didChange - --- pending_changes: table (uri -> list of pending changeset tables)); - --- Only set if incremental_sync is used + --- @class CTGroup + --- @field sync_kind number TextDocumentSyncKind, considers config.flags.allow_incremental_sync + --- @field offset_encoding "utf-8"|"utf-16"|"utf-32" --- - --- timer?: uv_timer - --- lines: table - local state_by_client = {} + --- @class CTBufferState + --- @field name string name of the buffer + --- @field lines string[] snapshot of buffer lines from last didChange + --- @field lines_tmp string[] + --- @field pending_changes table[] List of debounced changes in incremental sync mode + --- @field timer nil|userdata uv_timer + --- @field last_flush nil|number uv.hrtime of the last flush/didChange-notification + --- @field needs_flush boolean true if buffer updates haven't been sent to clients/servers yet + --- @field refs number how many clients are using this group + --- + --- @class CTGroupState + --- @field buffers table<number, CTBufferState> + --- @field debounce number debounce duration in ms + --- @field clients table<number, table> clients using this state. {client_id, client} ---@private - function changetracking.init(client, bufnr) - local use_incremental_sync = ( - if_nil(client.config.flags.allow_incremental_sync, true) - and vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'change') - == protocol.TextDocumentSyncKind.Incremental + ---@param group CTGroup + ---@return string + local function group_key(group) + if group.sync_kind == protocol.TextDocumentSyncKind.Incremental then + return tostring(group.sync_kind) .. '\0' .. group.offset_encoding + end + return tostring(group.sync_kind) + end + + ---@private + ---@type table<CTGroup, CTGroupState> + local state_by_group = setmetatable({}, { + __index = function(tbl, k) + return rawget(tbl, group_key(k)) + end, + __newindex = function(tbl, k, v) + rawset(tbl, group_key(k), v) + end, + }) + + ---@private + ---@return CTGroup + local function get_group(client) + local allow_inc_sync = if_nil(client.config.flags.allow_incremental_sync, true) + local change_capability = + vim.tbl_get(client.server_capabilities or {}, 'textDocumentSync', 'change') + local sync_kind = change_capability or protocol.TextDocumentSyncKind.None + if not allow_inc_sync and change_capability == protocol.TextDocumentSyncKind.Incremental then + sync_kind = protocol.TextDocumentSyncKind.Full + end + return { + sync_kind = sync_kind, + offset_encoding = client.offset_encoding, + } + end + + ---@private + ---@param state CTBufferState + local function incremental_changes(state, encoding, bufnr, firstline, lastline, new_lastline) + local prev_lines = state.lines + local curr_lines = state.lines_tmp + + local changed_lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true) + for i = 1, firstline do + curr_lines[i] = prev_lines[i] + end + for i = firstline + 1, new_lastline do + curr_lines[i] = changed_lines[i - firstline] + end + for i = lastline + 1, #prev_lines do + curr_lines[i - lastline + new_lastline] = prev_lines[i] + end + if tbl_isempty(curr_lines) then + -- Can happen when deleting the entire contents of a buffer, see https://github.com/neovim/neovim/issues/16259. + curr_lines[1] = '' + end + + local line_ending = buf_get_line_ending(bufnr) + local incremental_change = sync.compute_diff( + state.lines, + curr_lines, + firstline, + lastline, + new_lastline, + encoding, + line_ending ) - local state = state_by_client[client.id] - if not state then + + -- Double-buffering of lines tables is used to reduce the load on the garbage collector. + -- At this point the prev_lines table is useless, but its internal storage has already been allocated, + -- so let's keep it around for the next didChange event, in which it will become the next + -- curr_lines table. Note that setting elements to nil doesn't actually deallocate slots in the + -- internal storage - it merely marks them as free, for the GC to deallocate them. + for i in ipairs(prev_lines) do + prev_lines[i] = nil + end + state.lines = curr_lines + state.lines_tmp = prev_lines + + return incremental_change + end + + ---@private + function changetracking.init(client, bufnr) + assert(client.offset_encoding, 'lsp client must have an offset_encoding') + local group = get_group(client) + local state = state_by_group[group] + if state then + state.debounce = math.min(state.debounce, client.config.flags.debounce_text_changes or 150) + state.clients[client.id] = client + else state = { buffers = {}, debounce = client.config.flags.debounce_text_changes or 150, - use_incremental_sync = use_incremental_sync, + clients = { + [client.id] = client, + }, } - state_by_client[client.id] = state + state_by_group[group] = state end - if not state.buffers[bufnr] then - local buf_state = { + local buf_state = state.buffers[bufnr] + if buf_state then + buf_state.refs = buf_state.refs + 1 + else + buf_state = { name = api.nvim_buf_get_name(bufnr), + lines = {}, + lines_tmp = {}, + pending_changes = {}, + needs_flush = false, + refs = 1, } state.buffers[bufnr] = buf_state - if use_incremental_sync then + if group.sync_kind == protocol.TextDocumentSyncKind.Incremental then buf_state.lines = nvim_buf_get_lines(bufnr, 0, -1, true) - buf_state.lines_tmp = {} - buf_state.pending_changes = {} end end end ---@private function changetracking._get_and_set_name(client, bufnr, name) - local state = state_by_client[client.id] or {} + local state = state_by_group[get_group(client)] or {} local buf_state = (state.buffers or {})[bufnr] local old_name = buf_state.name buf_state.name = name @@ -395,32 +507,33 @@ do ---@private function changetracking.reset_buf(client, bufnr) changetracking.flush(client, bufnr) - local state = state_by_client[client.id] - if state and state.buffers then - local buf_state = state.buffers[bufnr] + local state = state_by_group[get_group(client)] + if not state then + return + end + assert(state.buffers, 'CTGroupState must have buffers') + local buf_state = state.buffers[bufnr] + buf_state.refs = buf_state.refs - 1 + assert(buf_state.refs >= 0, 'refcount on buffer state must not get negative') + if buf_state.refs == 0 then state.buffers[bufnr] = nil - if buf_state and buf_state.timer then - buf_state.timer:stop() - buf_state.timer:close() - buf_state.timer = nil - end + changetracking._reset_timer(buf_state) end end ---@private - function changetracking.reset(client_id) - local state = state_by_client[client_id] + function changetracking.reset(client) + local state = state_by_group[get_group(client)] if not state then return end - for _, buf_state in pairs(state.buffers) do - if buf_state.timer then - buf_state.timer:stop() - buf_state.timer:close() - buf_state.timer = nil + state.clients[client.id] = nil + if vim.tbl_count(state.clients) == 0 then + for _, buf_state in pairs(state.buffers) do + changetracking._reset_timer(buf_state) end + state.buffers = {} end - state.buffers = {} end ---@private @@ -430,6 +543,10 @@ do -- debounce can be skipped and otherwise maybe reduced. -- -- This turns the debounce into a kind of client rate limiting + -- + ---@param debounce number + ---@param buf_state CTBufferState + ---@return number local function next_debounce(debounce, buf_state) if debounce == 0 then return 0 @@ -444,83 +561,36 @@ do end ---@private - function changetracking.prepare(bufnr, firstline, lastline, new_lastline) - local incremental_changes = function(client, buf_state) - local prev_lines = buf_state.lines - local curr_lines = buf_state.lines_tmp - - local changed_lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true) - for i = 1, firstline do - curr_lines[i] = prev_lines[i] - end - for i = firstline + 1, new_lastline do - curr_lines[i] = changed_lines[i - firstline] - end - for i = lastline + 1, #prev_lines do - curr_lines[i - lastline + new_lastline] = prev_lines[i] - end - if tbl_isempty(curr_lines) then - -- Can happen when deleting the entire contents of a buffer, see https://github.com/neovim/neovim/issues/16259. - curr_lines[1] = '' - end - - local line_ending = buf_get_line_ending(bufnr) - local incremental_change = sync.compute_diff( - buf_state.lines, - curr_lines, - firstline, - lastline, - new_lastline, - client.offset_encoding or 'utf-16', - line_ending - ) - - -- Double-buffering of lines tables is used to reduce the load on the garbage collector. - -- At this point the prev_lines table is useless, but its internal storage has already been allocated, - -- so let's keep it around for the next didChange event, in which it will become the next - -- curr_lines table. Note that setting elements to nil doesn't actually deallocate slots in the - -- internal storage - it merely marks them as free, for the GC to deallocate them. - for i in ipairs(prev_lines) do - prev_lines[i] = nil - end - buf_state.lines = curr_lines - buf_state.lines_tmp = prev_lines + ---@param bufnr number + ---@param sync_kind number protocol.TextDocumentSyncKind + ---@param state CTGroupState + ---@param buf_state CTBufferState + local function send_changes(bufnr, sync_kind, state, buf_state) + if not buf_state.needs_flush then + return + end + buf_state.last_flush = uv.hrtime() + buf_state.needs_flush = false - return incremental_change + if not api.nvim_buf_is_valid(bufnr) then + buf_state.pending_changes = {} + return end - local full_changes = once(function() - return { - text = buf_get_full_text(bufnr), + + local changes + if sync_kind == protocol.TextDocumentSyncKind.None then + return + elseif sync_kind == protocol.TextDocumentSyncKind.Incremental then + changes = buf_state.pending_changes + buf_state.pending_changes = {} + else + changes = { + { text = buf_get_full_text(bufnr) }, } - end) + end local uri = vim.uri_from_bufnr(bufnr) - return function(client) - if - vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'change') - == protocol.TextDocumentSyncKind.None - then - return - end - local state = state_by_client[client.id] - local buf_state = state.buffers[bufnr] - changetracking._reset_timer(buf_state) - local debounce = next_debounce(state.debounce, buf_state) - if state.use_incremental_sync then - -- This must be done immediately and cannot be delayed - -- The contents would further change and startline/endline may no longer fit - table.insert(buf_state.pending_changes, incremental_changes(client, buf_state)) - end - buf_state.pending_change = function() - if buf_state.pending_change == nil then - return - end - buf_state.pending_change = nil - buf_state.last_flush = uv.hrtime() - if client.is_stopped() or not api.nvim_buf_is_valid(bufnr) then - return - end - local changes = state.use_incremental_sync and buf_state.pending_changes - or { full_changes() } + for _, client in pairs(state.clients) do + if not client.is_stopped() and lsp.buf_is_attached(bufnr, client.id) then client.notify('textDocument/didChange', { textDocument = { uri = uri, @@ -528,46 +598,90 @@ do }, contentChanges = changes, }) - buf_state.pending_changes = {} + end + end + end + + ---@private + function changetracking.send_changes(bufnr, firstline, lastline, new_lastline) + local groups = {} + for _, client in pairs(lsp.get_active_clients({ bufnr = bufnr })) do + local group = get_group(client) + groups[group_key(group)] = group + end + for _, group in pairs(groups) do + local state = state_by_group[group] + if not state then + error( + string.format( + 'changetracking.init must have been called for all LSP clients. group=%s states=%s', + vim.inspect(group), + vim.inspect(vim.tbl_keys(state_by_group)) + ) + ) + end + local buf_state = state.buffers[bufnr] + buf_state.needs_flush = true + changetracking._reset_timer(buf_state) + local debounce = next_debounce(state.debounce, buf_state) + if group.sync_kind == protocol.TextDocumentSyncKind.Incremental then + -- This must be done immediately and cannot be delayed + -- The contents would further change and startline/endline may no longer fit + local changes = incremental_changes( + buf_state, + group.offset_encoding, + bufnr, + firstline, + lastline, + new_lastline + ) + table.insert(buf_state.pending_changes, changes) end if debounce == 0 then - buf_state.pending_change() + send_changes(bufnr, group.sync_kind, state, buf_state) else local timer = uv.new_timer() buf_state.timer = timer - -- Must use schedule_wrap because `full_changes()` calls nvim_buf_get_lines - timer:start(debounce, 0, vim.schedule_wrap(buf_state.pending_change)) + timer:start( + debounce, + 0, + vim.schedule_wrap(function() + changetracking._reset_timer(buf_state) + send_changes(bufnr, group.sync_kind, state, buf_state) + end) + ) end end end + ---@private function changetracking._reset_timer(buf_state) - if buf_state.timer then - buf_state.timer:stop() - buf_state.timer:close() + local timer = buf_state.timer + if timer then buf_state.timer = nil + if not timer:is_closing() then + timer:stop() + timer:close() + end end end --- Flushes any outstanding change notification. ---@private function changetracking.flush(client, bufnr) - local state = state_by_client[client.id] + local group = get_group(client) + local state = state_by_group[group] if not state then return end if bufnr then local buf_state = state.buffers[bufnr] or {} changetracking._reset_timer(buf_state) - if buf_state.pending_change then - buf_state.pending_change() - end + send_changes(bufnr, group.sync_kind, state, buf_state) else - for _, buf_state in pairs(state.buffers) do + for buf, buf_state in pairs(state.buffers) do changetracking._reset_timer(buf_state) - if buf_state.pending_change then - buf_state.pending_change() - end + send_changes(buf, group.sync_kind, state, buf_state) end end end @@ -577,7 +691,7 @@ end --- Default handler for the 'textDocument/didOpen' LSP notification. --- ---@param bufnr number Number of the buffer, or 0 for current ----@param client Client object +---@param client table Client object local function text_document_did_open_handler(bufnr, client) changetracking.init(client, bufnr) if not vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'openClose') then @@ -871,7 +985,7 @@ end --- - debounce_text_changes (number, default 150): Debounce didChange --- notifications to the server by the given number in milliseconds. No debounce --- occurs if nil ---- - exit_timeout (number, default 500): Milliseconds to wait for server to +--- - exit_timeout (number|boolean, default false): Milliseconds to wait for server to --- exit cleanly after sending the 'shutdown' request before sending kill -15. --- If set to false, nvim exits immediately after sending the 'shutdown' request to the server. --- @@ -968,12 +1082,20 @@ function lsp.start_client(config) ---@private local function set_defaults(client, bufnr) - if client.server_capabilities.definitionProvider and vim.bo[bufnr].tagfunc == '' then + local capabilities = client.server_capabilities + if capabilities.definitionProvider and vim.bo[bufnr].tagfunc == '' then vim.bo[bufnr].tagfunc = 'v:lua.vim.lsp.tagfunc' end - if client.server_capabilities.completionProvider and vim.bo[bufnr].omnifunc == '' then + if capabilities.completionProvider and vim.bo[bufnr].omnifunc == '' then vim.bo[bufnr].omnifunc = 'v:lua.vim.lsp.omnifunc' end + if + capabilities.documentRangeFormattingProvider + and vim.bo[bufnr].formatprg == '' + and vim.bo[bufnr].formatexpr == '' + then + vim.bo[bufnr].formatexpr = 'v:lua.vim.lsp.formatexpr()' + end end ---@private @@ -986,6 +1108,9 @@ function lsp.start_client(config) if vim.bo[bufnr].omnifunc == 'v:lua.vim.lsp.omnifunc' then vim.bo[bufnr].omnifunc = nil end + if vim.bo[bufnr].formatexpr == 'v:lua.vim.lsp.formatexpr()' then + vim.bo[bufnr].formatexpr = nil + end end ---@private @@ -1019,11 +1144,16 @@ function lsp.start_client(config) end) end end - + local client = active_clients[client_id] and active_clients[client_id] + or uninitialized_clients[client_id] active_clients[client_id] = nil uninitialized_clients[client_id] = nil - changetracking.reset(client_id) + -- Client can be absent if executable starts, but initialize fails + -- init/attach won't have happened + if client then + changetracking.reset(client) + end if code ~= 0 or (signal ~= 0 and signal ~= 15) then local msg = string.format('Client %s quit with exit code %s and signal %s', client_id, code, signal) @@ -1403,9 +1533,7 @@ do return true end util.buf_versions[bufnr] = changedtick - local compute_change_and_notify = - changetracking.prepare(bufnr, firstline, lastline, new_lastline) - for_each_buffer_client(bufnr, compute_change_and_notify) + changetracking.send_changes(bufnr, firstline, lastline, new_lastline) end end @@ -1681,7 +1809,7 @@ api.nvim_create_autocmd('VimLeavePre', { local send_kill = false for client_id, client in pairs(active_clients) do - local timeout = if_nil(client.config.flags.exit_timeout, 500) + local timeout = if_nil(client.config.flags.exit_timeout, false) if timeout then send_kill = true timeouts[client_id] = timeout @@ -1717,13 +1845,14 @@ api.nvim_create_autocmd('VimLeavePre', { end, }) +---@private --- Sends an async request for all active clients attached to the --- buffer. --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ----@param params (optional, table) Parameters to send to the server ----@param handler (optional, function) See |lsp-handler| +---@param params table|nil Parameters to send to the server +---@param handler function|nil See |lsp-handler| --- If nil, follows resolution strategy defined in |lsp-handler-configuration| --- ---@returns 2-tuple: @@ -1982,29 +2111,32 @@ function lsp.formatexpr(opts) return 1 end - local start_line = vim.v.lnum - local end_line = start_line + vim.v.count - 1 + local start_lnum = vim.v.lnum + local end_lnum = start_lnum + vim.v.count - 1 - if start_line > 0 and end_line > 0 then - local params = { - textDocument = util.make_text_document_params(), - range = { - start = { line = start_line - 1, character = 0 }, - ['end'] = { line = end_line - 1, character = 0 }, - }, - } - params.options = util.make_formatting_params().options - local client_results = - vim.lsp.buf_request_sync(0, 'textDocument/rangeFormatting', params, timeout_ms) - - -- Apply the text edits from one and only one of the clients. - for client_id, response in pairs(client_results) do + if start_lnum <= 0 or end_lnum <= 0 then + return 0 + end + local bufnr = api.nvim_get_current_buf() + for _, client in pairs(lsp.get_active_clients({ bufnr = bufnr })) do + if client.supports_method('textDocument/rangeFormatting') then + local params = util.make_formatting_params() + local end_line = vim.fn.getline(end_lnum) + local end_col = util._str_utfindex_enc(end_line, nil, client.offset_encoding) + params.range = { + start = { + line = start_lnum - 1, + character = 0, + }, + ['end'] = { + line = end_lnum - 1, + character = end_col, + }, + } + local response = + client.request_sync('textDocument/rangeFormatting', params, timeout_ms, bufnr) if response.result then - vim.lsp.util.apply_text_edits( - response.result, - 0, - vim.lsp.get_client_by_id(client_id).offset_encoding - ) + vim.lsp.util.apply_text_edits(response.result, 0, client.offset_encoding) return 0 end end |