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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 173 additions and 65 deletions

View File

@ -130,6 +130,8 @@ LSP FUNCTIONS
{async = false} instead. {async = false} instead.
- *vim.lsp.buf.range_formatting()* Use |vim.lsp.formatexpr()| - *vim.lsp.buf.range_formatting()* Use |vim.lsp.formatexpr()|
or |vim.lsp.buf.format()| instead. or |vim.lsp.buf.format()| instead.
- *vim.lsp.util.get_progress_messages()* Use |vim.lsp.status()| or access
`progress` of |vim.lsp.client|
TREESITTER FUNCTIONS TREESITTER FUNCTIONS
- *vim.treesitter.language.require_language()* Use |vim.treesitter.language.add()| - *vim.treesitter.language.require_language()* Use |vim.treesitter.language.add()|

View File

@ -659,14 +659,20 @@ callbacks. Example: >lua
}) })
< <
Also the following |User| |autocommand| is provided: LspProgress *LspProgress*
Upon receipt of a progress notification from the server. Notifications can
be polled from a `progress` ring buffer of a |vim.lsp.client| or use
|vim.lsp.status()| to get an aggregate message
LspProgressUpdate *LspProgressUpdate* If the server sends a "work done progress", the `pattern` is set to `kind`
Upon receipt of a progress notification from the server. See (one of `begin`, `report` or `end`).
|vim.lsp.util.get_progress_messages()|.
When used from Lua, the event contains a `data` table with `client_id` and
`result` properties. `result` will contain the request params sent by the
server.
Example: >vim Example: >vim
autocmd User LspProgressUpdate redrawstatus autocmd LspProgress * redrawstatus
< <
============================================================================== ==============================================================================
@ -806,6 +812,8 @@ client() *vim.lsp.client*
|vim.lsp.start_client()|. |vim.lsp.start_client()|.
• {server_capabilities} (table): Response from the server sent on • {server_capabilities} (table): Response from the server sent on
`initialize` describing the server's capabilities. `initialize` describing the server's capabilities.
• {progress} A ring buffer (|vim.ringbuf()|) containing progress
messages sent by the server.
client_is_stopped({client_id}) *vim.lsp.client_is_stopped()* client_is_stopped({client_id}) *vim.lsp.client_is_stopped()*
Checks whether a client is stopped. Checks whether a client is stopped.
@ -1092,6 +1100,13 @@ start_client({config}) *vim.lsp.start_client()*
not be fully initialized. Use `on_init` to do any actions once the not be fully initialized. Use `on_init` to do any actions once the
client has been initialized. client has been initialized.
status() *vim.lsp.status()*
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)
stop_client({client_id}, {force}) *vim.lsp.stop_client()* stop_client({client_id}, {force}) *vim.lsp.stop_client()*
Stops a client(s). Stops a client(s).

View File

@ -33,8 +33,8 @@ The following changes may require adaptations in user config or plugins.
• When switching windows, |CursorMoved| autocommands trigger when Nvim is back • When switching windows, |CursorMoved| autocommands trigger when Nvim is back
in the main loop rather than immediately. This is more compatible with Vim. in the main loop rather than immediately. This is more compatible with Vim.
• |LspRequest| autocmd was promoted from a |User| autocmd to a first class • |LspRequest| and LspProgressUpdate (renamed to |LspProgress|) autocmds were
citizen. promoted from a |User| autocmd to first class citizen.
• Renamed `vim.treesitter.playground` to `vim.treesitter.dev`. • Renamed `vim.treesitter.playground` to `vim.treesitter.dev`.
@ -43,6 +43,8 @@ ADDED FEATURES *news-added*
The following new APIs or features were added. The following new APIs or features were added.
• Added |vim.lsp.status()| to consume the last progress messages as a string.
• Neovim's LSP client now always saves and restores named buffer marks when • Neovim's LSP client now always saves and restores named buffer marks when
applying text edits. applying text edits.
@ -142,6 +144,9 @@ release.
- |nvim_win_get_option()| Use |nvim_get_option_value()| instead. - |nvim_win_get_option()| Use |nvim_get_option_value()| instead.
- |nvim_win_set_option()| Use |nvim_set_option_value()| instead. - |nvim_win_set_option()| Use |nvim_set_option_value()| instead.
• vim.lsp functions:
- |vim.lsp.util.get_progress_messages()| Use |vim.lsp.status()| instead.
• `vim.loop` has been renamed to `vim.uv`. • `vim.loop` has been renamed to `vim.uv`.
vim:tw=78:ts=8:sw=2:et:ft=help:norl: vim:tw=78:ts=8:sw=2:et:ft=help:norl:

View File

@ -807,6 +807,9 @@ end
--- ---
--- - {server_capabilities} (table): Response from the server sent on --- - {server_capabilities} (table): Response from the server sent on
--- `initialize` describing the server's capabilities. --- `initialize` describing the server's capabilities.
---
--- - {progress} A ring buffer (|vim.ringbuf()|) containing progress messages
--- sent by the server.
function lsp.client() function lsp.client()
error() error()
end end
@ -891,6 +894,50 @@ function lsp.start(config, opts)
return client_id return client_id
end 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 ---@private
-- Determines whether the given option can be set by `set_defaults`. -- Determines whether the given option can be set by `set_defaults`.
local function is_empty_or_default(bufnr, option) 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}> --- @type table<integer,{ type: string, bufnr: integer, method: string}>
requests = {}, 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 = {} }, messages = { name = name, messages = {}, progress = {}, status = {} },
dynamic_capabilities = require('vim.lsp._dynamic').new(client_id), 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 --- @type lsp.ClientCapabilities
client.config.capabilities = config.capabilities or protocol.make_client_capabilities() client.config.capabilities = config.capabilities or protocol.make_client_capabilities()

View File

@ -9,7 +9,7 @@ local M = {}
---@private ---@private
--- Writes to error buffer. --- 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(...) local function err_message(...)
vim.notify(table.concat(vim.tbl_flatten({ ... })), vim.log.levels.ERROR) vim.notify(table.concat(vim.tbl_flatten({ ... })), vim.log.levels.ERROR)
api.nvim_command('redraw') 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 -- Error handling is done implicitly by wrapping all handlers; see end of this file
end end
---@private --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress
local function progress_handler(_, result, ctx, _) ---@param result lsp.ProgressParams
local client_id = ctx.client_id ---@param ctx lsp.HandlerContext
local client = vim.lsp.get_client_by_id(client_id) M['$/progress'] = function(_, result, ctx)
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 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 return vim.NIL
end end
local val = result.value -- unspecified yet local kind = nil
local token = result.token -- string or number local value = result.value
if type(val) ~= 'table' then if type(value) == 'table' then
val = { content = val } kind = value.kind
end -- Carry over title of `begin` messages to `report` and `end` messages
if val.kind then -- So that consumers always have it available, even if they consume a
if val.kind == 'begin' then -- subset of the full sequence
client.messages.progress[token] = { if kind == 'begin' then
title = val.title, client.progress.pending[result.token] = value.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 else
client.messages.progress[token].message = val.message value.title = client.progress.pending[result.token]
client.messages.progress[token].done = true if kind == 'end' then
client.progress.pending[result.token] = nil
end end
end end
else
client.messages.progress[token] = val
client.messages.progress[token].done = true
end 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 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 --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) M['window/workDoneProgress/create'] = function(_, result, ctx)
local client_id = ctx.client_id local client = vim.lsp.get_client_by_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)
if not client then 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 return vim.NIL
end end
client.messages.progress[token] = {} client.progress:push(result)
return vim.NIL return vim.NIL
end end

View File

@ -1,6 +1,12 @@
---@meta ---@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 ---@class lsp.ResponseError
---@field code integer ---@field code integer

View File

@ -353,11 +353,40 @@ end
--- Process and return progress reports from lsp server --- Process and return progress reports from lsp server
---@private ---@private
---@deprecated Use vim.lsp.status() or access client.progress directly
function M.get_progress_messages() function M.get_progress_messages()
vim.deprecate('vim.lsp.util.get_progress_messages', 'vim.lsp.status', '0.11.0')
local new_messages = {} local new_messages = {}
local progress_remove = {} local progress_remove = {}
for _, client in ipairs(vim.lsp.get_active_clients()) do 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 messages = client.messages
local data = messages local data = messages
for token, ctx in pairs(data.progress) do for token, ctx in pairs(data.progress) do

View File

@ -74,6 +74,7 @@ return {
'LspDetach', -- after an LSP client detaches from a buffer 'LspDetach', -- after an LSP client detaches from a buffer
'LspRequest', -- after an LSP request is started, canceled, or completed 'LspRequest', -- after an LSP request is started, canceled, or completed
'LspTokenUpdate', -- after a visible LSP token is updated 'LspTokenUpdate', -- after a visible LSP token is updated
'LspProgress', -- after a LSP progress update
'MenuPopup', -- just before popup menu is displayed 'MenuPopup', -- just before popup menu is displayed
'ModeChanged', -- after changing the mode 'ModeChanged', -- after changing the mode
'OptionSet', -- after setting any option 'OptionSet', -- after setting any option
@ -154,6 +155,7 @@ return {
LspAttach=true, LspAttach=true,
LspDetach=true, LspDetach=true,
LspRequest=true, LspRequest=true,
LspProgress=true,
LspTokenUpdate=true, LspTokenUpdate=true,
RecordingEnter=true, RecordingEnter=true,
RecordingLeave=true, RecordingLeave=true,