From 63594ffa04cc041fa108f6b0a2c4c8e9b2b53ca3 Mon Sep 17 00:00:00 2001 From: glepnir Date: Wed, 11 Mar 2026 17:48:31 +0800 Subject: [PATCH] feat(lsp): do completionItem/resolve if completeopt=popup #32820 Problem: No completionItem/resolve handler. Solution: If completeopt=popup is set, invoke completionItem/resolve when a completion item is selected. Show resolved documentation in popup next to the completion menu. --- runtime/lua/vim/lsp/completion.lua | 254 ++++++++++++++++-- runtime/lua/vim/lsp/protocol.lua | 1 + .../functional/plugin/lsp/completion_spec.lua | 67 +++++ 3 files changed, 298 insertions(+), 24 deletions(-) diff --git a/runtime/lua/vim/lsp/completion.lua b/runtime/lua/vim/lsp/completion.lua index fd12809b7a..4910c415d9 100644 --- a/runtime/lua/vim/lsp/completion.lua +++ b/runtime/lua/vim/lsp/completion.lua @@ -67,6 +67,8 @@ local Context = { last_request_time = nil, --- @type integer? pending_requests = {}, --- @type function[] isIncomplete = false, + -- Handles "completionItem/resolve". + resolve_handler = nil, --- @type CompletionResolver? } --- @nodoc @@ -84,6 +86,10 @@ function Context:reset() self.isIncomplete = false self.last_request_time = nil self:cancel_pending() + if self.resolve_handler then + self.resolve_handler:cleanup() + self.resolve_handler = nil + end end --- @type uv.uv_timer_t? @@ -105,7 +111,7 @@ end --- @param window integer --- @param warmup integer ---- @return fun(sample: number): number +--- @return fun(sample: integer): integer local function exp_avg(window, warmup) local count = 0 local sum = 0 @@ -125,14 +131,23 @@ local function exp_avg(window, warmup) end local compute_new_average = exp_avg(10, 10) ---- @return number -local function next_debounce() - if not Context.last_request_time then - return rtt_ms +--- Calculates the adaptive debounce time based on the elapsed time since the last request. +--- +--- @param last_request_time integer? +--- @param current_rtt_ms number +--- @return integer +local function adaptive_debounce(last_request_time, current_rtt_ms) + if not last_request_time then + return current_rtt_ms end + local ms_since_request = (vim.uv.hrtime() - last_request_time) * ns_to_ms + return math.max((ms_since_request - current_rtt_ms) * -1, 0) +end - local ms_since_request = (vim.uv.hrtime() - Context.last_request_time) * ns_to_ms - return math.max((ms_since_request - rtt_ms) * -1, 0) +--- @param flag string +--- @return boolean +local function has_completeopt(flag) + return vim.list_contains(vim.opt.completeopt:get(), flag) end --- @param input string Unparsed snippet @@ -244,7 +259,7 @@ end ---@return string local function get_doc(item) if - vim.o.completeopt:find('popup') + has_completeopt('popup') and item.insertTextFormat == protocol.InsertTextFormat.Snippet and #(item.documentation or '') == 0 and vim.bo.filetype ~= '' @@ -278,7 +293,7 @@ local function match_item_by_value(value, prefix) if prefix == '' then return true, nil end - if vim.o.completeopt:find('fuzzy') ~= nil then + if has_completeopt('fuzzy') then local score = vim.fn.matchfuzzypos({ value }, prefix)[3] ---@type table return #score > 0, score[1] end @@ -454,8 +469,8 @@ function M._lsp_to_complete_items( return (itema.sortText or itema.label) < (itemb.sortText or itemb.label) end - local use_fuzzy_sort = vim.o.completeopt:find('fuzzy') ~= nil - and vim.o.completeopt:find('nosort') == nil + local use_fuzzy_sort = has_completeopt('fuzzy') + and not has_completeopt('nosort') and not result.isIncomplete and #prefix > 0 @@ -594,6 +609,195 @@ local function request(clients, bufnr, win, ctx, callback) end end +---@param bufnr integer +---@return string +local function get_augroup(bufnr) + return string.format('nvim.lsp.completion_%d', bufnr) +end + +--- Updates the completion preview popup: configures conceal level, applies Treesitter or +--- fallback syntax, and resizes height to fit content +--- +--- @param winid integer +--- @param bufnr integer +--- @param kind? string +local function update_popup_window(winid, bufnr, kind) + if winid and api.nvim_win_is_valid(winid) and bufnr and api.nvim_buf_is_valid(bufnr) then + if kind == lsp.protocol.MarkupKind.Markdown then + vim.wo[winid].conceallevel = 2 + vim.treesitter.start(bufnr, kind) + end + local all = api.nvim_win_text_height(winid, {}).all + api.nvim_win_set_height(winid, all) + end +end + +--- Handles the LSP "completionItem/resolve" +--- +--- @nodoc +--- @class CompletionResolver +--- @field timer uv.uv_timer_t? Timer used for debouncing +--- @field bufnr integer? Buffer number for which the resolution is triggered +--- @field word string? Word being completed +--- @field last_request_time integer? Last request timestamp +--- @field doc_rtt_ms integer Last request timestamp +--- @field doc_compute_new_average fun(sample: integer): integer Last request timestamp +local CompletionResolver = {} +CompletionResolver.__index = CompletionResolver + +--- @nodoc +--- +--- Creates a new instance of `resolve_completion_item`. +--- +--- @return CompletionResolver +function CompletionResolver.new() + local self = setmetatable({}, CompletionResolver) + self.timer = nil + self.bufnr = nil + self.word = nil + self.last_request_time = nil + self.doc_rtt_ms = 100 + self.doc_compute_new_average = exp_avg(10, 5) + return self +end + +--- @nodoc +--- +--- Cancels any pending requests. +function CompletionResolver:cancel_pending_requests() + if self.bufnr then + lsp.util._cancel_requests({ + method = 'completionItem/resolve', + bufnr = self.bufnr, + }) + end +end + +--- @nodoc +--- +--- Cleans up the timer and cancels any ongoing requests. +function CompletionResolver:cleanup() + if self.timer and not self.timer:is_closing() then + self.timer:stop() + self.timer:close() + end + self.timer = nil + self:cancel_pending_requests() +end + +--- @nodoc +--- +--- Checks if the completionItem/resolve request is valid by ensuring the buffer +--- and word are valid. +--- +--- @return boolean, table Validity of the request and the completion info +function CompletionResolver:is_valid() + local cmp_info = vim.fn.complete_info({ 'selected', 'completed' }) + return vim.api.nvim_buf_is_valid(self.bufnr) + and vim.api.nvim_get_current_buf() == self.bufnr + and vim.startswith(vim.api.nvim_get_mode().mode, 'i') + and tonumber(vim.fn.pumvisible()) == 1 + and (vim.tbl_get(cmp_info, 'completed', 'word') or '') == self.word, + cmp_info +end + +--- @nodoc +--- +--- Invokes and handles "completionItem/resolve". +--- +--- @param bufnr integer The buffer number where the request is triggered +--- @param param table The parameters for the LSP request +--- @param selected_word string The word being completed +function CompletionResolver:request(bufnr, param, selected_word) + self:cleanup() + + self.bufnr = bufnr + self.word = selected_word + local debounce_time = adaptive_debounce(self.last_request_time, self.doc_rtt_ms) + + self.timer = vim.defer_fn(function() + local valid, cmp_info = self:is_valid() + if not valid then + self:cleanup() + return + end + self:cancel_pending_requests() + + local client_id = vim.tbl_get(cmp_info.completed, 'user_data', 'nvim', 'lsp', 'client_id') + local client = client_id and vim.lsp.get_client_by_id(client_id) + if not client or not client:supports_method('completionItem/resolve') then + return + end + + local start_time = vim.uv.hrtime() + self.last_request_time = start_time + + client:request('completionItem/resolve', param, function(err, result) + local end_time = vim.uv.hrtime() + local response_time = (end_time - start_time) * ns_to_ms + self.doc_rtt_ms = self.doc_compute_new_average(response_time) + + if err or not result or next(result) == nil then + if err then + vim.notify(err.message, vim.log.levels.WARN) + end + return + end + + valid, cmp_info = self:is_valid() + if not valid then + return + end + + local value = vim.tbl_get(result, 'documentation', 'value') + if not value then + return + end + local windata = vim.api.nvim__complete_set(cmp_info.selected, { + info = value, + }) + local kind = vim.tbl_get(result, 'documentation', 'kind') + update_popup_window(windata.winid, windata.bufnr, kind) + end, bufnr) + end, debounce_time) +end + +--- Defines a CompleteChanged handler to request and display LSP completion item documentation +--- via completionItem/resolve +local function on_completechanged(group, bufnr) + api.nvim_create_autocmd('CompleteChanged', { + group = group, + buffer = bufnr, + callback = function(args) + local completed_item = vim.v.event.completed_item or {} + if (completed_item.info or '') ~= '' then + local data = vim.fn.complete_info({ 'selected' }) + update_popup_window(data.preview_winid, data.preview_bufnr) + return + end + + if + #lsp.get_clients({ + id = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'client_id'), + method = 'completionItem/resolve', + bufnr = args.buf, + }) == 0 + then + return + end + + -- Retrieve the raw LSP completionItem from completed_item as the parameter for + -- the completionItem/resolve request + local param = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'completion_item') + if param then + Context.resolve_handler = Context.resolve_handler or CompletionResolver.new() + Context.resolve_handler:request(args.buf, param, completed_item.word) + end + end, + desc = 'Request and display LSP completion item documentation via completionItem/resolve', + }) +end + --- @param bufnr integer --- @param clients vim.lsp.Client[] --- @param ctx? lsp.CompletionContext @@ -683,6 +887,14 @@ local function trigger(bufnr, clients, ctx) local start_col = (server_start_boundary or word_boundary) + 1 Context.cursor = { cursor_row, start_col } + if #matches > 0 and has_completeopt('popup') then + local group = get_augroup(bufnr) + if + #api.nvim_get_autocmds({ buffer = bufnr, event = 'CompleteChanged', group = group }) == 0 + then + on_completechanged(group, bufnr) + end + end vim.fn.complete(start_col, matches) end) @@ -695,7 +907,7 @@ local function on_insert_char_pre(handle) if Context.isIncomplete then reset_timer() - local debounce_ms = next_debounce() + 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() @@ -716,7 +928,7 @@ local function on_insert_char_pre(handle) return end - local char = api.nvim_get_vvar('char') + local char = vim.v.char local matched_clients = handle.triggers[char] -- Discard pending trigger char, complete the "latest" one. -- Can happen if a mapping inputs multiple trigger chars simultaneously. @@ -726,11 +938,10 @@ local function on_insert_char_pre(handle) completion_timer:start(25, 0, function() reset_timer() vim.schedule(function() - trigger( - api.nvim_get_current_buf(), - matched_clients, - { triggerKind = protocol.CompletionTriggerKind.TriggerCharacter, triggerCharacter = char } - ) + trigger(api.nvim_get_current_buf(), matched_clients, { + triggerKind = protocol.CompletionTriggerKind.TriggerCharacter, + triggerCharacter = char, + }) end) end) end @@ -831,12 +1042,6 @@ local function on_complete_done() end end ----@param bufnr integer ----@return string -local function get_augroup(bufnr) - return string.format('nvim.lsp.completion_%d', bufnr) -end - --- @param client_id integer --- @param bufnr integer local function disable_completions(client_id, bufnr) @@ -904,6 +1109,7 @@ local function enable_completions(client_id, bufnr, opts) end end, }) + if opts.autotrigger then api.nvim_create_autocmd('InsertCharPre', { group = group, diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index a28b51762c..19165f6561 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -482,6 +482,7 @@ function protocol.make_client_capabilities() properties = { 'additionalTextEdits', 'command', + 'documentation', }, }, tagSupport = { diff --git a/test/functional/plugin/lsp/completion_spec.lua b/test/functional/plugin/lsp/completion_spec.lua index b50b7191cc..ed76e44157 100644 --- a/test/functional/plugin/lsp/completion_spec.lua +++ b/test/functional/plugin/lsp/completion_spec.lua @@ -9,6 +9,7 @@ local neq = t.neq local exec_lua = n.exec_lua local feed = n.feed local retry = t.retry +local Screen = require('test.functional.ui.screen') local create_server_definition = t_lsp.create_server_definition @@ -1000,6 +1001,9 @@ describe('vim.lsp.completion: protocol', function() }, }, } + exec_lua(function() + vim.o.completeopt = 'menuone,noselect' + end) local client_id = create_server('dummy', completion_list) exec_lua(function() @@ -1038,6 +1042,9 @@ describe('vim.lsp.completion: protocol', function() isIncomplete = false, items = { { label = 'hello' } }, } + exec_lua(function() + vim.o.completeopt = 'menuone,noselect' + end) local client_id = create_server('dummy', completion_list, { resolve_result = { label = 'hello', @@ -1354,6 +1361,66 @@ describe('vim.lsp.completion: integration', function() feed('') eq('w-1/2', n.api.nvim_get_current_line()) end) + + it('completionItem/resolve', function() + local screen = Screen.new(50, 20) + screen:add_extra_attr_ids({ + [100] = { background = Screen.colors.Plum1, foreground = Screen.colors.Blue }, + }) + local completion_list = { + isIncomplete = false, + items = { + { + insertText = 'nvim__id_array', + insertTextFormat = 1, + kind = 3, + label = 'nvim__id_array(arr)', + sortText = '0002', + }, + }, + } + exec_lua(function() + vim.o.completeopt = 'menuone,popup' + end) + create_server('dummy', completion_list, { + resolve_result = { + detail = 'function', + documentation = { + kind = 'markdown', + value = [[```lua\nfunction vim.api.nvim__id_array(arr: any[])\n -> any[]\n```]], + }, + insertText = 'nvim__id_array', + insertTextFormat = 1, + kind = 3, + label = 'nvim__id_array(arr)', + sortText = '0002', + }, + }) + + feed('Sapi.') + retry(nil, nil, function() + eq( + { true, true, [[```lua\nfunction vim.api.nvim__id_array(arr: any[])\n -> any[]\n```]] }, + exec_lua(function() + local data = vim.fn.complete_info({ 'selected' }) + return { + vim.api.nvim_win_is_valid(data.preview_winid), + vim.api.nvim_buf_is_valid(data.preview_bufnr), + vim.api.nvim_buf_get_lines(data.preview_bufnr, 0, -1, false)[1], + } + end) + ) + end) + screen:expect([[ + api.nvim__id_array^ | + {1:~ }{12: nvim__id_array Function }{100:lua\nfunction vim.}{4: }{1: }| + {1:~ }{100:api.nvim__id_array(ar}{1: }| + {1:~ }{100:r: any[])\n -> any[]}{1: }| + {1:~ }{100:\n}{4: }{1: }| + {1:~ }|*14 + {5:-- INSERT --} | + ]]) + end) end) describe("vim.lsp.completion: omnifunc + 'autocomplete'", function()