Files
neovim/test/functional/plugin/lsp/completion_spec.lua
Jeff Martin ff792f8e69 fix(lsp): enable insertReplaceSupport for use in adjust_start_col #36569
Problem:
With the typescript LSes typescript-language-server and vtsls,
omnicompletion on partial tokens for certain types, such as array
methods, and functions that are attached as attributes to other
functions, either results in no entries populated in the completion menu
(typescript-language-server), or an unfiltered completion menu with all
array methods included, even if they don't share the same prefix as the
partial token being completed (vtsls).

Solution:
Enable insertReplaceSupport and uses the insert portion of the lsp
completion response in adjust_start_col if it's included in the
response.

Completion results are still filtered client side.
2025-11-18 23:03:40 -08:00

1444 lines
36 KiB
Lua

---@diagnostic disable: no-unknown
local t = require('test.testutil')
local t_lsp = require('test.functional.plugin.lsp.testutil')
local n = require('test.functional.testnvim')()
local clear = n.clear
local eq = t.eq
local neq = t.neq
local exec_lua = n.exec_lua
local feed = n.feed
local retry = t.retry
local create_server_definition = t_lsp.create_server_definition
--- Convert completion results.
---
---@param line string line contents. Mark cursor position with `|`
---@param candidates lsp.CompletionList|lsp.CompletionItem[]
---@param lnum? integer 0-based, defaults to 0
---@return {items: table[], server_start_boundary: integer?}
local function complete(line, candidates, lnum, server_boundary)
lnum = lnum or 0
-- nvim_win_get_cursor returns 0 based column, line:find returns 1 based
local cursor_col = line:find('|') - 1
line = line:gsub('|', '')
return exec_lua(function(result)
local line_to_cursor = line:sub(1, cursor_col)
local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$')
local items, new_server_boundary = require('vim.lsp.completion')._convert_results(
line,
lnum,
cursor_col,
1,
client_start_boundary,
server_boundary,
result,
'utf-16'
)
return {
items = items,
server_start_boundary = new_server_boundary,
}
end, candidates)
end
describe('vim.lsp.completion: item conversion', function()
before_each(n.clear)
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
it('prefers textEdit over label as word', function()
local range0 = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 0 },
}
local completion_list = {
-- resolves into label
{ label = 'foobar', sortText = 'a', documentation = 'documentation' },
{
label = 'foobar',
sortText = 'b',
documentation = { value = 'documentation' },
},
-- resolves into insertText
{ label = 'foocar', sortText = 'c', insertText = 'foobar' },
{ label = 'foocar', sortText = 'd', insertText = 'foobar' },
-- resolves into textEdit.newText
{
label = 'foocar',
sortText = 'e',
insertText = 'foodar',
textEdit = { newText = 'foobar', range = range0 },
},
{ label = 'foocar', sortText = 'f', textEdit = { newText = 'foobar', range = range0 } },
-- plain text
{
label = 'foocar',
sortText = 'g',
insertText = 'foodar(${1:var1})',
insertTextFormat = 1,
},
{
label = '•INT16_C(c)',
insertText = 'INT16_C(${1:c})',
insertTextFormat = 2,
filterText = 'INT16_C',
sortText = 'h',
textEdit = {
newText = 'INT16_C(${1:c})',
range = range0,
},
},
}
local expected = {
{
abbr = 'foobar',
word = 'foobar',
},
{
abbr = 'foobar',
word = 'foobar',
},
{
abbr = 'foocar',
word = 'foobar',
},
{
abbr = 'foocar',
word = 'foobar',
},
{
abbr = 'foocar',
word = 'foobar',
},
{
abbr = 'foocar',
word = 'foobar',
},
{
abbr = 'foocar',
word = 'foodar(${1:var1})', -- marked as PlainText, text is used as is
},
{
abbr = '•INT16_C(c)',
word = 'INT16_C',
},
}
local result = complete('|', completion_list)
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
eq(expected, result)
end)
it('does not filter if there is a textEdit', function()
local range0 = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 0 },
}
local completion_list = {
{ label = 'foo', textEdit = { newText = 'foo', range = range0 } },
{ label = 'bar', textEdit = { newText = 'bar', range = range0 } },
}
local result = complete('fo|', completion_list)
local expected = {
{
abbr = 'foo',
word = 'foo',
},
}
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
local sorter = function(a, b)
return a.word > b.word
end
table.sort(expected, sorter)
table.sort(result, sorter)
eq(expected, result)
end)
---@param prefix string
---@param items lsp.CompletionItem[]
---@param expected table[]
local assert_completion_matches = function(prefix, items, expected)
local result = complete(prefix .. '|', items)
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
local sorter = function(a, b)
return a.word > b.word
end
table.sort(expected, sorter)
table.sort(result, sorter)
eq(expected, result)
end
describe('when completeopt has fuzzy matching enabled', function()
before_each(function()
exec_lua(function()
vim.opt.completeopt:append('fuzzy')
end)
end)
after_each(function()
exec_lua(function()
vim.opt.completeopt:remove('fuzzy')
end)
end)
it('fuzzy matches on filterText', function()
assert_completion_matches('fo', {
{ label = '?.foo', filterText = 'foo' },
{ label = 'faz other', filterText = 'faz other' },
{ label = 'bar', filterText = 'bar' },
}, {
{
abbr = 'faz other',
word = 'faz other',
},
{
abbr = '?.foo',
word = '?.foo',
},
})
end)
it('uses filterText as word if label/newText would not match', function()
local items = {
{
filterText = '<module',
insertTextFormat = 2,
kind = 10,
label = 'module',
sortText = 'module',
textEdit = {
newText = '<module>$1</module>$0',
range = {
start = {
character = 0,
line = 0,
},
['end'] = {
character = 0,
line = 0,
},
},
},
},
}
assert_completion_matches('<mo', items, {
{
abbr = 'module',
word = '<module',
},
})
assert_completion_matches('', items, {
{
abbr = 'module',
word = 'module',
},
})
end)
it('fuzzy matches on label when filterText is missing', function()
assert_completion_matches('fo', {
{ label = 'foo' },
{ label = 'faz other' },
{ label = 'bar' },
}, {
{
abbr = 'faz other',
word = 'faz other',
},
{
abbr = 'foo',
word = 'foo',
},
})
end)
end)
describe('when smartcase is enabled', function()
before_each(function()
exec_lua(function()
vim.opt.smartcase = true
end)
end)
after_each(function()
exec_lua(function()
vim.opt.smartcase = false
end)
end)
it('matches filterText case sensitively', function()
assert_completion_matches('Fo', {
{ label = 'foo', filterText = 'foo' },
{ label = '?.Foo', filterText = 'Foo' },
{ label = 'Faz other', filterText = 'Faz other' },
{ label = 'faz other', filterText = 'faz other' },
{ label = 'bar', filterText = 'bar' },
}, {
{
abbr = '?.Foo',
word = '?.Foo',
},
})
end)
it('matches label case sensitively when filterText is missing', function()
assert_completion_matches('Fo', {
{ label = 'foo' },
{ label = 'Foo' },
{ label = 'Faz other' },
{ label = 'faz other' },
{ label = 'bar' },
}, {
{
abbr = 'Foo',
word = 'Foo',
},
})
end)
describe('when ignorecase is enabled', function()
before_each(function()
exec_lua(function()
vim.opt.ignorecase = true
end)
end)
after_each(function()
exec_lua(function()
vim.opt.ignorecase = false
end)
end)
it('matches filterText case insensitively if prefix is lowercase', function()
assert_completion_matches('fo', {
{ label = '?.foo', filterText = 'foo' },
{ label = '?.Foo', filterText = 'Foo' },
{ label = 'Faz other', filterText = 'Faz other' },
{ label = 'faz other', filterText = 'faz other' },
{ label = 'bar', filterText = 'bar' },
}, {
{
abbr = '?.Foo',
word = '?.Foo',
},
{
abbr = '?.foo',
word = '?.foo',
},
})
end)
it(
'matches label case insensitively if prefix is lowercase and filterText is missing',
function()
assert_completion_matches('fo', {
{ label = 'foo' },
{ label = 'Foo' },
{ label = 'Faz other' },
{ label = 'faz other' },
{ label = 'bar' },
}, {
{
abbr = 'Foo',
word = 'Foo',
},
{
abbr = 'foo',
word = 'foo',
},
})
end
)
it('matches filterText case sensitively if prefix has uppercase letters', function()
assert_completion_matches('Fo', {
{ label = 'foo', filterText = 'foo' },
{ label = '?.Foo', filterText = 'Foo' },
{ label = 'Faz other', filterText = 'Faz other' },
{ label = 'faz other', filterText = 'faz other' },
{ label = 'bar', filterText = 'bar' },
}, {
{
abbr = '?.Foo',
word = '?.Foo',
},
})
end)
it(
'matches label case sensitively if prefix has uppercase letters and filterText is missing',
function()
assert_completion_matches('Fo', {
{ label = 'foo' },
{ label = 'Foo' },
{ label = 'Faz other' },
{ label = 'faz other' },
{ label = 'bar' },
}, {
{
abbr = 'Foo',
word = 'Foo',
},
})
end
)
end)
end)
describe('when ignorecase is enabled', function()
before_each(function()
exec_lua(function()
vim.opt.ignorecase = true
end)
end)
after_each(function()
exec_lua(function()
vim.opt.ignorecase = false
end)
end)
it('matches filterText case insensitively', function()
assert_completion_matches('Fo', {
{ label = '?.foo', filterText = 'foo' },
{ label = '?.Foo', filterText = 'Foo' },
{ label = 'Faz other', filterText = 'Faz other' },
{ label = 'faz other', filterText = 'faz other' },
{ label = 'bar', filterText = 'bar' },
}, {
{
abbr = '?.Foo',
word = '?.Foo',
},
{
abbr = '?.foo',
word = '?.foo',
},
})
end)
it('matches label case insensitively when filterText is missing', function()
assert_completion_matches('Fo', {
{ label = 'foo' },
{ label = 'Foo' },
{ label = 'Faz other' },
{ label = 'faz other' },
{ label = 'bar' },
}, {
{
abbr = 'Foo',
word = 'Foo',
},
{
abbr = 'foo',
word = 'foo',
},
})
end)
end)
it('works on non word prefix', function()
local completion_list = {
{ label = ' foo', insertText = '->foo' },
}
local result = complete('wp.|', completion_list, 0, 2)
local expected = {
{
abbr = ' foo',
word = '->foo',
},
}
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
eq(expected, result)
end)
it('trims trailing newline or tab from textEdit', function()
local range0 = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 0 },
}
local items = {
{
detail = 'ansible.builtin',
filterText = 'lineinfile ansible.builtin.lineinfile builtin ansible',
kind = 7,
label = 'ansible.builtin.lineinfile',
sortText = '2_ansible.builtin.lineinfile',
textEdit = {
newText = 'ansible.builtin.lineinfile:\n ',
range = range0,
},
},
}
local result = complete('|', items)
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
local expected = {
{
abbr = 'ansible.builtin.lineinfile',
word = 'ansible.builtin.lineinfile:',
},
}
eq(expected, result)
end)
it('prefers wordlike components for snippets', function()
-- There are two goals here:
--
-- 1. The `word` should match what the user started typing, so that vim.fn.complete() doesn't
-- filter it away, preventing snippet expansion
--
-- For example, if they type `items@ins`, luals returns `table.insert(items, $0)` as
-- textEdit.newText and `insert` as label.
-- There would be no prefix match if textEdit.newText is used as `word`
--
-- 2. If users do not expand a snippet, but continue typing, they should see a somewhat reasonable
-- `word` getting inserted.
--
-- For example in:
--
-- insertText: "testSuites ${1:Env}"
-- label: "testSuites"
--
-- "testSuites" should have priority as `word`, as long as the full snippet gets expanded on accept (<c-y>)
local range0 = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 0 },
}
local completion_list = {
-- luals postfix snippet (typed text: items@ins|)
{
label = 'insert',
insertTextFormat = 2,
textEdit = {
newText = 'table.insert(items, $0)',
range = range0,
},
},
-- eclipse.jdt.ls `new` snippet
{
label = 'new',
insertTextFormat = 2,
textEdit = {
newText = '${1:Object} ${2:foo} = new ${1}(${3});\n${0}',
range = range0,
},
textEditText = '${1:Object} ${2:foo} = new ${1}(${3});\n${0}',
},
-- eclipse.jdt.ls `List.copyO` function call completion
{
label = 'copyOf(Collection<? extends E> coll) : List<E>',
insertTextFormat = 2,
insertText = 'copyOf',
textEdit = {
newText = 'copyOf(${1:coll})',
range = range0,
},
},
-- luals for snippet
{
insertText = 'for ${1:index}, ${2:value} in ipairs(${3:t}) do\n\t$0\nend',
insertTextFormat = 2,
kind = 15,
label = 'for .. ipairs',
},
}
local expected = {
{
abbr = 'copyOf(Collection<? extends E> coll) : List<E>',
word = 'copyOf',
},
{
abbr = 'for .. ipairs',
word = 'for .. ipairs',
},
{
abbr = 'insert',
word = 'insert',
},
{
abbr = 'new',
word = 'new',
},
}
local result = complete('|', completion_list)
result = vim.tbl_map(function(x)
return {
abbr = x.abbr,
word = x.word,
}
end, result.items)
eq(expected, result)
end)
it('uses correct start boundary', function()
local completion_list = {
isIncomplete = false,
items = {
{
filterText = 'this_thread',
insertText = 'this_thread',
insertTextFormat = 1,
kind = 9,
label = ' this_thread',
score = 1.3205767869949,
sortText = '4056f757this_thread',
textEdit = {
newText = 'this_thread',
range = {
start = { line = 0, character = 7 },
['end'] = { line = 0, character = 11 },
},
},
},
},
}
local expected = {
{
abbr = ' this_thread',
dup = 1,
empty = 1,
icase = 1,
info = '',
kind = 'Module',
menu = '',
abbr_hlgroup = '',
word = 'this_thread',
},
}
local result = complete(' std::this|', completion_list)
eq(7, result.server_start_boundary)
for _, item in ipairs(result.items) do
item.user_data = nil
end
eq(expected, result.items)
end)
it('should search from start boundary to cursor position', function()
local completion_list = {
isIncomplete = false,
items = {
{
filterText = 'this_thread',
insertText = 'this_thread',
insertTextFormat = 1,
kind = 9,
label = ' this_thread',
score = 1.3205767869949,
sortText = '4056f757this_thread',
textEdit = {
newText = 'this_thread',
range = {
start = { line = 0, character = 7 },
['end'] = { line = 0, character = 11 },
},
},
},
{
filterText = 'no_match',
insertText = 'notthis_thread',
insertTextFormat = 1,
kind = 9,
label = ' notthis_thread',
score = 1.3205767869949,
sortText = '4056f757this_thread',
textEdit = {
newText = 'notthis_thread',
range = {
start = { line = 0, character = 7 },
['end'] = { line = 0, character = 11 },
},
},
},
},
}
local expected = {
abbr = ' this_thread',
dup = 1,
empty = 1,
icase = 1,
info = '',
kind = 'Module',
menu = '',
abbr_hlgroup = '',
word = 'this_thread',
}
local result = complete(' std::this|is', completion_list)
eq(1, #result.items)
local item = result.items[1]
item.user_data = nil
eq(expected, item)
end)
it('uses defaults from itemDefaults', function()
--- @type lsp.CompletionList
local completion_list = {
isIncomplete = false,
itemDefaults = {
editRange = {
start = { line = 1, character = 1 },
['end'] = { line = 1, character = 4 },
},
insertTextFormat = 2,
data = 'foobar',
},
items = {
{
label = 'hello',
data = 'item-property-has-priority',
textEditText = 'hello',
},
},
}
local result = complete('|', completion_list)
eq(1, #result.items)
local item = result.items[1].user_data.nvim.lsp.completion_item --- @type lsp.CompletionItem
eq(2, item.insertTextFormat)
eq('item-property-has-priority', item.data)
eq({ line = 1, character = 1 }, item.textEdit.range.start)
end)
it(
'uses insertText as textEdit.newText if there are editRange defaults but no textEditText',
function()
--- @type lsp.CompletionList
local completion_list = {
isIncomplete = false,
itemDefaults = {
editRange = {
start = { line = 1, character = 1 },
['end'] = { line = 1, character = 4 },
},
insertTextFormat = 2,
data = 'foobar',
},
items = {
{
insertText = 'the-insertText',
label = 'hello',
data = 'item-property-has-priority',
},
},
}
local result = complete('|', completion_list)
eq(1, #result.items)
local text = result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText
eq('the-insertText', text)
end
)
it(
'defaults to label as textEdit.newText if insertText or textEditText are not present',
function()
local completion_list = {
isIncomplete = false,
itemDefaults = {
editRange = {
start = { line = 1, character = 1 },
['end'] = { line = 1, character = 4 },
},
insertTextFormat = 2,
data = 'foobar',
},
items = {
{
label = 'hello',
data = 'item-property-has-priority',
},
},
}
local result = complete('|', completion_list)
eq(1, #result.items)
local text = result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText
eq('hello', text)
end
)
it('uses the start boundary from an insertReplace response', function()
local completion_list = {
isIncomplete = false,
items = {
{
data = { cacheId = 1 },
kind = 2,
label = 'foobar',
sortText = '11',
textEdit = {
insert = {
start = { character = 4, line = 4 },
['end'] = { character = 8, line = 4 },
},
newText = 'foobar',
replace = {
start = { character = 4, line = 4 },
['end'] = { character = 8, line = 4 },
},
},
},
{
data = { cacheId = 2 },
kind = 2,
label = 'bazqux',
sortText = '11',
textEdit = {
insert = {
start = { character = 4, line = 4 },
['end'] = { character = 5, line = 4 },
},
newText = 'bazqux',
replace = {
start = { character = 4, line = 4 },
['end'] = { character = 5, line = 4 },
},
},
},
},
}
local result = complete('foo.f|', completion_list)
eq(1, #result.items)
local text = result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText
eq('foobar', text)
end)
end)
--- @param name string
--- @param completion_result lsp.CompletionList
--- @param opts? {trigger_chars?: string[], resolve_result?: lsp.CompletionItem, delay?: integer, cmp?: string}
--- @return integer
local function create_server(name, completion_result, opts)
opts = opts or {}
return exec_lua(function()
local server = _G._create_server({
capabilities = {
completionProvider = {
triggerCharacters = opts.trigger_chars or { '.' },
resolveProvider = opts.resolve_result ~= nil,
},
},
handlers = {
['textDocument/completion'] = function(_, _, callback)
if opts.delay then
-- simulate delay in completion request, needed for some of these tests
vim.defer_fn(function()
callback(nil, completion_result)
end, opts.delay)
else
callback(nil, completion_result)
end
end,
['completionItem/resolve'] = function(_, _, callback)
callback(nil, opts.resolve_result)
end,
},
})
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
local cmp_fn
if opts.cmp then
cmp_fn = assert(loadstring(opts.cmp))
end
return vim.lsp.start({
name = name,
cmd = server.cmd,
on_attach = function(client, bufnr0)
vim.lsp.completion.enable(true, client.id, bufnr0, {
autotrigger = opts.trigger_chars ~= nil,
convert = function(item)
return { abbr = item.label:gsub('%b()', '') }
end,
cmp = cmp_fn,
})
end,
})
end)
end
describe('vim.lsp.completion: protocol', function()
before_each(function()
clear()
exec_lua(create_server_definition)
exec_lua(function()
_G.capture = {}
--- @diagnostic disable-next-line:duplicate-set-field
vim.fn.complete = function(col, matches)
_G.capture.col = col
_G.capture.matches = matches
end
end)
end)
local function assert_matches(fn)
retry(nil, nil, function()
fn(exec_lua('return _G.capture.matches'))
end)
end
--- @param pos [integer, integer]
local function trigger_at_pos(pos)
exec_lua(function()
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_cursor(win, pos)
vim.lsp.completion.get()
end)
retry(nil, nil, function()
neq(nil, exec_lua('return _G.capture.col'))
end)
end
it('fetches completions and shows them using complete on trigger', function()
create_server('dummy', {
isIncomplete = false,
items = {
{
label = 'hello',
},
{
label = 'hercules',
tags = { 1 }, -- 1 represents Deprecated tag
},
{
label = 'hero',
deprecated = true,
},
},
})
feed('ih')
trigger_at_pos({ 1, 1 })
assert_matches(function(matches)
eq({
{
abbr = 'hello',
dup = 1,
empty = 1,
icase = 1,
info = '',
kind = 'Unknown',
menu = '',
abbr_hlgroup = '',
user_data = {
nvim = {
lsp = {
client_id = 1,
completion_item = {
label = 'hello',
},
},
},
},
word = 'hello',
},
{
abbr = 'hercules',
dup = 1,
empty = 1,
icase = 1,
info = '',
kind = 'Unknown',
menu = '',
abbr_hlgroup = 'DiagnosticDeprecated',
user_data = {
nvim = {
lsp = {
client_id = 1,
completion_item = {
label = 'hercules',
tags = { 1 },
},
},
},
},
word = 'hercules',
},
{
abbr = 'hero',
dup = 1,
empty = 1,
icase = 1,
info = '',
kind = 'Unknown',
menu = '',
abbr_hlgroup = 'DiagnosticDeprecated',
user_data = {
nvim = {
lsp = {
client_id = 1,
completion_item = {
label = 'hero',
deprecated = true,
},
},
},
},
word = 'hero',
},
}, matches)
end)
end)
it('merges results from multiple clients', function()
create_server('dummy1', {
isIncomplete = false,
items = {
{
label = 'hello',
},
},
})
create_server('dummy2', {
isIncomplete = false,
items = {
{
label = 'hallo',
},
},
})
feed('ih')
trigger_at_pos({ 1, 1 })
assert_matches(function(matches)
eq(2, #matches)
eq('hello', matches[1].word)
eq('hallo', matches[2].word)
end)
end)
it('insert char triggers clients matching trigger characters', function()
local results1 = {
isIncomplete = false,
items = {
{
label = 'hello',
},
},
}
create_server('dummy1', results1, { trigger_chars = { 'e' } })
local results2 = {
isIncomplete = false,
items = {
{
label = 'hallo',
},
},
}
create_server('dummy2', results2, { trigger_chars = { 'h' } })
feed('h')
exec_lua(function()
vim.v.char = 'h'
vim.cmd.startinsert()
vim.api.nvim_exec_autocmds('InsertCharPre', {})
end)
assert_matches(function(matches)
eq(1, #matches)
eq('hallo', matches[1].word)
end)
end)
it('treats 2-triggers-at-once as "last char wins"', function()
local results1 = {
isIncomplete = false,
items = {
{
label = 'first',
},
},
}
create_server('dummy1', results1, { trigger_chars = { '-' } })
local results2 = {
isIncomplete = false,
items = {
{
label = 'second',
},
},
}
create_server('dummy2', results2, { trigger_chars = { '>' } })
feed('i->')
assert_matches(function(matches)
eq(1, #matches)
eq('second', matches[1].word)
end)
end)
it('executes commands', function()
local completion_list = {
isIncomplete = false,
items = {
{
label = 'hello',
command = {
arguments = { '1', '0' },
command = 'dummy',
title = '',
},
},
},
}
local client_id = create_server('dummy', completion_list)
exec_lua(function()
_G.called = false
local client = assert(vim.lsp.get_client_by_id(client_id))
client.commands.dummy = function()
_G.called = true
end
end)
feed('ih')
trigger_at_pos({ 1, 1 })
local item = completion_list.items[1]
exec_lua(function()
vim.v.completed_item = {
user_data = {
nvim = {
lsp = {
client_id = client_id,
completion_item = item,
},
},
},
}
end)
feed('<C-x><C-o><C-y>')
assert_matches(function(matches)
eq(1, #matches)
eq('hello', matches[1].word)
eq(true, exec_lua('return _G.called'))
end)
end)
it('resolves and executes commands', function()
local completion_list = {
isIncomplete = false,
items = {
{
label = 'hello',
},
},
}
local client_id = create_server('dummy', completion_list, {
resolve_result = {
label = 'hello',
command = {
arguments = { '1', '0' },
command = 'dummy',
title = '',
},
},
})
exec_lua(function()
_G.called = false
local client = assert(vim.lsp.get_client_by_id(client_id))
client.commands.dummy = function()
_G.called = true
end
end)
feed('ih')
trigger_at_pos({ 1, 1 })
local item = completion_list.items[1]
exec_lua(function()
vim.v.completed_item = {
user_data = {
nvim = {
lsp = {
client_id = client_id,
completion_item = item,
},
},
},
}
end)
feed('<C-x><C-o><C-y>')
assert_matches(function(matches)
eq(1, #matches)
eq('hello', matches[1].word)
eq(true, exec_lua('return _G.called'))
end)
end)
it('enable(…,{convert=fn}) custom word/abbr format', function()
create_server('dummy', {
isIncomplete = false,
items = {
{
label = 'foo(bar)',
},
},
})
feed('ifo')
trigger_at_pos({ 1, 1 })
assert_matches(function(matches)
eq('foo', matches[1].abbr)
end)
end)
it('enable(…,{cmp=fn}) custom sort order', function()
create_server('dummy', {
isIncomplete = false,
items = {
{ label = 'zzz', sortText = 'a' },
{ label = 'aaa', sortText = 'z' },
{ label = 'mmm', sortText = 'm' },
},
}, {
cmp = string.dump(function(a, b)
return a.abbr < b.abbr
end),
})
feed('i')
trigger_at_pos({ 1, 0 })
assert_matches(function(matches)
eq(3, #matches)
eq('aaa', matches[1].abbr)
eq('mmm', matches[2].abbr)
eq('zzz', matches[3].abbr)
end)
end)
it('sends completion context when invoked', function()
local params = exec_lua(function()
local params
local server = _G._create_server({
capabilities = {
completionProvider = true,
},
handlers = {
['textDocument/completion'] = function(_, params0, callback)
params = params0
callback(nil, nil)
end,
},
})
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
vim.lsp.start({
name = 'dummy',
cmd = server.cmd,
on_attach = function(client, bufnr0)
vim.lsp.completion.enable(true, client.id, bufnr0)
end,
})
vim.lsp.completion.get()
return params
end)
eq({ triggerKind = 1 }, params.context)
end)
it('sends completion context with trigger characters', function()
exec_lua(function()
local server = _G._create_server({
capabilities = {
completionProvider = {
triggerCharacters = { 'h' },
},
},
handlers = {
['textDocument/completion'] = function(_, params, callback)
_G.params = params
callback(nil, { isIncomplete = false, items = { label = 'hello' } })
end,
},
})
local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr)
vim.lsp.start({
name = 'dummy',
cmd = server.cmd,
on_attach = function(client, bufnr0)
vim.lsp.completion.enable(true, client.id, bufnr0, { autotrigger = true })
end,
})
end)
feed('ih')
retry(100, nil, function()
eq({ triggerKind = 2, triggerCharacter = 'h' }, exec_lua('return _G.params.context'))
end)
end)
end)
describe('vim.lsp.completion: integration', function()
before_each(function()
clear()
exec_lua(create_server_definition)
exec_lua(function()
vim.fn.complete = vim.schedule_wrap(vim.fn.complete)
end)
end)
it('puts cursor at the end of completed word', function()
local completion_list = {
isIncomplete = false,
items = {
{
label = 'hello',
insertText = '${1:hello} friends',
insertTextFormat = 2,
},
},
}
exec_lua(function()
vim.o.completeopt = 'menuone,noselect'
end)
create_server('dummy', completion_list)
feed('i world<esc>0ih<c-x><c-o>')
retry(nil, nil, function()
eq(
1,
exec_lua(function()
return vim.fn.pumvisible()
end)
)
end)
feed('<C-n><C-y>')
eq(
{ true, { 'hello friends world' } },
exec_lua(function()
return {
vim.snippet.active({ direction = 1 }),
vim.api.nvim_buf_get_lines(0, 0, -1, true),
}
end)
)
exec_lua(function()
vim.snippet.jump(1)
end)
eq(
#'hello friends',
exec_lua(function()
return vim.api.nvim_win_get_cursor(0)[2]
end)
)
end)
it('#clear multiple-lines word', function()
local completion_list = {
isIncomplete = false,
items = {
{
label = 'then...end',
sortText = '0001',
insertText = 'then\n\t$0\nend',
kind = 15,
insertTextFormat = 2,
},
},
}
exec_lua(function()
vim.o.completeopt = 'menuone,noselect'
end)
create_server('dummy', completion_list)
feed('Sif true <C-X><C-O>')
retry(nil, nil, function()
eq(
1,
exec_lua(function()
return vim.fn.pumvisible()
end)
)
end)
feed('<C-n><C-y>')
eq(
{ false, { 'if true then', '\t', 'end' } },
exec_lua(function()
return {
vim.snippet.active({ direction = 1 }),
vim.api.nvim_buf_get_lines(0, 0, -1, true),
}
end)
)
end)
end)
describe("vim.lsp.completion: omnifunc + 'autocomplete'", function()
before_each(function()
clear()
exec_lua(create_server_definition)
exec_lua(function()
-- enable buffer and omnifunc autocompletion
-- omnifunc will be the lsp omnifunc
vim.o.complete = '.,o'
vim.o.autocomplete = true
end)
local completion_list = {
isIncomplete = false,
items = {
{ label = 'hello' },
{ label = 'hallo' },
},
}
create_server('dummy', completion_list, { delay = 50 })
end)
local function assert_matches(expected)
retry(nil, nil, function()
local matches = vim.tbl_map(function(m)
return m.word
end, exec_lua('return vim.fn.complete_info({ "items" })').items)
eq(expected, matches)
end)
end
it('merges with other completions', function()
feed('ihillo<cr><esc>ih')
assert_matches({ 'hillo', 'hallo', 'hello' })
end)
it('fuzzy matches without duplication', function()
-- wait for one completion request to start and then request another before
-- the first one finishes, then wait for both to finish
feed('ihillo<cr>h')
vim.uv.sleep(1)
feed('e')
assert_matches({ 'hello' })
end)
end)