From 02b74153247df60c6770e1918db9ef2fd5f83058 Mon Sep 17 00:00:00 2001 From: glepnir Date: Tue, 16 Jun 2026 06:29:27 +0800 Subject: [PATCH] fix(lsp): requery empty isIncomplete completion lists #40100 Problem: an empty `{ isIncomplete = true, items = {} }` ends completion instead of requerying. Solution: keep isIncomplete on empty lists and retrigger on keypress while incomplete. Reset on so it doesn't immediately re-query. --- runtime/lua/vim/lsp/completion.lua | 73 +++++++++++-------- .../functional/plugin/lsp/completion_spec.lua | 38 ++++++++++ 2 files changed, 79 insertions(+), 32 deletions(-) diff --git a/runtime/lua/vim/lsp/completion.lua b/runtime/lua/vim/lsp/completion.lua index 4dceae7623..a77c06075d 100644 --- a/runtime/lua/vim/lsp/completion.lua +++ b/runtime/lua/vim/lsp/completion.lua @@ -937,6 +937,9 @@ local function register_completedone(bufnr) local reason = api.nvim_get_vvar('event').reason ---@type string if reason == 'accept' then on_complete_done() + elseif reason == 'cancel' then + -- dismissed the pum; stop re-querying an incomplete list. + Context:reset() end end) @@ -1002,23 +1005,27 @@ local function trigger(bufnr, clients, ctx) client and client.name or 'UNKNOWN' ) ) - elseif not vim.isnil(result) and #(result.items or result) > 0 then + -- result is CompletionItem[] or CompletionList; result.items may be empty, and + -- an empty incomplete list means request again when needed. + elseif not vim.isnil(result) then Context.isIncomplete = Context.isIncomplete or result.isIncomplete - local encoding = client and client.offset_encoding or 'utf-16' - local client_matches, tmp_server_start_boundary - client_matches, tmp_server_start_boundary = M._convert_results( - line, - cursor_row - 1, - cursor_col, - client_id, - word_boundary, - nil, - result, - encoding - ) + if #(result.items or result) > 0 then + local encoding = client and client.offset_encoding or 'utf-16' + local client_matches, tmp_server_start_boundary + client_matches, tmp_server_start_boundary = M._convert_results( + line, + cursor_row - 1, + cursor_col, + client_id, + word_boundary, + nil, + result, + encoding + ) - server_start_boundary = tmp_server_start_boundary or server_start_boundary - vim.list_extend(matches, client_matches) + server_start_boundary = tmp_server_start_boundary or server_start_boundary + vim.list_extend(matches, client_matches) + end end end @@ -1056,31 +1063,33 @@ end --- @param handle vim.lsp.completion.BufHandle local function on_insert_char_pre(handle) - if vim.fn.pumvisible() ~= 0 then - if Context.isIncomplete then - reset_timer() + if Context.isIncomplete then + reset_timer() - local debounce_ms = adaptive_debounce(Context.last_request_time, rtt_ms) - local ctx = { triggerKind = protocol.CompletionTriggerKind.TriggerForIncompleteCompletions } - if debounce_ms == 0 then - vim.schedule(function() + local debounce_ms = adaptive_debounce(Context.last_request_time, rtt_ms) + local ctx = { triggerKind = protocol.CompletionTriggerKind.TriggerForIncompleteCompletions } + if debounce_ms == 0 then + vim.schedule(function() + M.get({ ctx = ctx }) + end) + else + completion_timer = new_timer() + completion_timer:start( + math.floor(debounce_ms), + 0, + vim.schedule_wrap(function() M.get({ ctx = ctx }) end) - else - completion_timer = new_timer() - completion_timer:start( - math.floor(debounce_ms), - 0, - vim.schedule_wrap(function() - M.get({ ctx = ctx }) - end) - ) - end + ) end return end + if vim.fn.pumvisible() ~= 0 then + return + end + local char = vim.v.char local matched_clients = handle.triggers[char] -- Discard pending trigger char, complete the "latest" one. diff --git a/test/functional/plugin/lsp/completion_spec.lua b/test/functional/plugin/lsp/completion_spec.lua index ac1d23de0a..46b34db69f 100644 --- a/test/functional/plugin/lsp/completion_spec.lua +++ b/test/functional/plugin/lsp/completion_spec.lua @@ -1241,6 +1241,44 @@ describe('vim.lsp.completion: protocol', function() end) t.matches('items=null', err) end) + + it('keeps requerying while the completion list is incomplete #40096', function() + exec_lua(function() + _G.contexts = {} + local server = _G._create_server({ + capabilities = { + completionProvider = { triggerCharacters = { 'h' } }, + }, + handlers = { + ['textDocument/completion'] = function(_, params, callback) + _G.contexts[#_G.contexts + 1] = params.context + callback(nil, { isIncomplete = true, items = { { label = 'hello' } } }) + end, + }, + }) + local bufnr = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_buf(0, bufnr) + vim.lsp.start({ + name = 'dummy', + cmd = server.cmd, + on_attach = function(client, bufnr0) + vim.lsp.completion.enable(true, client.id, bufnr0, { autotrigger = true }) + end, + }) + end) + feed('ih') + assert_matches(function(matches) + eq('hello', matches[1].word) + end) + eq({ triggerKind = 2, triggerCharacter = 'h' }, exec_lua('return _G.contexts[1]')) + + exec_lua('_G.capture = {}') + feed('e') + assert_matches(function(matches) + eq('hello', matches[1].word) + end) + eq({ triggerKind = 3 }, exec_lua('return _G.contexts[2]')) + end) end) describe('vim.lsp.completion: integration', function()