diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index d18329aee8..6b9f81fd45 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2431,10 +2431,11 @@ enable({enable}, {filter}) *vim.lsp.semantic_tokens.enable()* Parameters: ~ • {enable} (`boolean?`) true/nil to enable, false to disable - • {filter} (`table?`) A table with the following fields: - • {bufnr}? (`integer`) Buffer number, or 0 for current - buffer, or nil for all. - • {client_id}? (`integer`) Client ID, or nil for all + • {filter} (`table?`) Optional filters |kwargs|, + • {bufnr}? (`integer`, default: all) Buffer number, or 0 for + current buffer, or nil for all. + • {client_id}? (`integer`, default: all) Client ID, or nil + for all. force_refresh({bufnr}) *vim.lsp.semantic_tokens.force_refresh()* Force a refresh of all semantic tokens @@ -2493,10 +2494,11 @@ is_enabled({filter}) *vim.lsp.semantic_tokens.is_enabled()* Query whether semantic tokens is enabled in the {filter}ed scope Parameters: ~ - • {filter} (`table?`) A table with the following fields: - • {bufnr}? (`integer`) Buffer number, or 0 for current - buffer, or nil for all. - • {client_id}? (`integer`) Client ID, or nil for all + • {filter} (`table?`) Optional filters |kwargs|, + • {bufnr}? (`integer`, default: all) Buffer number, or 0 for + current buffer, or nil for all. + • {client_id}? (`integer`, default: all) Client ID, or nil + for all. ============================================================================== diff --git a/runtime/lua/vim/lsp/_capability.lua b/runtime/lua/vim/lsp/_capability.lua index 2834052b74..e3aeac8d43 100644 --- a/runtime/lua/vim/lsp/_capability.lua +++ b/runtime/lua/vim/lsp/_capability.lua @@ -1,18 +1,32 @@ 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. +---@alias vim.lsp.capability.Name +---| 'semantic_tokens' +---| 'folding_range' +---| 'linked_editing_range' + +--- Tracks all supported capabilities, all of which derive from `vim.lsp.Capability`. +--- Returns capability *prototypes*, not their instances. +---@type table +local all_capabilities = {} + +-- Abstract base class (not instantiable directly). +-- For each buffer that has at least one supported client attached, +-- exactly one instance of each concrete subclass is created. +-- That instance is destroyed once all supporting clients detach from the buffer. ---@class vim.lsp.Capability --- +--- Static field as the identifier of the LSP capability it supports. +---@field name vim.lsp.capability.Name +--- +--- Static field records the method this capability requires. +---@field method vim.lsp.protocol.Method.ClientToServer +--- --- Static field for retrieving the instance associated with a specific `bufnr`. --- ---- Index inthe form of `bufnr` -> `capability` +--- Index in the form of `bufnr` -> `capability` ---@field active table --- ---- The LSP feature it supports. ----@field name string ---- --- Buffer number it associated with. ---@field bufnr integer --- @@ -33,27 +47,21 @@ function M:new(bufnr) -- `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') + if M == Class then + error('Do not instantiate the abstract class') + elseif all_capabilities[Class.name] and all_capabilities[Class.name] ~= Class then + error('Duplicated capability name') + else + all_capabilities[Class.name] = Class + end ---@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, + self.augroup = api.nvim_create_augroup(string.format('nvim.lsp.%s:%s', self.name, bufnr), { + clear = true, }) + self.client_state = {} Class.active[bufnr] = self return self @@ -69,9 +77,137 @@ function M:destroy() self.active[self.bufnr] = nil end +--- Callback invoked when an LSP client attaches. +--- Use it to initialize per-client state (empty table, new namespaces, etc.), +--- or issue requests as needed. +---@param client_id integer +function M:on_attach(client_id) + self.client_state[client_id] = {} +end + +--- Callback invoked when an LSP client detaches. +--- Use it to clear per-client state (cached data, extmarks, etc.). ---@param client_id integer function M:on_detach(client_id) self.client_state[client_id] = nil end +---@param name vim.lsp.capability.Name +local function make_enable_var(name) + return ('_lsp_enabled_%s'):format(name) +end + +--- Optional filters |kwargs|, +---@class vim.lsp.capability.enable.Filter +---@inlinedoc +--- +--- Buffer number, or 0 for current buffer, or nil for all. +--- (default: all) +---@field bufnr? integer +--- +--- Client ID, or nil for all. +--- (default: all) +---@field client_id? integer + +---@param name vim.lsp.capability.Name +---@param enable? boolean +---@param filter? vim.lsp.capability.enable.Filter +function M.enable(name, enable, filter) + vim.validate('name', name, 'string') + vim.validate('enable', enable, 'boolean', true) + vim.validate('filter', filter, 'table', true) + + enable = enable == nil or enable + filter = filter or {} + local bufnr = filter.bufnr and vim._resolve_bufnr(filter.bufnr) + local client_id = filter.client_id + assert(not (bufnr and client_id), '`bufnr` and `client_id` are mutually exclusive.') + + local var = make_enable_var(name) + local client = client_id and vim.lsp.get_client_by_id(client_id) + + -- Attach or detach the client and its capability + -- based on the user’s latest marker value. + for _, it_client in ipairs(client and { client } or vim.lsp.get_clients()) do + for _, it_bufnr in + ipairs( + bufnr and { it_client.attached_buffers[bufnr] and bufnr } + or vim.lsp.get_buffers_by_client_id(it_client.id) + ) + do + if enable ~= M.is_enabled(name, { bufnr = it_bufnr, client_id = it_client.id }) then + local Capability = all_capabilities[name] + + if enable then + if it_client:supports_method(Capability.method) then + local capability = Capability.active[bufnr] or Capability:new(it_bufnr) + if not capability.client_state[it_client.id] then + capability:on_attach(it_client.id) + end + end + else + local capability = Capability.active[it_bufnr] + if capability then + capability:on_detach(it_client.id) + if not next(capability.client_state) then + capability:destroy() + end + end + end + end + end + end + + -- Updates the marker value. + -- If local marker matches the global marker, set it to nil + -- so that `is_enable` falls back to the global marker. + if client then + if enable == vim.g[var] then + client._enabled_capabilities[name] = nil + else + client._enabled_capabilities[name] = enable + end + elseif bufnr then + if enable == vim.g[var] then + vim.b[bufnr][var] = nil + else + vim.b[bufnr][var] = enable + end + else + vim.g[var] = enable + for _, it_bufnr in ipairs(api.nvim_list_bufs()) do + if api.nvim_buf_is_loaded(it_bufnr) and vim.b[it_bufnr][var] == enable then + vim.b[it_bufnr][var] = nil + end + end + for _, it_client in ipairs(vim.lsp.get_clients()) do + if it_client._enabled_capabilities[name] == enable then + it_client._enabled_capabilities[name] = nil + end + end + end +end + +---@param name vim.lsp.capability.Name +---@param filter? vim.lsp.capability.enable.Filter +function M.is_enabled(name, filter) + vim.validate('name', name, 'string') + vim.validate('filter', filter, 'table', true) + + filter = filter or {} + local bufnr = filter.bufnr and vim._resolve_bufnr(filter.bufnr) + local client_id = filter.client_id + + local var = make_enable_var(name) + local client = client_id and vim.lsp.get_client_by_id(client_id) + + -- As a fallback when not explicitly enabled or disabled: + -- Clients are treated as "enabled" since their capabilities can control behavior. + -- Buffers are treated as "disabled" to allow users to enable them as needed. + return vim.F.if_nil(client and client._enabled_capabilities[name], vim.g[var], true) + and vim.F.if_nil(bufnr and vim.b[bufnr][var], vim.g[var], false) +end + +M.all = all_capabilities + return M diff --git a/runtime/lua/vim/lsp/_folding_range.lua b/runtime/lua/vim/lsp/_folding_range.lua index 2037217eed..6997ec2771 100644 --- a/runtime/lua/vim/lsp/_folding_range.lua +++ b/runtime/lua/vim/lsp/_folding_range.lua @@ -35,9 +35,14 @@ local Capability = require('vim.lsp._capability') --- --- Index in the form of start_row -> collapsed_text ---@field row_text table -local State = { name = 'Folding Range', active = {} } +local State = { + name = 'folding_range', + method = ms.textDocument_foldingRange, + active = {}, +} State.__index = State setmetatable(State, Capability) +Capability.all[State.name] = State --- Re-evaluate the cached foldinfo in the buffer. function State:evaluate() @@ -87,9 +92,6 @@ 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_enable_folding_range 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 @@ -159,10 +161,6 @@ end --- `foldupdate()` is scheduled once after the request is completed. ---@param client? vim.lsp.Client The client whose server supports `foldingRange`. function State:refresh(client) - if not vim.b._lsp_enable_folding_range then - return - end - ---@type lsp.FoldingRangeParams local params = { textDocument = util.make_text_document_params(self.bufnr) } @@ -252,7 +250,7 @@ function State:new(bufnr) pattern = 'foldexpr', callback = function() if vim.v.option_type == 'global' or vim.api.nvim_get_current_buf() == bufnr then - vim.b[bufnr]._lsp_enable_folding_range = nil + vim.lsp._capability.enable('folding_range', false, { bufnr = bufnr }) end end, }) @@ -265,6 +263,12 @@ function State:destroy() State.active[self.bufnr] = nil end +---@param client_id integer +function State:on_attach(client_id) + self.client_state = {} + self:refresh(vim.lsp.get_client_by_id(client_id)) +end + ---@params client_id integer function State:on_detach(client_id) self.client_state[client_id] = nil @@ -272,17 +276,6 @@ function State:on_detach(client_id) 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) - end - - state:refresh(client_id and vim.lsp.get_client_by_id(client_id)) -end - ---@param kind lsp.FoldingRangeKind ---@param winid integer function State:foldclose(kind, winid) @@ -348,14 +341,14 @@ end ---@return string level function M.foldexpr(lnum) local bufnr = api.nvim_get_current_buf() - local state = State.active[bufnr] - if not vim.b[bufnr]._lsp_enable_folding_range then - vim.b[bufnr]._lsp_enable_folding_range = true - if state then - state:refresh() - end + if not vim.lsp._capability.is_enabled('folding_range', { bufnr = bufnr }) then + -- `foldexpr` lead to a textlock, so any further operations need to be scheduled. + vim.schedule(function() + vim.lsp._capability.enable('folding_range', true, { bufnr = bufnr }) + end) end + local state = State.active[bufnr] if not state then return '0' end @@ -364,6 +357,4 @@ function M.foldexpr(lnum) 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 a370e1412d..75ac0751bd 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -209,8 +209,7 @@ local all_clients = {} --- See [vim.lsp.ClientConfig]. --- @field workspace_folders lsp.WorkspaceFolder[]? --- ---- Whether linked editing ranges are enabled for this client. ---- @field _linked_editing_enabled boolean? +--- @field _enabled_capabilities table --- --- Track this so that we can escalate automatically if we've already tried a --- graceful shutdown @@ -436,6 +435,9 @@ function Client.create(config) end, } + ---@type table + self._enabled_capabilities = {} + --- @type table title of unfinished progress sequences by token self.progress.pending = {} @@ -509,6 +511,10 @@ function Client:initialize() root_path = vim.uri_to_fname(root_uri) end + -- HACK: Capability modules must be loaded + require('vim.lsp.semantic_tokens') + require('vim.lsp._folding_range') + local init_params = { -- The process Id of the parent process that started the server. Is null if -- the process has not been started by another process. If the parent @@ -1084,16 +1090,21 @@ function Client:on_attach(bufnr) }) self:_run_callbacks(self._on_attach_cbs, lsp.client_errors.ON_ATTACH_ERROR, self, bufnr) - - -- schedule the initialization of semantic tokens to give the above + -- schedule the initialization of capabilities to give the above -- on_attach and LspAttach callbacks the ability to schedule wrap the -- opt-out (deleting the semanticTokensProvider from capabilities) vim.schedule(function() - 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) + for _, Capability in pairs(vim.lsp._capability.all) do + if + self:supports_method(Capability.method) + and vim.lsp._capability.is_enabled(Capability.name, { + bufnr = bufnr, + client_id = self.id, + }) + then + local capability = Capability.active[bufnr] or Capability:new(bufnr) + capability:on_attach(self.id) + end end end) @@ -1207,6 +1218,24 @@ function Client:_on_detach(bufnr) }) end + for _, Capability in pairs(vim.lsp._capability.all) do + if + self:supports_method(Capability.method) + and vim.lsp._capability.is_enabled(Capability.name, { + bufnr = bufnr, + client_id = self.id, + }) + then + local capability = Capability.active[bufnr] + if capability then + capability:on_detach(self.id) + if next(capability.client_state) == nil then + capability:destroy() + end + end + end + end + changetracking.reset_buf(self, bufnr) if self:supports_method(ms.textDocument_didClose) then diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index 5d8c2cc16e..29f3e7c6a4 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -30,15 +30,10 @@ 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 + for _, Capability in pairs(vim.lsp._capability.all) do ---@type string[] local buf_infos = {} - for bufnr, instance in pairs(feature.active) do + for bufnr, instance in pairs(Capability.active) do local client_info = vim .iter(pairs(instance.client_state)) :map(function(client_id) @@ -58,7 +53,7 @@ local function check_active_features() end report_info(table.concat({ - feature.name, + Capability.name, '- Active buffers:', string.format(table.concat(buf_infos, '\n')), }, '\n')) diff --git a/runtime/lua/vim/lsp/linked_editing_range.lua b/runtime/lua/vim/lsp/linked_editing_range.lua index ad18a9180e..1146ddd4ee 100644 --- a/runtime/lua/vim/lsp/linked_editing_range.lua +++ b/runtime/lua/vim/lsp/linked_editing_range.lua @@ -268,7 +268,10 @@ api.nvim_create_autocmd('LspAttach', { desc = 'Enable linked editing ranges for all buffers this client attaches to, if enabled', callback = function(ev) local client = assert(lsp.get_client_by_id(ev.data.client_id)) - if not client._linked_editing_enabled or not client:supports_method(method, ev.buf) then + if + not client._enabled_capabilities['linked_editing_range'] + or not client:supports_method(method, ev.buf) + then return end @@ -286,7 +289,7 @@ local function toggle_linked_editing_for_client(enable, client) handler(bufnr, client) end - client._linked_editing_enabled = enable + client._enabled_capabilities['linked_editing_range'] = enable end ---@param enable boolean diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index 24bf5d46c1..2ea837dc47 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -41,9 +41,14 @@ local M = {} ---@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 = { name = 'Semantic Tokens', active = {} } +local STHighlighter = { + name = 'semantic_tokens', + method = ms.textDocument_semanticTokens_full, + active = {}, +} STHighlighter.__index = STHighlighter setmetatable(STHighlighter, Capability) +Capability.all[STHighlighter.name] = STHighlighter --- Extracts modifier strings from the encoded number in the token array --- @@ -156,6 +161,7 @@ end ---@param bufnr integer ---@return STHighlighter function STHighlighter:new(bufnr) + self.debounce = 200 self = Capability.new(self, bufnr) api.nvim_buf_attach(bufnr, false, { @@ -164,13 +170,11 @@ function STHighlighter:new(bufnr) if not highlighter then return true end - if M.is_enabled({ bufnr = buf }) then - highlighter:on_change() - end + highlighter:on_change() end, on_reload = function(_, buf) local highlighter = STHighlighter.active[buf] - if highlighter and M.is_enabled({ bufnr = bufnr }) then + if highlighter then highlighter:reset() highlighter:send_request() end @@ -181,9 +185,7 @@ function STHighlighter:new(bufnr) buffer = self.bufnr, group = self.augroup, callback = function() - if M.is_enabled({ bufnr = bufnr }) then - self:send_request() - end + self:send_request() end, }) @@ -201,6 +203,7 @@ function STHighlighter:on_attach(client_id) } self.client_state[client_id] = state end + self:send_request() end ---@package @@ -581,9 +584,6 @@ function M._start(bufnr, client_id, debounce) end highlighter:on_attach(client_id) - if M.is_enabled({ bufnr = bufnr }) then - highlighter:send_request() - end end --- Start the semantic token highlighting engine for the given buffer with the @@ -670,9 +670,9 @@ function M.stop(bufnr, client_id) end --- Query whether semantic tokens is enabled in the {filter}ed scope ----@param filter? vim.lsp.enable.Filter +---@param filter? vim.lsp.capability.enable.Filter function M.is_enabled(filter) - return util._is_enabled('semantic_tokens', filter) + return vim.lsp._capability.is_enabled('semantic_tokens', filter) end --- Enables or disables semantic tokens for the {filter}ed scope. @@ -684,20 +684,9 @@ end --- ``` --- ---@param enable? boolean true/nil to enable, false to disable ----@param filter? vim.lsp.enable.Filter +---@param filter? vim.lsp.capability.enable.Filter function M.enable(enable, filter) - util._enable('semantic_tokens', enable, filter) - - for _, bufnr in ipairs(api.nvim_list_bufs()) do - local highlighter = STHighlighter.active[bufnr] - if highlighter then - if M.is_enabled({ bufnr = bufnr }) then - highlighter:send_request() - else - highlighter:reset() - end - end - end + vim.lsp._capability.enable('semantic_tokens', enable, filter) end --- @nodoc @@ -779,7 +768,7 @@ function M.force_refresh(bufnr) for _, buffer in ipairs(buffers) do local highlighter = STHighlighter.active[buffer] - if highlighter and M.is_enabled({ bufnr = bufnr }) then + if highlighter then highlighter:reset() highlighter:send_request() end @@ -863,6 +852,6 @@ api.nvim_set_decoration_provider(namespace, { M.__STHighlighter = STHighlighter -- Semantic tokens is enabled by default -util._enable('semantic_tokens', true) +vim.lsp._capability.enable('semantic_tokens', true) return M diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 6c3641e627..1d8265436d 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -2331,85 +2331,6 @@ function M._cancel_requests(filter) end end ----@param feature string ----@param client_id? integer -local function make_enable_var(feature, client_id) - return ('_lsp_enabled_%s%s'):format(feature, client_id and ('_client_%d'):format(client_id) or '') -end - ----@class vim.lsp.enable.Filter ----@inlinedoc ---- ---- Buffer number, or 0 for current buffer, or nil for all. ----@field bufnr? integer ---- ---- Client ID, or nil for all ----@field client_id? integer - ----@param feature string ----@param filter? vim.lsp.enable.Filter -function M._is_enabled(feature, filter) - vim.validate('feature', feature, 'string') - vim.validate('filter', filter, 'table', true) - - filter = filter or {} - local bufnr = filter.bufnr - local client_id = filter.client_id - - local var = make_enable_var(feature) - local client_var = make_enable_var(feature, client_id) - return vim.F.if_nil(client_id and vim.g[client_var], vim.g[var]) - and vim.F.if_nil(bufnr and vim.b[bufnr][var], vim.g[var]) -end - ----@param feature 'semantic_tokens' ----@param enable? boolean ----@param filter? vim.lsp.enable.Filter -function M._enable(feature, enable, filter) - vim.validate('feature', feature, 'string') - vim.validate('enable', enable, 'boolean', true) - vim.validate('filter', filter, 'table', true) - - enable = enable == nil or enable - filter = filter or {} - local bufnr = filter.bufnr - local client_id = filter.client_id - assert( - not (bufnr and client_id), - 'Only one of `bufnr` or `client_id` filters can be specified at a time.' - ) - - local var = make_enable_var(feature) - local client_var = make_enable_var(feature, client_id) - - if client_id then - if enable == vim.g[var] then - vim.g[client_var] = nil - else - vim.g[client_var] = enable - end - elseif bufnr then - if enable == vim.g[var] then - vim.b[bufnr][var] = nil - else - vim.b[bufnr][var] = enable - end - else - vim.g[var] = enable - for _, it_bufnr in ipairs(api.nvim_list_bufs()) do - if api.nvim_buf_is_loaded(it_bufnr) and vim.b[it_bufnr][var] == enable then - vim.b[it_bufnr][var] = nil - end - end - for _, it_client in ipairs(vim.lsp.get_clients()) do - local it_client_var = make_enable_var(feature, it_client.id) - if vim.g[it_client_var] and vim.g[it_client_var] == enable then - vim.g[it_client_var] = nil - end - end - end -end - M._get_line_byte_from_position = get_line_byte_from_position ---@nodoc diff --git a/test/functional/plugin/lsp/folding_range_spec.lua b/test/functional/plugin/lsp/folding_range_spec.lua index 96e0a1e88d..d82b98b045 100644 --- a/test/functional/plugin/lsp/folding_range_spec.lua +++ b/test/functional/plugin/lsp/folding_range_spec.lua @@ -135,25 +135,25 @@ static int foldLevel(linenr_T lnum) command([[split]]) end) - it('controls the value of `b:_lsp_enable_folding_range`', function() + it('controls whether folding range is enabled', function() eq( true, exec_lua(function() - return vim.b._lsp_enable_folding_range + return vim.lsp._capability.is_enabled('folding_range', { bufnr = 0 }) end) ) command [[setlocal foldexpr=]] eq( - nil, + false, exec_lua(function() - return vim.b._lsp_enable_folding_range + return vim.lsp._capability.is_enabled('folding_range', { bufnr = 0 }) end) ) command([[set foldexpr=v:lua.vim.lsp.foldexpr()]]) eq( true, exec_lua(function() - return vim.b._lsp_enable_folding_range + return vim.lsp._capability.is_enabled('folding_range', { bufnr = 0 }) end) ) end)