diff options
Diffstat (limited to 'test/functional/plugin/lsp/completion_spec.lua')
-rw-r--r-- | test/functional/plugin/lsp/completion_spec.lua | 547 |
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) |