feat(lsp): completion side effects

This commit is contained in:
Maria José Solano 2024-02-04 14:13:23 -08:00
parent 28c04948a1
commit ff097f2091
10 changed files with 789 additions and 296 deletions

View File

@ -1604,6 +1604,32 @@ save({lenses}, {bufnr}, {client_id}) *vim.lsp.codelens.save()*
• {client_id} (`integer`) • {client_id} (`integer`)
==============================================================================
Lua module: vim.lsp.completion *lsp-completion*
*vim.lsp.completion.BufferOpts*
Fields: ~
• {autotrigger}? (`boolean`) Whether to trigger completion
automatically. Default: false
*vim.lsp.completion.enable()*
enable({enable}, {client_id}, {bufnr}, {opts})
Enables or disables completions from the given language client in the
given buffer.
Parameters: ~
• {enable} (`boolean`) True to enable, false to disable
• {client_id} (`integer`) Client ID
• {bufnr} (`integer`) Buffer handle, or 0 for the current buffer
• {opts} (`vim.lsp.completion.BufferOpts?`) See
|vim.lsp.completion.BufferOpts|.
trigger() *vim.lsp.completion.trigger()*
Trigger LSP completion in the current buffer.
============================================================================== ==============================================================================
Lua module: vim.lsp.inlay_hint *lsp-inlay_hint* Lua module: vim.lsp.inlay_hint *lsp-inlay_hint*

View File

@ -95,7 +95,8 @@ EVENTS
LSP LSP
• TODO • Completion side effects (including snippet expansion, execution of commands
and application of additional text edits) is now built-in.
LUA LUA

View File

@ -3,7 +3,6 @@ local validate = vim.validate
local lsp = vim._defer_require('vim.lsp', { local lsp = vim._defer_require('vim.lsp', {
_changetracking = ..., --- @module 'vim.lsp._changetracking' _changetracking = ..., --- @module 'vim.lsp._changetracking'
_completion = ..., --- @module 'vim.lsp._completion'
_dynamic = ..., --- @module 'vim.lsp._dynamic' _dynamic = ..., --- @module 'vim.lsp._dynamic'
_snippet_grammar = ..., --- @module 'vim.lsp._snippet_grammar' _snippet_grammar = ..., --- @module 'vim.lsp._snippet_grammar'
_tagfunc = ..., --- @module 'vim.lsp._tagfunc' _tagfunc = ..., --- @module 'vim.lsp._tagfunc'
@ -11,6 +10,7 @@ local lsp = vim._defer_require('vim.lsp', {
buf = ..., --- @module 'vim.lsp.buf' buf = ..., --- @module 'vim.lsp.buf'
client = ..., --- @module 'vim.lsp.client' client = ..., --- @module 'vim.lsp.client'
codelens = ..., --- @module 'vim.lsp.codelens' codelens = ..., --- @module 'vim.lsp.codelens'
completion = ..., --- @module 'vim.lsp.completion'
diagnostic = ..., --- @module 'vim.lsp.diagnostic' diagnostic = ..., --- @module 'vim.lsp.diagnostic'
handlers = ..., --- @module 'vim.lsp.handlers' handlers = ..., --- @module 'vim.lsp.handlers'
inlay_hint = ..., --- @module 'vim.lsp.inlay_hint' inlay_hint = ..., --- @module 'vim.lsp.inlay_hint'
@ -1002,8 +1002,7 @@ end
--- - findstart=0: column where the completion starts, or -2 or -3 --- - findstart=0: column where the completion starts, or -2 or -3
--- - findstart=1: list of matches (actually just calls |complete()|) --- - findstart=1: list of matches (actually just calls |complete()|)
function lsp.omnifunc(findstart, base) function lsp.omnifunc(findstart, base)
log.debug('omnifunc.findstart', { findstart = findstart, base = base }) return vim.lsp.completion._omnifunc(findstart, base)
return vim.lsp._completion.omnifunc(findstart, base)
end end
--- @class vim.lsp.formatexpr.Opts --- @class vim.lsp.formatexpr.Opts

View File

@ -1,276 +0,0 @@
local M = {}
local api = vim.api
local lsp = vim.lsp
local protocol = lsp.protocol
local ms = protocol.Methods
--- @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).
--- @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
---@param input string unparsed snippet
---@return string parsed snippet
local function parse_snippet(input)
local ok, parsed = pcall(function()
return vim.lsp._snippet_grammar.parse(input)
end)
return ok and tostring(parsed) or input
end
--- Returns text that should be inserted when 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
---@return string
local function get_completion_word(item)
if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then
if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
return item.textEdit.newText
else
return parse_snippet(item.textEdit.newText)
end
elseif item.insertText ~= nil and item.insertText ~= '' then
if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
return item.insertText
else
return parse_snippet(item.insertText)
end
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
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
for _, item in ipairs(result.items) do
---@diagnostic disable-next-line: param-type-mismatch
apply_defaults(item, result.itemDefaults)
end
return result.items
else
return result
end
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
---@return table[]
---@see complete-items
function M._lsp_to_complete_items(result, prefix)
local items = get_items(result)
if vim.tbl_isempty(items) then
return {}
end
local function matches_prefix(item)
return vim.startswith(get_completion_word(item), prefix)
end
items = vim.tbl_filter(matches_prefix, items) --[[@as lsp.CompletionItem[]|]]
table.sort(items, function(a, b)
return (a.sortText or a.label) < (b.sortText or b.label)
end)
local matches = {}
for _, item in ipairs(items) do
local info = ''
local documentation = item.documentation
if documentation then
if type(documentation) == 'string' and documentation ~= '' then
info = documentation
elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
info = documentation.value
else
vim.notify(
('invalid documentation value %s'):format(vim.inspect(documentation)),
vim.log.levels.WARN
)
end
end
local word = get_completion_word(item)
table.insert(matches, {
word = word,
abbr = item.label,
kind = protocol.CompletionItemKind[item.kind] or 'Unknown',
menu = item.detail or '',
info = #info > 0 and info or nil,
icase = 1,
dup = 1,
empty = 1,
user_data = {
nvim = {
lsp = {
completion_item = item,
},
},
},
})
end
return matches
end
---@param lnum integer 0-indexed
---@param items lsp.CompletionItem[]
local function adjust_start_col(lnum, line, items, encoding)
local min_start_char = nil
for _, item in pairs(items) do
if item.textEdit and item.textEdit.range.start.line == lnum then
if min_start_char and min_start_char ~= item.textEdit.range.start.character then
return nil
end
min_start_char = item.textEdit.range.start.character
end
end
if min_start_char then
return vim.lsp.util._str_byteindex_enc(line, min_start_char, encoding)
else
return nil
end
end
---@private
---@param line string line content
---@param lnum integer 0-indexed line number
---@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 string
---@return table[] matches
---@return integer? server_start_boundary
function M._convert_results(
line,
lnum,
cursor_col,
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)
return matches, server_start_boundary
end
---@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)
assert(base) -- silence luals
local bufnr = api.nvim_get_current_buf()
local clients = lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_completion })
local remaining = #clients
if remaining == 0 then
return findstart == 1 and -1 or {}
end
local win = api.nvim_get_current_win()
local cursor = api.nvim_win_get_cursor(win)
local lnum = cursor[1] - 1
local cursor_col = cursor[2]
local line = api.nvim_get_current_line()
local line_to_cursor = line:sub(1, cursor_col)
local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') --[[@as integer]]
local server_start_boundary = nil
local items = {}
local function on_done()
local mode = api.nvim_get_mode()['mode']
if mode == 'i' or mode == 'ic' then
vim.fn.complete((server_start_boundary or client_start_boundary) + 1, items)
end
end
local util = vim.lsp.util
for _, client in ipairs(clients) do
local params = util.make_position_params(win, client.offset_encoding)
client.request(ms.textDocument_completion, params, function(err, result)
if err then
vim.lsp.log.warn(err.message)
end
if result and vim.fn.mode() == 'i' then
local matches
matches, server_start_boundary = M._convert_results(
line,
lnum,
cursor_col,
client_start_boundary,
server_start_boundary,
result,
client.offset_encoding
)
vim.list_extend(items, matches)
end
remaining = remaining - 1
if remaining == 0 then
vim.schedule(on_done)
end
end, bufnr)
end
-- Return -2 to signal that we should continue completion so that we can
-- async complete.
return -2
end
return M

View File

@ -869,7 +869,8 @@ end
--- @param command lsp.Command --- @param command lsp.Command
--- @param context? {bufnr: integer} --- @param context? {bufnr: integer}
--- @param handler? lsp.Handler only called if a server command --- @param handler? lsp.Handler only called if a server command
function Client:_exec_cmd(command, context, handler) --- @param on_unsupported? function handler invoked when the command is not supported by the client.
function Client:_exec_cmd(command, context, handler, on_unsupported)
context = vim.deepcopy(context or {}, true) --[[@as lsp.HandlerContext]] context = vim.deepcopy(context or {}, true) --[[@as lsp.HandlerContext]]
context.bufnr = context.bufnr or api.nvim_get_current_buf() context.bufnr = context.bufnr or api.nvim_get_current_buf()
context.client_id = self.id context.client_id = self.id
@ -883,14 +884,18 @@ function Client:_exec_cmd(command, context, handler)
local command_provider = self.server_capabilities.executeCommandProvider local command_provider = self.server_capabilities.executeCommandProvider
local commands = type(command_provider) == 'table' and command_provider.commands or {} local commands = type(command_provider) == 'table' and command_provider.commands or {}
if not vim.list_contains(commands, cmdname) then if not vim.list_contains(commands, cmdname) then
vim.notify_once( if on_unsupported then
string.format( on_unsupported()
'Language server `%s` does not support command `%s`. This command may require a client extension.', else
self.name, vim.notify_once(
cmdname string.format(
), 'Language server `%s` does not support command `%s`. This command may require a client extension.',
vim.log.levels.WARN self.name,
) cmdname
),
vim.log.levels.WARN
)
end
return return
end end
-- Not using command directly to exclude extra properties, -- Not using command directly to exclude extra properties,

View File

@ -0,0 +1,734 @@
local M = {}
local api = vim.api
local lsp = vim.lsp
local protocol = lsp.protocol
local ms = protocol.Methods
local rtt_ms = 50
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[]>
--- @type table<integer, vim.lsp.completion.BufHandle>
local buf_handles = {}
--- @nodoc
--- @class vim.lsp.completion.Context
local Context = {
cursor = nil, --- @type { [1]: integer, [2]: integer }?
last_request_time = nil, --- @type integer?
pending_requests = {}, --- @type function[]
isIncomplete = false,
}
--- @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()
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: number): number
local function exp_avg(window, warmup)
local count = 0
local sum = 0
local value = 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)
--- @return number
local function next_debounce()
if not Context.last_request_time then
return rtt_ms
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)
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
--- @param suffix? string
local function apply_snippet(item, suffix)
if item.textEdit then
vim.snippet.expand(item.textEdit.newText .. suffix)
elseif item.insertText then
vim.snippet.expand(item.insertText .. suffix)
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
--- @return string
local function get_completion_word(item)
if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then
if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
return item.textEdit.newText
else
return parse_snippet(item.textEdit.newText)
end
elseif item.insertText ~= nil and item.insertText ~= '' then
if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
return item.insertText
else
return parse_snippet(item.insertText)
end
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
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
--- Turns the result of a `textDocument/completion` request into vim-compatible
--- |complete-items|.
---
--- @private
--- @param result vim.lsp.CompletionResult Result of `textDocument/completion`
--- @param prefix string prefix to filter the completion items
--- @param client_id integer? Client ID
--- @return table[]
--- @see complete-items
function M._lsp_to_complete_items(result, prefix, client_id)
local items = get_items(result)
if vim.tbl_isempty(items) then
return {}
end
local function matches_prefix(item)
return vim.startswith(get_completion_word(item), prefix)
end
items = vim.tbl_filter(matches_prefix, items) --[[@as lsp.CompletionItem[]|]]
table.sort(items, function(a, b)
return (a.sortText or a.label) < (b.sortText or b.label)
end)
local matches = {}
for _, item in ipairs(items) do
local info = ''
local documentation = item.documentation
if documentation then
if type(documentation) == 'string' and documentation ~= '' then
info = documentation
elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
info = documentation.value
else
vim.notify(
('invalid documentation value %s'):format(vim.inspect(documentation)),
vim.log.levels.WARN
)
end
end
local word = get_completion_word(item)
table.insert(matches, {
word = word,
abbr = item.label,
kind = protocol.CompletionItemKind[item.kind] or 'Unknown',
menu = item.detail or '',
info = #info > 0 and info or '',
icase = 1,
dup = 1,
empty = 1,
user_data = {
nvim = {
lsp = {
completion_item = item,
client_id = client_id,
},
},
},
})
end
return matches
end
--- @param lnum integer 0-indexed
--- @param line string
--- @param items lsp.CompletionItem[]
--- @param encoding string
--- @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 and item.textEdit.range.start.line == lnum then
if min_start_char and min_start_char ~= item.textEdit.range.start.character then
return nil
end
min_start_char = item.textEdit.range.start.character
end
end
if min_start_char then
return lsp.util._str_byteindex_enc(line, min_start_char, encoding)
else
return nil
end
end
--- @private
--- @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 string
--- @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)
return matches, server_start_boundary
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)
vim.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 = ms.textDocument_completion })
local remaining = #clients
if remaining == 0 then
return findstart == 1 and -1 or {}
end
local win = api.nvim_get_current_win()
local cursor = api.nvim_win_get_cursor(win)
local lnum = cursor[1] - 1
local cursor_col = cursor[2]
local line = api.nvim_get_current_line()
local line_to_cursor = line:sub(1, cursor_col)
local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') --[[@as integer]]
local server_start_boundary = nil
local items = {}
local function on_done()
local mode = api.nvim_get_mode()['mode']
if mode == 'i' or mode == 'ic' then
vim.fn.complete((server_start_boundary or client_start_boundary) + 1, items)
end
end
local util = vim.lsp.util
for _, client in ipairs(clients) do
local params = util.make_position_params(win, client.offset_encoding)
client.request(ms.textDocument_completion, params, function(err, result)
if err then
lsp.log.warn(err.message)
end
if result and vim.fn.mode() == 'i' then
local matches
matches, server_start_boundary = M._convert_results(
line,
lnum,
cursor_col,
client.id,
client_start_boundary,
server_start_boundary,
result,
client.offset_encoding
)
vim.list_extend(items, matches)
end
remaining = remaining - 1
if remaining == 0 then
vim.schedule(on_done)
end
end, bufnr)
end
-- Return -2 to signal that we should continue completion so that we can
-- async complete.
return -2
end
--- @param clients table<integer, vim.lsp.Client>
--- @param bufnr integer
--- @param win integer
--- @param callback fun(responses: table<integer, { err: lsp.ResponseError, result: vim.lsp.CompletionResult }>)
--- @return function # Cancellation function
local function request(clients, bufnr, win, 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_id, client in pairs(clients) do
local params = lsp.util.make_position_params(win, client.offset_encoding)
local ok, request_id = client.request(ms.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 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 = next_debounce()
if debounce_ms == 0 then
vim.schedule(M.trigger)
else
completion_timer = new_timer()
completion_timer:start(debounce_ms, 0, vim.schedule_wrap(M.trigger))
end
end
return
end
local char = api.nvim_get_vvar('char')
if not completion_timer and handle.triggers[char] then
completion_timer = assert(vim.uv.new_timer())
completion_timer:start(25, 0, function()
reset_timer()
vim.schedule(M.trigger)
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 offset_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.
local start_char = cursor_col - #completed_item.word
local line = api.nvim_buf_get_lines(bufnr, cursor_row, cursor_row + 1, true)[1]
api.nvim_buf_set_text(bufnr, cursor_row, start_char, cursor_row, #line, { '' })
return line:sub(cursor_col + 1)
end
--- @param suffix? string
local function apply_snippet_and_command(suffix)
if expand_snippet then
apply_snippet(completion_item, suffix)
end
local command = completion_item.command
if command then
client:_exec_cmd(command, { bufnr = bufnr }, nil, function()
vim.lsp.log.warn(
string.format(
'Language server `%s` does not support command `%s`. This command may require a client extension.',
client.name,
command.command
)
)
end)
end
end
if completion_item.additionalTextEdits and next(completion_item.additionalTextEdits) then
local suffix = clear_word()
lsp.util.apply_text_edits(completion_item.additionalTextEdits, bufnr, offset_encoding)
apply_snippet_and_command(suffix)
elseif resolve_provider and type(completion_item) == 'table' then
local changedtick = vim.b[bufnr].changedtick
--- @param result lsp.CompletionItem
client.request(ms.completionItem_resolve, completion_item, function(err, result)
if changedtick ~= vim.b[bufnr].changedtick then
return
end
local suffix = clear_word()
if err then
vim.notify_once(err.message, vim.log.levels.WARN)
elseif result and result.additionalTextEdits then
lsp.util.apply_text_edits(result.additionalTextEdits, bufnr, offset_encoding)
if result.command then
completion_item.command = result.command
end
end
apply_snippet_and_command(suffix)
end, bufnr)
else
local suffix = clear_word()
apply_snippet_and_command(suffix)
end
end
--- @class vim.lsp.completion.BufferOpts
--- @field autotrigger? boolean Whether to trigger completion automatically. Default: false
--- @param client_id integer
---@param bufnr integer
---@param opts vim.lsp.completion.BufferOpts
local function enable_completions(client_id, bufnr, opts)
if not buf_handles[bufnr] then
buf_handles[bufnr] = { clients = {}, triggers = {} }
-- 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(string.format('vim/lsp/completion-%d', bufnr), { clear = true })
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_handles[bufnr].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_handles[bufnr].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_handles[bufnr].triggers[char]
if not clients_for_trigger then
clients_for_trigger = {}
buf_handles[bufnr].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
--- @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(string.format('vim/lsp/completion-%d', 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
--- Enables or disables completions from the given language client in the given buffer.
---
--- @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 = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr
if enable then
enable_completions(client_id, bufnr, opts or {})
else
disable_completions(client_id, bufnr)
end
end
--- Trigger LSP completion in the current buffer.
function M.trigger()
reset_timer()
Context:cancel_pending()
local win = api.nvim_get_current_win()
local bufnr = api.nvim_get_current_buf()
local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(win)) --- @type integer, integer
local line = api.nvim_get_current_line()
local line_to_cursor = line:sub(1, cursor_col)
local clients = (buf_handles[bufnr] or {}).clients or {}
local word_boundary = vim.fn.match(line_to_cursor, '\\k*$')
local start_time = vim.uv.hrtime()
Context.last_request_time = start_time
local cancel_request = request(clients, bufnr, win, 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 row_changed = api.nvim_win_get_cursor(win)[1] ~= cursor_row
local mode = api.nvim_get_mode().mode
if row_changed or not (mode == 'i' or mode == 'ic') then
return
end
local matches = {}
local server_start_boundary --- @type integer?
for client_id, response in pairs(responses) do
if response.err then
vim.notify_once(response.err.message, vim.log.levels.warn)
end
local result = response.result
if result then
Context.isIncomplete = Context.isIncomplete or result.isIncomplete
local client = lsp.get_client_by_id(client_id)
local encoding = client and client.offset_encoding or 'utf-16'
local client_matches
client_matches, server_start_boundary = M._convert_results(
line,
cursor_row - 1,
cursor_col,
client_id,
word_boundary,
nil,
result,
encoding
)
vim.list_extend(matches, client_matches)
end
end
local start_col = (server_start_boundary or word_boundary) + 1
vim.fn.complete(start_col, matches)
end)
table.insert(Context.pending_requests, cancel_request)
end
return M

View File

@ -3,7 +3,7 @@ local protocol = require('vim.lsp.protocol')
local ms = protocol.Methods local ms = protocol.Methods
local util = require('vim.lsp.util') local util = require('vim.lsp.util')
local api = vim.api local api = vim.api
local completion = require('vim.lsp._completion') local completion = require('vim.lsp.completion')
--- @type table<string,lsp.Handler> --- @type table<string,lsp.Handler>
local M = {} local M = {}

View File

@ -738,14 +738,16 @@ function protocol.make_client_capabilities()
completion = { completion = {
dynamicRegistration = false, dynamicRegistration = false,
completionItem = { completionItem = {
-- Until we can actually expand snippet, move cursor and allow for true snippet experience, snippetSupport = true,
-- this should be disabled out of the box.
-- However, users can turn this back on if they have a snippet plugin.
snippetSupport = false,
commitCharactersSupport = false, commitCharactersSupport = false,
preselectSupport = false, preselectSupport = false,
deprecatedSupport = false, deprecatedSupport = false,
documentationFormat = { constants.MarkupKind.Markdown, constants.MarkupKind.PlainText }, documentationFormat = { constants.MarkupKind.Markdown, constants.MarkupKind.PlainText },
resolveSupport = {
properties = {
'additionalTextEdits',
},
},
}, },
completionItemKind = { completionItemKind = {
valueSet = get_value_set(constants.CompletionItemKind), valueSet = get_value_set(constants.CompletionItemKind),

View File

@ -273,6 +273,7 @@ local config = {
'buf.lua', 'buf.lua',
'diagnostic.lua', 'diagnostic.lua',
'codelens.lua', 'codelens.lua',
'completion.lua',
'inlay_hint.lua', 'inlay_hint.lua',
'tagfunc.lua', 'tagfunc.lua',
'semantic_tokens.lua', 'semantic_tokens.lua',

View File

@ -21,7 +21,7 @@ local function complete(line, candidates, lnum)
local line, cursor_col, lnum, result = ... local line, cursor_col, lnum, result = ...
local line_to_cursor = line:sub(1, cursor_col) local line_to_cursor = line:sub(1, cursor_col)
local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$')
local items, server_start_boundary = require("vim.lsp._completion")._convert_results( local items, server_start_boundary = require("vim.lsp.completion")._convert_results(
line, line,
lnum, lnum,
cursor_col, cursor_col,
@ -42,7 +42,7 @@ local function complete(line, candidates, lnum)
) )
end end
describe('vim.lsp._completion', function() describe('vim.lsp.completion', function()
before_each(n.clear) before_each(n.clear)
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
@ -159,6 +159,7 @@ describe('vim.lsp._completion', function()
end, result.items) end, result.items)
eq(expected, result) eq(expected, result)
end) end)
it('uses correct start boundary', function() it('uses correct start boundary', function()
local completion_list = { local completion_list = {
isIncomplete = false, isIncomplete = false,