feat(lsp)!: add vim.lsp.status, client.progress and promote LspProgressUpdate (#23958)

`client.messages` could grow unbounded because the default handler only
added new messages, never removing them.

A user either had to consume the messages by calling
`vim.lsp.util.get_progress_messages` or by manually removing them from
`client.messages.progress`. If they didn't do that, using LSP
effectively leaked memory.

To fix this, this deprecates the `messages` property and instead adds a
`progress` ring buffer that only keeps at most 50 messages. In addition
it deprecates `vim.lsp.util.get_progress_messages` in favour of a new
`vim.lsp.status()` and also promotes the `LspProgressUpdate` user
autocmd to a regular autocmd to allow users to pattern match on the
progress kind.

Also closes https://github.com/neovim/neovim/pull/20327
This commit is contained in:
Mathias Fußenegger
2023-06-09 11:32:43 +02:00
committed by GitHub
parent f31dba93f9
commit e5e0bda41b
8 changed files with 173 additions and 65 deletions

View File

@@ -807,6 +807,9 @@ end
---
--- - {server_capabilities} (table): Response from the server sent on
--- `initialize` describing the server's capabilities.
---
--- - {progress} A ring buffer (|vim.ringbuf()|) containing progress messages
--- sent by the server.
function lsp.client()
error()
end
@@ -891,6 +894,50 @@ function lsp.start(config, opts)
return client_id
end
--- Consumes the latest progress messages from all clients and formats them as a string.
--- Empty if there are no clients or if no new messages
---
---@return string
function lsp.status()
local percentage = nil
local groups = {}
for _, client in ipairs(vim.lsp.get_active_clients()) do
for progress in client.progress do
local value = progress.value
if type(value) == 'table' and value.kind then
local group = groups[progress.token]
if not group then
group = {}
groups[progress.token] = group
end
group.title = value.title or group.title
group.message = value.message or group.message
if value.percentage then
percentage = math.max(percentage or 0, value.percentage)
end
end
-- else: Doesn't look like work done progress and can be in any format
-- Just ignore it as there is no sensible way to display it
end
end
local messages = {}
for _, group in pairs(groups) do
if group.title then
table.insert(
messages,
group.message and (group.title .. ': ' .. group.message) or group.title
)
elseif group.message then
table.insert(messages, group.message)
end
end
local message = table.concat(messages, ', ')
if percentage then
return string.format('%03d: %s', percentage, message)
end
return message
end
---@private
-- Determines whether the given option can be set by `set_defaults`.
local function is_empty_or_default(bufnr, option)
@@ -1266,10 +1313,23 @@ function lsp.start_client(config)
--- @type table<integer,{ type: string, bufnr: integer, method: string}>
requests = {},
-- for $/progress report
--- Contains $/progress report messages.
--- They have the format {token: integer|string, value: any}
--- For "work done progress", value will be one of:
--- - lsp.WorkDoneProgressBegin,
--- - lsp.WorkDoneProgressReport (extended with title from Begin)
--- - lsp.WorkDoneProgressEnd (extended with title from Begin)
progress = vim.ringbuf(50),
---@deprecated use client.progress instead
messages = { name = name, messages = {}, progress = {}, status = {} },
dynamic_capabilities = require('vim.lsp._dynamic').new(client_id),
}
---@type table<string|integer, string> title of unfinished progress sequences by token
client.progress.pending = {}
--- @type lsp.ClientCapabilities
client.config.capabilities = config.capabilities or protocol.make_client_capabilities()

View File

@@ -9,7 +9,7 @@ local M = {}
---@private
--- Writes to error buffer.
---@param ... (table of strings) Will be concatenated before being written
---@param ... string Will be concatenated before being written
local function err_message(...)
vim.notify(table.concat(vim.tbl_flatten({ ... })), vim.log.levels.ERROR)
api.nvim_command('redraw')
@@ -20,63 +20,52 @@ M['workspace/executeCommand'] = function(_, _, _, _)
-- Error handling is done implicitly by wrapping all handlers; see end of this file
end
---@private
local function progress_handler(_, result, ctx, _)
local client_id = ctx.client_id
local client = vim.lsp.get_client_by_id(client_id)
local client_name = client and client.name or string.format('id=%d', client_id)
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress
---@param result lsp.ProgressParams
---@param ctx lsp.HandlerContext
M['$/progress'] = function(_, result, ctx)
local client = vim.lsp.get_client_by_id(ctx.client_id)
if not client then
err_message('LSP[', client_name, '] client has shut down during progress update')
err_message('LSP[id=', tostring(ctx.client_id), '] client has shut down during progress update')
return vim.NIL
end
local val = result.value -- unspecified yet
local token = result.token -- string or number
local kind = nil
local value = result.value
if type(val) ~= 'table' then
val = { content = val }
end
if val.kind then
if val.kind == 'begin' then
client.messages.progress[token] = {
title = val.title,
cancellable = val.cancellable,
message = val.message,
percentage = val.percentage,
}
elseif val.kind == 'report' then
client.messages.progress[token].cancellable = val.cancellable
client.messages.progress[token].message = val.message
client.messages.progress[token].percentage = val.percentage
elseif val.kind == 'end' then
if client.messages.progress[token] == nil then
err_message('LSP[', client_name, '] received `end` message with no corresponding `begin`')
else
client.messages.progress[token].message = val.message
client.messages.progress[token].done = true
if type(value) == 'table' then
kind = value.kind
-- Carry over title of `begin` messages to `report` and `end` messages
-- So that consumers always have it available, even if they consume a
-- subset of the full sequence
if kind == 'begin' then
client.progress.pending[result.token] = value.title
else
value.title = client.progress.pending[result.token]
if kind == 'end' then
client.progress.pending[result.token] = nil
end
end
else
client.messages.progress[token] = val
client.messages.progress[token].done = true
end
api.nvim_exec_autocmds('User', { pattern = 'LspProgressUpdate', modeline = false })
client.progress:push(result)
api.nvim_exec_autocmds('LspProgress', {
pattern = kind,
modeline = false,
data = { client_id = ctx.client_id, result = result },
})
end
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress
M['$/progress'] = progress_handler
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_workDoneProgress_create
---@param result lsp.WorkDoneProgressCreateParams
---@param ctx lsp.HandlerContext
M['window/workDoneProgress/create'] = function(_, result, ctx)
local client_id = ctx.client_id
local client = vim.lsp.get_client_by_id(client_id)
local token = result.token -- string or number
local client_name = client and client.name or string.format('id=%d', client_id)
local client = vim.lsp.get_client_by_id(ctx.client_id)
if not client then
err_message('LSP[', client_name, '] client has shut down while creating progress report')
err_message('LSP[id=', tostring(ctx.client_id), '] client has shut down during progress update')
return vim.NIL
end
client.messages.progress[token] = {}
client.progress:push(result)
return vim.NIL
end

View File

@@ -1,6 +1,12 @@
---@meta
---@alias lsp-handler fun(err: lsp.ResponseError|nil, result: any, context: table, config: table|nil)
---@alias lsp-handler fun(err: lsp.ResponseError|nil, result: any, context: lsp.HandlerContext, config: table|nil)
---@class lsp.HandlerContext
---@field method string
---@field client_id integer
---@field bufnr integer
---@field params any
---@class lsp.ResponseError
---@field code integer

View File

@@ -353,11 +353,40 @@ end
--- Process and return progress reports from lsp server
---@private
---@deprecated Use vim.lsp.status() or access client.progress directly
function M.get_progress_messages()
vim.deprecate('vim.lsp.util.get_progress_messages', 'vim.lsp.status', '0.11.0')
local new_messages = {}
local progress_remove = {}
for _, client in ipairs(vim.lsp.get_active_clients()) do
local groups = {}
for progress in client.progress do
local value = progress.value
if type(value) == 'table' and value.kind then
local group = groups[progress.token]
if not group then
group = {
done = false,
progress = true,
title = 'empty title',
}
groups[progress.token] = group
end
group.title = value.title or group.title
group.cancellable = value.cancellable or group.cancellable
if value.kind == 'end' then
group.done = true
end
group.message = value.message or group.message
group.percentage = value.percentage or group.percentage
end
end
for _, group in pairs(groups) do
table.insert(new_messages, group)
end
local messages = client.messages
local data = messages
for token, ctx in pairs(data.progress) do