refactor(lsp): turn codelens module into Row/State classes

This commit is contained in:
2026-04-15 21:53:50 +02:00
parent 227900d81c
commit fe51624839
+146 -114
View File
@@ -10,62 +10,70 @@ local M = {}
M.virt_text_pos = "eol" M.virt_text_pos = "eol"
M.separator = " | " M.separator = " | "
---@class ow.lsp.codelens.Row
---@field ready lsp.CodeLens[] resolved lenses with `command`, ready to paint
---@field pending integer count of unresolved lenses still in flight
---@class ow.lsp.codelens.State
---@field enabled boolean
---@field attached boolean
---@field generation integer
---@field rows table<integer, ow.lsp.codelens.Row>
---@type table<integer, ow.lsp.codelens.State> ---@type table<integer, ow.lsp.codelens.State>
local state_by_buf = {} local state_by_buf = {}
---@param buf integer local refresh_debounced = util.debounce(function(buf)
---@return ow.lsp.codelens.State local state = state_by_buf[buf]
local function get_state(buf) if state then
state_by_buf[buf] = state_by_buf[buf] state:refresh()
or { end
enabled = false, end, REFRESH_DEBOUNCE_MS)
attached = false,
generation = 0, ---@class ow.lsp.codelens.Row
rows = {}, ---@field row integer
} ---@field ready lsp.CodeLens[]
return state_by_buf[buf] ---@field pending integer count of unresolved lenses still in flight
local Row = {}
Row.__index = Row
---@param row integer
---@return ow.lsp.codelens.Row
function Row.new(row)
return setmetatable({ row = row, ready = {}, pending = 0 }, Row)
end end
---@param lens lsp.CodeLens
function Row:add(lens)
table.insert(self.ready, lens)
end
function Row:await()
self.pending = self.pending + 1
end
---@param resolved lsp.CodeLens?
function Row:resolved(resolved)
self.pending = self.pending - 1
if resolved then
table.insert(self.ready, resolved)
end
end
--- Paint this row's extmark. Preserves the previous extmark while any lens
--- on the row is still resolving so the user doesn't see a partial row
--- (e.g. "refs" without "impls").
---@param buf integer ---@param buf integer
---@param row integer function Row:render(buf)
local function render_row(buf, row)
if not vim.api.nvim_buf_is_valid(buf) then if not vim.api.nvim_buf_is_valid(buf) then
return return
end end
local state = state_by_buf[buf] if self.pending > 0 or #self.ready == 0 then
if not state or not state.enabled then
return return
end end
local entry = state.rows[row] table.sort(self.ready, function(a, b)
-- Preserve existing extmark while any lens on the row is still resolving
-- so the user doesn't see a partial row (e.g. "refs" without "impls").
if not entry or entry.pending > 0 or #entry.ready == 0 then
return
end
table.sort(entry.ready, function(a, b)
return a.range.start.character < b.range.start.character return a.range.start.character < b.range.start.character
end) end)
local parts = {} local parts = {}
for i, lens in ipairs(entry.ready) do for i, lens in ipairs(self.ready) do
table.insert(parts, { lens.command.title, "LspCodeLens" }) table.insert(parts, { lens.command.title, "LspCodeLens" })
if i < #entry.ready then if i < #self.ready then
table.insert(parts, { M.separator, "LspCodeLensSeparator" }) table.insert(parts, { M.separator, "LspCodeLensSeparator" })
end end
end end
local col = M.virt_text_pos == "inline" local col = M.virt_text_pos == "inline"
and entry.ready[1].range.start.character and self.ready[1].range.start.character
or 0 or 0
-- One extmark per row. Extmarks auto-shift with edits, so multiple from -- One extmark per row. Extmarks auto-shift with edits, so multiple from
@@ -74,8 +82,8 @@ local function render_row(buf, row)
local marks = vim.api.nvim_buf_get_extmarks( local marks = vim.api.nvim_buf_get_extmarks(
buf, buf,
NS, NS,
{ row, 0 }, { self.row, 0 },
{ row, -1 }, { self.row, -1 },
{ details = true } { details = true }
) )
local primary local primary
@@ -97,7 +105,7 @@ local function render_row(buf, row)
return return
end end
vim.api.nvim_buf_set_extmark(buf, NS, row, col, { vim.api.nvim_buf_set_extmark(buf, NS, self.row, col, {
id = primary and primary.id or nil, id = primary and primary.id or nil,
virt_text = parts, virt_text = parts,
virt_text_pos = M.virt_text_pos, virt_text_pos = M.virt_text_pos,
@@ -105,130 +113,161 @@ local function render_row(buf, row)
}) })
end end
---@class ow.lsp.codelens.State
---@field buf integer
---@field enabled boolean
---@field attached boolean
---@field generation integer
---@field rows table<integer, ow.lsp.codelens.Row>
local State = {}
State.__index = State
---@param buf integer ---@param buf integer
local function render_all(buf) ---@return ow.lsp.codelens.State
if not vim.api.nvim_buf_is_valid(buf) then function State.new(buf)
return setmetatable({
buf = buf,
enabled = false,
attached = false,
generation = 0,
rows = {},
}, State)
end
--- Paint every row and drop extmarks on rows that are no longer present.
function State:render()
if not vim.api.nvim_buf_is_valid(self.buf) then
return return
end end
local state = state_by_buf[buf] if not self.enabled then
if not state or not state.enabled then vim.api.nvim_buf_clear_namespace(self.buf, NS, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1)
return return
end end
for row in pairs(state.rows) do for _, row in pairs(self.rows) do
render_row(buf, row) row:render(self.buf)
end end
-- Drop extmarks on rows that are no longer in state (lens removed). for _, mark in
for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, NS, 0, -1, {})) do ipairs(vim.api.nvim_buf_get_extmarks(self.buf, NS, 0, -1, {}))
if not state.rows[mark[2]] then do
vim.api.nvim_buf_del_extmark(buf, NS, mark[1]) if not self.rows[mark[2]] then
vim.api.nvim_buf_del_extmark(self.buf, NS, mark[1])
end end
end end
end end
---@param buf integer function State:refresh()
local function refresh(buf) if not self.enabled then
local state = get_state(buf)
if not state.enabled then
return return
end end
state.generation = state.generation + 1 self.generation = self.generation + 1
local gen = state.generation local gen = self.generation
local params = local params = {
{ textDocument = vim.lsp.util.make_text_document_params(buf) } textDocument = vim.lsp.util.make_text_document_params(self.buf),
}
local new_rows = {} local new_rows = {}
vim.lsp.buf_request_all( vim.lsp.buf_request_all(
buf, self.buf,
vim.lsp.protocol.Methods.textDocument_codeLens, vim.lsp.protocol.Methods.textDocument_codeLens,
params, params,
function(results) function(results)
if state.generation ~= gen then if self.generation ~= gen then
return return
end end
for client_id, response in pairs(results) do for client_id, response in pairs(results) do
if not response.err and type(response.result) == "table" then if not response.err and type(response.result) == "table" then
local client = vim.lsp.get_client_by_id(client_id) local client = vim.lsp.get_client_by_id(client_id)
for _, lens in ipairs(response.result) do for _, lens in ipairs(response.result) do
local row = lens.range.start.line local row_num = lens.range.start.line
new_rows[row] = new_rows[row] local row = new_rows[row_num]
or { ready = {}, pending = 0 } if not row then
row = Row.new(row_num)
new_rows[row_num] = row
end
if lens.command then if lens.command then
table.insert(new_rows[row].ready, lens) row:add(lens)
elseif elseif
client client
and client:supports_method( and client:supports_method(
vim.lsp.protocol.Methods.codeLens_resolve vim.lsp.protocol.Methods.codeLens_resolve
) )
then then
new_rows[row].pending = new_rows[row].pending + 1 row:await()
client:request( client:request(
vim.lsp.protocol.Methods.codeLens_resolve, vim.lsp.protocol.Methods.codeLens_resolve,
lens, lens,
function(_, resolved) function(_, resolved)
if state.generation ~= gen then if self.generation ~= gen then
return return
end end
local entry = new_rows[row] row:resolved(
entry.pending = entry.pending - 1 type(resolved) == "table" and resolved
if type(resolved) == "table" then or nil
table.insert(entry.ready, resolved) )
end row:render(self.buf)
render_row(buf, row)
end, end,
buf self.buf
) )
end end
end end
end end
end end
-- Swap in the new row map and paint everything once. Rows with self.rows = new_rows
-- pending resolves keep their prior extmark in place; each self:render()
-- resolve callback above repaints its own row when it arrives.
state.rows = new_rows
render_all(buf)
end end
) )
end end
local refresh_debounced = util.debounce(refresh, REFRESH_DEBOUNCE_MS) function State:attach()
if self.attached then
---@param buf integer
local function attach_buf(buf)
local state = get_state(buf)
if state.attached then
return return
end end
state.attached = true self.attached = true
vim.api.nvim_buf_attach(buf, false, { vim.api.nvim_buf_attach(self.buf, false, {
on_lines = function(_, b) on_lines = function()
local s = state_by_buf[b] if not self.enabled then
if not s or not s.enabled then self.attached = false
if s then
s.attached = false
end
return true return true
end end
refresh_debounced:call(b) refresh_debounced:call(self.buf)
end, end,
on_reload = function(_, b) on_reload = function()
local s = state_by_buf[b] if self.enabled then
if s and s.enabled then refresh_debounced:call(self.buf)
refresh_debounced:call(b)
end end
end, end,
on_detach = function(_, b) on_detach = function()
local s = state_by_buf[b] self.attached = false
if s then
s.attached = false
end
end, end,
}) })
end end
---@param value boolean
function State:enable(value)
self.enabled = value
if value then
self:attach()
self:refresh()
else
refresh_debounced:cancel(self.buf)
self:render()
end
end
function State:toggle()
self:enable(not self.enabled)
end
---@param buf integer
---@return ow.lsp.codelens.State
local function get_state(buf)
state_by_buf[buf] = state_by_buf[buf] or State.new(buf)
return state_by_buf[buf]
end
---@param buf? integer ---@param buf? integer
---@return boolean ---@return boolean
function M.is_enabled(buf) function M.is_enabled(buf)
@@ -240,24 +279,17 @@ end
---@param value? boolean ---@param value? boolean
---@param buf? integer ---@param buf? integer
function M.enable(value, buf) function M.enable(value, buf)
buf = buf or vim.api.nvim_get_current_buf()
if value == nil then if value == nil then
value = true value = true
end end
local state = get_state(buf) buf = buf or vim.api.nvim_get_current_buf()
state.enabled = value get_state(buf):enable(value)
if value then
attach_buf(buf)
refresh(buf)
else
refresh_debounced:cancel(buf)
render_all(buf)
end
end end
---@param buf? integer ---@param buf? integer
function M.toggle(buf) function M.toggle(buf)
M.enable(not M.is_enabled(buf), buf) buf = buf or vim.api.nvim_get_current_buf()
get_state(buf):toggle()
end end
function M.setup() function M.setup()
@@ -266,7 +298,7 @@ function M.setup()
callback = function(ev) callback = function(ev)
local state = state_by_buf[ev.buf] local state = state_by_buf[ev.buf]
if state and state.enabled then if state and state.enabled then
refresh(ev.buf) state:refresh()
end end
end, end,
}) })