neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit f486f1742e5403c3e0604fb2e8cf8761ffb316c3
parent 83156974497df69a5f449c15d1fb472afdf7b6ff
Author: Yi Ming <ofseed@foxmail.com>
Date:   Sun, 27 Apr 2025 00:09:20 +0800

perf(lsp): include `previousResultId` in `DocumentDiagnosticParams` #32887

Problem:
Users of the Roslyn (C#) LSP have encountered significant delays when
retrieving pull diagnostics in large documents while using Neovim. For
instance, diagnostics in a 2000-line .cs file can take over 20 seconds
to display after edits in Neovim, whereas in VS Code, diagnostics for
the same file are displayed almost instantly.

As [mparq noted](https://github.com/seblj/roslyn.nvim/issues/93#issuecomment-2508940330)
in https://github.com/seblj/roslyn.nvim/issues/93, VS Code leverages
additional parameters specified in the [LSP documentation for
textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#documentDiagnosticParams),
specifically:

- previousResultId
- identifier

Solution:
When requesting diagnostics, Neovim should include the
`previousResultId` and `identifier` parameters as part of the request.
These parameters enable the server to utilize caching and return
incremental results.

Support for maintaining state is already present in the
[textDocument/semanticTokens implementation](https://github.com/neovim/neovim/blob/8f84167c30692555d3332565605e8a625aebc43c/runtime/lua/vim/lsp/semantic_tokens.lua#L289).
A similar mechanism can be implemented in `textDocument/diagnostic` handler.
Diffstat:
Mruntime/doc/news.txt | 2++
Mruntime/lua/vim/lsp/diagnostic.lua | 60++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtest/functional/plugin/lsp/diagnostic_spec.lua | 40+++++++++++++++++++++++++++++++++++++++-
3 files changed, 87 insertions(+), 15 deletions(-)

diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt @@ -133,6 +133,8 @@ LSP • |vim.lsp.ClientConfig| gained `workspace_required`. • Support for `textDocument/documentColor`: |lsp-document_color| https://microsoft.github.io/language-server-protocol/specification/#textDocument_documentColor +• The `textDocument/diagnostic` request now includes the previous id in its + parameters. LUA diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua @@ -1,5 +1,6 @@ local protocol = require('vim.lsp.protocol') local ms = protocol.Methods +local util = vim.lsp.util local api = vim.api @@ -7,6 +8,12 @@ local M = {} local augroup = api.nvim_create_augroup('nvim.lsp.diagnostic', {}) +---@class (private) vim.lsp.diagnostic.BufState +---@field enabled boolean Whether diagnostics are enabled for this buffer +---@field client_result_id table<integer, string?> Latest responded `resultId` +---@type table<integer, vim.lsp.diagnostic.BufState?> +local bufstates = {} + local DEFAULT_CLIENT_ID = -1 ---@param severity lsp.DiagnosticSeverity @@ -256,7 +263,12 @@ function M.on_diagnostic(error, result, ctx) return end - handle_diagnostics(ctx.params.textDocument.uri, ctx.client_id, result.items, true) + local client_id = ctx.client_id + handle_diagnostics(ctx.params.textDocument.uri, client_id, result.items, true) + + local bufnr = assert(ctx.bufnr) + local bufstate = assert(bufstates[bufnr]) + bufstate.client_result_id[client_id] = result.resultId end --- Clear push diagnostics and diagnostic cache. @@ -319,11 +331,6 @@ local function clear(bufnr) end end ----@class (private) lsp.diagnostic.bufstate ----@field enabled boolean Whether inlay hints are enabled for this buffer ----@type table<integer, lsp.diagnostic.bufstate> -local bufstates = {} - --- Disable pull diagnostics for a buffer --- @param bufnr integer --- @private @@ -336,13 +343,38 @@ local function disable(bufnr) end --- Refresh diagnostics, only if we have attached clients that support it ----@param bufnr (integer) buffer number ----@param opts? table Additional options to pass to util._refresh +---@param bufnr integer buffer number +---@param client_id? integer Client ID to refresh (default: all clients) +---@param only_visible? boolean Whether to only refresh for the visible regions of the buffer (default: false) ---@private -local function _refresh(bufnr, opts) - opts = opts or {} - opts['bufnr'] = bufnr - vim.lsp.util._refresh(ms.textDocument_diagnostic, opts) +local function _refresh(bufnr, client_id, only_visible) + if + only_visible + and vim.iter(api.nvim_list_wins()):all(function(window) + return api.nvim_win_get_buf(window) ~= bufnr + end) + then + return + end + + local method = ms.textDocument_diagnostic + local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method, id = client_id }) + local bufstate = assert(bufstates[bufnr]) + + util._cancel_requests({ + bufnr = bufnr, + clients = clients, + method = method, + type = 'pending', + }) + for _, client in ipairs(clients) do + ---@type lsp.DocumentDiagnosticParams + local params = { + textDocument = util.make_text_document_params(bufnr), + previousResultId = bufstate.client_result_id[client.id], + } + client:request(method, params, nil, bufnr) + end end --- Enable pull diagnostics for a buffer @@ -352,7 +384,7 @@ function M._enable(bufnr) bufnr = vim._resolve_bufnr(bufnr) if not bufstates[bufnr] then - bufstates[bufnr] = { enabled = true } + bufstates[bufnr] = { enabled = true, client_result_id = {} } api.nvim_create_autocmd('LspNotify', { buffer = bufnr, @@ -365,7 +397,7 @@ function M._enable(bufnr) end if bufstates[bufnr] and bufstates[bufnr].enabled then local client_id = opts.data.client_id --- @type integer? - _refresh(bufnr, { only_visible = true, client_id = client_id }) + _refresh(bufnr, client_id, true) end end, group = augroup, diff --git a/test/functional/plugin/lsp/diagnostic_spec.lua b/test/functional/plugin/lsp/diagnostic_spec.lua @@ -215,7 +215,8 @@ describe('vim.lsp.diagnostic', function() diagnosticProvider = {}, }, handlers = { - [vim.lsp.protocol.Methods.textDocument_diagnostic] = function() + [vim.lsp.protocol.Methods.textDocument_diagnostic] = function(_, params) + _G.params = params _G.requests = _G.requests + 1 end, }, @@ -275,6 +276,7 @@ describe('vim.lsp.diagnostic', function() }, uri = fake_uri, client_id = client_id, + bufnr = diagnostic_bufnr, }, {}) return vim.diagnostic.get(diagnostic_bufnr) @@ -300,6 +302,7 @@ describe('vim.lsp.diagnostic', function() }, uri = fake_uri, client_id = client_id, + bufnr = diagnostic_bufnr, }, {}) return vim.diagnostic.get(diagnostic_bufnr) end) @@ -320,6 +323,7 @@ describe('vim.lsp.diagnostic', function() }, uri = fake_uri, client_id = client_id, + bufnr = diagnostic_bufnr, }, {}) end) @@ -358,6 +362,7 @@ describe('vim.lsp.diagnostic', function() }, uri = fake_uri, client_id = client_id, + bufnr = diagnostic_bufnr, }, {}) end) @@ -392,6 +397,7 @@ describe('vim.lsp.diagnostic', function() }, {}, { method = vim.lsp.protocol.Methods.textDocument_diagnostic, client_id = client_id, + bufnr = diagnostic_bufnr, }) return _G.requests @@ -408,6 +414,7 @@ describe('vim.lsp.diagnostic', function() }, {}, { method = vim.lsp.protocol.Methods.textDocument_diagnostic, client_id = client_id, + bufnr = diagnostic_bufnr, }) return _G.requests @@ -424,11 +431,42 @@ describe('vim.lsp.diagnostic', function() }, {}, { method = vim.lsp.protocol.Methods.textDocument_diagnostic, client_id = client_id, + bufnr = diagnostic_bufnr, }) return _G.requests end) ) end) + + it('requests with the `previousResultId`', function() + eq( + 'dummy_server', + exec_lua(function() + vim.lsp.diagnostic.on_diagnostic(nil, { + kind = 'full', + resultId = 'dummy_server', + items = { + _G.make_error('Pull Diagnostic', 4, 4, 4, 4), + }, + }, { + method = vim.lsp.protocol.Methods.textDocument_diagnostic, + params = { + textDocument = { uri = fake_uri }, + }, + client_id = client_id, + bufnr = diagnostic_bufnr, + }) + vim.api.nvim_exec_autocmds('LspNotify', { + buffer = diagnostic_bufnr, + data = { + method = vim.lsp.protocol.Methods.textDocument_didChange, + client_id = client_id, + }, + }) + return _G.params.previousResultId + end) + ) + end) end) end)