diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 8a0b144e83..3151a17417 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -1719,7 +1719,7 @@ extract_completion_items({result}) • {result} (table) The result of a `textDocument/completion` request Return: ~ - (table) List of completion items + lsp.CompletionItem[] List of completion items See also: ~ • https://microsoft.github.io/language-server-protocol/specification#textDocument_completion @@ -2014,7 +2014,7 @@ text_document_completion_list_to_complete_items({result}, {prefix}) • {prefix} (string) the prefix to filter the completion items Return: ~ - (table) { matches = complete-items table, incomplete = bool } + table[] items See also: ~ • complete-items diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 4b1da94d77..82a88772bd 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -2273,24 +2273,6 @@ function lsp.buf_notify(bufnr, method, params) return resp end ----@private -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 - 1 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 util._str_byteindex_enc(line, min_start_char, encoding) - else - return nil - end -end - --- Implements 'omnifunc' compatible LSP completion. --- ---@see |complete-functions| @@ -2307,82 +2289,7 @@ function lsp.omnifunc(findstart, base) if log.debug() then log.debug('omnifunc.findstart', { findstart = findstart, base = base }) end - - local bufnr = resolve_bufnr() - 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 - - -- Then, perform standard completion request - if log.info() then - log.info('base ', base) - end - - local win = api.nvim_get_current_win() - local pos = api.nvim_win_get_cursor(win) - local line = api.nvim_get_current_line() - local line_to_cursor = line:sub(1, pos[2]) - local _ = log.trace() and log.trace('omnifunc.line', pos, line) - - -- Get the start position of the current keyword - local match_pos = vim.fn.match(line_to_cursor, '\\k*$') + 1 - local items = {} - - local startbyte - - local function on_done() - local mode = api.nvim_get_mode()['mode'] - if mode == 'i' or mode == 'ic' then - vim.fn.complete(startbyte or match_pos, items) - end - end - - 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 - log.warn(err.message) - end - if result and vim.fn.mode() == 'i' then - -- Completion response items may be relative to a position different than `textMatch`. - -- Concrete example, with sumneko/lua-language-server: - -- - -- require('plenary.asy| - -- ▲ ▲ ▲ - -- │ │ └── cursor_pos: 20 - -- │ └────── textMatch: 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 encoding = client.offset_encoding - local candidates = util.extract_completion_items(result) - local curstartbyte = adjust_start_col(pos[1], line, candidates, encoding) - if startbyte == nil then - startbyte = curstartbyte - elseif curstartbyte ~= nil and curstartbyte ~= startbyte then - startbyte = match_pos - end - local prefix = startbyte and line:sub(startbyte + 1) or line_to_cursor:sub(match_pos) - local matches = util.text_document_completion_list_to_complete_items(result, prefix) - 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 + return require('vim.lsp._completion').omnifunc(findstart, base) end --- Provides an interface between the built-in client and a `formatexpr` function. diff --git a/runtime/lua/vim/lsp/_completion.lua b/runtime/lua/vim/lsp/_completion.lua new file mode 100644 index 0000000000..efd1aaacf7 --- /dev/null +++ b/runtime/lua/vim/lsp/_completion.lua @@ -0,0 +1,210 @@ +local M = {} +local api = vim.api +local lsp = vim.lsp +local protocol = lsp.protocol +local ms = protocol.Methods + +---@param input string unparsed snippet +---@return string parsed snippet +local function parse_snippet(input) + local ok, parsed = pcall(function() + return require('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 + +---@param result lsp.CompletionList|lsp.CompletionItem[] +---@return lsp.CompletionItem[] +local function get_items(result) + if result.items then + return result.items + end + return result +end + +--- Turns the result of a `textDocument/completion` request into vim-compatible +--- |complete-items|. +--- +---@param result lsp.CompletionList|lsp.CompletionItem[] 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 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 - 1 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 + +---@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) + 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 log = require('vim.lsp.log') + -- Then, perform standard completion request + if log.info() then + log.info('base ', base) + end + + local win = api.nvim_get_current_win() + local pos = api.nvim_win_get_cursor(win) + local line = api.nvim_get_current_line() + local line_to_cursor = line:sub(1, pos[2]) + log.trace('omnifunc.line', pos, line) + + local word_boundary = vim.fn.match(line_to_cursor, '\\k*$') + 1 --[[@as integer]] + local items = {} + local startbyte = nil + + local function on_done() + local mode = api.nvim_get_mode()['mode'] + if mode == 'i' or mode == 'ic' then + vim.fn.complete(startbyte or word_boundary, 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 + log.warn(err.message) + end + if result and vim.fn.mode() == 'i' then + -- Completion response items may be relative to a position different than `textMatch`. + -- Concrete example, with sumneko/lua-language-server: + -- + -- require('plenary.asy| + -- ▲ ▲ ▲ + -- │ │ └── cursor_pos: 20 + -- │ └────── textMatch: 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 encoding = client.offset_encoding + local candidates = get_items(result) + local curstartbyte = adjust_start_col(pos[1], line, candidates, encoding) + if startbyte == nil then + startbyte = curstartbyte + elseif curstartbyte ~= nil and curstartbyte ~= startbyte then + startbyte = word_boundary + end + local prefix = startbyte and line:sub(startbyte + 1) or line_to_cursor:sub(word_boundary) + local matches = M._lsp_to_complete_items(result, prefix) + 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 diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 3a1b16c450..7a48c800c6 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -242,6 +242,7 @@ local constants = { -- Defines whether the insert text in a completion item should be interpreted as -- plain text or a snippet. + --- @enum lsp.InsertTextFormat InsertTextFormat = { -- The primary text to be inserted is treated as a plain string. PlainText = 1, diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 42c1508cbf..7ccb8a38b1 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -548,7 +548,7 @@ end --- `textDocument/completion` request, which may return one of --- `CompletionItem[]`, `CompletionList` or null. ---@param result table The result of a `textDocument/completion` request ----@return table List of completion items +---@return lsp.CompletionItem[] List of completion items ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion function M.extract_completion_items(result) if type(result) == 'table' and result.items then @@ -619,47 +619,6 @@ function M.parse_snippet(input) return tostring(parsed) end ---- Sorts by CompletionItem.sortText. ---- ---see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion -local function sort_completion_items(items) - table.sort(items, function(a, b) - return (a.sortText or a.label) < (b.sortText or b.label) - end) -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 -local function get_completion_word(item) - if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then - local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] - if insert_text_format == 'PlainText' or insert_text_format == nil then - return item.textEdit.newText - else - return M.parse_snippet(item.textEdit.newText) - end - elseif item.insertText ~= nil and item.insertText ~= '' then - local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] - if insert_text_format == 'PlainText' or insert_text_format == nil then - return item.insertText - else - return M.parse_snippet(item.insertText) - end - end - return item.label -end - ---- Some language servers return complementary candidates whose prefixes do not ---- match are also returned. So we exclude completion candidates whose prefix ---- does not match. -local function remove_unmatch_completion_items(items, prefix) - return vim.tbl_filter(function(item) - local word = get_completion_word(item) - return vim.startswith(word, prefix) - end, items) -end - --- According to LSP spec, if the client set `completionItemKind.valueSet`, --- the client must handle it properly even if it receives a value outside the --- specification. @@ -678,56 +637,10 @@ end --- from |vim.lsp.buf.completion()|, which may be one of `CompletionItem[]`, --- `CompletionList` or `null` ---@param prefix (string) the prefix to filter the completion items ----@return table { matches = complete-items table, incomplete = bool } +---@return table[] items ---@see complete-items function M.text_document_completion_list_to_complete_items(result, prefix) - local items = M.extract_completion_items(result) - if vim.tbl_isempty(items) then - return {} - end - - items = remove_unmatch_completion_items(items, prefix) - sort_completion_items(items) - - local matches = {} - - for _, completion_item in ipairs(items) do - local info = '' - local documentation = completion_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(completion_item) - table.insert(matches, { - word = word, - abbr = completion_item.label, - kind = M._get_completion_item_kind_name(completion_item.kind), - menu = completion_item.detail or '', - info = #info > 0 and info or nil, - icase = 1, - dup = 1, - empty = 1, - user_data = { - nvim = { - lsp = { - completion_item = completion_item, - }, - }, - }, - }) - end - - return matches + return require('vim.lsp._completion')._lsp_to_complete_items(result, prefix) end --- Like vim.fn.bufwinid except it works across tabpages.