mirror of
https://github.com/neovim/neovim.git
synced 2026-07-15 05:40:34 +00:00
refactor(lsp): use LspNotify for codelens #40604
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.
This commit is contained in:
@@ -1,32 +1,27 @@
|
||||
local util = require('vim.lsp.util')
|
||||
local log = require('vim.lsp.log')
|
||||
local tableclear = require('vim._core.table').clear
|
||||
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, lsp.CodeLens[]?> row -> lens
|
||||
---@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?>
|
||||
---
|
||||
--- `TextDocument` version current state corresponds to.
|
||||
---@field version? integer
|
||||
---
|
||||
--- Last version of codelens applied to this line.
|
||||
---
|
||||
--- Index In the form of row -> true?
|
||||
---@field row_version table<integer, integer?>
|
||||
---@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?>
|
||||
---
|
||||
--- Timer for debouncing automatic requests.
|
||||
---
|
||||
---@field timer? uv.uv_timer_t
|
||||
local Provider = {
|
||||
name = 'codelens',
|
||||
method = 'textDocument/codeLens',
|
||||
@@ -42,46 +37,34 @@ Capability.all[Provider.name] = Provider
|
||||
function Provider:new(bufnr)
|
||||
---@type vim.lsp.codelens.Provider
|
||||
self = Capability.new(self, bufnr)
|
||||
self.client_state = {}
|
||||
self.row_version = {}
|
||||
|
||||
api.nvim_buf_attach(bufnr, false, {
|
||||
on_lines = function(_, buf)
|
||||
local provider = Provider.active[buf]
|
||||
if not provider then
|
||||
return true
|
||||
end
|
||||
provider:automatic_request()
|
||||
end,
|
||||
on_reload = function(_, buf)
|
||||
local provider = Provider.active[buf]
|
||||
if provider then
|
||||
provider:clear()
|
||||
provider:automatic_request()
|
||||
end
|
||||
end,
|
||||
})
|
||||
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
|
||||
function Provider:destroy()
|
||||
self:reset_timer()
|
||||
api.nvim_del_augroup_by_id(self.augroup)
|
||||
self.active[self.bufnr] = nil
|
||||
end
|
||||
|
||||
---@package
|
||||
---@param client_id integer
|
||||
function Provider:on_attach(client_id)
|
||||
local state = self.client_state[client_id]
|
||||
if not state then
|
||||
state = {
|
||||
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 = {},
|
||||
}
|
||||
self.client_state[client_id] = state
|
||||
end
|
||||
self:request(client_id)
|
||||
end
|
||||
@@ -91,23 +74,20 @@ end
|
||||
function Provider:on_detach(client_id)
|
||||
local state = self.client_state[client_id]
|
||||
if state then
|
||||
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
|
||||
self:clear(client_id)
|
||||
self.client_state[client_id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
---@package
|
||||
function Provider:clear()
|
||||
self:reset_timer()
|
||||
self.version = nil
|
||||
tableclear(self.row_version)
|
||||
|
||||
for _, state in pairs(self.client_state) do
|
||||
---@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
|
||||
|
||||
api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false })
|
||||
end
|
||||
|
||||
--- `lsp.Handler` for `textDocument/codeLens`.
|
||||
@@ -131,71 +111,34 @@ function Provider:handler(err, result, ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local row_lenses = state.row_lenses
|
||||
tableclear(row_lenses)
|
||||
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 lenses = row_lenses[row] or {}
|
||||
table.insert(lenses, lens)
|
||||
row_lenses[row] = lenses
|
||||
local row_lenses = state.row_lenses[row] or { lenses = {} }
|
||||
table.insert(row_lenses.lenses, lens)
|
||||
state.row_lenses[row] = row_lenses
|
||||
end
|
||||
self.version = ctx.version
|
||||
state.version = ctx.version
|
||||
|
||||
api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false })
|
||||
end
|
||||
|
||||
---@package
|
||||
---@param client_id? integer
|
||||
---@param on_response? function
|
||||
function Provider:request(client_id, on_response)
|
||||
---@param client_id integer
|
||||
function Provider:request(client_id)
|
||||
---@type lsp.CodeLensParams
|
||||
local params = { textDocument = util.make_text_document_params(self.bufnr) }
|
||||
for id in pairs(self.client_state) do
|
||||
if not client_id or client_id == id then
|
||||
local client = assert(vim.lsp.get_client_by_id(id))
|
||||
client:request('textDocument/codeLens', params, function(...)
|
||||
self:handler(...)
|
||||
|
||||
if on_response then
|
||||
on_response()
|
||||
end
|
||||
end, self.bufnr)
|
||||
end
|
||||
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
|
||||
function Provider:reset_timer()
|
||||
local timer = self.timer
|
||||
if timer then
|
||||
self.timer = nil
|
||||
if not timer:is_closing() then
|
||||
timer:stop()
|
||||
timer:close()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Automatically request with debouncing, used as callbacks in autocmd events.
|
||||
---
|
||||
---@package
|
||||
function Provider:automatic_request()
|
||||
self:reset_timer()
|
||||
self.timer = vim.defer_fn(function()
|
||||
self:request()
|
||||
end, 200)
|
||||
end
|
||||
|
||||
---@package
|
||||
---@param client_id integer
|
||||
---@param on_response function
|
||||
function Provider:refresh(client_id, on_response)
|
||||
self:reset_timer()
|
||||
self:request(client_id, on_response)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param client vim.lsp.Client
|
||||
---@param unresolved_lens lsp.CodeLens
|
||||
@@ -217,17 +160,17 @@ function Provider:resolve(client, unresolved_lens)
|
||||
end
|
||||
|
||||
local row = unresolved_lens.range.start.line
|
||||
local lenses = state.row_lenses[row]
|
||||
local row_lenses = state.row_lenses[row]
|
||||
-- A newer textDocument/codeLens response can replace row_lenses while resolve is in flight.
|
||||
if not lenses then
|
||||
if not row_lenses then
|
||||
return
|
||||
end
|
||||
|
||||
for i, lens in ipairs(lenses) do
|
||||
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
|
||||
lenses[i] = resolved_lens
|
||||
self.row_version[row] = nil
|
||||
row_lenses.lenses[i] = resolved_lens
|
||||
row_lenses.version = nil
|
||||
api.nvim__redraw({
|
||||
buf = self.bufnr,
|
||||
range = { row, row + 1 },
|
||||
@@ -245,27 +188,31 @@ end
|
||||
---@param botrow integer
|
||||
function Provider:on_win(toprow, botrow)
|
||||
for row = toprow, botrow do
|
||||
if self.row_version[row] ~= self.version then
|
||||
for client_id, state in pairs(self.client_state) do
|
||||
local bufnr = self.bufnr
|
||||
local namespace = state.namespace
|
||||
local lenses = state.row_lenses[row]
|
||||
if not lenses then
|
||||
api.nvim_buf_clear_namespace(bufnr, namespace, row, row + 1)
|
||||
else
|
||||
table.sort(lenses, function(a, b)
|
||||
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, lenses[1].range, client.offset_encoding)
|
||||
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(lenses) do
|
||||
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
|
||||
@@ -310,7 +257,6 @@ function Provider:on_win(toprow, botrow)
|
||||
end
|
||||
end
|
||||
end
|
||||
self.row_version[row] = self.version
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -393,8 +339,8 @@ function M.get(filter)
|
||||
---@type lsp.CodeLens[]
|
||||
local result = {}
|
||||
for _, state in pairs(provider.client_state) do
|
||||
for _, lenses in pairs(state.row_lenses) do
|
||||
result = vim.list_extend(result, lenses)
|
||||
for _, row_lenses in pairs(state.row_lenses) do
|
||||
result = vim.list_extend(result, row_lenses.lenses)
|
||||
end
|
||||
end
|
||||
return result
|
||||
@@ -412,8 +358,8 @@ function M.get(filter)
|
||||
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 _, lenses in pairs(state.row_lenses) do
|
||||
for _, lens in ipairs(lenses) do
|
||||
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
|
||||
@@ -516,18 +462,13 @@ function M.on_refresh(err, _, ctx)
|
||||
return vim.NIL
|
||||
end
|
||||
|
||||
for bufnr, provider in pairs(Provider.active) do
|
||||
for client_id in pairs(provider.client_state) do
|
||||
if client_id == ctx.client_id then
|
||||
provider:refresh(client_id, function()
|
||||
if api.nvim_buf_is_valid(bufnr) then
|
||||
tableclear(provider.row_version)
|
||||
vim.api.nvim__redraw({ buf = bufnr, valid = true, flush = false })
|
||||
end
|
||||
end)
|
||||
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
|
||||
|
||||
|
||||
@@ -235,9 +235,8 @@ function M.on_refresh(err, _, ctx)
|
||||
return vim.NIL
|
||||
end
|
||||
|
||||
for bufnr in pairs(vim.lsp.get_client_by_id(ctx.client_id).attached_buffers or {}) do
|
||||
local provider = InlayHint.active[bufnr]
|
||||
if provider and provider.client_state[ctx.client_id] then
|
||||
for bufnr, provider in pairs(InlayHint.active) do
|
||||
if provider.client_state[ctx.client_id] then
|
||||
provider:reset(ctx.client_id)
|
||||
|
||||
if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then
|
||||
|
||||
@@ -994,9 +994,8 @@ function M._refresh(err, _, ctx)
|
||||
return vim.NIL
|
||||
end
|
||||
|
||||
for bufnr in pairs(vim.lsp.get_client_by_id(ctx.client_id).attached_buffers or {}) do
|
||||
local highlighter = STHighlighter.active[bufnr]
|
||||
if highlighter and highlighter.client_state[ctx.client_id] then
|
||||
for bufnr, highlighter in pairs(STHighlighter.active) do
|
||||
if highlighter.client_state[ctx.client_id] then
|
||||
highlighter:mark_dirty(ctx.client_id)
|
||||
|
||||
if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then
|
||||
|
||||
@@ -87,12 +87,18 @@ describe('vim.lsp.codelens', function()
|
||||
before_each(function()
|
||||
clear_notrace()
|
||||
exec_lua(create_server_definition)
|
||||
exec_lua(function()
|
||||
vim.lsp.config('*', {
|
||||
flags = { debounce_text_changes = 0 },
|
||||
})
|
||||
end)
|
||||
|
||||
screen = Screen.new(nil, 20)
|
||||
|
||||
client_id = exec_lua(function()
|
||||
_G.server = _G._create_server({
|
||||
capabilities = {
|
||||
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
|
||||
codeLensProvider = {
|
||||
resolveProvider = true,
|
||||
},
|
||||
@@ -251,8 +257,27 @@ describe('vim.lsp.codelens', function()
|
||||
end)
|
||||
|
||||
it('refreshes code lenses on request', function()
|
||||
local get_message_count = function()
|
||||
local messages = exec_lua('return _G.server.messages')
|
||||
local count = 0
|
||||
for _, m in ipairs(messages) do
|
||||
if m.method == 'textDocument/codeLens' then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
eq(2, get_message_count())
|
||||
|
||||
feed('ggdd')
|
||||
|
||||
-- Second codelens (▶︎ Run) is not resolved immediately, so its previous extmark (same row) isn't
|
||||
-- cleared and it stays anchored to where it was. The new one (which would effectively look like
|
||||
-- it moved a row down) needs to be resolved first, which goes through another screen update.
|
||||
-- This is a quirk of the test since it returns the exact same result no matter what the text in
|
||||
-- the buffer actually is
|
||||
|
||||
screen:expect([[
|
||||
{1: 1 implementation} |
|
||||
^a: i32, |
|
||||
@@ -275,13 +300,9 @@ describe('vim.lsp.codelens', function()
|
||||
{1:~ }|
|
||||
|
|
||||
]])
|
||||
exec_lua(function()
|
||||
vim.lsp.codelens.on_refresh(
|
||||
nil,
|
||||
nil,
|
||||
{ method = 'workspace/codeLens/refresh', client_id = client_id }
|
||||
)
|
||||
end)
|
||||
|
||||
eq(2, get_message_count())
|
||||
|
||||
screen:expect([[
|
||||
{1: 1 implementation} |
|
||||
^a: i32, |
|
||||
@@ -304,58 +325,18 @@ describe('vim.lsp.codelens', function()
|
||||
{1:~ }|
|
||||
|
|
||||
]])
|
||||
end)
|
||||
|
||||
it('refreshes immediately and cancels a pending automatic refresh', function()
|
||||
eq(3, get_message_count())
|
||||
|
||||
exec_lua(function()
|
||||
local deferred
|
||||
local request_count = 0
|
||||
local defer_fn = vim.defer_fn
|
||||
local client = assert(vim.lsp.get_client_by_id(client_id))
|
||||
local request = client.request
|
||||
|
||||
--- @diagnostic disable-next-line: duplicate-set-field
|
||||
vim.defer_fn = function(callback)
|
||||
deferred = {
|
||||
callback = callback,
|
||||
closed = false,
|
||||
stopped = false,
|
||||
is_closing = function(self)
|
||||
return self.closed
|
||||
end,
|
||||
stop = function(self)
|
||||
self.stopped = true
|
||||
end,
|
||||
close = function(self)
|
||||
self.closed = true
|
||||
end,
|
||||
}
|
||||
return deferred
|
||||
end
|
||||
|
||||
client.request = function(self, method, ...)
|
||||
if method == 'textDocument/codeLens' then
|
||||
request_count = request_count + 1
|
||||
end
|
||||
return request(self, method, ...)
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_lines(0, 0, 0, false, { '// changed' })
|
||||
assert(deferred, 'expected pending automatic codelens refresh')
|
||||
|
||||
vim.lsp.codelens.on_refresh(
|
||||
nil,
|
||||
nil,
|
||||
{ method = 'workspace/codeLens/refresh', client_id = client_id }
|
||||
)
|
||||
|
||||
vim.defer_fn = defer_fn
|
||||
client.request = request
|
||||
|
||||
assert(deferred.stopped, 'expected pending codelens refresh to stop')
|
||||
assert(deferred.closed, 'expected pending codelens refresh to close')
|
||||
assert(request_count == 1, 'expected exactly one immediate codelens refresh request')
|
||||
end)
|
||||
|
||||
eq(4, get_message_count())
|
||||
end)
|
||||
|
||||
it('ignores stale codeLens/resolve responses', function()
|
||||
@@ -369,6 +350,7 @@ describe('vim.lsp.codelens', function()
|
||||
_G.stale_resolve_sent = false
|
||||
_G.server = _G._create_server({
|
||||
capabilities = {
|
||||
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
|
||||
codeLensProvider = {
|
||||
resolveProvider = true,
|
||||
},
|
||||
@@ -446,6 +428,7 @@ describe('vim.lsp.codelens', function()
|
||||
_G.refresh_response_sent = false
|
||||
_G.server = _G._create_server({
|
||||
capabilities = {
|
||||
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
|
||||
codeLensProvider = {
|
||||
resolveProvider = true,
|
||||
},
|
||||
@@ -529,11 +512,12 @@ describe('vim.lsp.codelens', function()
|
||||
} |
|
||||
} |
|
||||
|
|
||||
{1: ▶︎ Run } |
|
||||
{1:▶︎ Run } |
|
||||
^ |
|
||||
{1:~ }|*5
|
||||
4 fewer lines |
|
||||
]])
|
||||
|
||||
feed('dd')
|
||||
screen:expect([[
|
||||
{1: 1 implementation} |
|
||||
|
||||
Reference in New Issue
Block a user