From 5a2536de0c4beae4eba50a0d2868983c1690ecc7 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Sun, 17 Dec 2023 09:54:38 +0000 Subject: refactor(lsp): move changetracking to separate file (#26577) * refactor(lsp): move changetracking to separate file - Prefixed changetracking types with `vim.lsp.` * fixup!: make _reset_timer a local function * fixup!: remove @private annotations * fixup!: changetracking.lua -> _changetracking.lua * fixup! types * fixup! add send_changes_for_group --- runtime/lua/vim/lsp/_changetracking.lua | 373 ++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 runtime/lua/vim/lsp/_changetracking.lua (limited to 'runtime/lua/vim/lsp/_changetracking.lua') diff --git a/runtime/lua/vim/lsp/_changetracking.lua b/runtime/lua/vim/lsp/_changetracking.lua new file mode 100644 index 0000000000..67c74f069d --- /dev/null +++ b/runtime/lua/vim/lsp/_changetracking.lua @@ -0,0 +1,373 @@ +local protocol = require('vim.lsp.protocol') +local sync = require('vim.lsp.sync') +local util = require('vim.lsp.util') + +local api = vim.api +local uv = vim.uv + +local M = {} + +--- 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` +--- +--- 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. +--- +--- @class vim.lsp.CTGroup +--- @field sync_kind integer TextDocumentSyncKind, considers config.flags.allow_incremental_sync +--- @field offset_encoding "utf-8"|"utf-16"|"utf-32" +--- +--- @class vim.lsp.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 uv.uv_timer_t? 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 integer how many clients are using this group +--- +--- @class vim.lsp.CTGroupState +--- @field buffers table +--- @field debounce integer debounce duration in ms +--- @field clients table clients using this state. {client_id, client} + +---@param group vim.lsp.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 + +---@type table +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, +}) + +---@param client lsp.Client +---@return vim.lsp.CTGroup +local function get_group(client) + local allow_inc_sync = vim.F.if_nil(client.config.flags.allow_incremental_sync, true) + local change_capability = vim.tbl_get(client.server_capabilities, '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 --[[@as integer]] + end + return { + sync_kind = sync_kind, + offset_encoding = client.offset_encoding, + } +end + +---@param state vim.lsp.CTBufferState +---@param encoding string +---@param bufnr integer +---@param firstline integer +---@param lastline integer +---@param new_lastline integer +---@return lsp.TextDocumentContentChangeEvent +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 = api.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 vim.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 = vim.lsp._buf_get_line_ending(bufnr) + local incremental_change = sync.compute_diff( + state.lines, + curr_lines, + firstline, + lastline, + new_lastline, + encoding, + 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 + state.lines = curr_lines + state.lines_tmp = prev_lines + + return incremental_change +end + +---@param client lsp.Client +---@param bufnr integer +function M.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, + clients = { + [client.id] = client, + }, + } + state_by_group[group] = state + end + 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 group.sync_kind == protocol.TextDocumentSyncKind.Incremental then + buf_state.lines = api.nvim_buf_get_lines(bufnr, 0, -1, true) + end + end +end + +--- @param client lsp.Client +--- @param bufnr integer +--- @param name string +--- @return string +function M._get_and_set_name(client, bufnr, name) + 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 + return old_name +end + +---@param buf_state vim.lsp.CTBufferState +local function reset_timer(buf_state) + 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 + +--- @param client lsp.Client +--- @param bufnr integer +function M.reset_buf(client, bufnr) + M.flush(client, 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 + reset_timer(buf_state) + end +end + +--- @param client lsp.Client +function M.reset(client) + local state = state_by_group[get_group(client)] + if not state then + return + end + state.clients[client.id] = nil + if vim.tbl_count(state.clients) == 0 then + for _, buf_state in pairs(state.buffers) do + reset_timer(buf_state) + end + state.buffers = {} + end +end + +-- Adjust debounce time by taking time of last didChange notification into +-- consideration. If the last didChange happened more than `debounce` time ago, +-- debounce can be skipped and otherwise maybe reduced. +-- +-- This turns the debounce into a kind of client rate limiting +-- +---@param debounce integer +---@param buf_state vim.lsp.CTBufferState +---@return number +local function next_debounce(debounce, buf_state) + if debounce == 0 then + return 0 + end + local ns_to_ms = 0.000001 + if not buf_state.last_flush then + return debounce + end + local now = uv.hrtime() + local ms_since_last_flush = (now - buf_state.last_flush) * ns_to_ms + return math.max(debounce - ms_since_last_flush, 0) +end + +---@param bufnr integer +---@param sync_kind integer protocol.TextDocumentSyncKind +---@param state vim.lsp.CTGroupState +---@param buf_state vim.lsp.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 + + if not api.nvim_buf_is_valid(bufnr) then + buf_state.pending_changes = {} + return + end + + local changes --- @type lsp.TextDocumentContentChangeEvent[] + 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 = vim.lsp._buf_get_full_text(bufnr) }, + } + end + local uri = vim.uri_from_bufnr(bufnr) + for _, client in pairs(state.clients) do + if not client.is_stopped() and vim.lsp.buf_is_attached(bufnr, client.id) then + client.notify(protocol.Methods.textDocument_didChange, { + textDocument = { + uri = uri, + version = util.buf_versions[bufnr], + }, + contentChanges = changes, + }) + end + end +end + +--- @param bufnr integer +--- @param firstline integer +--- @param lastline integer +--- @param new_lastline integer +--- @param group vim.lsp.CTGroup +local function send_changes_for_group(bufnr, firstline, lastline, new_lastline, group) + 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 + 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 + send_changes(bufnr, group.sync_kind, state, buf_state) + else + local timer = assert(uv.new_timer(), 'Must be able to create timer') + buf_state.timer = timer + timer:start( + debounce, + 0, + vim.schedule_wrap(function() + reset_timer(buf_state) + send_changes(bufnr, group.sync_kind, state, buf_state) + end) + ) + end +end + +--- @param bufnr integer +--- @param firstline integer +--- @param lastline integer +--- @param new_lastline integer +function M.send_changes(bufnr, firstline, lastline, new_lastline) + local groups = {} ---@type table + for _, client in pairs(vim.lsp.get_clients({ bufnr = bufnr })) do + local group = get_group(client) + groups[group_key(group)] = group + end + for _, group in pairs(groups) do + send_changes_for_group(bufnr, firstline, lastline, new_lastline, group) + end +end + +--- Flushes any outstanding change notification. +---@param client lsp.Client +---@param bufnr? integer +function M.flush(client, bufnr) + 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 {} + reset_timer(buf_state) + send_changes(bufnr, group.sync_kind, state, buf_state) + else + for buf, buf_state in pairs(state.buffers) do + reset_timer(buf_state) + send_changes(buf, group.sync_kind, state, buf_state) + end + end +end + +return M -- cgit From c73d67d283c296bdb7a44a0283346e7b61d837f0 Mon Sep 17 00:00:00 2001 From: Maria José Solano Date: Sat, 10 Feb 2024 14:03:44 -0800 Subject: refactor(lsp): add type annotations --- runtime/lua/vim/lsp/_changetracking.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'runtime/lua/vim/lsp/_changetracking.lua') diff --git a/runtime/lua/vim/lsp/_changetracking.lua b/runtime/lua/vim/lsp/_changetracking.lua index 67c74f069d..8b624cd5ea 100644 --- a/runtime/lua/vim/lsp/_changetracking.lua +++ b/runtime/lua/vim/lsp/_changetracking.lua @@ -64,7 +64,7 @@ local state_by_group = setmetatable({}, { ---@param client lsp.Client ---@return vim.lsp.CTGroup local function get_group(client) - local allow_inc_sync = vim.F.if_nil(client.config.flags.allow_incremental_sync, true) + local allow_inc_sync = vim.F.if_nil(client.config.flags.allow_incremental_sync, true) --- @type boolean local change_capability = vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'change') local sync_kind = change_capability or protocol.TextDocumentSyncKind.None if not allow_inc_sync and change_capability == protocol.TextDocumentSyncKind.Incremental then -- cgit From 9f8c96240dc0318bd92a646966917e8fe0641144 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Mon, 12 Feb 2024 13:46:32 +0000 Subject: refactor(lsp): resolve the config-client entanglement Previously the LSP-Client object contained some fields that are also in the client config, but for a lot of other fields, the config was used directly making the two objects vaguely entangled with either not having a clear role. Now the config object is treated purely as config (read-only) from the client, and any fields the client needs from the config are now copied in as additional fields. This means: - the config object is no longet normalised and is left as the user provided it. - the client only reads the config on creation of the client and all other implementations now read the clients version of the fields. In addition, internal support for multiple callbacks has been added to the client so the client tracking logic (done in lua.lsp) can be done more robustly instead of wrapping the user callbacks which may error. --- runtime/lua/vim/lsp/_changetracking.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'runtime/lua/vim/lsp/_changetracking.lua') diff --git a/runtime/lua/vim/lsp/_changetracking.lua b/runtime/lua/vim/lsp/_changetracking.lua index 8b624cd5ea..3ecdec1659 100644 --- a/runtime/lua/vim/lsp/_changetracking.lua +++ b/runtime/lua/vim/lsp/_changetracking.lua @@ -64,7 +64,7 @@ local state_by_group = setmetatable({}, { ---@param client lsp.Client ---@return vim.lsp.CTGroup local function get_group(client) - local allow_inc_sync = vim.F.if_nil(client.config.flags.allow_incremental_sync, true) --- @type boolean + local allow_inc_sync = vim.F.if_nil(client.flags.allow_incremental_sync, true) --- @type boolean local change_capability = vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'change') local sync_kind = change_capability or protocol.TextDocumentSyncKind.None if not allow_inc_sync and change_capability == protocol.TextDocumentSyncKind.Incremental then @@ -134,12 +134,12 @@ function M.init(client, bufnr) 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.debounce = math.min(state.debounce, client.flags.debounce_text_changes or 150) state.clients[client.id] = client else state = { buffers = {}, - debounce = client.config.flags.debounce_text_changes or 150, + debounce = client.flags.debounce_text_changes or 150, clients = { [client.id] = client, }, -- cgit From a5fe8f59d98398d04bed8586cee73864bbcdde92 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Tue, 27 Feb 2024 15:20:32 +0000 Subject: docs: improve/add documentation of Lua types - Added `@inlinedoc` so single use Lua types can be inlined into the functions docs. E.g. ```lua --- @class myopts --- @inlinedoc --- --- Documentation for some field --- @field somefield integer --- @param opts myOpts function foo(opts) end ``` Will be rendered as ``` foo(opts) Parameters: - {opts} (table) Object with the fields: - somefield (integer) Documentation for some field ``` - Marked many classes with with `@nodoc` or `(private)`. We can eventually introduce these when we want to. --- runtime/lua/vim/lsp/_changetracking.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'runtime/lua/vim/lsp/_changetracking.lua') diff --git a/runtime/lua/vim/lsp/_changetracking.lua b/runtime/lua/vim/lsp/_changetracking.lua index 3ecdec1659..b2be53269f 100644 --- a/runtime/lua/vim/lsp/_changetracking.lua +++ b/runtime/lua/vim/lsp/_changetracking.lua @@ -61,7 +61,7 @@ local state_by_group = setmetatable({}, { end, }) ----@param client lsp.Client +---@param client vim.lsp.Client ---@return vim.lsp.CTGroup local function get_group(client) local allow_inc_sync = vim.F.if_nil(client.flags.allow_incremental_sync, true) --- @type boolean @@ -127,7 +127,7 @@ local function incremental_changes(state, encoding, bufnr, firstline, lastline, return incremental_change end ----@param client lsp.Client +---@param client vim.lsp.Client ---@param bufnr integer function M.init(client, bufnr) assert(client.offset_encoding, 'lsp client must have an offset_encoding') @@ -165,7 +165,7 @@ function M.init(client, bufnr) end end ---- @param client lsp.Client +--- @param client vim.lsp.Client --- @param bufnr integer --- @param name string --- @return string @@ -189,7 +189,7 @@ local function reset_timer(buf_state) end end ---- @param client lsp.Client +--- @param client vim.lsp.Client --- @param bufnr integer function M.reset_buf(client, bufnr) M.flush(client, bufnr) @@ -207,7 +207,7 @@ function M.reset_buf(client, bufnr) end end ---- @param client lsp.Client +--- @param client vim.lsp.Client function M.reset(client) local state = state_by_group[get_group(client)] if not state then @@ -350,7 +350,7 @@ function M.send_changes(bufnr, firstline, lastline, new_lastline) end --- Flushes any outstanding change notification. ----@param client lsp.Client +---@param client vim.lsp.Client ---@param bufnr? integer function M.flush(client, bufnr) local group = get_group(client) -- cgit