feat(lsp): semanticTokens/range #36705

Problem:
Nvim supports `textDocument/semanticTokens/full` and `…/full/delta`
already, but most servers don't support `…/full/delta` and Nvim will try
to request and process full semantic tokens response on every buffer
change. Even though the request is debounced, there is noticeable lag if
the token response is large (in a big file).

Solution:
Support `textDocument/semanticTokens/range`, which requests semantic
tokens for visible screen only.
This commit is contained in:
Tristan Knight
2025-12-01 02:06:56 +00:00
committed by GitHub
parent f9ef1a4cab
commit 23ddb2028b
4 changed files with 301 additions and 66 deletions

View File

@@ -616,6 +616,7 @@ local static_registration_capabilities = {
['textDocument/moniker'] = 'monikerProvider', ['textDocument/moniker'] = 'monikerProvider',
['textDocument/selectionRange'] = 'selectionRangeProvider', ['textDocument/selectionRange'] = 'selectionRangeProvider',
['textDocument/semanticTokens/full'] = 'semanticTokensProvider', ['textDocument/semanticTokens/full'] = 'semanticTokensProvider',
['textDocument/semanticTokens/range'] = 'semanticTokensProvider',
['textDocument/typeDefinition'] = 'typeDefinitionProvider', ['textDocument/typeDefinition'] = 'typeDefinitionProvider',
['textDocument/prepareTypeHierarchy'] = 'typeHierarchyProvider', ['textDocument/prepareTypeHierarchy'] = 'typeHierarchyProvider',
} }

View File

@@ -412,8 +412,7 @@ function protocol.make_client_capabilities()
}, },
formats = { 'relative' }, formats = { 'relative' },
requests = { requests = {
-- TODO(jdrouhard): Add support for this range = true,
range = false,
full = { delta = true }, full = { delta = true },
}, },

View File

@@ -23,14 +23,19 @@ local M = {}
--- @field highlights? STTokenRange[] cache of highlight ranges for this document version --- @field highlights? STTokenRange[] cache of highlight ranges for this document version
--- @field tokens? integer[] raw token array as received by the server. used for calculating delta responses --- @field tokens? integer[] raw token array as received by the server. used for calculating delta responses
--- @field namespace_cleared? boolean whether the namespace was cleared for this result yet --- @field namespace_cleared? boolean whether the namespace was cleared for this result yet
---
--- @class (private) STActiveRequest --- @class (private) STActiveRequest
--- @field request_id? integer the LSP request ID of the most recent request sent to the server --- @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 --- @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 --- @class (private) STClientState
--- @field namespace integer --- @field namespace integer
--- @field active_request STActiveRequest --- @field active_requests table<lsp.Range | full_request, STActiveRequest>
--- @field current_result STCurrentResult --- @field current_result STCurrentResult
---@class (private) STHighlighter : vim.lsp.Capability ---@class (private) STHighlighter : vim.lsp.Capability
@@ -42,6 +47,7 @@ local M = {}
---@field client_state table<integer, STClientState> ---@field client_state table<integer, STClientState>
local STHighlighter = { local STHighlighter = {
name = 'semantic_tokens', name = 'semantic_tokens',
-- TODO: how to handle this (tris203)
method = 'textDocument/semanticTokens/full', method = 'textDocument/semanticTokens/full',
active = {}, active = {},
} }
@@ -188,16 +194,38 @@ function STHighlighter:new(bufnr)
end, end,
}) })
api.nvim_create_autocmd('WinScrolled', {
buffer = self.bufnr,
group = self.augroup,
callback = function()
local visible_range = self:get_visible_range()
self:send_request(visible_range)
end,
})
return self return self
end 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
end
end
---@package ---@package
function STHighlighter:on_attach(client_id) function STHighlighter:on_attach(client_id)
local state = self.client_state[client_id] local state = self.client_state[client_id]
if not state then if not state then
state = { state = {
namespace = api.nvim_create_namespace('nvim.lsp.semantic_tokens:' .. client_id), namespace = api.nvim_create_namespace('nvim.lsp.semantic_tokens:' .. client_id),
active_request = {}, active_requests = {},
current_result = {}, current_result = {},
} }
self.client_state[client_id] = state self.client_state[client_id] = state
@@ -229,65 +257,107 @@ end
--- are saved to facilitate document synchronization in the response. --- are saved to facilitate document synchronization in the response.
--- ---
---@package ---@package
function STHighlighter:send_request() ---@param range? lsp.Range
function STHighlighter:send_request(range)
local version = util.buf_versions[self.bufnr] local version = util.buf_versions[self.bufnr]
self:reset_timer() self:reset_timer()
for client_id, state in pairs(self.client_state) do for client_id, state in pairs(self.client_state) do
local client = vim.lsp.get_client_by_id(client_id) 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 current_result = state.current_result local full_request_version = active_requests[FULL] and active_requests[FULL].version
local active_request = state.active_request
-- Only send a request for this client if the current result is out of date and local new_version = current_result.version ~= version and full_request_version ~= version
-- there isn't a current a request in flight for this version
if client and 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
local spec = client.server_capabilities.semanticTokensProvider.full if new_version or range then
local hasEditProvider = type(spec) == 'table' and spec.delta -- Cancel stale in-flight request
if new_version then
self:cancel_all_requests(client)
end
local params = { textDocument = util.make_text_document_params(self.bufnr) } local params = { textDocument = util.make_text_document_params(self.bufnr) }
local method = 'textDocument/semanticTokens/full'
if hasEditProvider and current_result.result_id then ---@type vim.lsp.protocol.Method.ClientToServer.Request
method = method .. '/delta' local method = 'textDocument/semanticTokens/full'
params.previousResultId = current_result.result_id
end if client:supports_method('textDocument/semanticTokens/range', self.bufnr) then
---@cast method vim.lsp.protocol.Method.ClientToServer.Request method = 'textDocument/semanticTokens/range'
---@param response? lsp.SemanticTokens|lsp.SemanticTokensDelta if range then
local success, request_id = client:request(method, params, function(err, response, ctx) params.range = range
-- look client up again using ctx.client_id instead of using a captured else
-- client object -- If no range is provided, send requests for all visible ranges
local c = vim.lsp.get_client_by_id(ctx.client_id) -- This should be made better/removed once we can record capability for textDocument/semanticTokens/range
local bufnr = assert(ctx.bufnr) -- only
local highlighter = STHighlighter.active[bufnr] local visible_range = self:get_visible_range()
if not (c and highlighter) then 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 return
end end
if err or not response then ---@param response? lsp.SemanticTokens|lsp.SemanticTokensDelta
highlighter.client_state[c.id].active_request = {} local success, request_id = client:request(method, params, function(err, response, ctx)
return local bufnr = assert(ctx.bufnr)
local highlighter = STHighlighter.active[bufnr]
if not highlighter then
return
end
if err or not response then
highlighter.client_state[client.id].active_requests[range or FULL] = {}
return
end
coroutine.wrap(STHighlighter.process_response)(highlighter, response, client, version)
end, self.bufnr)
if success then
active_requests[range or FULL] = { request_id = request_id, version = version }
end end
coroutine.wrap(STHighlighter.process_response)(highlighter, response, c, version)
end, self.bufnr)
if success then
active_request.request_id = request_id
active_request.version = version
end end
end end
end end
end end
--- Gets a range that encompasses all visible lines across all windows
--- @private
--- @return lsp.Range
function STHighlighter:get_visible_range()
local wins = vim.fn.win_findbuf(self.bufnr)
local min_start, max_end = nil, nil
for _, win in ipairs(wins) do
local wininfo = vim.fn.getwininfo(win)[1]
if wininfo then
local start_line = wininfo.topline - 1
local end_line = wininfo.botline
if not min_start or start_line < min_start then
min_start = start_line
end
if not max_end or end_line > max_end then
max_end = end_line
end
end
end
---@type lsp.Range
return {
['start'] = { line = min_start or 0, character = 0 },
['end'] = { line = max_end or 0, character = 0 },
}
end
--- This function will parse the semantic token responses and set up the cache --- This function will parse the semantic token responses and set up the cache
--- (current_result). It also performs document synchronization by checking the --- (current_result). It also performs document synchronization by checking the
--- version of the document associated with the resulting request_id and only --- version of the document associated with the resulting request_id and only
@@ -301,15 +371,22 @@ end
--- ---
---@async ---@async
---@param response lsp.SemanticTokens|lsp.SemanticTokensDelta ---@param response lsp.SemanticTokens|lsp.SemanticTokensDelta
---@param client vim.lsp.Client
---@param version integer
---@param range? lsp.Range
---@private ---@private
function STHighlighter:process_response(response, client, version) function STHighlighter:process_response(response, client, version, range)
local state = self.client_state[client.id] local state = self.client_state[client.id]
if not state then if not state then
return return
end 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 -- ignore stale responses
if state.active_request.version and version ~= state.active_request.version then if request_version and version ~= request_version then
return return
end end
@@ -343,17 +420,34 @@ function STHighlighter:process_response(response, client, version)
-- convert token list to highlight ranges -- convert token list to highlight ranges
-- this could yield and run over multiple event loop iterations -- this could yield and run over multiple event loop iterations
local highlights = tokens_to_ranges(tokens, self.bufnr, client, state.active_request) local highlights =
tokens_to_ranges(tokens, self.bufnr, client, state.active_requests[request_idx])
-- reset active request -- reset active request
state.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
-- update the state with the new results -- update the state with the new results
local current_result = state.current_result local current_result = state.current_result
current_result.version = version current_result.version = version
current_result.result_id = response.resultId -- These only need to be set for full so it can be used with delta
current_result.tokens = tokens if not range then
current_result.highlights = highlights 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.namespace_cleared = false current_result.namespace_cleared = false
-- redraw all windows displaying buffer (if still valid) -- redraw all windows displaying buffer (if still valid)
@@ -509,12 +603,9 @@ function STHighlighter:reset()
for client_id, state in pairs(self.client_state) do for client_id, state in pairs(self.client_state) do
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
state.current_result = {} state.current_result = {}
if state.active_request.request_id then local client = vim.lsp.get_client_by_id(client_id)
local client = vim.lsp.get_client_by_id(client_id) assert(client)
assert(client) self:cancel_all_requests(client)
client:cancel_request(state.active_request.request_id)
state.active_request = {}
end
end end
end end
@@ -536,13 +627,9 @@ function STHighlighter:mark_dirty(client_id)
if state.current_result then if state.current_result then
state.current_result.version = nil state.current_result.version = nil
end end
local client = vim.lsp.get_client_by_id(client_id)
if state.active_request.request_id then assert(client)
local client = vim.lsp.get_client_by_id(client_id) self:cancel_all_requests(client)
assert(client)
client:cancel_request(state.active_request.request_id)
state.active_request = {}
end
end end
---@package ---@package
@@ -632,7 +719,10 @@ function M.start(bufnr, client_id, opts)
return return
end end
if not vim.tbl_get(client.server_capabilities, 'semanticTokensProvider', 'full') then if
not client:supports_method('textDocument/semanticTokens/full', bufnr)
and not client:supports_method('textDocument/semanticTokens/range', bufnr)
then
vim.notify('[LSP] Server does not support semantic tokens', vim.log.levels.WARN) vim.notify('[LSP] Server does not support semantic tokens', vim.log.levels.WARN)
return return
end end

View File

@@ -74,6 +74,11 @@ describe('semantic token highlighting', function()
"resultId": 1 "resultId": 1
}]] }]]
local range_response = [[{
"data": [ 2, 4, 4, 3, 8193, 2, 8, 1, 1, 1025 ],
"resultId": "1"
}]]
local edit_response = [[{ local edit_response = [[{
"edits": [ {"data": [ 2, 8, 1, 3, 8193, 1, 7, 11, 19, 8192, 1, 4, 3, 15, 8448, 0, 5, 4, 0, 8448, 0, 8, 1, 3, 8192 ], "deleteCount": 25, "start": 5 } ], "edits": [ {"data": [ 2, 8, 1, 3, 8193, 1, 7, 11, 19, 8192, 1, 4, 3, 15, 8448, 0, 5, 4, 0, 8448, 0, 8, 1, 3, 8192 ], "deleteCount": 25, "start": 5 } ],
"resultId":"2" "resultId":"2"
@@ -86,6 +91,7 @@ describe('semantic token highlighting', function()
capabilities = { capabilities = {
semanticTokensProvider = { semanticTokensProvider = {
full = { delta = true }, full = { delta = true },
range = false,
legend = vim.fn.json_decode(legend), legend = vim.fn.json_decode(legend),
}, },
}, },
@@ -177,6 +183,145 @@ describe('semantic token highlighting', function()
} }
end) end)
it('does not call full when only range is supported', function()
insert(text)
exec_lua(function()
_G.server_range_only = _G._create_server({
capabilities = {
semanticTokensProvider = {
range = true,
full = false,
legend = vim.fn.json_decode(legend),
},
},
handlers = {
['textDocument/semanticTokens/range'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(range_response))
end,
['textDocument/semanticTokens/full'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(response))
end,
},
})
end, legend, range_response)
exec_lua(function()
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
vim.lsp.start({ name = 'dummy', cmd = _G.server_range_only.cmd })
end)
screen:expect {
grid = [[
#include <iostream> |
|
int {8:main}() |
{ |
int {7:x}; |
#ifdef __cplusplus |
std::cout << x << "\n"; |
#else |
printf("%d\n", x); |
#endif |
} |
^} |
{1:~ }|*3
|
]],
}
local messages = exec_lua('return server_range_only.messages')
local called_range = false
local called_full = false
for _, m in ipairs(messages) do
if m.method == 'textDocument/semanticTokens/range' then
called_range = true
end
if m.method == 'textDocument/semanticTokens/full' then
called_full = true
end
end
eq(true, called_range)
eq(false, called_full)
end)
it('does not call range when only full is supported', function()
exec_lua(create_server_definition)
insert(text)
exec_lua(function()
_G.server_full = _G._create_server({
capabilities = {
semanticTokensProvider = {
full = { delta = false },
range = false,
legend = vim.fn.json_decode(legend),
},
},
handlers = {
['textDocument/semanticTokens/full'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(response))
end,
['textDocument/semanticTokens/range'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(range_response))
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server_full.cmd })
end, legend, response, range_response)
local messages = exec_lua('return server_full.messages')
local called_full = false
local called_range = false
for _, m in ipairs(messages) do
if m.method == 'textDocument/semanticTokens/full' then
called_full = true
end
if m.method == 'textDocument/semanticTokens/range' then
called_range = true
end
end
eq(true, called_full)
eq(false, called_range)
end)
it('prefers range when both are supported', function()
exec_lua(create_server_definition)
insert(text)
exec_lua(function()
_G.server_full = _G._create_server({
capabilities = {
semanticTokensProvider = {
full = { delta = true },
range = true,
legend = vim.fn.json_decode(legend),
},
},
handlers = {
['textDocument/semanticTokens/full'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(response))
end,
['textDocument/semanticTokens/range'] = function(_, _, callback)
callback(nil, vim.fn.json_decode(range_response))
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server_full.cmd })
end, legend, response, range_response)
local messages = exec_lua('return server_full.messages')
local called_full = false
local called_range = false
for _, m in ipairs(messages) do
if m.method == 'textDocument/semanticTokens/full' then
called_full = true
end
if m.method == 'textDocument/semanticTokens/range' then
called_range = true
end
end
eq(false, called_full)
eq(true, called_range)
end)
it('use LspTokenUpdate and highlight_token', function() it('use LspTokenUpdate and highlight_token', function()
insert(text) insert(text)
exec_lua(function() exec_lua(function()