Files
neovim/runtime/lua/vim/lsp/codelens.lua
jdrouhard 44d5593afd refactor(lsp): unify LspNotify, decor provider for capabilities #40691
Problem: Every LSP capability that sends a request on a document change
to update its state was using its own buffer-local autocmd to do so.
That means there is a separate autocmd per buffer per active
capability/feature, and the actual code inside the autocmd callback was
virtually identical. The same problem occurred for capabilities that had
components to draw on the screen that had their own decoration providers.

Solution: Introduce a central `LspNotify` autocmd and decoration
provider in the capability module itself. New base class methods
`on_close` and `on_change` that take a client_id have been provided, for
`didClose` and `didOpen`/`didChange` notifications respectively. New
base class method `on_win` that takes topline and botline has been
provided to add extmarks or perform other work when lines of a buffer
are being drawn.

The autocmd callback loops through each active capability instance for
the buffer and calls a corresponding method with the triggering
client_id if it is currently attached to the buffer that triggered the
autocmd. The decoration provider just calls on_win for all active
capabilities on the buffer.

This slightly tweaks folding range to make use of these new client-id
specific callbacks.
2026-07-15 07:07:17 -04:00

505 lines
14 KiB
Lua

local api = vim.api
local log = require('vim.lsp.log')
local tableclear = require('vim._core.table').clear
local util = require('vim.lsp.util')
local M = {}
local Capability = require('vim.lsp._capability')
---@class (private) vim.lsp.codelens.RowLenses
---@field lenses lsp.CodeLens[]
---@field version? integer `TextDocument` version most recently applied to this row.
---
---@class (private) vim.lsp.codelens.ClientState
---@field row_lenses table<integer, vim.lsp.codelens.RowLenses>
---@field namespace integer
---@field version? integer `TextDocument` version current state corresponds to.
---
---@class (private) vim.lsp.codelens.Provider : vim.lsp.Capability
---@field active table<integer, vim.lsp.codelens.Provider>
---
--- Index In the form of client_id -> client_state
---@field client_state? table<integer, vim.lsp.codelens.ClientState?>
local Provider = {
name = 'codelens',
method = 'textDocument/codeLens',
active = {},
}
Provider.__index = Provider
setmetatable(Provider, Capability)
Capability.all[Provider.name] = Provider
---@package
---@param client_id integer
function Provider:on_attach(client_id)
if not self.client_state[client_id] then
self.client_state[client_id] = {
namespace = api.nvim_create_namespace('nvim.lsp.codelens:' .. client_id),
row_lenses = {},
}
end
self:request(client_id)
end
---@package
---@param client_id integer
function Provider:on_detach(client_id)
local state = self.client_state[client_id]
if state then
self:clear(client_id)
self.client_state[client_id] = nil
end
end
---@private
function Provider:on_close(client_id)
self:clear(client_id)
end
---@private
function Provider:on_change(client_id)
self:request(client_id)
end
---@package
---@param client_id integer
function Provider:clear(client_id)
local state = self.client_state[client_id]
if state then
state.version = nil
tableclear(state.row_lenses)
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
end
end
--- `lsp.Handler` for `textDocument/codeLens`.
---
---@package
---@param err? lsp.ResponseError
---@param result? lsp.CodeLens[]
---@param ctx lsp.HandlerContext
function Provider:handler(err, result, ctx)
local state = self.client_state[ctx.client_id]
if not state then
return
end
if err then
log.error('codelens', err)
return
end
if util.buf_versions[self.bufnr] ~= ctx.version then
return
end
tableclear(state.row_lenses)
-- Code lenses should only span a single line.
for _, lens in ipairs(result or {}) do
local row = lens.range.start.line
local row_lenses = state.row_lenses[row] or { lenses = {} }
table.insert(row_lenses.lenses, lens)
state.row_lenses[row] = row_lenses
end
state.version = ctx.version
api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false })
end
---@package
---@param client_id integer
function Provider:request(client_id)
---@type lsp.CodeLensParams
local params = { textDocument = util.make_text_document_params(self.bufnr) }
local state = self.client_state[client_id]
local client = vim.lsp.get_client_by_id(client_id)
if state and client then
client:request('textDocument/codeLens', params, function(...)
self:handler(...)
end, self.bufnr)
end
end
---@private
---@param client vim.lsp.Client
---@param unresolved_lens lsp.CodeLens
function Provider:resolve(client, unresolved_lens)
---@param resolved_lens lsp.CodeLens
client:request('codeLens/resolve', unresolved_lens, function(err, resolved_lens, ctx)
local state = self.client_state[client.id]
if not state then
return
end
if err then
log.error('codelens/resolve', err)
return
end
if util.buf_versions[self.bufnr] ~= ctx.version then
return
end
local row = unresolved_lens.range.start.line
local row_lenses = state.row_lenses[row]
-- A newer textDocument/codeLens response can replace row_lenses while resolve is in flight.
if not row_lenses then
return
end
for i, lens in ipairs(row_lenses.lenses) do
-- Only apply if this exact unresolved lens still exists; otherwise response is stale.
if lens == unresolved_lens then
row_lenses.lenses[i] = resolved_lens
row_lenses.version = nil
api.nvim__redraw({
buf = self.bufnr,
range = { row, row + 1 },
valid = true,
flush = false,
})
return
end
end
end, self.bufnr)
end
---@package
---@param toprow integer
---@param botrow integer
function Provider:on_win(toprow, botrow)
for row = toprow, botrow do
for client_id, state in pairs(self.client_state) do
if state.version == util.buf_versions[self.bufnr] then
local row_lenses = state.row_lenses[row]
if not row_lenses then
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, row, row + 1)
elseif row_lenses.version ~= state.version then
row_lenses.version = state.version
local bufnr = self.bufnr
local namespace = state.namespace
table.sort(row_lenses.lenses, function(a, b)
return a.range.start.character < b.range.start.character
end)
local client = assert(vim.lsp.get_client_by_id(client_id))
local range = vim.range.lsp(bufnr, row_lenses.lenses[1].range, client.offset_encoding)
---@type [string, string][]
local virt_text = {
{ string.rep(' ', range.start_col), 'LspCodeLensSeparator' },
}
local has_unresolved = false
for _, lens in ipairs(row_lenses.lenses) do
-- A code lens is unresolved when no command is associated to it.
if not lens.command then
has_unresolved = true
self:resolve(client, lens)
else
vim.list_extend(virt_text, {
{ lens.command.title, 'LspCodeLens' },
{ ' | ', 'LspCodeLensSeparator' },
})
end
end
local had_extmark = #api.nvim_buf_get_extmarks(
bufnr,
namespace,
{ row, 0 },
{ row, -1 },
{}
) > 0
if not has_unresolved or not had_extmark then
-- Remove trailing separator.
table.remove(virt_text)
-- Use a placeholder to prevent flickering caused by layout shifts.
if #virt_text == 1 then
table.insert(virt_text, { '', 'LspCodeLens' })
end
api.nvim_buf_clear_namespace(bufnr, namespace, row, row + 1)
api.nvim_buf_set_extmark(bufnr, namespace, row, 0, {
virt_lines = { virt_text },
virt_lines_above = true,
virt_lines_overflow = 'scroll',
hl_mode = 'combine',
})
-- Fix https://github.com/neovim/neovim/issues/16166
-- Make sure the code lens on the first line is visible when updating.
if row == 0 then
vim.fn.winrestview({ topfill = 1 })
end
end
end
end
end
end
-- Clear extmarks beyond the bottom of the buffer.
if botrow == api.nvim_buf_line_count(self.bufnr) - 1 then
for _, state in pairs(self.client_state) do
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, botrow + 1, -1)
end
end
end
--- Query whether code lens is enabled in the {filter}ed scope
---
---@param filter? vim.lsp.capability.enable.Filter
---@return boolean whether code lens is enabled.
function M.is_enabled(filter)
return vim.lsp._capability.is_enabled('codelens', filter)
end
--- Enables or disables code lens for the {filter}ed scope.
---
--- To "toggle", pass the inverse of `is_enabled()`:
---
--- ```lua
--- vim.lsp.codelens.enable(not vim.lsp.codelens.is_enabled())
--- ```
---
--- To run a code lens, see |vim.lsp.codelens.run()|.
---
---@param enable? boolean true/nil to enable, false to disable
---@param filter? vim.lsp.capability.enable.Filter
function M.enable(enable, filter)
vim.lsp._capability.enable('codelens', enable, filter)
end
--- Optional filters |kwargs|:
---@class vim.lsp.codelens.get.Filter
---@inlinedoc
---
--- Buffer handle, or 0 for current.
--- (default: 0)
---@field bufnr? integer
---
--- Client ID, or nil for all.
--- (default: all)
---@field client_id? integer
---@class vim.lsp.codelens.get.Result
---@inlinedoc
---@field client_id integer
---@field lens lsp.CodeLens
--- Get all code lenses in the {filter}ed scope.
---
---@param filter? vim.lsp.codelens.get.Filter
---@return vim.lsp.codelens.get.Result[]
function M.get(filter)
if type(filter) == 'number' then
vim.deprecate(
'vim.lsp.codelens.get(bufnr)',
'vim.lsp.codelens.get({ bufnr = bufnr })',
'0.13.0'
)
local bufnr = vim._resolve_bufnr(filter)
local provider = Provider.active[bufnr]
if not provider then
return {}
end
---@type lsp.CodeLens[]
local result = {}
for _, state in pairs(provider.client_state) do
for _, row_lenses in pairs(state.row_lenses) do
result = vim.list_extend(result, row_lenses.lenses)
end
end
return result
end
vim.validate('filter', filter, 'table', true)
filter = filter or {}
local bufnr = vim._resolve_bufnr(filter.bufnr)
local provider = Provider.active[bufnr]
if not provider then
return {}
end
local result = {}
for client_id, state in pairs(provider.client_state) do
if not filter.client_id or filter.client_id == client_id then
for _, row_lenses in pairs(state.row_lenses) do
for _, lens in ipairs(row_lenses.lenses) do
table.insert(result, { client_id = client_id, lens = lens })
end
end
end
end
return result
end
---@param lnum integer
---@param opts vim.lsp.codelens.run.Opts
---@param results table<integer, {err: lsp.ResponseError?, result: lsp.CodeLens[]?}>
---@param context lsp.HandlerContext
local function on_lenses_run(lnum, opts, results, context)
local bufnr = context.bufnr or 0
---@type {client: vim.lsp.Client, lens: lsp.CodeLens}[]
local candidates = {}
local pending_resolve = 1
local function on_resolved()
pending_resolve = pending_resolve - 1
if pending_resolve > 0 then
return
end
if #candidates == 0 then
vim.notify('No codelens at current line')
elseif #candidates == 1 then
local candidate = candidates[1]
candidate.client:exec_cmd(candidate.lens.command, { bufnr = bufnr })
else
local selectopts = {
prompt = 'Code lenses: ',
kind = 'codelens',
---@param candidate {client: vim.lsp.Client, lens: lsp.CodeLens}
format_item = function(candidate)
return string.format('%s [%s]', candidate.lens.command.title, candidate.client.name)
end,
}
vim.ui.select(candidates, selectopts, function(candidate)
if candidate then
candidate.client:exec_cmd(candidate.lens.command, { bufnr = bufnr })
end
end)
end
end
for client_id, result in pairs(results) do
if opts.client_id == nil or opts.client_id == client_id then
local client = assert(vim.lsp.get_client_by_id(client_id))
for _, lens in ipairs(result.result or {}) do
if lens.range.start.line == lnum then
if lens.command then
table.insert(candidates, { client = client, lens = lens })
else
pending_resolve = pending_resolve + 1
client:request('codeLens/resolve', lens, function(_, resolved_lens)
if resolved_lens then
table.insert(candidates, { client = client, lens = resolved_lens })
end
on_resolved()
end, bufnr)
end
end
end
end
end
on_resolved()
end
--- Optional parameters |kwargs|:
---@class vim.lsp.codelens.run.Opts
---@inlinedoc
---
--- Client ID, or nil for all.
--- (default: all)
---@field client_id? integer
--- Run code lens at the current cursor position.
---
---@param opts? vim.lsp.codelens.run.Opts
function M.run(opts)
vim.validate('opts', opts, 'table', true)
opts = opts or {}
local winid = api.nvim_get_current_win()
local bufnr = api.nvim_win_get_buf(winid)
local pos = vim.pos.cursor(winid)
local params = {
textDocument = vim.lsp.util.make_text_document_params(bufnr),
}
vim.lsp.buf_request_all(bufnr, 'textDocument/codeLens', params, function(results, context)
on_lenses_run(pos.row, opts, results, context)
end)
end
--- |lsp-handler| for the method `workspace/codeLens/refresh`
---
---@private
---@type lsp.Handler
function M.on_refresh(err, _, ctx)
if err then
return vim.NIL
end
for _, provider in pairs(Provider.active) do
local state = provider.client_state[ctx.client_id]
if state then
provider:request(ctx.client_id)
end
end
return vim.NIL
end
---@deprecated
---@param client_id? integer
---@param bufnr? integer
function M.clear(client_id, bufnr)
vim.deprecate(
'vim.lsp.codelens.clear(client_id, bufnr)',
'vim.lsp.codelens.enable(false, { bufnr = bufnr, client_id = client_id })',
'0.13.0'
)
M.enable(false, { bufnr = bufnr, client_id = client_id })
end
---@deprecated
---@param lenses? lsp.CodeLens[] lenses to display
---@param bufnr integer
---@param client_id integer
function M.display(lenses, bufnr, client_id)
vim.deprecate('vim.lsp.codelens.display()', nil, '0.13.0')
local _, _, _ = lenses, bufnr, client_id
end
---@deprecated
---@param lenses? lsp.CodeLens[] lenses to store
---@param bufnr integer
---@param client_id integer
function M.save(lenses, bufnr, client_id)
vim.deprecate('vim.lsp.codelens.save()', nil, '0.13.0')
local _, _, _ = lenses, bufnr, client_id
end
---@deprecated
---@param err? lsp.ResponseError
---@param result lsp.CodeLens[]
---@param ctx lsp.HandlerContext
function M.on_codelens(err, result, ctx)
vim.deprecate('vim.lsp.codelens.on_codelens()', nil, '0.13.0')
local _, _, _ = err, result, ctx
end
---@class vim.lsp.codelens.refresh.Opts
---@inlinedoc
---@field bufnr? integer
---@deprecated
---@param opts? vim.lsp.codelens.refresh.Opts Optional fields
function M.refresh(opts)
vim.deprecate(
'vim.lsp.codelens.refresh({ bufnr = bufnr})',
'vim.lsp.codelens.enable(true, { bufnr = bufnr })',
'0.13.0'
)
vim.validate('opts', opts, 'table', true)
M.enable(true, { bufnr = opts and opts.bufnr })
end
return M