mirror of
https://github.com/neovim/neovim.git
synced 2026-03-27 19:02:02 +00:00
gen_vimdoc.lua: In prepare for the upcoming release, comment-out the "Experimental" warning for prerelease features.
1248 lines
39 KiB
Lua
1248 lines
39 KiB
Lua
--- @brief
|
|
--- The `vim.lsp.completion` module enables insert-mode completion driven by an LSP server. Call
|
|
--- `enable()` to make it available through Nvim builtin completion (via the |CompleteDone| event).
|
|
--- Specify `autotrigger=true` to activate "auto-completion" when you type any of the server-defined
|
|
--- `triggerCharacters`. Use CTRL-Y to select an item from the completion menu. |complete_CTRL-Y|
|
|
---
|
|
--- Example: activate LSP-driven auto-completion:
|
|
--- ```lua
|
|
--- -- Works best if 'completeopt' has "noselect".
|
|
--- -- Use CTRL-Y to select an item. |complete_CTRL-Y|
|
|
--- vim.cmd[[set completeopt+=menuone,noselect,popup]]
|
|
--- vim.lsp.start({
|
|
--- name = 'ts_ls',
|
|
--- cmd = …,
|
|
--- on_attach = function(client, bufnr)
|
|
--- vim.lsp.completion.enable(true, client.id, bufnr, {
|
|
--- autotrigger = true,
|
|
--- convert = function(item)
|
|
--- return { abbr = item.label:gsub('%b()', '') }
|
|
--- end,
|
|
--- })
|
|
--- end,
|
|
--- })
|
|
--- ```
|
|
---
|
|
--- [lsp-autocompletion]()
|
|
---
|
|
--- The LSP `triggerCharacters` field decides when to trigger autocompletion. If you want to trigger
|
|
--- on EVERY keypress you can either:
|
|
--- - Extend `client.server_capabilities.completionProvider.triggerCharacters` on `LspAttach`,
|
|
--- before you call `vim.lsp.completion.enable(… {autotrigger=true})`. See the |lsp-attach| example.
|
|
--- - Call `vim.lsp.completion.get()` from an |InsertCharPre| autocommand.
|
|
|
|
local M = {}
|
|
|
|
local api = vim.api
|
|
local lsp = vim.lsp
|
|
local protocol = lsp.protocol
|
|
|
|
local rtt_ms = 50.0
|
|
local ns_to_ms = 0.000001
|
|
|
|
--- @alias vim.lsp.CompletionResult lsp.CompletionList | lsp.CompletionItem[]
|
|
|
|
-- TODO(mariasolos): Remove this declaration once we figure out a better way to handle
|
|
-- literal/anonymous types (see https://github.com/neovim/neovim/pull/27542/files#r1495259331).
|
|
--- @nodoc
|
|
--- @class lsp.ItemDefaults
|
|
--- @field editRange lsp.Range | { insert: lsp.Range, replace: lsp.Range } | nil
|
|
--- @field insertTextFormat lsp.InsertTextFormat?
|
|
--- @field insertTextMode lsp.InsertTextMode?
|
|
--- @field data any
|
|
|
|
--- @nodoc
|
|
--- @class vim.lsp.completion.BufHandle
|
|
--- @field clients table<integer, vim.lsp.Client>
|
|
--- @field triggers table<string, vim.lsp.Client[]>
|
|
--- @field convert? fun(item: lsp.CompletionItem): table
|
|
|
|
--- @type table<integer, vim.lsp.completion.BufHandle>
|
|
local buf_handles = {}
|
|
|
|
--- @nodoc
|
|
--- @class vim.lsp.completion.Context
|
|
local Context = {
|
|
cursor = nil, --- @type [integer, integer]?
|
|
last_request_time = nil, --- @type integer?
|
|
pending_requests = {}, --- @type function[]
|
|
isIncomplete = false,
|
|
-- Handles "completionItem/resolve".
|
|
resolve_handler = nil, --- @type CompletionResolver?
|
|
}
|
|
|
|
--- @nodoc
|
|
function Context:cancel_pending()
|
|
for _, cancel in ipairs(self.pending_requests) do
|
|
cancel()
|
|
end
|
|
|
|
self.pending_requests = {}
|
|
end
|
|
|
|
--- @nodoc
|
|
function Context:reset()
|
|
-- Note that the cursor isn't reset here, it needs to survive a `CompleteDone` event.
|
|
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?
|
|
local completion_timer = nil
|
|
|
|
--- @return uv.uv_timer_t
|
|
local function new_timer()
|
|
return (assert(vim.uv.new_timer()))
|
|
end
|
|
|
|
local function reset_timer()
|
|
if completion_timer then
|
|
completion_timer:stop()
|
|
completion_timer:close()
|
|
end
|
|
|
|
completion_timer = nil
|
|
end
|
|
|
|
--- @param window integer
|
|
--- @param warmup integer
|
|
--- @return fun(sample: integer): integer
|
|
local function exp_avg(window, warmup)
|
|
local count = 0
|
|
local sum = 0
|
|
local value = 0.0
|
|
|
|
return function(sample)
|
|
if count < warmup then
|
|
count = count + 1
|
|
sum = sum + sample
|
|
value = sum / count
|
|
else
|
|
local factor = 2.0 / (window + 1)
|
|
value = value * (1 - factor) + sample * factor
|
|
end
|
|
return value
|
|
end
|
|
end
|
|
local compute_new_average = exp_avg(10, 10)
|
|
|
|
--- 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
|
|
|
|
--- @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
|
|
--- @return string # Parsed snippet if successful, else returns its input
|
|
local function parse_snippet(input)
|
|
local ok, parsed = pcall(function()
|
|
return lsp._snippet_grammar.parse(input)
|
|
end)
|
|
return ok and tostring(parsed) or input
|
|
end
|
|
|
|
--- @param item lsp.CompletionItem
|
|
local function apply_snippet(item)
|
|
if item.textEdit then
|
|
vim.snippet.expand(item.textEdit.newText)
|
|
elseif item.insertText then
|
|
vim.snippet.expand(item.insertText)
|
|
end
|
|
end
|
|
|
|
--- Returns text that should be inserted when a selecting completion item. The
|
|
--- precedence is as follows: textEdit.newText > insertText > label
|
|
---
|
|
--- See https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
|
|
---
|
|
--- @param item lsp.CompletionItem
|
|
--- @param prefix string
|
|
--- @param match fun(text: string, prefix: string):boolean
|
|
--- @return string
|
|
local function get_completion_word(item, prefix, match)
|
|
if item.insertTextFormat == protocol.InsertTextFormat.Snippet then
|
|
if item.textEdit or (item.insertText and item.insertText ~= '') then
|
|
-- Use label instead of text if text has different starting characters.
|
|
-- label is used as abbr (=displayed), but word is used for filtering
|
|
-- This is required for things like postfix completion.
|
|
-- E.g. in lua:
|
|
--
|
|
-- local f = {}
|
|
-- f@|
|
|
-- ▲
|
|
-- └─ cursor
|
|
--
|
|
-- item.textEdit.newText: table.insert(f, $0)
|
|
-- label: insert
|
|
--
|
|
-- Typing `i` would remove the candidate because newText starts with `t`.
|
|
local text = parse_snippet(item.insertText or item.textEdit.newText)
|
|
local word = #text < #item.label and vim.fn.matchstr(text, '\\k*') or item.label
|
|
if item.filterText and not match(word, prefix) then
|
|
return item.filterText
|
|
else
|
|
return word
|
|
end
|
|
else
|
|
return item.label
|
|
end
|
|
elseif item.textEdit then
|
|
local word = item.textEdit.newText
|
|
word = string.gsub(word, '\r\n?', '\n')
|
|
return word:match('([^\n]*)') or word
|
|
elseif item.insertText and item.insertText ~= '' then
|
|
return item.insertText
|
|
end
|
|
return item.label
|
|
end
|
|
|
|
--- Applies the given defaults to the completion item, modifying it in place.
|
|
---
|
|
--- @param item lsp.CompletionItem
|
|
--- @param defaults lsp.ItemDefaults?
|
|
local function apply_defaults(item, defaults)
|
|
if not defaults then
|
|
return
|
|
end
|
|
|
|
item.insertTextFormat = item.insertTextFormat or defaults.insertTextFormat
|
|
item.insertTextMode = item.insertTextMode or defaults.insertTextMode
|
|
item.data = item.data or defaults.data
|
|
if defaults.editRange then
|
|
local textEdit = item.textEdit or {}
|
|
item.textEdit = textEdit
|
|
textEdit.newText = textEdit.newText or item.textEditText or item.insertText or item.label
|
|
if defaults.editRange.start then
|
|
textEdit.range = textEdit.range or defaults.editRange
|
|
elseif defaults.editRange.insert then
|
|
textEdit.insert = defaults.editRange.insert
|
|
textEdit.replace = defaults.editRange.replace
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @param result vim.lsp.CompletionResult
|
|
--- @return lsp.CompletionItem[]
|
|
local function get_items(result)
|
|
if result.items then
|
|
-- When we have a list, apply the defaults and return an array of items.
|
|
for _, item in ipairs(result.items) do
|
|
---@diagnostic disable-next-line: param-type-mismatch
|
|
apply_defaults(item, result.itemDefaults)
|
|
end
|
|
return result.items
|
|
else
|
|
-- Else just return the items as they are.
|
|
return result
|
|
end
|
|
end
|
|
|
|
---@param item lsp.CompletionItem
|
|
---@return string
|
|
local function get_doc(item)
|
|
if
|
|
has_completeopt('popup')
|
|
and item.insertTextFormat == protocol.InsertTextFormat.Snippet
|
|
and #(item.documentation or '') == 0
|
|
and vim.bo.filetype ~= ''
|
|
and (item.textEdit or (item.insertText and item.insertText ~= ''))
|
|
then
|
|
-- Shows snippet preview in doc popup if completeopt=popup.
|
|
local text = parse_snippet(item.insertText or item.textEdit.newText)
|
|
return ('```%s\n%s\n```'):format(vim.bo.filetype, text)
|
|
end
|
|
|
|
local doc = item.documentation
|
|
if not doc then
|
|
return ''
|
|
end
|
|
if type(doc) == 'string' then
|
|
return doc
|
|
end
|
|
if type(doc) == 'table' and type(doc.value) == 'string' then
|
|
return doc.value
|
|
end
|
|
|
|
vim.notify('invalid documentation value: ' .. vim.inspect(doc), vim.log.levels.WARN)
|
|
return ''
|
|
end
|
|
|
|
---@param value string
|
|
---@param prefix string
|
|
---@return boolean
|
|
---@return integer?
|
|
local function match_item_by_value(value, prefix)
|
|
if prefix == '' then
|
|
return true, nil
|
|
end
|
|
if has_completeopt('fuzzy') then
|
|
local score = vim.fn.matchfuzzypos({ value }, prefix)[3] ---@type table
|
|
return #score > 0, score[1]
|
|
end
|
|
|
|
if vim.o.ignorecase and (not vim.o.smartcase or not prefix:find('%u')) then
|
|
return vim.startswith(value:lower(), prefix:lower()), nil
|
|
end
|
|
return vim.startswith(value, prefix), nil
|
|
end
|
|
|
|
--- Generate kind text for completion color items
|
|
--- Parse color from doc and return colored symbol ■
|
|
---
|
|
---@param item table completion item with kind and documentation
|
|
---@return string? kind text or "■" for colors
|
|
---@return string? highlight group for colors
|
|
local function generate_kind(item)
|
|
if not lsp.protocol.CompletionItemKind[item.kind] then
|
|
return 'Unknown'
|
|
end
|
|
if item.kind ~= lsp.protocol.CompletionItemKind.Color then
|
|
return lsp.protocol.CompletionItemKind[item.kind]
|
|
end
|
|
local doc = get_doc(item)
|
|
if #doc == 0 then
|
|
return
|
|
end
|
|
|
|
-- extract hex from RGB format
|
|
local r, g, b = doc:match('rgb%((%d+)%s*,?%s*(%d+)%s*,?%s*(%d+)%)')
|
|
local hex = r and string.format('%02x%02x%02x', tonumber(r), tonumber(g), tonumber(b))
|
|
or doc:match('#?([%da-fA-F]+)')
|
|
|
|
if not hex then
|
|
return
|
|
end
|
|
|
|
-- expand 3-digit hex to 6-digit
|
|
if #hex == 3 then
|
|
hex = hex:gsub('.', '%1%1')
|
|
end
|
|
|
|
if #hex ~= 6 then
|
|
return
|
|
end
|
|
|
|
hex = hex:lower()
|
|
local group = ('@lsp.color.%s'):format(hex)
|
|
if #api.nvim_get_hl(0, { name = group }) == 0 then
|
|
api.nvim_set_hl(0, group, { fg = '#' .. hex })
|
|
end
|
|
|
|
return '■', group
|
|
end
|
|
|
|
--- Turns the result of a `textDocument/completion` request into vim-compatible
|
|
--- |complete-items|.
|
|
---
|
|
--- @param result vim.lsp.CompletionResult Result of `textDocument/completion`
|
|
--- @param prefix string prefix to filter the completion items
|
|
--- @param client_id integer? Client ID
|
|
--- @param server_start_boundary integer? server start boundary
|
|
--- @param line string? current line content
|
|
--- @param lnum integer? 0-indexed line number
|
|
--- @param encoding string? encoding
|
|
--- @return table[]
|
|
--- @see complete-items
|
|
function M._lsp_to_complete_items(
|
|
result,
|
|
prefix,
|
|
client_id,
|
|
server_start_boundary,
|
|
line,
|
|
lnum,
|
|
encoding
|
|
)
|
|
local items = get_items(result)
|
|
if vim.tbl_isempty(items) then
|
|
return {}
|
|
end
|
|
|
|
---@type fun(item: lsp.CompletionItem):boolean
|
|
local matches
|
|
if not prefix:find('%w') then
|
|
matches = function(_)
|
|
return true
|
|
end
|
|
else
|
|
---@param item lsp.CompletionItem
|
|
matches = function(item)
|
|
if item.filterText then
|
|
return match_item_by_value(item.filterText, prefix)
|
|
end
|
|
|
|
if item.textEdit and not item.textEdit.newText then
|
|
-- server took care of filtering
|
|
return true
|
|
end
|
|
|
|
return match_item_by_value(item.label, prefix)
|
|
end
|
|
end
|
|
|
|
local candidates = {}
|
|
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')
|
|
for _, item in ipairs(items) do
|
|
local match, score = matches(item)
|
|
if match then
|
|
local word = get_completion_word(item, prefix, match_item_by_value)
|
|
|
|
if server_start_boundary and line and lnum and encoding and item.textEdit then
|
|
--- @type integer?
|
|
local item_start_char
|
|
if item.textEdit.range and item.textEdit.range.start.line == lnum then
|
|
item_start_char = item.textEdit.range.start.character
|
|
elseif item.textEdit.insert and item.textEdit.insert.start.line == lnum then
|
|
item_start_char = item.textEdit.insert.start.character
|
|
end
|
|
|
|
if item_start_char then
|
|
local item_start_byte = vim.str_byteindex(line, encoding, item_start_char, false)
|
|
if item_start_byte > server_start_boundary then
|
|
local missing_prefix = line:sub(server_start_boundary + 1, item_start_byte)
|
|
word = missing_prefix .. word
|
|
end
|
|
end
|
|
end
|
|
|
|
local hl_group = ''
|
|
if
|
|
item.deprecated
|
|
or vim.list_contains((item.tags or {}), protocol.CompletionTag.Deprecated)
|
|
then
|
|
hl_group = 'DiagnosticDeprecated'
|
|
end
|
|
local kind, kind_hlgroup = generate_kind(item)
|
|
local completion_item = {
|
|
word = word,
|
|
abbr = item.label,
|
|
kind = kind,
|
|
menu = item.detail or '',
|
|
info = get_doc(item),
|
|
icase = 1,
|
|
dup = 1,
|
|
empty = 1,
|
|
abbr_hlgroup = hl_group,
|
|
kind_hlgroup = kind_hlgroup,
|
|
user_data = {
|
|
nvim = {
|
|
lsp = {
|
|
completion_item = item,
|
|
client_id = client_id,
|
|
},
|
|
},
|
|
},
|
|
_fuzzy_score = score,
|
|
}
|
|
if user_convert then
|
|
completion_item = vim.tbl_extend('keep', user_convert(item), completion_item)
|
|
end
|
|
table.insert(candidates, completion_item)
|
|
end
|
|
end
|
|
|
|
if not user_cmp then
|
|
local compare_by_sortText_and_label = function(a, b)
|
|
---@type lsp.CompletionItem
|
|
local itema = a.user_data.nvim.lsp.completion_item
|
|
---@type lsp.CompletionItem
|
|
local itemb = b.user_data.nvim.lsp.completion_item
|
|
return (itema.sortText or itema.label) < (itemb.sortText or itemb.label)
|
|
end
|
|
|
|
local use_fuzzy_sort = has_completeopt('fuzzy')
|
|
and not has_completeopt('nosort')
|
|
and not result.isIncomplete
|
|
and #prefix > 0
|
|
|
|
local compare_fn = use_fuzzy_sort
|
|
and function(a, b)
|
|
local score_a = a._fuzzy_score or 0
|
|
local score_b = b._fuzzy_score or 0
|
|
if score_a ~= score_b then
|
|
return score_a > score_b
|
|
end
|
|
return compare_by_sortText_and_label(a, b)
|
|
end
|
|
or compare_by_sortText_and_label
|
|
|
|
table.sort(candidates, compare_fn)
|
|
end
|
|
return candidates
|
|
end
|
|
|
|
--- @param lnum integer 0-indexed
|
|
--- @param line string
|
|
--- @param items lsp.CompletionItem[]
|
|
--- @param encoding 'utf-8'|'utf-16'|'utf-32'
|
|
--- @return integer?
|
|
local function adjust_start_col(lnum, line, items, encoding)
|
|
local min_start_char = nil
|
|
for _, item in pairs(items) do
|
|
if item.textEdit then
|
|
local start_char = nil
|
|
if item.textEdit.range and item.textEdit.range.start.line == lnum then
|
|
start_char = item.textEdit.range.start.character
|
|
elseif item.textEdit.insert and item.textEdit.insert.start.line == lnum then
|
|
start_char = item.textEdit.insert.start.character
|
|
end
|
|
if start_char then
|
|
if not min_start_char or start_char < min_start_char then
|
|
min_start_char = start_char
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if min_start_char then
|
|
return vim.str_byteindex(line, encoding, min_start_char, false)
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
|
|
--- @param line string line content
|
|
--- @param lnum integer 0-indexed line number
|
|
--- @param cursor_col integer
|
|
--- @param client_id integer client ID
|
|
--- @param client_start_boundary integer 0-indexed word boundary
|
|
--- @param server_start_boundary? integer 0-indexed word boundary, based on textEdit.range.start.character
|
|
--- @param result vim.lsp.CompletionResult
|
|
--- @param encoding 'utf-8'|'utf-16'|'utf-32'
|
|
--- @return table[] matches
|
|
--- @return integer? server_start_boundary
|
|
function M._convert_results(
|
|
line,
|
|
lnum,
|
|
cursor_col,
|
|
client_id,
|
|
client_start_boundary,
|
|
server_start_boundary,
|
|
result,
|
|
encoding
|
|
)
|
|
-- Completion response items may be relative to a position different than `client_start_boundary`.
|
|
-- Concrete example, with lua-language-server:
|
|
--
|
|
-- require('plenary.asy|
|
|
-- ▲ ▲ ▲
|
|
-- │ │ └── cursor_pos: 20
|
|
-- │ └────── client_start_boundary: 17
|
|
-- └────────────── textEdit.range.start.character: 9
|
|
-- .newText = 'plenary.async'
|
|
-- ^^^
|
|
-- prefix (We'd remove everything not starting with `asy`,
|
|
-- so we'd eliminate the `plenary.async` result
|
|
--
|
|
-- `adjust_start_col` is used to prefer the language server boundary.
|
|
--
|
|
local candidates = get_items(result)
|
|
local curstartbyte = adjust_start_col(lnum, line, candidates, encoding)
|
|
if server_start_boundary == nil then
|
|
server_start_boundary = curstartbyte
|
|
elseif curstartbyte ~= nil and curstartbyte ~= server_start_boundary then
|
|
server_start_boundary = client_start_boundary
|
|
end
|
|
local prefix = line:sub((server_start_boundary or client_start_boundary) + 1, cursor_col)
|
|
local matches =
|
|
M._lsp_to_complete_items(result, prefix, client_id, server_start_boundary, line, lnum, encoding)
|
|
|
|
return matches, server_start_boundary
|
|
end
|
|
|
|
-- NOTE: The reason we don't use `lsp.buf_request_all` here is because we want to filter the clients
|
|
-- that received the request based on the trigger characters.
|
|
--- @param clients table<integer, vim.lsp.Client> # keys != client_id
|
|
--- @param bufnr integer
|
|
--- @param win integer
|
|
--- @param ctx? lsp.CompletionContext
|
|
--- @param callback fun(responses: table<integer, { err: lsp.ResponseError, result: vim.lsp.CompletionResult }>)
|
|
--- @return function # Cancellation function
|
|
local function request(clients, bufnr, win, ctx, callback)
|
|
local responses = {} --- @type table<integer, { err: lsp.ResponseError, result: any }>
|
|
local request_ids = {} --- @type table<integer, integer>
|
|
local remaining_requests = vim.tbl_count(clients)
|
|
|
|
for _, client in pairs(clients) do
|
|
local client_id = client.id
|
|
local params = lsp.util.make_position_params(win, client.offset_encoding)
|
|
--- @cast params lsp.CompletionParams
|
|
params.context = ctx
|
|
local ok, request_id = client:request('textDocument/completion', params, function(err, result)
|
|
responses[client_id] = { err = err, result = result }
|
|
remaining_requests = remaining_requests - 1
|
|
if remaining_requests == 0 then
|
|
callback(responses)
|
|
end
|
|
end, bufnr)
|
|
|
|
if ok then
|
|
request_ids[client_id] = request_id
|
|
end
|
|
end
|
|
|
|
return function()
|
|
for client_id, request_id in pairs(request_ids) do
|
|
local client = lsp.get_client_by_id(client_id)
|
|
if client then
|
|
client:cancel_request(request_id)
|
|
end
|
|
end
|
|
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
|
|
local function trigger(bufnr, clients, ctx)
|
|
reset_timer()
|
|
Context:cancel_pending()
|
|
|
|
if tonumber(vim.fn.pumvisible()) == 1 and not Context.isIncomplete then
|
|
return
|
|
end
|
|
|
|
local win = api.nvim_get_current_win()
|
|
local cursor_row = api.nvim_win_get_cursor(win)[1]
|
|
local start_time = vim.uv.hrtime() --[[@as integer]]
|
|
Context.last_request_time = start_time
|
|
|
|
local cancel_request = request(clients, bufnr, win, ctx, function(responses)
|
|
local end_time = vim.uv.hrtime()
|
|
rtt_ms = compute_new_average((end_time - start_time) * ns_to_ms)
|
|
|
|
Context.pending_requests = {}
|
|
Context.isIncomplete = false
|
|
|
|
local new_cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(win)) --- @type integer, integer
|
|
local row_changed = new_cursor_row ~= cursor_row
|
|
local mode = api.nvim_get_mode().mode
|
|
if row_changed or not (mode == 'i' or mode == 'ic') then
|
|
return
|
|
end
|
|
|
|
local line = api.nvim_get_current_line()
|
|
local line_to_cursor = line:sub(1, cursor_col)
|
|
local word_boundary = vim.fn.match(line_to_cursor, '\\k*$')
|
|
|
|
local matches = {}
|
|
|
|
local server_start_boundary --- @type integer?
|
|
for client_id, response in pairs(responses) do
|
|
local client = lsp.get_client_by_id(client_id)
|
|
if response.err then
|
|
local msg = ('%s: %s %s'):format(
|
|
client and client.name or 'UNKNOWN',
|
|
response.err.code or 'NO_CODE',
|
|
response.err.message
|
|
)
|
|
vim.notify_once(msg, vim.log.levels.WARN)
|
|
end
|
|
|
|
local result = response.result
|
|
if result and #(result.items or result) > 0 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
|
|
)
|
|
|
|
server_start_boundary = tmp_server_start_boundary or server_start_boundary
|
|
vim.list_extend(matches, client_matches)
|
|
end
|
|
end
|
|
|
|
--- @type table[]
|
|
local prev_matches = vim.fn.complete_info({ 'items', 'matches' })['items']
|
|
|
|
--- @param prev_match table
|
|
prev_matches = vim.tbl_filter(function(prev_match)
|
|
local client_id = vim.tbl_get(prev_match, 'user_data', 'nvim', 'lsp', 'client_id')
|
|
if client_id and responses[client_id] ~= nil then
|
|
return false
|
|
end
|
|
return vim.tbl_get(prev_match, 'match')
|
|
end, prev_matches)
|
|
|
|
matches = vim.list_extend(prev_matches, matches)
|
|
local user_cmp = vim.tbl_get(buf_handles, bufnr, 'cmp')
|
|
if user_cmp then
|
|
table.sort(matches, user_cmp)
|
|
end
|
|
|
|
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)
|
|
|
|
table.insert(Context.pending_requests, cancel_request)
|
|
end
|
|
|
|
--- @param handle vim.lsp.completion.BufHandle
|
|
local function on_insert_char_pre(handle)
|
|
if tonumber(vim.fn.pumvisible()) == 1 then
|
|
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()
|
|
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
|
|
|
|
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.
|
|
reset_timer()
|
|
if matched_clients then
|
|
completion_timer = assert(vim.uv.new_timer())
|
|
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,
|
|
})
|
|
end)
|
|
end)
|
|
end
|
|
end
|
|
|
|
local function on_insert_leave()
|
|
reset_timer()
|
|
Context.cursor = nil
|
|
Context:reset()
|
|
end
|
|
|
|
local function on_complete_done()
|
|
local completed_item = api.nvim_get_vvar('completed_item')
|
|
if not completed_item or not completed_item.user_data or not completed_item.user_data.nvim then
|
|
Context:reset()
|
|
return
|
|
end
|
|
|
|
local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(0)) --- @type integer, integer
|
|
cursor_row = cursor_row - 1
|
|
local completion_item = completed_item.user_data.nvim.lsp.completion_item --- @type lsp.CompletionItem
|
|
local client_id = completed_item.user_data.nvim.lsp.client_id --- @type integer
|
|
if not completion_item or not client_id then
|
|
Context:reset()
|
|
return
|
|
end
|
|
|
|
local bufnr = api.nvim_get_current_buf()
|
|
local expand_snippet = completion_item.insertTextFormat == protocol.InsertTextFormat.Snippet
|
|
and (completion_item.textEdit ~= nil or completion_item.insertText ~= nil)
|
|
|
|
Context:reset()
|
|
|
|
local client = lsp.get_client_by_id(client_id)
|
|
if not client then
|
|
return
|
|
end
|
|
|
|
local position_encoding = client.offset_encoding or 'utf-16'
|
|
local resolve_provider = (client.server_capabilities.completionProvider or {}).resolveProvider
|
|
|
|
local function clear_word()
|
|
if not expand_snippet then
|
|
return nil
|
|
end
|
|
|
|
-- Remove the already inserted word.
|
|
api.nvim_buf_set_text(
|
|
bufnr,
|
|
Context.cursor[1] - 1,
|
|
Context.cursor[2] - 1,
|
|
cursor_row,
|
|
cursor_col,
|
|
{ '' }
|
|
)
|
|
end
|
|
|
|
local function apply_snippet_and_command()
|
|
if expand_snippet then
|
|
apply_snippet(completion_item)
|
|
end
|
|
|
|
local command = completion_item.command
|
|
if command then
|
|
client:exec_cmd(command, { bufnr = bufnr })
|
|
end
|
|
end
|
|
|
|
if completion_item.additionalTextEdits and next(completion_item.additionalTextEdits) then
|
|
clear_word()
|
|
lsp.util.apply_text_edits(completion_item.additionalTextEdits, bufnr, position_encoding)
|
|
apply_snippet_and_command()
|
|
elseif resolve_provider and type(completion_item) == 'table' then
|
|
local changedtick = vim.b[bufnr].changedtick
|
|
|
|
--- @param result lsp.CompletionItem
|
|
client:request('completionItem/resolve', completion_item, function(err, result)
|
|
if changedtick ~= vim.b[bufnr].changedtick then
|
|
return
|
|
end
|
|
|
|
clear_word()
|
|
if err then
|
|
vim.notify_once(err.message, vim.log.levels.WARN)
|
|
elseif result then
|
|
if result.additionalTextEdits then
|
|
lsp.util.apply_text_edits(result.additionalTextEdits, bufnr, position_encoding)
|
|
end
|
|
if result.command then
|
|
completion_item.command = result.command
|
|
end
|
|
end
|
|
apply_snippet_and_command()
|
|
end, bufnr)
|
|
else
|
|
clear_word()
|
|
apply_snippet_and_command()
|
|
end
|
|
end
|
|
|
|
--- @param client_id integer
|
|
--- @param bufnr integer
|
|
local function disable_completions(client_id, bufnr)
|
|
local handle = buf_handles[bufnr]
|
|
if not handle then
|
|
return
|
|
end
|
|
|
|
handle.clients[client_id] = nil
|
|
if not next(handle.clients) then
|
|
buf_handles[bufnr] = nil
|
|
api.nvim_del_augroup_by_name(get_augroup(bufnr))
|
|
else
|
|
for char, clients in pairs(handle.triggers) do
|
|
--- @param c vim.lsp.Client
|
|
handle.triggers[char] = vim.tbl_filter(function(c)
|
|
return c.id ~= client_id
|
|
end, clients)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @inlinedoc
|
|
--- @class vim.lsp.completion.BufferOpts
|
|
--- @field autotrigger? boolean (default: false) When true, completion triggers automatically based on the server's `triggerCharacters`.
|
|
--- @field convert? fun(item: lsp.CompletionItem): table Transforms an LSP CompletionItem to |complete-items|.
|
|
--- @field cmp? fun(a: table, b: table): boolean Comparator for sorting merged completion items from all servers.
|
|
|
|
---@param client_id integer
|
|
---@param bufnr integer
|
|
---@param opts vim.lsp.completion.BufferOpts
|
|
local function enable_completions(client_id, bufnr, opts)
|
|
local buf_handle = buf_handles[bufnr]
|
|
if not buf_handle then
|
|
buf_handle = { clients = {}, triggers = {}, convert = opts.convert, cmp = opts.cmp }
|
|
buf_handles[bufnr] = buf_handle
|
|
|
|
-- Attach to buffer events.
|
|
api.nvim_buf_attach(bufnr, false, {
|
|
on_detach = function(_, buf)
|
|
buf_handles[buf] = nil
|
|
end,
|
|
on_reload = function(_, buf)
|
|
M.enable(true, client_id, buf, opts)
|
|
end,
|
|
})
|
|
|
|
-- Set up autocommands.
|
|
local group = api.nvim_create_augroup(get_augroup(bufnr), { clear = true })
|
|
api.nvim_create_autocmd('LspDetach', {
|
|
group = group,
|
|
buffer = bufnr,
|
|
desc = 'vim.lsp.completion: clean up client on detach',
|
|
callback = function(args)
|
|
disable_completions(args.data.client_id, args.buf)
|
|
end,
|
|
})
|
|
api.nvim_create_autocmd('CompleteDone', {
|
|
group = group,
|
|
buffer = bufnr,
|
|
callback = function()
|
|
local reason = api.nvim_get_vvar('event').reason --- @type string
|
|
if reason == 'accept' then
|
|
on_complete_done()
|
|
end
|
|
end,
|
|
})
|
|
|
|
if opts.autotrigger then
|
|
api.nvim_create_autocmd('InsertCharPre', {
|
|
group = group,
|
|
buffer = bufnr,
|
|
callback = function()
|
|
on_insert_char_pre(buf_handles[bufnr])
|
|
end,
|
|
})
|
|
api.nvim_create_autocmd('InsertLeave', {
|
|
group = group,
|
|
buffer = bufnr,
|
|
callback = on_insert_leave,
|
|
})
|
|
end
|
|
end
|
|
|
|
if not buf_handle.clients[client_id] then
|
|
local client = lsp.get_client_by_id(client_id)
|
|
assert(client, 'invalid client ID')
|
|
|
|
-- Add the new client to the buffer's clients.
|
|
buf_handle.clients[client_id] = client
|
|
|
|
-- Add the new client to the clients that should be triggered by its trigger characters.
|
|
--- @type string[]
|
|
local triggers = vim.tbl_get(
|
|
client.server_capabilities,
|
|
'completionProvider',
|
|
'triggerCharacters'
|
|
) or {}
|
|
for _, char in ipairs(triggers) do
|
|
local clients_for_trigger = buf_handle.triggers[char]
|
|
if not clients_for_trigger then
|
|
clients_for_trigger = {}
|
|
buf_handle.triggers[char] = clients_for_trigger
|
|
end
|
|
local client_exists = vim.iter(clients_for_trigger):any(function(c)
|
|
return c.id == client_id
|
|
end)
|
|
if not client_exists then
|
|
table.insert(clients_for_trigger, client)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Enables or disables completions from the given language client in the given
|
|
--- buffer. Effects of enabling completions are:
|
|
---
|
|
--- - Calling |vim.lsp.completion.get()| uses the enabled clients to retrieve completion candidates.
|
|
--- - Selecting a completion item shows a preview popup ("completionItem/resolve") if 'completeopt'
|
|
--- has "popup".
|
|
--- - Accepting a completion item using `<c-y>` applies side effects like expanding snippets,
|
|
--- text edits (e.g. insert import statements) and executing associated commands. This works for
|
|
--- completions triggered via autotrigger, 'omnifunc' or [vim.lsp.completion.get()].
|
|
---
|
|
--- Examples: |lsp-attach| |lsp-completion|
|
|
---
|
|
--- Note: the behavior of `autotrigger=true` is controlled by the LSP `triggerCharacters` field. You
|
|
--- can override it on LspAttach, see |lsp-autocompletion|.
|
|
---
|
|
--- @param enable boolean True to enable, false to disable
|
|
--- @param client_id integer Client ID
|
|
--- @param bufnr integer Buffer handle, or 0 for the current buffer
|
|
--- @param opts? vim.lsp.completion.BufferOpts
|
|
function M.enable(enable, client_id, bufnr, opts)
|
|
bufnr = vim._resolve_bufnr(bufnr)
|
|
|
|
if enable then
|
|
enable_completions(client_id, bufnr, opts or {})
|
|
else
|
|
disable_completions(client_id, bufnr)
|
|
end
|
|
end
|
|
|
|
--- @inlinedoc
|
|
--- @class vim.lsp.completion.get.Opts
|
|
--- @field ctx? lsp.CompletionContext Completion context. Defaults to a trigger kind of `invoked`.
|
|
|
|
--- Triggers LSP completion once in the current buffer, if LSP completion is enabled
|
|
--- (see |lsp-attach| |lsp-completion|).
|
|
---
|
|
--- Used by the default LSP |omnicompletion| provider |vim.lsp.omnifunc()|, thus |i_CTRL-X_CTRL-O|
|
|
--- invokes this in LSP-enabled buffers. Use CTRL-Y to select an item from the completion menu.
|
|
--- |complete_CTRL-Y|
|
|
---
|
|
--- To invoke manually with CTRL-space, use this mapping:
|
|
--- ```lua
|
|
--- -- Use CTRL-space to trigger LSP completion.
|
|
--- -- Use CTRL-Y to select an item. |complete_CTRL-Y|
|
|
--- vim.keymap.set('i', '<c-space>', function()
|
|
--- vim.lsp.completion.get()
|
|
--- end)
|
|
--- ```
|
|
---
|
|
--- @param opts? vim.lsp.completion.get.Opts
|
|
function M.get(opts)
|
|
opts = opts or {}
|
|
local ctx = opts.ctx or { triggerKind = protocol.CompletionTriggerKind.Invoked }
|
|
local bufnr = api.nvim_get_current_buf()
|
|
local clients = (buf_handles[bufnr] or {}).clients or {}
|
|
|
|
trigger(bufnr, clients, ctx)
|
|
end
|
|
|
|
--- Implements 'omnifunc' compatible LSP completion.
|
|
---
|
|
--- @see |complete-functions|
|
|
--- @see |complete-items|
|
|
--- @see |CompleteDone|
|
|
---
|
|
--- @param findstart integer 0 or 1, decides behavior
|
|
--- @param base integer findstart=0, text to match against
|
|
---
|
|
--- @return integer|table Decided by {findstart}:
|
|
--- - findstart=0: column where the completion starts, or -2 or -3
|
|
--- - findstart=1: list of matches (actually just calls |complete()|)
|
|
function M._omnifunc(findstart, base)
|
|
lsp.log.debug('omnifunc.findstart', { findstart = findstart, base = base })
|
|
assert(base) -- silence luals
|
|
local bufnr = api.nvim_get_current_buf()
|
|
local clients = lsp.get_clients({ bufnr = bufnr, method = 'textDocument/completion' })
|
|
local remaining = #clients
|
|
if remaining == 0 then
|
|
return findstart == 1 and -1 or {}
|
|
end
|
|
|
|
trigger(bufnr, clients, { triggerKind = protocol.CompletionTriggerKind.Invoked })
|
|
|
|
-- Return -2 to signal that we should continue completion so that we can
|
|
-- async complete.
|
|
return -2
|
|
end
|
|
|
|
return M
|