mirror of
https://github.com/neovim/neovim.git
synced 2026-07-23 01:12:49 +00:00
Problem: The codelens LSP module was using its own raw buffer events and its own debounce mechanism for refreshing code lens in attached buffers. Solution: Switch the module to using the LspNotify autocmd events. LspNotify fires just after document versions are synced with the server and provides a built in debounce mechanism for changes. Additionally, this fixes some bugs with the previous implementation: 1. The workspace/codeLens/refresh handler re-requested codelens for all buffers but when the response came back, it forced an extra redraw after clearing the work the handler had just done. 2. Document synchronization was reworked to be more resilient to multiple clients providing codelens for a single buffer. The latest document version is now separately tracked per client (and per client's lenses per row) instead of for the buffer as a whole. This allows the on_win() function to properly redraw all codelens even when different clients' responses for a particular document version come back at different times.
532 lines
15 KiB
Lua
532 lines
15 KiB
Lua
local api = vim.api
|
|
local log = require('vim.lsp.log')
|
|
local nvim_on = require('vim._core.util').nvim_on
|
|
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 bufnr integer
|
|
---@return vim.lsp.codelens.Provider
|
|
function Provider:new(bufnr)
|
|
---@type vim.lsp.codelens.Provider
|
|
self = Capability.new(self, bufnr)
|
|
|
|
nvim_on('LspNotify', self.augroup, { buf = self.bufnr }, function(ev)
|
|
local client_id = ev.data.client_id ---@type integer
|
|
|
|
if not self.client_state[client_id] then
|
|
return
|
|
end
|
|
|
|
if ev.data.method == 'textDocument/didClose' then
|
|
self:clear(client_id)
|
|
end
|
|
|
|
if ev.data.method == 'textDocument/didChange' or ev.data.method == 'textDocument/didOpen' then
|
|
self:request(client_id)
|
|
end
|
|
end)
|
|
|
|
return self
|
|
end
|
|
|
|
---@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
|
|
|
|
---@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
|
|
|
|
local namespace = api.nvim_create_namespace('nvim.lsp.codelens')
|
|
api.nvim_set_decoration_provider(namespace, {
|
|
on_win = function(_, _, bufnr, toprow, botrow)
|
|
local provider = Provider.active[bufnr]
|
|
if provider then
|
|
provider:on_win(toprow, botrow)
|
|
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
|