From e5e0bda41b640d324350c5147b956e37e9f8b32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Fu=C3=9Fenegger?= Date: Fri, 9 Jun 2023 11:32:43 +0200 Subject: [PATCH] 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 --- runtime/doc/deprecated.txt | 28 ++++++------ runtime/doc/lsp.txt | 25 ++++++++--- runtime/doc/news.txt | 9 +++- runtime/lua/vim/lsp.lua | 62 +++++++++++++++++++++++++- runtime/lua/vim/lsp/handlers.lua | 75 ++++++++++++++------------------ runtime/lua/vim/lsp/types.lua | 8 +++- runtime/lua/vim/lsp/util.lua | 29 ++++++++++++ src/nvim/auevents.lua | 2 + 8 files changed, 173 insertions(+), 65 deletions(-) diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt index 6494c53059..73888a32cc 100644 --- a/runtime/doc/deprecated.txt +++ b/runtime/doc/deprecated.txt @@ -117,19 +117,21 @@ internally and are no longer exposed as part of the API. Instead, use - *vim.lsp.diagnostic.set_virtual_text()* LSP FUNCTIONS -- *vim.lsp.buf.range_code_action()* Use |vim.lsp.buf.code_action()| with - the `range` parameter. -- *vim.lsp.util.diagnostics_to_items()* Use |vim.diagnostic.toqflist()| instead. -- *vim.lsp.util.set_qflist()* Use |setqflist()| instead. -- *vim.lsp.util.set_loclist()* Use |setloclist()| instead. -- *vim.lsp.buf_get_clients()* Use |vim.lsp.get_active_clients()| with - {buffer = bufnr} instead. -- *vim.lsp.buf.formatting()* Use |vim.lsp.buf.format()| with - {async = true} instead. -- *vim.lsp.buf.formatting_sync()* Use |vim.lsp.buf.format()| with - {async = false} instead. -- *vim.lsp.buf.range_formatting()* Use |vim.lsp.formatexpr()| - or |vim.lsp.buf.format()| instead. +- *vim.lsp.buf.range_code_action()* Use |vim.lsp.buf.code_action()| with + the `range` parameter. +- *vim.lsp.util.diagnostics_to_items()* Use |vim.diagnostic.toqflist()| instead. +- *vim.lsp.util.set_qflist()* Use |setqflist()| instead. +- *vim.lsp.util.set_loclist()* Use |setloclist()| instead. +- *vim.lsp.buf_get_clients()* Use |vim.lsp.get_active_clients()| with + {buffer = bufnr} instead. +- *vim.lsp.buf.formatting()* Use |vim.lsp.buf.format()| with + {async = true} instead. +- *vim.lsp.buf.formatting_sync()* Use |vim.lsp.buf.format()| with + {async = false} instead. +- *vim.lsp.buf.range_formatting()* Use |vim.lsp.formatexpr()| + 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 - *vim.treesitter.language.require_language()* Use |vim.treesitter.language.add()| diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 5b7c013c57..7248d03196 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -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* - Upon receipt of a progress notification from the server. See - |vim.lsp.util.get_progress_messages()|. + If the server sends a "work done progress", the `pattern` is set to `kind` + (one of `begin`, `report` or `end`). + + 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 - autocmd User LspProgressUpdate redrawstatus + autocmd LspProgress * redrawstatus < ============================================================================== @@ -806,6 +812,8 @@ client() *vim.lsp.client* |vim.lsp.start_client()|. • {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. client_is_stopped({client_id}) *vim.lsp.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 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()* Stops a client(s). diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index dbf5b131eb..87dfefcce8 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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 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 - citizen. +• |LspRequest| and LspProgressUpdate (renamed to |LspProgress|) autocmds were + promoted from a |User| autocmd to first class citizen. • Renamed `vim.treesitter.playground` to `vim.treesitter.dev`. @@ -43,6 +43,8 @@ ADDED FEATURES *news-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 applying text edits. @@ -142,6 +144,9 @@ release. - |nvim_win_get_option()| Use |nvim_get_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:tw=78:ts=8:sw=2:et:ft=help:norl: diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 532504a7db..6ddbfc6df7 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -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 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 title of unfinished progress sequences by token + client.progress.pending = {} + --- @type lsp.ClientCapabilities client.config.capabilities = config.capabilities or protocol.make_client_capabilities() diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 5346160871..19338ae8f0 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -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 diff --git a/runtime/lua/vim/lsp/types.lua b/runtime/lua/vim/lsp/types.lua index e77e1fb63a..ef85a0d10f 100644 --- a/runtime/lua/vim/lsp/types.lua +++ b/runtime/lua/vim/lsp/types.lua @@ -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 diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index e36014d07d..538e48c805 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -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 diff --git a/src/nvim/auevents.lua b/src/nvim/auevents.lua index 048b8d6631..41d7ee9b47 100644 --- a/src/nvim/auevents.lua +++ b/src/nvim/auevents.lua @@ -74,6 +74,7 @@ return { 'LspDetach', -- after an LSP client detaches from a buffer 'LspRequest', -- after an LSP request is started, canceled, or completed 'LspTokenUpdate', -- after a visible LSP token is updated + 'LspProgress', -- after a LSP progress update 'MenuPopup', -- just before popup menu is displayed 'ModeChanged', -- after changing the mode 'OptionSet', -- after setting any option @@ -154,6 +155,7 @@ return { LspAttach=true, LspDetach=true, LspRequest=true, + LspProgress=true, LspTokenUpdate=true, RecordingEnter=true, RecordingLeave=true,