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:
fb8dba413f/runtime/lua/vim/lsp/semantic_tokens.lua (L192-L198)
- inlay hints, folding ranges, document color
fb8dba413f/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.
This commit is contained in:
Yi Ming
2025-07-07 11:51:30 +08:00
committed by GitHub
parent 55e3a75217
commit 8d5452c46d
8 changed files with 214 additions and 165 deletions

View File

@ -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

View File

@ -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'

View File

@ -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<integer, vim.lsp.Capability?>
---
--- 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<integer, 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

View File

@ -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<integer, vim.lsp.folding_range.State?>
---@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<integer, lsp.FoldingRange[]?>
---@field client_state table<integer, lsp.FoldingRange[]?>
---
--- Index in the form of row -> [foldlevel, mark]
---@field row_level table<integer, [integer, ">" | "<"?]?>
@ -33,10 +35,12 @@ local M = {}
---
--- Index in the form of start_row -> collapsed_text
---@field row_text table<integer, string?>
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<integer, [integer, ">" | "<"?]?>
local row_level = {}
---@type table<integer, table<lsp.FoldingRangeKind, true?>?>>
@ -44,7 +48,7 @@ function State:renew()
---@type table<integer, string?>
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

View File

@ -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

View File

@ -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()

View File

@ -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<integer, STHighlighter>
---@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<integer, STClientState>
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()

View File

@ -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<integer, string>
local foldlevels = {}