refactor(lsp): centralise complete item info generation logic

Problem:
The logic for generating the complete item `info` is spread across
`_lsp_to_complete_items`, the `CompleteChanged` event handler, and
`CompletionResolver:request`. This has previously caused the `info`
shown for resolved (via `completionItem/resolve`) and unresolved items
to differ.

Solution:
Centralise the logic in a new `complete_item_info` function which is now
solely responsible for determining:
1. The `info` to show.
2. The markup kind of the `info`.
3. Whether the `info` is complete.

This simplifies the interaction between the 3 functions mentioned in the
problem:
- `_lsp_to_complete_items` calls `complete_item_info` and passes along
  the markup kind and whether the item needs resolving via the complete
  item's `user_data`.
- The `CompleteChanged` consumes the markup kind and whether the
  item needs resolving from the complete item's `user_data`.
- `CompletionResolver:request`, like `_lsp_to_complete_items` calls
  `complete_item_info` again and updates the current `info` if it's
  changed.
This commit is contained in:
Marcus Caisey
2026-05-21 15:51:33 +01:00
parent d6d03716cf
commit c75365d22a
2 changed files with 74 additions and 84 deletions

View File

@@ -264,22 +264,25 @@ local function get_items(result)
end
end
---Returns an item's documentation value and its markup kind.
---@param item lsp.CompletionItem
---@return string
---@return lsp.MarkupKind
local function get_doc(item)
local doc = item.documentation
local default_kind = vim.lsp.protocol.MarkupKind.Markdown
if not doc then
return ''
return '', default_kind
end
if type(doc) == 'string' then
return doc
return doc, default_kind
end
if type(doc) == 'table' and type(doc.value) == 'string' then
return doc.value
return doc.value, doc.kind
end
vim.notify('invalid documentation value: ' .. vim.inspect(doc), vim.log.levels.WARN)
return ''
return '', default_kind
end
---@param value string
@@ -352,6 +355,43 @@ local function generate_kind(item)
return '', group
end
---Returns the [complete-items] info for an LSP completion item, its markup kind, and whether the
---info is complete.
---The info is complete when all of the fields of the item required to build it are present. If the
---info is not complete, resolving the item (via completionItem/resolve) may populate the missing
---fields.
---@param item lsp.CompletionItem
---@return string
---@return lsp.MarkupKind
---@return boolean complete
local function complete_item_info(item)
local info, kind = get_doc(item)
if item.detail and item.detail ~= '' then
local detail_block = ('```%s\n%s\n```'):format(vim.bo.filetype, item.detail)
if info == '' then
info = detail_block
elseif not info:find(item.detail, 1, true) then
info = detail_block .. '\n' .. info
end
end
if
info == ''
and has_completeopt('popup')
and item.insertTextFormat == protocol.InsertTextFormat.Snippet
then
local text = item.insertText or (item.textEdit and item.textEdit.newText)
if text then
local snippet = parse_snippet(text)
info = ('```%s\n%s\n```'):format(vim.bo.filetype, snippet)
end
end
local complete = item.detail ~= nil and item.documentation ~= nil
return info, kind, complete
end
--- Turns the result of a `textDocument/completion` request into vim-compatible
--- |complete-items|.
---
@@ -404,6 +444,8 @@ function M._lsp_to_complete_items(
local bufnr = api.nvim_get_current_buf()
local user_convert = vim.tbl_get(buf_handles, bufnr, 'convert')
local user_cmp = vim.tbl_get(buf_handles, bufnr, 'cmp')
local client = client_id and vim.lsp.get_client_by_id(client_id)
local server_supports_resolve = client and client:supports_method('completionItem/resolve')
for _, item in ipairs(items) do
local match, score = matches(item)
if match then
@@ -435,15 +477,7 @@ function M._lsp_to_complete_items(
hl_group = 'DiagnosticDeprecated'
end
local kind, kind_hlgroup = generate_kind(item)
local info = get_doc(item)
if item.detail and item.detail ~= '' then
if info == '' then
info = ('```%s\n%s\n```'):format(vim.bo.filetype, item.detail)
elseif not info:find(item.detail, 1, true) then
local detail_block = ('```%s\n%s\n```'):format(vim.bo.filetype, item.detail)
info = detail_block .. '\n' .. info
end
end
local info, info_kind, info_complete = complete_item_info(item)
local completion_item = {
word = word,
abbr = ('%s%s'):format(item.label, vim.tbl_get(item, 'labelDetails', 'detail') or ''),
@@ -460,6 +494,8 @@ function M._lsp_to_complete_items(
nvim = {
lsp = {
completion_item = item,
info_kind = info_kind,
completion_item_needs_resolving = server_supports_resolve and not info_complete,
client_id = client_id,
},
},
@@ -745,6 +781,7 @@ function CompletionResolver:request(bufnr, param, selected_word)
local start_time = vim.uv.hrtime()
self.last_request_time = start_time
---@param result lsp.CompletionItem
client:request('completionItem/resolve', param, function(err, result)
local end_time = vim.uv.hrtime()
local response_time = (end_time - start_time) * ns_to_ms
@@ -762,91 +799,35 @@ function CompletionResolver:request(bufnr, param, selected_word)
return
end
local value = vim.tbl_get(result, 'documentation', 'value') --[[@as string?]]
local kind = vim.tbl_get(result, 'documentation', 'kind')
local text_format = vim.tbl_get(result, 'insertTextFormat')
if result.detail and result.detail ~= '' then
if not value then
value = ('```%s\n%s\n```'):format(vim.bo.filetype, result.detail)
kind = kind or lsp.protocol.MarkupKind.Markdown
elseif not value:find(result.detail, 1, true) then
local detail_block = ('```%s\n%s\n```'):format(vim.bo.filetype, result.detail)
value = detail_block .. '\n' .. value
kind = kind or lsp.protocol.MarkupKind.Markdown
end
local info, kind = complete_item_info(result)
if info ~= '' and info ~= cmp_info.completed.info then
local windata = vim.api.nvim__complete_set(cmp_info.selected, { info = info })
update_popup_window(windata.winid, windata.bufnr, kind)
end
if not value then
if text_format ~= protocol.InsertTextFormat.Snippet then
return
end
-- generate snippet preview info
local text = vim.tbl_get(result, 'insertText') or vim.tbl_get(result, 'textEdit', 'newText')
if text then
value = ('```%s\n%s\n```'):format(vim.bo.filetype, parse_snippet(text))
kind = lsp.protocol.MarkupKind.Markdown
end
end
local windata = vim.api.nvim__complete_set(cmp_info.selected, {
info = value,
})
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
--- Defines a CompleteChanged handler to highlight the completion info popup and request/display LSP
--- completion item documentation via completionItem/resolve
local function on_completechanged(group, bufnr)
nvim_on('CompleteChanged', group, {
buf = bufnr,
desc = 'Request and display LSP completion item documentation via completionItem/resolve',
desc = 'Highlight completion info popup and request/display LSP completion item documentation via completionItem/resolve',
}, function(ev)
local completed_item = vim.v.event.completed_item or {}
local lsp_item = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'completion_item') --[[@as lsp.CompletionItem?]]
if not lsp_item then
local user_data = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp')
if not user_data then
return
end
local data = vim.fn.complete_info({ 'selected' })
local info = completed_item.info or ''
if info ~= '' then
local kind = vim.tbl_get(lsp_item, 'documentation', 'kind')
update_popup_window(
data.preview_winid,
data.preview_bufnr,
kind or protocol.MarkupKind.Markdown
)
if (completed_item.info or '') ~= '' then
local data = vim.fn.complete_info({ 'selected' })
update_popup_window(data.preview_winid, data.preview_bufnr, user_data.info_kind)
end
if
#lsp.get_clients({
id = vim.tbl_get(completed_item, 'user_data', 'nvim', 'lsp', 'client_id'),
method = 'completionItem/resolve',
bufnr = ev.buf,
}) == 0
then
if
has_completeopt('popup')
and info == ''
and lsp_item.insertTextFormat == protocol.InsertTextFormat.Snippet
then
-- Shows snippet preview in doc popup if completeopt=popup.
local text = parse_snippet(lsp_item.insertText or lsp_item.textEdit.newText)
local windata = api.nvim__complete_set(
data.selected,
{ info = ('```%s\n%s\n```'):format(vim.bo.filetype, text) }
)
update_popup_window(windata.winid, windata.bufnr, protocol.MarkupKind.Markdown)
end
return
end
-- Retrieve the raw LSP completionItem from completed_item as the parameter for
-- the completionItem/resolve request
if not lsp_item.detail or not lsp_item.documentation then
if user_data.completion_item_needs_resolving then
Context.resolve_handler = Context.resolve_handler or CompletionResolver.new()
Context.resolve_handler:request(ev.buf, lsp_item, completed_item.word)
Context.resolve_handler:request(ev.buf, user_data.completion_item, completed_item.word)
end
end)
end