aboutsummaryrefslogtreecommitdiff
path: root/test/functional/plugin/lsp/completion_spec.lua
diff options
context:
space:
mode:
Diffstat (limited to 'test/functional/plugin/lsp/completion_spec.lua')
-rw-r--r--test/functional/plugin/lsp/completion_spec.lua547
1 files changed, 497 insertions, 50 deletions
diff --git a/test/functional/plugin/lsp/completion_spec.lua b/test/functional/plugin/lsp/completion_spec.lua
index 2798d57381..4df8d77d44 100644
--- a/test/functional/plugin/lsp/completion_spec.lua
+++ b/test/functional/plugin/lsp/completion_spec.lua
@@ -1,9 +1,16 @@
---@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.
---
@@ -11,38 +18,32 @@ local exec_lua = n.exec_lua
---@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)
+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(
- [[
- local line, cursor_col, lnum, result = ...
+ 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, server_start_boundary = require("vim.lsp._completion")._convert_results(
+ local items, new_server_boundary = require('vim.lsp.completion')._convert_results(
line,
lnum,
cursor_col,
+ 1,
client_start_boundary,
- nil,
+ server_boundary,
result,
- "utf-16"
+ 'utf-16'
)
return {
items = items,
- server_start_boundary = server_start_boundary
+ server_start_boundary = new_server_boundary,
}
- ]],
- line,
- cursor_col,
- lnum,
- candidates
- )
+ end, candidates)
end
-describe('vim.lsp._completion', function()
+describe('vim.lsp.completion: item conversion', function()
before_each(n.clear)
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
@@ -70,39 +71,24 @@ describe('vim.lsp._completion', function()
textEdit = { newText = 'foobar', range = range0 },
},
{ label = 'foocar', sortText = 'f', textEdit = { newText = 'foobar', range = range0 } },
- -- real-world snippet text
+ -- plain text
{
label = 'foocar',
sortText = 'g',
- insertText = 'foodar',
+ insertText = 'foodar(${1:var1})',
+ insertTextFormat = 1,
+ },
+ {
+ label = '•INT16_C(c)',
+ insertText = 'INT16_C(${1:c})',
insertTextFormat = 2,
+ filterText = 'INT16_C',
+ sortText = 'h',
textEdit = {
- newText = 'foobar(${1:place holder}, ${2:more ...holder{\\}})',
+ newText = 'INT16_C(${1:c})',
range = range0,
},
},
- {
- label = 'foocar',
- sortText = 'h',
- insertText = 'foodar(${1:var1} typ1, ${2:var2} *typ2) {$0\\}',
- insertTextFormat = 2,
- },
- -- nested snippet tokens
- {
- label = 'foocar',
- sortText = 'i',
- insertText = 'foodar(${1:${2|typ1,typ2|}}) {$0\\}',
- insertTextFormat = 2,
- },
- -- braced tabstop
- { label = 'foocar', sortText = 'j', insertText = 'foodar()${0}', insertTextFormat = 2 },
- -- plain text
- {
- label = 'foocar',
- sortText = 'k',
- insertText = 'foodar(${1:var1})',
- insertTextFormat = 1,
- },
}
local expected = {
{
@@ -131,23 +117,167 @@ describe('vim.lsp._completion', function()
},
{
abbr = 'foocar',
- word = 'foobar(place holder, more ...holder{})',
+ word = 'foodar(${1:var1})', -- marked as PlainText, text is used as is
},
{
- abbr = 'foocar',
- word = 'foodar(var1 typ1, var2 *typ2) {}',
+ 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('filters on label if filterText is missing', function()
+ local completion_list = {
+ { label = 'foo' },
+ { label = 'bar' },
+ }
+ local result = complete('fo|', completion_list)
+ local expected = {
{
- abbr = 'foocar',
- word = 'foodar(typ1) {}',
+ 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('works on non word prefix', function()
+ local completion_list = {
+ { label = ' foo', insertText = '->foo' },
+ }
+ local result = complete('wp.|', completion_list, 0, 2)
+ local expected = {
{
- abbr = 'foocar',
- word = 'foodar()',
+ 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 = {
{
- abbr = 'foocar',
- word = 'foodar(${1:var1})',
+ 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,
+ },
+ },
+ }
+ local expected = {
+ {
+ abbr = 'copyOf(Collection<? extends E> coll) : List<E>',
+ word = 'copyOf',
+ },
+ {
+ abbr = 'insert',
+ word = 'insert',
+ },
+ {
+ abbr = 'new',
+ word = 'new',
},
}
local result = complete('|', completion_list)
@@ -159,6 +289,7 @@ describe('vim.lsp._completion', function()
end, result.items)
eq(expected, result)
end)
+
it('uses correct start boundary', function()
local completion_list = {
isIncomplete = false,
@@ -186,8 +317,10 @@ describe('vim.lsp._completion', function()
dup = 1,
empty = 1,
icase = 1,
+ info = '',
kind = 'Module',
menu = '',
+ hl_group = '',
word = 'this_thread',
}
local result = complete(' std::this|', completion_list)
@@ -218,7 +351,7 @@ describe('vim.lsp._completion', function()
},
},
{
- filterText = 'notthis_thread',
+ filterText = 'no_match',
insertText = 'notthis_thread',
insertTextFormat = 1,
kind = 9,
@@ -240,8 +373,10 @@ describe('vim.lsp._completion', function()
dup = 1,
empty = 1,
icase = 1,
+ info = '',
kind = 'Module',
menu = '',
+ hl_group = '',
word = 'this_thread',
}
local result = complete(' std::this|is', completion_list)
@@ -278,4 +413,316 @@ describe('vim.lsp._completion', function()
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
+ )
+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)
+
+ after_each(clear)
+
+ --- @param completion_result lsp.CompletionList
+ --- @return integer
+ local function create_server(completion_result)
+ return exec_lua(function()
+ local server = _G._create_server({
+ capabilities = {
+ completionProvider = {
+ triggerCharacters = { '.' },
+ },
+ },
+ handlers = {
+ ['textDocument/completion'] = function(_, _, callback)
+ callback(nil, completion_result)
+ end,
+ },
+ })
+
+ local bufnr = vim.api.nvim_get_current_buf()
+ vim.api.nvim_win_set_buf(0, bufnr)
+ return vim.lsp.start({
+ name = 'dummy',
+ cmd = server.cmd,
+ on_attach = function(client, bufnr0)
+ vim.lsp.completion.enable(true, client.id, bufnr0, {
+ convert = function(item)
+ return { abbr = item.label:gsub('%b()', '') }
+ end,
+ })
+ 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.trigger()
+ 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({
+ 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 = '',
+ hl_group = '',
+ 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 = '',
+ hl_group = '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 = '',
+ hl_group = '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({
+ isIncomplete = false,
+ items = {
+ {
+ label = 'hello',
+ },
+ },
+ })
+ create_server({
+ 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('executes commands', function()
+ local completion_list = {
+ isIncomplete = false,
+ items = {
+ {
+ label = 'hello',
+ command = {
+ arguments = { '1', '0' },
+ command = 'dummy',
+ title = '',
+ },
+ },
+ },
+ }
+ local client_id = create_server(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('enable(…,{convert=fn}) custom word/abbr format', function()
+ create_server({
+ isIncomplete = false,
+ items = {
+ {
+ label = 'foo(bar)',
+ },
+ },
+ })
+
+ feed('ifo')
+ trigger_at_pos({ 1, 1 })
+ assert_matches(function(matches)
+ eq('foo', matches[1].abbr)
+ end)
+ end)
end)