From 8d5452c46d01acc3f044326173ae8e1cb793cccf Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Mon, 7 Jul 2025 11:51:30 +0800 Subject: [PATCH] refactor(lsp): stateful data abstraction, vim.lsp.Capability #34639 Problem: Closes #31453 Solution: Introduce `vim.lsp.Capability`, which may serve as the base class for all LSP features that require caching data. it - was created if there is at least one client that supports the specific method; - was destroyed if all clients that support the method were detached. - Apply the refactor for `folding_range.lua` and `semantic_tokens.lua`. - Show active features in :checkhealth. Future: I found that these features that are expected to be refactored by `vim.lsp.Capability` have one characteristic in common: they all send LSP requests once the document is modified. The following code is different, but they are all for this purpose. - semantic tokens: https://github.com/neovim/neovim/blob/fb8dba413f2bcaa61c15d1854b28112e3e91a035/runtime/lua/vim/lsp/semantic_tokens.lua#L192-L198 - inlay hints, folding ranges, document color https://github.com/neovim/neovim/blob/fb8dba413f2bcaa61c15d1854b28112e3e91a035/runtime/lua/vim/lsp/inlay_hint.lua#L250-L266 I think I can sum up this characteristic as the need to keep certain data synchronized with the latest version computed by the server. I believe we can handle this at the `vim.lsp.Capability` level, and I think it will be very useful. Therefore, my next step is to implement LSP request sending and data synchronization on `vim.lsp.Capability`, rather than limiting it to the current create/destroy data approach. --- runtime/doc/news.txt | 1 + runtime/lua/vim/lsp.lua | 1 + runtime/lua/vim/lsp/_capability.lua | 77 ++++++++++ runtime/lua/vim/lsp/_folding_range.lua | 140 ++++++++---------- runtime/lua/vim/lsp/client.lua | 3 + runtime/lua/vim/lsp/health.lua | 38 +++++ runtime/lua/vim/lsp/semantic_tokens.lua | 49 ++---- .../plugin/lsp/folding_range_spec.lua | 70 +++------ 8 files changed, 214 insertions(+), 165 deletions(-) create mode 100644 runtime/lua/vim/lsp/_capability.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 29b39d8994..cb6d5c5025 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -195,6 +195,7 @@ LSP • 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. +• `:checkhealth vim.lsp` is now available to check which buffers the active LSP features are attached to. LUA diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 288e9a01e8..0135cda2f6 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -2,6 +2,7 @@ local api = vim.api local validate = vim.validate local lsp = vim._defer_require('vim.lsp', { + _capability = ..., --- @module 'vim.lsp._capability' _changetracking = ..., --- @module 'vim.lsp._changetracking' _folding_range = ..., --- @module 'vim.lsp._folding_range' _snippet_grammar = ..., --- @module 'vim.lsp._snippet_grammar' diff --git a/runtime/lua/vim/lsp/_capability.lua b/runtime/lua/vim/lsp/_capability.lua new file mode 100644 index 0000000000..2834052b74 --- /dev/null +++ b/runtime/lua/vim/lsp/_capability.lua @@ -0,0 +1,77 @@ +local api = vim.api + +--- `vim.lsp.Capability` is expected to be created one-to-one with a buffer +--- when there is at least one supported client attached to that buffer, +--- and will be destroyed when all supporting clients are detached. +---@class vim.lsp.Capability +--- +--- Static field for retrieving the instance associated with a specific `bufnr`. +--- +--- Index inthe form of `bufnr` -> `capability` +---@field active table +--- +--- The LSP feature it supports. +---@field name string +--- +--- Buffer number it associated with. +---@field bufnr integer +--- +--- The augroup owned by this instance, which will be cleared upon destruction. +---@field augroup integer +--- +--- Per-client state data, scoped to the lifetime of the attached client. +---@field client_state table +local M = {} +M.__index = M + +---@generic T : vim.lsp.Capability +---@param self T +---@param bufnr integer +---@return T +function M:new(bufnr) + -- `self` in the `new()` function refers to the concrete type (i.e., the metatable). + -- `Class` may be a subtype of `Capability`, as it supports inheritance. + ---@type vim.lsp.Capability + local Class = self + assert(Class.name and Class.active, 'Do not instantiate the abstract class') + + ---@type vim.lsp.Capability + self = setmetatable({}, Class) + self.bufnr = bufnr + self.augroup = api.nvim_create_augroup( + string.format('nvim.lsp.%s:%s', self.name:gsub('%s+', '_'):lower(), bufnr), + { clear = true } + ) + self.client_state = {} + + api.nvim_create_autocmd('LspDetach', { + group = self.augroup, + buffer = bufnr, + callback = function(args) + self:on_detach(args.data.client_id) + if next(self.client_state) == nil then + self:destroy() + end + end, + }) + + Class.active[bufnr] = self + return self +end + +function M:destroy() + -- In case the function is called before all the clients detached. + for client_id, _ in pairs(self.client_state) do + self:on_detach(client_id) + end + + api.nvim_del_augroup_by_id(self.augroup) + self.active[self.bufnr] = nil +end + +---@param client_id integer +function M:on_detach(client_id) + self.client_state[client_id] = nil +end + +return M diff --git a/runtime/lua/vim/lsp/_folding_range.lua b/runtime/lua/vim/lsp/_folding_range.lua index 8d57fad9d7..ca4114f746 100644 --- a/runtime/lua/vim/lsp/_folding_range.lua +++ b/runtime/lua/vim/lsp/_folding_range.lua @@ -12,18 +12,20 @@ local supported_fold_kinds = { local M = {} ----@class (private) vim.lsp.folding_range.State +local Capability = require('vim.lsp._capability') + +---@class (private) vim.lsp.folding_range.State : vim.lsp.Capability --- ---@field active table ----@field bufnr integer ----@field augroup integer +--- +--- `TextDocument` version this `state` corresponds to. ---@field version? integer --- ---- Never use this directly, `renew()` the cached foldinfo +--- Never use this directly, `evaluate()` the cached foldinfo --- then use on demand via `row_*` fields. --- --- Index In the form of client_id -> ranges ----@field client_ranges table +---@field client_state table --- --- Index in the form of row -> [foldlevel, mark] ---@field row_level table" | "<"?]?> @@ -33,10 +35,12 @@ local M = {} --- --- Index in the form of start_row -> collapsed_text ---@field row_text table -local State = { active = {} } +local State = { name = 'Folding Range', active = {} } +State.__index = State +setmetatable(State, Capability) ---- Renew the cached foldinfo in the buffer. -function State:renew() +--- Re-evaluate the cached foldinfo in the buffer. +function State:evaluate() ---@type table" | "<"?]?> local row_level = {} ---@type table?>> @@ -44,7 +48,7 @@ function State:renew() ---@type table local row_text = {} - for client_id, ranges in pairs(self.client_ranges) do + for client_id, ranges in pairs(self.client_state) do for _, range in ipairs(ranges) do local start_row = range.startLine local end_row = range.endLine @@ -83,6 +87,9 @@ end --- Force `foldexpr()` to be re-evaluated, without opening folds. ---@param bufnr integer local function foldupdate(bufnr) + if not api.nvim_buf_is_loaded(bufnr) or not vim.b[bufnr]._lsp_folding_range_enabled then + return + end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local wininfo = vim.fn.getwininfo(winid)[1] if wininfo and wininfo.tabnr == vim.fn.tabpagenr() then @@ -127,12 +134,12 @@ function State:multi_handler(results, ctx) if result.err then log.error(result.err) else - self.client_ranges[client_id] = result.result + self.client_state[client_id] = result.result end end self.version = ctx.version - self:renew() + self:evaluate() if api.nvim_get_mode().mode:match('^i') then -- `foldUpdate()` is guarded in insert mode. schedule_foldupdate(self.bufnr) @@ -151,7 +158,11 @@ end --- Request `textDocument/foldingRange` from the server. --- `foldupdate()` is scheduled once after the request is completed. ---@param client? vim.lsp.Client The client whose server supports `foldingRange`. -function State:request(client) +function State:refresh(client) + if not vim.b._lsp_folding_range_enabled then + return + end + ---@type lsp.FoldingRangeParams local params = { textDocument = util.make_text_document_params(self.bufnr) } @@ -174,7 +185,6 @@ function State:request(client) end function State:reset() - self.client_ranges = {} self.row_level = {} self.row_kinds = {} self.row_text = {} @@ -183,34 +193,17 @@ end --- Initialize `state` and event hooks, then request folding ranges. ---@param bufnr integer ---@return vim.lsp.folding_range.State -function State.new(bufnr) - local self = setmetatable({}, { __index = State }) - self.bufnr = bufnr - self.augroup = api.nvim_create_augroup('nvim.lsp.folding_range:' .. bufnr, { clear = true }) +function State:new(bufnr) + self = Capability.new(self, bufnr) self:reset() - State.active[bufnr] = self - api.nvim_buf_attach(bufnr, false, { - -- `on_detach` also runs on buffer reload (`:e`). - -- Ensure `state` and hooks are cleared to avoid duplication or leftover states. - on_detach = function() - util._cancel_requests({ - bufnr = bufnr, - method = ms.textDocument_foldingRange, - type = 'pending', - }) - local state = State.active[bufnr] - if state then - state:destroy() - end - end, -- Reset `bufstate` and request folding ranges. on_reload = function() local state = State.active[bufnr] if state then state:reset() - state:request() + state:refresh() end end, --- Sync changed rows with their previous foldlevels before applying new ones. @@ -238,44 +231,6 @@ function State.new(bufnr) end end, }) - api.nvim_create_autocmd('LspDetach', { - group = self.augroup, - buffer = bufnr, - callback = function(args) - if not api.nvim_buf_is_loaded(bufnr) then - return - end - - ---@type integer - local client_id = args.data.client_id - self.client_ranges[client_id] = nil - - ---@type vim.lsp.Client[] - local clients = vim - .iter(vim.lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_foldingRange })) - ---@param client vim.lsp.Client - :filter(function(client) - return client.id ~= client_id - end) - :totable() - if #clients == 0 then - self:reset() - end - - self:renew() - foldupdate(bufnr) - end, - }) - api.nvim_create_autocmd('LspAttach', { - group = self.augroup, - buffer = bufnr, - callback = function(args) - local client = assert(vim.lsp.get_client_by_id(args.data.client_id)) - if client:supports_method(vim.lsp.protocol.Methods.textDocument_foldingRange, bufnr) then - self:request(client) - end - end, - }) api.nvim_create_autocmd('LspNotify', { group = self.augroup, buffer = bufnr, @@ -288,7 +243,16 @@ function State.new(bufnr) or args.data.method == ms.textDocument_didOpen ) then - self:request(client) + self:refresh(client) + end + end, + }) + api.nvim_create_autocmd('OptionSet', { + group = self.augroup, + pattern = 'foldexpr', + callback = function() + if vim.v.option_type == 'global' or vim.api.nvim_get_current_buf() == bufnr then + vim.b[bufnr]._lsp_folding_range_enabled = nil end end, }) @@ -301,18 +265,22 @@ function State:destroy() State.active[self.bufnr] = nil end -local function setup(bufnr) - if not api.nvim_buf_is_loaded(bufnr) then - return - end +---@params client_id integer +function State:on_detach(client_id) + self.client_state[client_id] = nil + self:evaluate() + foldupdate(self.bufnr) +end +---@param bufnr integer +---@param client_id? integer +function M._setup(bufnr, client_id) local state = State.active[bufnr] if not state then - state = State.new(bufnr) + state = State:new(bufnr) end - state:request() - return state + state:refresh(client_id and vim.lsp.get_client_by_id(client_id)) end ---@param kind lsp.FoldingRangeKind @@ -344,11 +312,11 @@ function M.foldclose(kind, winid) return end + -- Schedule `foldclose()` if the buffer is not up-to-date. if state.version == util.buf_versions[bufnr] then state:foldclose(kind, winid) return end - -- Schedule `foldclose()` if the buffer is not up-to-date. if not next(vim.lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_foldingRange })) then return @@ -380,14 +348,22 @@ end ---@return string level function M.foldexpr(lnum) local bufnr = api.nvim_get_current_buf() - local state = State.active[bufnr] or setup(bufnr) + local state = State.active[bufnr] + if not vim.b[bufnr]._lsp_folding_range_enabled then + vim.b[bufnr]._lsp_folding_range_enabled = true + if state then + state:refresh() + end + end + if not state then return '0' end - local row = (lnum or vim.v.lnum) - 1 local level = state.row_level[row] return level and (level[2] or '') .. (level[1] or '0') or '0' end +M.__FoldEvaluator = State + return M diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index 7d8bf6c1fe..fe271ff801 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -1082,6 +1082,9 @@ function Client:on_attach(bufnr) if vim.tbl_get(self.server_capabilities, 'semanticTokensProvider', 'full') then lsp.semantic_tokens.start(bufnr, self.id) end + if vim.tbl_get(self.server_capabilities, 'foldingRangeProvider') then + lsp._folding_range._setup(bufnr) + end end) self.attached_buffers[bufnr] = true diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index 1d8d0e8e88..4ea961adee 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -28,6 +28,43 @@ local function check_log() report_fn(string.format('Log size: %d KB', log_size / 1000)) end +local function check_active_features() + vim.health.start('vim.lsp: Active Features') + ---@type vim.lsp.Capability[] + local features = { + require('vim.lsp.semantic_tokens').__STHighlighter, + require('vim.lsp._folding_range').__FoldEvaluator, + } + for _, feature in ipairs(features) do + ---@type string[] + local buf_infos = {} + for bufnr, instance in pairs(feature.active) do + local client_info = vim + .iter(pairs(instance.client_state)) + :map(function(client_id) + local client = vim.lsp.get_client_by_id(client_id) + if client then + return string.format('%s (id: %d)', client.name, client.id) + else + return string.format('unknow (id: %d)', client_id) + end + end) + :join(', ') + if client_info == '' then + client_info = 'No supported client attached' + end + + buf_infos[#buf_infos + 1] = string.format(' [%d]: %s', bufnr, client_info) + end + + report_info(table.concat({ + feature.name, + '- Active buffers:', + string.format(table.concat(buf_infos, '\n')), + }, '\n')) + end +end + --- @param f function --- @return string local function func_tostring(f) @@ -223,6 +260,7 @@ end --- Performs a healthcheck for LSP function M.check() check_log() + check_active_features() check_active_clients() check_enabled_configs() check_watcher() diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index a343bb7cb9..3a760c1156 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -5,6 +5,8 @@ local util = require('vim.lsp.util') local Range = require('vim.treesitter._range') local uv = vim.uv +local Capability = require('vim.lsp._capability') + --- @class (private) STTokenRange --- @field line integer line number 0-based --- @field start_col integer start column 0-based @@ -30,14 +32,16 @@ local uv = vim.uv --- @field active_request STActiveRequest --- @field current_result STCurrentResult ----@class (private) STHighlighter +---@class (private) STHighlighter : vim.lsp.Capability ---@field active table ---@field bufnr integer ---@field augroup integer augroup for buffer events ---@field debounce integer milliseconds to debounce requests for new tokens ---@field timer table uv_timer for debouncing requests for new tokens ---@field client_state table -local STHighlighter = { active = {} } +local STHighlighter = { name = 'Semantic Tokens', active = {} } +STHighlighter.__index = STHighlighter +setmetatable(STHighlighter, Capability) --- Do a binary search of the tokens in the half-open range [lo, hi). --- @@ -179,14 +183,8 @@ end ---@private ---@param bufnr integer ---@return STHighlighter -function STHighlighter.new(bufnr) - local self = setmetatable({}, { __index = STHighlighter }) - - self.bufnr = bufnr - self.augroup = api.nvim_create_augroup('nvim.lsp.semantic_tokens:' .. bufnr, { clear = true }) - self.client_state = {} - - STHighlighter.active[bufnr] = self +function STHighlighter:new(bufnr) + self = Capability.new(self, bufnr) api.nvim_buf_attach(bufnr, false, { on_lines = function(_, buf) @@ -213,32 +211,11 @@ function STHighlighter.new(bufnr) end, }) - api.nvim_create_autocmd('LspDetach', { - buffer = self.bufnr, - group = self.augroup, - callback = function(args) - self:detach(args.data.client_id) - if vim.tbl_isempty(self.client_state) then - self:destroy() - end - end, - }) - return self end ---@package -function STHighlighter:destroy() - for client_id, _ in pairs(self.client_state) do - self:detach(client_id) - end - - api.nvim_del_augroup_by_id(self.augroup) - STHighlighter.active[self.bufnr] = nil -end - ----@package -function STHighlighter:attach(client_id) +function STHighlighter:on_attach(client_id) local state = self.client_state[client_id] if not state then state = { @@ -251,7 +228,7 @@ function STHighlighter:attach(client_id) end ---@package -function STHighlighter:detach(client_id) +function STHighlighter:on_detach(client_id) local state = self.client_state[client_id] if state then --TODO: delete namespace if/when that becomes possible @@ -657,13 +634,13 @@ function M.start(bufnr, client_id, opts) local highlighter = STHighlighter.active[bufnr] if not highlighter then - highlighter = STHighlighter.new(bufnr) + highlighter = STHighlighter:new(bufnr) highlighter.debounce = opts.debounce or 200 else highlighter.debounce = math.max(highlighter.debounce, opts.debounce or 200) end - highlighter:attach(client_id) + highlighter:on_attach(client_id) highlighter:send_request() end @@ -687,7 +664,7 @@ function M.stop(bufnr, client_id) return end - highlighter:detach(client_id) + highlighter:on_detach(client_id) if vim.tbl_isempty(highlighter.client_state) then highlighter:destroy() diff --git a/test/functional/plugin/lsp/folding_range_spec.lua b/test/functional/plugin/lsp/folding_range_spec.lua index 601f00ef8a..277431c8c5 100644 --- a/test/functional/plugin/lsp/folding_range_spec.lua +++ b/test/functional/plugin/lsp/folding_range_spec.lua @@ -4,7 +4,6 @@ local Screen = require('test.functional.ui.screen') local t_lsp = require('test.functional.plugin.lsp.testutil') local eq = t.eq -local tempname = t.tmpname local clear_notrace = t_lsp.clear_notrace local create_server_definition = t_lsp.create_server_definition @@ -121,52 +120,6 @@ static int foldLevel(linenr_T lnum) api.nvim_exec_autocmds('VimLeavePre', { modeline = false }) end) - describe('setup()', function() - ---@type integer - local bufnr_set_expr - ---@type integer - local bufnr_never_set_expr - - local function buf_autocmd_num(bufnr_to_check) - return exec_lua(function() - return #vim.api.nvim_get_autocmds({ buffer = bufnr_to_check, event = 'LspNotify' }) - end) - end - - before_each(function() - command([[setlocal foldexpr=v:lua.vim.lsp.foldexpr()]]) - exec_lua(function() - bufnr_set_expr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_set_current_buf(bufnr_set_expr) - end) - insert(text) - command('write ' .. tempname(false)) - command([[setlocal foldexpr=v:lua.vim.lsp.foldexpr()]]) - exec_lua(function() - bufnr_never_set_expr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_set_current_buf(bufnr_never_set_expr) - end) - insert(text) - api.nvim_win_set_buf(0, bufnr_set_expr) - end) - - it('only create event hooks where foldexpr has been set', function() - eq(1, buf_autocmd_num(bufnr)) - eq(1, buf_autocmd_num(bufnr_set_expr)) - eq(0, buf_autocmd_num(bufnr_never_set_expr)) - end) - - it('does not create duplicate event hooks after reloaded', function() - command('edit') - eq(1, buf_autocmd_num(bufnr_set_expr)) - end) - - it('cleans up event hooks when buffer is unloaded', function() - command('bdelete') - eq(0, buf_autocmd_num(bufnr_set_expr)) - end) - end) - describe('expr()', function() --- @type test.functional.ui.screen local screen @@ -182,6 +135,29 @@ static int foldLevel(linenr_T lnum) command([[split]]) end) + it('controls the value of `b:_lsp_folding_range_enabled`', function() + eq( + true, + exec_lua(function() + return vim.b._lsp_folding_range_enabled + end) + ) + command [[setlocal foldexpr=]] + eq( + nil, + exec_lua(function() + return vim.b._lsp_folding_range_enabled + end) + ) + command([[set foldexpr=v:lua.vim.lsp.foldexpr()]]) + eq( + true, + exec_lua(function() + return vim.b._lsp_folding_range_enabled + end) + ) + end) + it('can compute fold levels', function() ---@type table local foldlevels = {}