refactor(lsp): redesign LSP folding state #34469

This commit is contained in:
Yi Ming
2025-06-19 21:23:40 +08:00
committed by GitHub
parent 0dc900d744
commit 528381587b

View File

@@ -12,8 +12,11 @@ local supported_fold_kinds = {
local M = {} local M = {}
---@class (private) vim.lsp.folding_range.BufState ---@class (private) vim.lsp.folding_range.State
--- ---
---@field active table<integer, vim.lsp.folding_range.State?>
---@field bufnr integer
---@field augroup integer
---@field version? integer ---@field version? integer
--- ---
--- Never use this directly, `renew()` the cached foldinfo --- Never use this directly, `renew()` the cached foldinfo
@@ -30,15 +33,10 @@ local M = {}
--- ---
--- Index in the form of start_row -> collapsed_text --- Index in the form of start_row -> collapsed_text
---@field row_text table<integer, string?> ---@field row_text table<integer, string?>
local State = { active = {} }
---@type table<integer, vim.lsp.folding_range.BufState?>
local bufstates = {}
--- Renew the cached foldinfo in the buffer. --- Renew the cached foldinfo in the buffer.
---@param bufnr integer function State:renew()
local function renew(bufnr)
local bufstate = assert(bufstates[bufnr])
---@type table<integer, [integer, ">" | "<"?]?> ---@type table<integer, [integer, ">" | "<"?]?>
local row_level = {} local row_level = {}
---@type table<integer, table<lsp.FoldingRangeKind, true?>?>> ---@type table<integer, table<lsp.FoldingRangeKind, true?>?>>
@@ -46,7 +44,7 @@ local function renew(bufnr)
---@type table<integer, string?> ---@type table<integer, string?>
local row_text = {} local row_text = {}
for client_id, ranges in pairs(bufstate.client_ranges) do for client_id, ranges in pairs(self.client_ranges) do
for _, range in ipairs(ranges) do for _, range in ipairs(ranges) do
local start_row = range.startLine local start_row = range.startLine
local end_row = range.endLine local end_row = range.endLine
@@ -77,16 +75,14 @@ local function renew(bufnr)
end end
end end
bufstate.row_level = row_level self.row_level = row_level
bufstate.row_kinds = row_kinds self.row_kinds = row_kinds
bufstate.row_text = row_text self.row_text = row_text
end end
--- Renew the cached foldinfo then force `foldexpr()` to be re-evaluated, --- Force `foldexpr()` to be re-evaluated, without opening folds.
--- without opening folds.
---@param bufnr integer ---@param bufnr integer
local function foldupdate(bufnr) local function foldupdate(bufnr)
renew(bufnr)
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
local wininfo = vim.fn.getwininfo(winid)[1] local wininfo = vim.fn.getwininfo(winid)[1]
if wininfo and wininfo.tabnr == vim.fn.tabpagenr() then if wininfo and wininfo.tabnr == vim.fn.tabpagenr() then
@@ -120,119 +116,110 @@ local function schedule_foldupdate(bufnr)
end end
---@param results table<integer,{err: lsp.ResponseError?, result: lsp.FoldingRange[]?}> ---@param results table<integer,{err: lsp.ResponseError?, result: lsp.FoldingRange[]?}>
---@type lsp.MultiHandler ---@param ctx lsp.HandlerContext
local function multi_handler(results, ctx) function State:multi_handler(results, ctx)
local bufnr = assert(ctx.bufnr)
-- Handling responses from outdated buffer only causes performance overhead. -- Handling responses from outdated buffer only causes performance overhead.
if util.buf_versions[bufnr] ~= ctx.version then if util.buf_versions[self.bufnr] ~= ctx.version then
return return
end end
local bufstate = assert(bufstates[bufnr])
for client_id, result in pairs(results) do for client_id, result in pairs(results) do
if result.err then if result.err then
log.error(result.err) log.error(result.err)
else else
bufstate.client_ranges[client_id] = result.result self.client_ranges[client_id] = result.result
end end
end end
bufstate.version = ctx.version self.version = ctx.version
self:renew()
if api.nvim_get_mode().mode:match('^i') then if api.nvim_get_mode().mode:match('^i') then
-- `foldUpdate()` is guarded in insert mode. -- `foldUpdate()` is guarded in insert mode.
schedule_foldupdate(bufnr) schedule_foldupdate(self.bufnr)
else else
foldupdate(bufnr) foldupdate(self.bufnr)
end end
end end
---@param err lsp.ResponseError?
---@param result lsp.FoldingRange[]? ---@param result lsp.FoldingRange[]?
---@type lsp.Handler ---@param ctx lsp.HandlerContext, config?: table
local function handler(err, result, ctx) function State:handler(err, result, ctx)
multi_handler({ [ctx.client_id] = { err = err, result = result } }, ctx) self:multi_handler({ [ctx.client_id] = { err = err, result = result } }, ctx)
end end
--- Request `textDocument/foldingRange` from the server. --- Request `textDocument/foldingRange` from the server.
--- `foldupdate()` is scheduled once after the request is completed. --- `foldupdate()` is scheduled once after the request is completed.
---@param bufnr integer
---@param client? vim.lsp.Client The client whose server supports `foldingRange`. ---@param client? vim.lsp.Client The client whose server supports `foldingRange`.
local function request(bufnr, client) function State:request(client)
---@type lsp.FoldingRangeParams ---@type lsp.FoldingRangeParams
local params = { textDocument = util.make_text_document_params(bufnr) } local params = { textDocument = util.make_text_document_params(self.bufnr) }
if client then if client then
client:request(ms.textDocument_foldingRange, params, handler, bufnr) client:request(ms.textDocument_foldingRange, params, function(...)
self:handler(...)
end, self.bufnr)
return return
end end
if not next(vim.lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_foldingRange })) then if
not next(vim.lsp.get_clients({ bufnr = self.bufnr, method = ms.textDocument_foldingRange }))
then
return return
end end
vim.lsp.buf_request_all(bufnr, ms.textDocument_foldingRange, params, multi_handler) vim.lsp.buf_request_all(self.bufnr, ms.textDocument_foldingRange, params, function(...)
self:multi_handler(...)
end)
end end
-- NOTE: function State:reset()
-- `bufstate` and event hooks are interdependent: self.client_ranges = {}
-- * `bufstate` needs event hooks for correctness. self.row_level = {}
-- * event hooks require the previous `bufstate` for updates. self.row_kinds = {}
-- Since they are manually created and destroyed, self.row_text = {}
-- we ensure their lifecycles are always synchronized. end
--
-- TODO(ofseed):
-- 1. Implement clearing `bufstate` and event hooks
-- when no clients in the buffer support the corresponding method.
-- 2. Then generalize this state management to other LSP modules.
local augroup_setup = api.nvim_create_augroup('nvim.lsp.folding_range.setup', {})
--- Initialize `bufstate` and event hooks, then request folding ranges. --- Initialize `state` and event hooks, then request folding ranges.
--- Manage their lifecycle within this function.
---@param bufnr integer ---@param bufnr integer
---@return vim.lsp.folding_range.BufState? ---@return vim.lsp.folding_range.State
local function setup(bufnr) function State.new(bufnr)
if not api.nvim_buf_is_loaded(bufnr) then local self = setmetatable({}, { __index = State })
return self.bufnr = bufnr
end self.augroup = api.nvim_create_augroup('nvim.lsp.folding_range:' .. bufnr, { clear = true })
self:reset()
-- Register the new `bufstate`. State.active[bufnr] = self
bufstates[bufnr] = {
client_ranges = {},
row_level = {},
row_kinds = {},
row_text = {},
}
-- Event hooks from `buf_attach` can't be removed externally.
-- Hooks and `bufstate` share the same lifecycle;
-- they should self-destroy if `bufstate == nil`.
api.nvim_buf_attach(bufnr, false, { api.nvim_buf_attach(bufnr, false, {
-- `on_detach` also runs on buffer reload (`:e`). -- `on_detach` also runs on buffer reload (`:e`).
-- Ensure `bufstate` and hooks are cleared to avoid duplication or leftover states. -- Ensure `state` and hooks are cleared to avoid duplication or leftover states.
on_detach = function() on_detach = function()
util._cancel_requests({ util._cancel_requests({
bufnr = bufnr, bufnr = bufnr,
method = ms.textDocument_foldingRange, method = ms.textDocument_foldingRange,
type = 'pending', type = 'pending',
}) })
bufstates[bufnr] = nil local state = State.active[bufnr]
api.nvim_clear_autocmds({ buffer = bufnr, group = augroup_setup }) if state then
state:destroy()
end
end, end,
-- Reset `bufstate` and request folding ranges. -- Reset `bufstate` and request folding ranges.
on_reload = function() on_reload = function()
bufstates[bufnr] = { local state = State.active[bufnr]
client_ranges = {}, if state then
row_level = {}, state:reset()
row_kinds = {}, state:request()
row_text = {}, end
}
request(bufnr)
end, end,
--- Sync changed rows with their previous foldlevels before applying new ones. --- Sync changed rows with their previous foldlevels before applying new ones.
on_bytes = function(_, _, _, start_row, _, _, old_row, _, _, new_row, _, _) on_bytes = function(_, _, _, start_row, _, _, old_row, _, _, new_row, _, _)
if bufstates[bufnr] == nil then local state = State.active[bufnr]
if state == nil then
return true return true
end end
local row_level = bufstates[bufnr].row_level local row_level = state.row_level
if next(row_level) == nil then if next(row_level) == nil then
return return
end end
@@ -252,7 +239,7 @@ local function setup(bufnr)
end, end,
}) })
api.nvim_create_autocmd('LspDetach', { api.nvim_create_autocmd('LspDetach', {
group = augroup_setup, group = self.augroup,
buffer = bufnr, buffer = bufnr,
callback = function(args) callback = function(args)
if not api.nvim_buf_is_loaded(bufnr) then if not api.nvim_buf_is_loaded(bufnr) then
@@ -261,7 +248,7 @@ local function setup(bufnr)
---@type integer ---@type integer
local client_id = args.data.client_id local client_id = args.data.client_id
bufstates[bufnr].client_ranges[client_id] = nil self.client_ranges[client_id] = nil
---@type vim.lsp.Client[] ---@type vim.lsp.Client[]
local clients = vim local clients = vim
@@ -272,29 +259,25 @@ local function setup(bufnr)
end) end)
:totable() :totable()
if #clients == 0 then if #clients == 0 then
bufstates[bufnr] = { self:reset()
client_ranges = {},
row_level = {},
row_kinds = {},
row_text = {},
}
end end
self:renew()
foldupdate(bufnr) foldupdate(bufnr)
end, end,
}) })
api.nvim_create_autocmd('LspAttach', { api.nvim_create_autocmd('LspAttach', {
group = augroup_setup, group = self.augroup,
buffer = bufnr, buffer = bufnr,
callback = function(args) callback = function(args)
local client = assert(vim.lsp.get_client_by_id(args.data.client_id)) 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 if client:supports_method(vim.lsp.protocol.Methods.textDocument_foldingRange, bufnr) then
request(bufnr, client) self:request(client)
end end
end, end,
}) })
api.nvim_create_autocmd('LspNotify', { api.nvim_create_autocmd('LspNotify', {
group = augroup_setup, group = self.augroup,
buffer = bufnr, buffer = bufnr,
callback = function(args) callback = function(args)
local client = assert(vim.lsp.get_client_by_id(args.data.client_id)) local client = assert(vim.lsp.get_client_by_id(args.data.client_id))
@@ -305,22 +288,39 @@ local function setup(bufnr)
or args.data.method == ms.textDocument_didOpen or args.data.method == ms.textDocument_didOpen
) )
then then
request(bufnr, client) self:request(client)
end end
end, end,
}) })
request(bufnr) return self
end
return bufstates[bufnr] function State:destroy()
api.nvim_del_augroup_by_id(self.augroup)
State.active[self.bufnr] = nil
end
local function setup(bufnr)
if not api.nvim_buf_is_loaded(bufnr) then
return
end
local state = State.active[bufnr]
if not state then
state = State.new(bufnr)
end
state:request()
return state
end end
---@param kind lsp.FoldingRangeKind ---@param kind lsp.FoldingRangeKind
---@param winid integer ---@param winid integer
local function foldclose(kind, winid) function State:foldclose(kind, winid)
vim._with({ win = winid }, function() vim._with({ win = winid }, function()
local bufnr = api.nvim_win_get_buf(winid) local bufnr = api.nvim_win_get_buf(winid)
local row_kinds = bufstates[bufnr].row_kinds local row_kinds = State.active[bufnr].row_kinds
-- Reverse traverse to ensure that the smallest ranges are closed first. -- Reverse traverse to ensure that the smallest ranges are closed first.
for row = api.nvim_buf_line_count(bufnr) - 1, 0, -1 do for row = api.nvim_buf_line_count(bufnr) - 1, 0, -1 do
local kinds = row_kinds[row] local kinds = row_kinds[row]
@@ -339,13 +339,13 @@ function M.foldclose(kind, winid)
winid = winid or api.nvim_get_current_win() winid = winid or api.nvim_get_current_win()
local bufnr = api.nvim_win_get_buf(winid) local bufnr = api.nvim_win_get_buf(winid)
local bufstate = bufstates[bufnr] local state = State.active[bufnr]
if not bufstate then if not state then
return return
end end
if bufstate.version == util.buf_versions[bufnr] then if state.version == util.buf_versions[bufnr] then
foldclose(kind, winid) state:foldclose(kind, winid)
return return
end end
-- Schedule `foldclose()` if the buffer is not up-to-date. -- Schedule `foldclose()` if the buffer is not up-to-date.
@@ -356,10 +356,10 @@ function M.foldclose(kind, winid)
---@type lsp.FoldingRangeParams ---@type lsp.FoldingRangeParams
local params = { textDocument = util.make_text_document_params(bufnr) } local params = { textDocument = util.make_text_document_params(bufnr) }
vim.lsp.buf_request_all(bufnr, ms.textDocument_foldingRange, params, function(...) vim.lsp.buf_request_all(bufnr, ms.textDocument_foldingRange, params, function(...)
multi_handler(...) state:multi_handler(...)
-- Ensure this buffer stays as the current buffer after the async request -- Ensure this buffer stays as the current buffer after the async request
if api.nvim_win_get_buf(winid) == bufnr then if api.nvim_win_get_buf(winid) == bufnr then
foldclose(kind, winid) state:foldclose(kind, winid)
end end
end) end)
end end
@@ -369,9 +369,9 @@ function M.foldtext()
local bufnr = api.nvim_get_current_buf() local bufnr = api.nvim_get_current_buf()
local lnum = vim.v.foldstart local lnum = vim.v.foldstart
local row = lnum - 1 local row = lnum - 1
local bufstate = bufstates[bufnr] local state = State.active[bufnr]
if bufstate and bufstate.row_text[row] then if state and state.row_text[row] then
return bufstate.row_text[row] return state.row_text[row]
end end
return vim.fn.getline(lnum) return vim.fn.getline(lnum)
end end
@@ -380,13 +380,13 @@ end
---@return string level ---@return string level
function M.foldexpr(lnum) function M.foldexpr(lnum)
local bufnr = api.nvim_get_current_buf() local bufnr = api.nvim_get_current_buf()
local bufstate = bufstates[bufnr] or setup(bufnr) local state = State.active[bufnr] or setup(bufnr)
if not bufstate then if not state then
return '0' return '0'
end end
local row = (lnum or vim.v.lnum) - 1 local row = (lnum or vim.v.lnum) - 1
local level = bufstate.row_level[row] local level = state.row_level[row]
return level and (level[2] or '') .. (level[1] or '0') or '0' return level and (level[2] or '') .. (level[1] or '0') or '0'
end end