backport: feat(lsp): use LspNotify for semantic tokens (#40242)

feat(lsp): use LspNotify for semantic tokens

Problem: The semantic token module is using its own debounce timer for
the buffer on_lines event. If its internal debounce is shorter than the
changetracking module's debounce, it's possible for semantic token
requests to fire for changed buffers before the textDocument/didChange
notification is sent to the server.

Solution: Trigger semantic token requests from the LspNotify autocmd
when the method is the didChange or didOpen notifications, which
enforces a strict happens-before relationship for the sync change
notification followed by a semantic token request.

Note: There is still an internal debounce mechanism in the semantic
token module to handle other debouncing needs specific to its
functionality, such as debouncing server refresh notifications and
handling WinScrolled events when using range requests.

Co-authored-by: jdrouhard <john@drouhard.dev>
This commit is contained in:
Justin M. Keyes
2026-06-14 12:43:08 -04:00
committed by GitHub
parent 304f8ed67a
commit 55205b3231
3 changed files with 146 additions and 78 deletions

View File

@@ -215,24 +215,6 @@ end
function STHighlighter:new(bufnr)
self.debounce = 200
self = Capability.new(self, bufnr)
api.nvim_buf_attach(bufnr, false, {
on_lines = function(_, buf)
local highlighter = STHighlighter.active[buf]
if not highlighter then
return true
end
highlighter:on_change()
end,
on_reload = function(_, buf)
local highlighter = STHighlighter.active[buf]
if highlighter then
highlighter:reset()
highlighter:send_request()
end
end,
})
return self
end
@@ -257,6 +239,22 @@ function STHighlighter:on_attach(client_id)
self.client_state[client_id] = state
end
api.nvim_create_autocmd('LspNotify', {
buf = self.bufnr,
group = self.augroup,
callback = function(opts)
if opts.data.method == 'textDocument/didClose' then
self:reset()
end
if
opts.data.method == 'textDocument/didChange' or opts.data.method == 'textDocument/didOpen'
then
self:send_request()
end
end,
})
api.nvim_create_autocmd({ 'BufWinEnter', 'InsertLeave' }, {
buf = self.bufnr,
group = self.augroup,
@@ -270,7 +268,7 @@ function STHighlighter:on_attach(client_id)
buf = self.bufnr,
group = self.augroup,
callback = function()
self:on_change()
self:debounce_request()
end,
})
end
@@ -774,7 +772,7 @@ function STHighlighter:mark_dirty(client_id)
end
---@package
function STHighlighter:on_change()
function STHighlighter:debounce_request()
self:reset_timer()
if self.debounce > 0 then
self.timer = vim.defer_fn(function()
@@ -1059,8 +1057,8 @@ function M._refresh(err, _, ctx)
highlighter:mark_dirty(ctx.client_id)
if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then
-- some LSPs send rapid fire refresh notifications, so we'll debounce them with on_change()
highlighter:on_change()
-- some LSPs send rapid fire refresh notifications, so we'll debounce them with debounce_request()
highlighter:debounce_request()
end
end
end

View File

@@ -14,8 +14,23 @@ local api = n.api
local clear_notrace = t_lsp.clear_notrace
local create_server_definition = t_lsp.create_server_definition
local function create_start_server()
function _G._start_server(server)
return vim.lsp.start({
name = 'dummy',
cmd = server.cmd,
flags = {
allow_incremental_sync = false,
debounce_text_changes = 0, -- disable debounce to make tests faster
},
})
end
end
before_each(function()
clear_notrace()
exec_lua(create_server_definition)
exec_lua(create_start_server)
end)
after_each(function()
@@ -85,10 +100,10 @@ describe('semantic token highlighting', function()
}]]
before_each(function()
exec_lua(create_server_definition)
exec_lua(function()
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = true },
range = false,
@@ -109,11 +124,12 @@ describe('semantic token highlighting', function()
it('buffer is highlighted when attached', function()
insert(text)
exec_lua(function()
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
vim.bo[bufnr].filetype = 'some-filetype'
vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
_G._start_server(_G.server)
end)
screen:expect {
@@ -138,9 +154,11 @@ describe('semantic token highlighting', function()
it('buffer is highlighted with multiline tokens', function()
insert(text)
exec_lua(function()
_G.server2 = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = false },
legend = vim.fn.json_decode(legend),
@@ -160,7 +178,7 @@ describe('semantic token highlighting', function()
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
vim.bo[bufnr].filetype = 'some-filetype'
vim.lsp.start({ name = 'dummy', cmd = _G.server2.cmd })
_G._start_server(_G.server2)
end)
screen:expect {
@@ -185,6 +203,7 @@ describe('semantic token highlighting', function()
it('calls both range and full when range is supported', function()
insert(text)
exec_lua(function()
_G.server_range = _G._create_server({
capabilities = {
@@ -207,7 +226,7 @@ describe('semantic token highlighting', function()
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.cmd })
_G._start_server(_G.server_range)
end)
screen:expect {
@@ -245,8 +264,8 @@ describe('semantic token highlighting', function()
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 = {
@@ -265,7 +284,7 @@ describe('semantic token highlighting', function()
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server_full.cmd })
return _G._start_server(_G.server_full)
end)
local messages = exec_lua('return _G.server_full.messages')
@@ -284,8 +303,8 @@ describe('semantic token highlighting', function()
end)
it('does not call range after full request received', function()
exec_lua(create_server_definition)
insert(text)
exec_lua(function()
_G.server_full = _G._create_server({
capabilities = {
@@ -304,7 +323,7 @@ describe('semantic token highlighting', function()
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server_full.cmd })
return _G._start_server(_G.server_full)
end)
-- ensure initial semantic token requests have been sent before feeding input
@@ -338,6 +357,7 @@ describe('semantic token highlighting', function()
}]]
_G.server2 = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
range = true,
legend = vim.fn.json_decode(legend),
@@ -350,7 +370,7 @@ describe('semantic token highlighting', function()
},
})
local bufnr = vim.api.nvim_get_current_buf()
local client_id = assert(vim.lsp.start({ name = 'dummy', cmd = _G.server2.cmd }))
local client_id = assert(_G._start_server(_G.server2))
vim.schedule(function()
vim.lsp.semantic_tokens._start(bufnr, client_id, 0)
end)
@@ -499,6 +519,7 @@ describe('semantic token highlighting', function()
it('use LspTokenUpdate and highlight_token', function()
insert(text)
exec_lua(function()
vim.api.nvim_create_autocmd('LspTokenUpdate', {
callback = function(ev)
@@ -510,7 +531,7 @@ describe('semantic token highlighting', 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.cmd })
_G._start_server(_G.server)
end)
screen:expect {
@@ -538,14 +559,28 @@ describe('semantic token highlighting', function()
local bufnr = n.api.nvim_get_current_buf()
local client_id = exec_lua(function()
vim.api.nvim_win_set_buf(0, bufnr)
local client_id = vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
vim.wait(1000, function()
return #_G.server.messages > 1
end)
return client_id
return _G._start_server(_G.server)
end)
screen:expect {
grid = [[
#include <iostream> |
|
int {8:main}() |
{ |
int {7:x}; |
#ifdef {5:__cplusplus} |
{4:std}::{2:cout} << {2:x} << "\n"; |
{6:#else} |
{6: printf("%d\n", x);} |
{6:#endif} |
} |
^} |
{1:~ }|*3
|
]],
}
exec_lua(function()
--- @diagnostic disable-next-line:duplicate-set-field
vim.notify = function() end
@@ -575,13 +610,30 @@ describe('semantic token highlighting', function()
it(
'buffer is highlighted and unhighlighted when semantic token highlighting is enabled and disabled',
function()
local bufnr = n.api.nvim_get_current_buf()
insert(text)
exec_lua(function()
vim.api.nvim_win_set_buf(0, bufnr)
return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
return _G._start_server(_G.server)
end)
insert(text)
screen:expect {
grid = [[
#include <iostream> |
|
int {8:main}() |
{ |
int {7:x}; |
#ifdef {5:__cplusplus} |
{4:std}::{2:cout} << {2:x} << "\n"; |
{6:#else} |
{6: printf("%d\n", x);} |
{6:#endif} |
} |
^} |
{1:~ }|*3
|
]],
}
exec_lua(function()
--- @diagnostic disable-next-line:duplicate-set-field
@@ -591,20 +643,20 @@ describe('semantic token highlighting', function()
screen:expect {
grid = [[
#include <iostream> |
|
int main() |
{ |
int x; |
#ifdef __cplusplus |
std::cout << x << "\n"; |
#else |
printf("%d\n", x); |
#endif |
} |
^} |
{1:~ }|*3
|
#include <iostream> |
|
int main() |
{ |
int x; |
#ifdef __cplusplus |
std::cout << x << "\n"; |
#else |
printf("%d\n", x); |
#endif |
} |
^} |
{1:~ }|*3
|
]],
}
@@ -634,11 +686,30 @@ describe('semantic token highlighting', function()
)
it('highlights start and stop when using "0" for current buffer', function()
insert(text)
exec_lua(function()
return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
return _G._start_server(_G.server)
end)
insert(text)
screen:expect {
grid = [[
#include <iostream> |
|
int {8:main}() |
{ |
int {7:x}; |
#ifdef {5:__cplusplus} |
{4:std}::{2:cout} << {2:x} << "\n"; |
{6:#else} |
{6: printf("%d\n", x);} |
{6:#endif} |
} |
^} |
{1:~ }|*3
|
]],
}
exec_lua(function()
--- @diagnostic disable-next-line:duplicate-set-field
@@ -691,8 +762,9 @@ describe('semantic token highlighting', function()
it('buffer is re-highlighted when force refreshed', function()
insert(text)
exec_lua(function()
vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
return _G._start_server(_G.server)
end)
screen:expect {
@@ -752,12 +824,12 @@ describe('semantic token highlighting', function()
end)
it('destroys the highlighter if the buffer is deleted', function()
exec_lua(function()
vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
end)
insert(text)
exec_lua(function()
return _G._start_server(_G.server)
end)
eq(
{},
exec_lua(function()
@@ -772,7 +844,7 @@ describe('semantic token highlighting', function()
insert(text)
exec_lua(function()
vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
return _G._start_server(_G.server)
end)
screen:expect {
@@ -876,6 +948,7 @@ describe('semantic token highlighting', function()
local client_id = exec_lua(function()
_G.server2 = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = false },
},
@@ -891,7 +964,7 @@ describe('semantic token highlighting', function()
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server2.cmd })
return _G._start_server(_G.server2)
end)
eq(
true,
@@ -927,6 +1000,7 @@ describe('semantic token highlighting', function()
exec_lua(function()
_G.server2 = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = false },
},
@@ -946,7 +1020,7 @@ describe('semantic token highlighting', function()
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server2.cmd })
return _G._start_server(_G.server2)
end)
screen:expect([[
^ |
@@ -960,6 +1034,7 @@ describe('semantic token highlighting', function()
exec_lua(function()
_G.server2 = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = false },
legend = vim.fn.json_decode(legend),
@@ -974,7 +1049,7 @@ describe('semantic token highlighting', function()
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server2.cmd })
return _G._start_server(_G.server2)
end)
screen:expect {
@@ -1447,10 +1522,10 @@ b = "as"]],
},
}) do
it(test.it, function()
exec_lua(create_server_definition)
local client_id = exec_lua(function(legend, resp)
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = false },
legend = vim.fn.json_decode(legend),
@@ -1462,7 +1537,7 @@ b = "as"]],
end,
},
})
return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
return _G._start_server(_G.server)
end, test.legend, test.response)
insert(test.text)
@@ -1852,10 +1927,10 @@ int main()
it(test.it, function()
local bufnr = n.api.nvim_get_current_buf()
insert(test.text1)
exec_lua(create_server_definition)
local client_id = exec_lua(function(legend, resp1, resp2)
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
full = { delta = true },
legend = vim.fn.json_decode(legend),
@@ -1870,13 +1945,7 @@ int main()
end,
},
})
local client_id = assert(vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd }))
-- speed up vim.api.nvim_buf_set_lines calls by changing debounce to 10 for these tests
vim.schedule(function()
vim.lsp.semantic_tokens._start(bufnr, client_id, 10)
end)
return client_id
return assert(_G._start_server(_G.server))
end, test.legend, test.response1, test.response2)
test.expected_screen1()
@@ -1893,10 +1962,10 @@ int main()
else
exec_lua(function(text)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, vim.fn.split(text, '\n'))
vim.wait(15) -- wait for debounce
end, test.text2)
end
feed('<Ignore>')
test.expected_screen2()
eq(

View File

@@ -102,6 +102,7 @@ M.create_server_definition = function()
if method == 'exit' then
dispatchers.on_exit(0, 15)
end
return true
end
function srv.is_closing()