From 8a94daf80eadbd9179768fe3d7da2f06c81dc740 Mon Sep 17 00:00:00 2001 From: jdrouhard Date: Tue, 16 Dec 2025 21:06:55 -0600 Subject: [PATCH] fix(lsp): simplify semantic tokens range request logic #36950 By simplifying the way range is supported, we can fix a couple issues as well as making it less complex and more efficient: * For non-range LSP servers, don't send requests on WinScrolled. The semantic tokens module has been reworked to only send one active request at a time, as it was before range support was added. If range is not supported, then send_request() only fires if there's been a change to the buffer's document version. * Cache the server's support of range and delta requests when attaching to a buffer to save the lookup on each request. * Range requests always use the visible window, so just use that for the `range` param when sending requests when range is supported by the server. This reduces the API surface area of send_request(). * Debounce the WinScrolled autocmd requests in the same the way requests are debounced when the buffer contents are changing. Should allow scrolling via mouse wheel or holding down "j" or "k" work a bit smoother. The previous iteration of range support allowed multiple active requests to be in progress simultaneously. However, a bug was preventing any but the most recent request to actually apply to the client's highlighting state so that complexity was unused. It was effectively only using one active request at a time but was just using range requests on WinScrolled events instead of a full (or delta) request when the document version changed. --- runtime/lua/vim/lsp/semantic_tokens.lua | 142 +++++++++--------------- 1 file changed, 52 insertions(+), 90 deletions(-) diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index fd6f447280..7940b429b6 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -16,7 +16,7 @@ local M = {} --- @field type string token type as string --- @field modifiers table token modifiers as a set. E.g., { static = true, readonly = true } --- @field marked boolean whether this token has had extmarks applied ---- + --- @class (private) STCurrentResult --- @field version? integer document version associated with this result --- @field result_id? string resultId from the server; used with delta requests @@ -28,14 +28,11 @@ local M = {} --- @field request_id? integer the LSP request ID of the most recent request sent to the server --- @field version? integer the document version associated with the most recent request ----@alias full_request 'FULL' - ----@type full_request -local FULL = 'FULL' - --- @class (private) STClientState --- @field namespace integer ---- @field active_requests table +--- @field supports_range boolean +--- @field supports_delta boolean +--- @field active_request STActiveRequest --- @field current_result STCurrentResult ---@class (private) STHighlighter : vim.lsp.Capability @@ -79,7 +76,7 @@ end ---@param data integer[] ---@param bufnr integer ---@param client vim.lsp.Client ----@param request STActiveRequest | nil +---@param request STActiveRequest ---@return STTokenRange[] local function tokens_to_ranges(data, bufnr, client, request) local legend = client.server_capabilities.semanticTokensProvider.legend @@ -107,7 +104,7 @@ local function tokens_to_ranges(data, bufnr, client, request) vim.schedule(function() coroutine.resume(co, util.buf_versions[bufnr]) end) - if not request or request.version ~= coroutine.yield() then + if request.version ~= coroutine.yield() then -- request became stale since the last time the coroutine ran. -- abandon it by yielding without a way to resume coroutine.yield() @@ -197,8 +194,7 @@ function STHighlighter:new(bufnr) buffer = self.bufnr, group = self.augroup, callback = function() - local visible_range = self:get_visible_range() - self:send_request(visible_range) + self:on_change() end, }) @@ -206,25 +202,29 @@ function STHighlighter:new(bufnr) end ---@private ----@param client vim.lsp.Client -function STHighlighter:cancel_all_requests(client) - local state = self.client_state[client.id] - - for idx, request in pairs(state.active_requests) do - if request.request_id then - client:cancel_request(request.request_id) - state.active_requests[idx] = nil - end +function STHighlighter:cancel_active_request(client_id) + local state = self.client_state[client_id] + if state.active_request.request_id then + local client = assert(vim.lsp.get_client_by_id(client_id)) + client:cancel_request(state.active_request.request_id) + state.active_request = {} end end ---@package function STHighlighter:on_attach(client_id) + local client = vim.lsp.get_client_by_id(client_id) local state = self.client_state[client_id] if not state then state = { namespace = api.nvim_create_namespace('nvim.lsp.semantic_tokens:' .. client_id), - active_requests = {}, + supports_range = client + and client:supports_method('textDocument/semanticTokens/range', self.bufnr) + or false, + supports_delta = client + and client:supports_method('textDocument/semanticTokens/full/delta', self.bufnr) + or false, + active_request = {}, current_result = {}, } self.client_state[client_id] = state @@ -256,8 +256,7 @@ end --- are saved to facilitate document synchronization in the response. --- ---@package ----@param range? lsp.Range -function STHighlighter:send_request(range) +function STHighlighter:send_request() local version = util.buf_versions[self.bufnr] self:reset_timer() @@ -266,43 +265,31 @@ function STHighlighter:send_request(range) local client = vim.lsp.get_client_by_id(client_id) if client then local current_result = state.current_result - local active_requests = state.active_requests + local active_request = state.active_request - local full_request_version = active_requests[FULL] and active_requests[FULL].version - - local new_version = current_result.version ~= version and full_request_version ~= version - - if new_version or range then - -- Cancel stale in-flight request - if new_version then - self:cancel_all_requests(client) + if + state.supports_range + or (current_result.version ~= version and active_request.version ~= version) + then + -- cancel stale in-flight request + if active_request.request_id then + client:cancel_request(active_request.request_id) + active_request = {} + state.active_request = active_request end + ---@type lsp.SemanticTokensParams|lsp.SemanticTokensRangeParams|lsp.SemanticTokensDeltaParams local params = { textDocument = util.make_text_document_params(self.bufnr) } ---@type vim.lsp.protocol.Method.ClientToServer.Request local method = 'textDocument/semanticTokens/full' - if client:supports_method('textDocument/semanticTokens/range', self.bufnr) then + if state.supports_range then method = 'textDocument/semanticTokens/range' - if range then - params.range = range - else - -- If no range is provided, send requests for all visible ranges - -- This should be made better/removed once we can record capability for textDocument/semanticTokens/range - -- only - local visible_range = self:get_visible_range() - self:send_request(visible_range) - return - end - elseif client:supports_method('textDocument/semanticTokens/full/delta', self.bufnr) then - if current_result.result_id then - method = 'textDocument/semanticTokens/full/delta' - params.previousResultId = current_result.result_id - end - elseif not client:supports_method('textDocument/semanticTokens/full', self.bufnr) then - -- No suitable provider, skip this client - return + params.range = self:get_visible_range() + elseif state.supports_delta and current_result.result_id then + method = 'textDocument/semanticTokens/full/delta' + params.previousResultId = current_result.result_id end ---@param response? lsp.SemanticTokens|lsp.SemanticTokensDelta @@ -314,7 +301,7 @@ function STHighlighter:send_request(range) end if err or not response then - highlighter.client_state[client.id].active_requests[range or FULL] = {} + highlighter.client_state[client.id].active_request = {} return end @@ -322,7 +309,8 @@ function STHighlighter:send_request(range) end, self.bufnr) if success then - active_requests[range or FULL] = { request_id = request_id, version = version } + active_request.request_id = request_id + active_request.version = version end end end @@ -372,20 +360,15 @@ end ---@param response lsp.SemanticTokens|lsp.SemanticTokensDelta ---@param client vim.lsp.Client ---@param version integer ----@param range? lsp.Range ---@private -function STHighlighter:process_response(response, client, version, range) +function STHighlighter:process_response(response, client, version) local state = self.client_state[client.id] if not state then return end - local request_idx = range or FULL - - local request_version = state.active_requests[request_idx] - and state.active_requests[request_idx].version -- ignore stale responses - if request_version and version ~= request_version then + if state.active_request.version and version ~= state.active_request.version then return end @@ -419,34 +402,17 @@ function STHighlighter:process_response(response, client, version, range) -- convert token list to highlight ranges -- this could yield and run over multiple event loop iterations - local highlights = - tokens_to_ranges(tokens, self.bufnr, client, state.active_requests[request_idx]) + local highlights = tokens_to_ranges(tokens, self.bufnr, client, state.active_request) -- reset active request - state.active_requests[request_idx] = nil - if not range then - -- Cancel any range requests because they are no longer needed - self:cancel_all_requests(client) - state.active_requests = {} - end + state.active_request = {} -- update the state with the new results local current_result = state.current_result current_result.version = version - -- These only need to be set for full so it can be used with delta - if not range then - current_result.result_id = response.resultId - current_result.tokens = tokens - end - - if range then - if not current_result.highlights then - current_result.highlights = {} - end - vim.list_extend(current_result.highlights, highlights) - else - current_result.highlights = highlights - end + current_result.result_id = response.resultId + current_result.tokens = tokens + current_result.highlights = highlights current_result.namespace_cleared = false -- redraw all windows displaying buffer (if still valid) @@ -602,9 +568,7 @@ function STHighlighter:reset() for client_id, state in pairs(self.client_state) do api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) state.current_result = {} - local client = vim.lsp.get_client_by_id(client_id) - assert(client) - self:cancel_all_requests(client) + self:cancel_active_request(client_id) end end @@ -616,8 +580,7 @@ end ---@package ---@param client_id integer function STHighlighter:mark_dirty(client_id) - local state = self.client_state[client_id] - assert(state) + local state = assert(self.client_state[client_id]) -- if we clear the version from current_result, it'll cause the -- next request to be sent and will also pause new highlights @@ -626,9 +589,8 @@ function STHighlighter:mark_dirty(client_id) if state.current_result then state.current_result.version = nil end - local client = vim.lsp.get_client_by_id(client_id) - assert(client) - self:cancel_all_requests(client) + + self:cancel_active_request(client_id) end ---@package