completion_spec.lua (39015B)
1 ---@diagnostic disable: no-unknown 2 local t = require('test.testutil') 3 local t_lsp = require('test.functional.plugin.lsp.testutil') 4 local n = require('test.functional.testnvim')() 5 6 local clear = n.clear 7 local eq = t.eq 8 local neq = t.neq 9 local exec_lua = n.exec_lua 10 local feed = n.feed 11 local retry = t.retry 12 13 local create_server_definition = t_lsp.create_server_definition 14 15 --- Extract only abbr/word from a list of completion items for assertion 16 ---@param items table 17 ---@return table 18 local function extract_word_abbr(items) 19 return vim.tbl_map(function(x) 20 return { abbr = x.abbr, word = x.word } 21 end, items) 22 end 23 24 --- Convert completion results. 25 --- 26 ---@param line string line contents. Mark cursor position with `|` 27 ---@param candidates lsp.CompletionList|lsp.CompletionItem[] 28 ---@param lnum? integer 0-based, defaults to 0 29 ---@return {items: table[], server_start_boundary: integer?} 30 local function complete(line, candidates, lnum, server_boundary) 31 lnum = lnum or 0 32 -- nvim_win_get_cursor returns 0 based column, line:find returns 1 based 33 local cursor_col = line:find('|') - 1 34 line = line:gsub('|', '') 35 return exec_lua(function(result) 36 local line_to_cursor = line:sub(1, cursor_col) 37 local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') 38 local items, new_server_boundary = require('vim.lsp.completion')._convert_results( 39 line, 40 lnum, 41 cursor_col, 42 1, 43 client_start_boundary, 44 server_boundary, 45 result, 46 'utf-16' 47 ) 48 return { 49 items = items, 50 server_start_boundary = new_server_boundary, 51 } 52 end, candidates) 53 end 54 55 --- Wait for pumvisible() to equal `visible` (default 1) 56 ---@param visible? integer 1 to wait for pum shown, 0 to wait for pum hidden 57 local function wait_for_pum(visible) 58 visible = visible == nil and 1 or visible 59 retry(nil, nil, function() 60 eq( 61 visible, 62 exec_lua(function() 63 return vim.fn.pumvisible() 64 end) 65 ) 66 end) 67 end 68 69 --- Detach client and assert the pum no longer appears. 70 ---@param client_id integer 71 local function assert_cleanup_after_detach(client_id) 72 feed('<Esc>o') 73 exec_lua(function() 74 vim.lsp.completion.get() 75 end) 76 wait_for_pum(1) 77 feed('<C-e>') 78 79 -- Detach then re-trigger under identical conditions. 80 exec_lua(function() 81 vim.lsp.buf_detach_client(0, client_id) 82 end) 83 exec_lua(function() 84 vim.lsp.completion.get() 85 end) 86 wait_for_pum(0) 87 feed('<Esc>') 88 end 89 90 describe('vim.lsp.completion: item conversion', function() 91 before_each(n.clear) 92 93 -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion 94 it('prefers textEdit over label as word', function() 95 local range0 = { 96 start = { line = 0, character = 0 }, 97 ['end'] = { line = 0, character = 0 }, 98 } 99 local completion_list = { 100 -- resolves into label 101 { label = 'foobar', sortText = 'a', documentation = 'documentation' }, 102 { 103 label = 'foobar', 104 sortText = 'b', 105 documentation = { value = 'documentation' }, 106 }, 107 -- resolves into insertText 108 { label = 'foocar', sortText = 'c', insertText = 'foobar' }, 109 { label = 'foocar', sortText = 'd', insertText = 'foobar' }, 110 -- resolves into textEdit.newText 111 { 112 label = 'foocar', 113 sortText = 'e', 114 insertText = 'foodar', 115 textEdit = { newText = 'foobar', range = range0 }, 116 }, 117 { label = 'foocar', sortText = 'f', textEdit = { newText = 'foobar', range = range0 } }, 118 -- plain text 119 { 120 label = 'foocar', 121 sortText = 'g', 122 insertText = 'foodar(${1:var1})', 123 insertTextFormat = 1, 124 }, 125 { 126 label = '•INT16_C(c)', 127 insertText = 'INT16_C(${1:c})', 128 insertTextFormat = 2, 129 filterText = 'INT16_C', 130 sortText = 'h', 131 textEdit = { 132 newText = 'INT16_C(${1:c})', 133 range = range0, 134 }, 135 }, 136 } 137 local expected = { 138 { abbr = 'foobar', word = 'foobar' }, 139 { abbr = 'foobar', word = 'foobar' }, 140 { abbr = 'foocar', word = 'foobar' }, 141 { abbr = 'foocar', word = 'foobar' }, 142 { abbr = 'foocar', word = 'foobar' }, 143 { abbr = 'foocar', word = 'foobar' }, 144 { abbr = 'foocar', word = 'foodar(${1:var1})' }, -- marked as PlainText, text is used as is 145 { abbr = '•INT16_C(c)', word = 'INT16_C' }, 146 } 147 local result = complete('|', completion_list) 148 eq(expected, extract_word_abbr(result.items)) 149 end) 150 151 local word_sorter = function(a, b) 152 return a.word > b.word 153 end 154 155 it('does not filter if there is a textEdit', function() 156 local range0 = { 157 start = { line = 0, character = 0 }, 158 ['end'] = { line = 0, character = 0 }, 159 } 160 local completion_list = { 161 { label = 'foo', textEdit = { newText = 'foo', range = range0 } }, 162 { label = 'bar', textEdit = { newText = 'bar', range = range0 } }, 163 } 164 local result = complete('fo|', completion_list) 165 local expected = { 166 { abbr = 'foo', word = 'foo' }, 167 } 168 local got = extract_word_abbr(result.items) 169 table.sort(expected, word_sorter) 170 table.sort(got, word_sorter) 171 eq(expected, got) 172 end) 173 174 it('generate "■" symbol with highlight group for CompletionItemKind.Color', function() 175 local completion_list = { 176 { label = 'text-red-300', kind = 16, documentation = 'color: rgb(252, 165, 165)' }, 177 } 178 local result = complete('|', completion_list) 179 result = vim.tbl_map(function(x) 180 return { 181 word = x.word, 182 kind_hlgroup = x.kind_hlgroup, 183 kind = x.kind, 184 } 185 end, result.items) 186 eq({ { word = 'text-red-300', kind_hlgroup = '@lsp.color.fca5a5', kind = '■' } }, result) 187 end) 188 189 ---@param prefix string 190 ---@param items lsp.CompletionItem[] 191 ---@param expected table[] 192 local assert_completion_matches = function(prefix, items, expected) 193 local got = extract_word_abbr(complete(prefix .. '|', items).items) 194 table.sort(expected, word_sorter) 195 table.sort(got, word_sorter) 196 eq(expected, got) 197 end 198 199 describe('when completeopt has fuzzy matching enabled', function() 200 before_each(function() 201 exec_lua(function() 202 vim.opt.completeopt:append('fuzzy') 203 end) 204 end) 205 after_each(function() 206 exec_lua(function() 207 vim.opt.completeopt:remove('fuzzy') 208 end) 209 end) 210 211 it('fuzzy matches on filterText', function() 212 assert_completion_matches('fo', { 213 { label = '?.foo', filterText = 'foo' }, 214 { label = 'faz other', filterText = 'faz other' }, 215 { label = 'bar', filterText = 'bar' }, 216 }, { 217 { abbr = 'faz other', word = 'faz other' }, 218 { abbr = '?.foo', word = '?.foo' }, 219 }) 220 end) 221 222 it('uses filterText as word if label/newText would not match', function() 223 local items = { 224 { 225 filterText = '<module', 226 insertTextFormat = 2, 227 kind = 10, 228 label = 'module', 229 sortText = 'module', 230 textEdit = { 231 newText = '<module>$1</module>$0', 232 range = { 233 start = { character = 0, line = 0 }, 234 ['end'] = { character = 0, line = 0 }, 235 }, 236 }, 237 }, 238 } 239 assert_completion_matches('<mo', items, { 240 { abbr = 'module', word = '<module' }, 241 }) 242 assert_completion_matches('', items, { 243 { abbr = 'module', word = 'module' }, 244 }) 245 end) 246 247 it('fuzzy matches on label when filterText is missing', function() 248 assert_completion_matches('fo', { 249 { label = 'foo' }, 250 { label = 'faz other' }, 251 { label = 'bar' }, 252 }, { 253 { abbr = 'faz other', word = 'faz other' }, 254 { abbr = 'foo', word = 'foo' }, 255 }) 256 end) 257 end) 258 259 describe('when smartcase is enabled', function() 260 before_each(function() 261 exec_lua(function() 262 vim.opt.smartcase = true 263 end) 264 end) 265 after_each(function() 266 exec_lua(function() 267 vim.opt.smartcase = false 268 end) 269 end) 270 271 it('matches filterText case sensitively', function() 272 assert_completion_matches('Fo', { 273 { label = 'foo', filterText = 'foo' }, 274 { label = '?.Foo', filterText = 'Foo' }, 275 { label = 'Faz other', filterText = 'Faz other' }, 276 { label = 'faz other', filterText = 'faz other' }, 277 { label = 'bar', filterText = 'bar' }, 278 }, { 279 { abbr = '?.Foo', word = '?.Foo' }, 280 }) 281 end) 282 283 it('matches label case sensitively when filterText is missing', function() 284 assert_completion_matches('Fo', { 285 { label = 'foo' }, 286 { label = 'Foo' }, 287 { label = 'Faz other' }, 288 { label = 'faz other' }, 289 { label = 'bar' }, 290 }, { 291 { abbr = 'Foo', word = 'Foo' }, 292 }) 293 end) 294 295 describe('when ignorecase is enabled', function() 296 before_each(function() 297 exec_lua(function() 298 vim.opt.ignorecase = true 299 end) 300 end) 301 after_each(function() 302 exec_lua(function() 303 vim.opt.ignorecase = false 304 end) 305 end) 306 307 it('matches filterText case insensitively if prefix is lowercase', function() 308 assert_completion_matches('fo', { 309 { label = '?.foo', filterText = 'foo' }, 310 { label = '?.Foo', filterText = 'Foo' }, 311 { label = 'Faz other', filterText = 'Faz other' }, 312 { label = 'faz other', filterText = 'faz other' }, 313 { label = 'bar', filterText = 'bar' }, 314 }, { 315 { abbr = '?.Foo', word = '?.Foo' }, 316 { abbr = '?.foo', word = '?.foo' }, 317 }) 318 end) 319 320 it( 321 'matches label case insensitively if prefix is lowercase and filterText is missing', 322 function() 323 assert_completion_matches('fo', { 324 { label = 'foo' }, 325 { label = 'Foo' }, 326 { label = 'Faz other' }, 327 { label = 'faz other' }, 328 { label = 'bar' }, 329 }, { 330 { abbr = 'Foo', word = 'Foo' }, 331 { abbr = 'foo', word = 'foo' }, 332 }) 333 end 334 ) 335 336 it('matches filterText case sensitively if prefix has uppercase letters', function() 337 assert_completion_matches('Fo', { 338 { label = 'foo', filterText = 'foo' }, 339 { label = '?.Foo', filterText = 'Foo' }, 340 { label = 'Faz other', filterText = 'Faz other' }, 341 { label = 'faz other', filterText = 'faz other' }, 342 { label = 'bar', filterText = 'bar' }, 343 }, { 344 { abbr = '?.Foo', word = '?.Foo' }, 345 }) 346 end) 347 348 it( 349 'matches label case sensitively if prefix has uppercase letters and filterText is missing', 350 function() 351 assert_completion_matches('Fo', { 352 { label = 'foo' }, 353 { label = 'Foo' }, 354 { label = 'Faz other' }, 355 { label = 'faz other' }, 356 { label = 'bar' }, 357 }, { 358 { abbr = 'Foo', word = 'Foo' }, 359 }) 360 end 361 ) 362 end) 363 end) 364 365 describe('when ignorecase is enabled', function() 366 before_each(function() 367 exec_lua(function() 368 vim.opt.ignorecase = true 369 end) 370 end) 371 after_each(function() 372 exec_lua(function() 373 vim.opt.ignorecase = false 374 end) 375 end) 376 377 it('matches filterText case insensitively', function() 378 assert_completion_matches('Fo', { 379 { label = '?.foo', filterText = 'foo' }, 380 { label = '?.Foo', filterText = 'Foo' }, 381 { label = 'Faz other', filterText = 'Faz other' }, 382 { label = 'faz other', filterText = 'faz other' }, 383 { label = 'bar', filterText = 'bar' }, 384 }, { 385 { abbr = '?.Foo', word = '?.Foo' }, 386 { abbr = '?.foo', word = '?.foo' }, 387 }) 388 end) 389 390 it('matches label case insensitively when filterText is missing', function() 391 assert_completion_matches('Fo', { 392 { label = 'foo' }, 393 { label = 'Foo' }, 394 { label = 'Faz other' }, 395 { label = 'faz other' }, 396 { label = 'bar' }, 397 }, { 398 { abbr = 'Foo', word = 'Foo' }, 399 { abbr = 'foo', word = 'foo' }, 400 }) 401 end) 402 end) 403 404 it('works on non word prefix', function() 405 local completion_list = { 406 { label = ' foo', insertText = '->foo' }, 407 } 408 local result = complete('wp.|', completion_list, 0, 2) 409 eq({ { abbr = ' foo', word = '->foo' } }, extract_word_abbr(result.items)) 410 end) 411 412 it('trims trailing newline or tab from textEdit', function() 413 local range0 = { 414 start = { line = 0, character = 0 }, 415 ['end'] = { line = 0, character = 0 }, 416 } 417 local items = { 418 { 419 detail = 'ansible.builtin', 420 filterText = 'lineinfile ansible.builtin.lineinfile builtin ansible', 421 kind = 7, 422 label = 'ansible.builtin.lineinfile', 423 sortText = '2_ansible.builtin.lineinfile', 424 textEdit = { 425 newText = 'ansible.builtin.lineinfile:\n ', 426 range = range0, 427 }, 428 }, 429 } 430 eq( 431 { { abbr = 'ansible.builtin.lineinfile', word = 'ansible.builtin.lineinfile:' } }, 432 extract_word_abbr(complete('|', items).items) 433 ) 434 end) 435 436 it('handles multiword textEdits', function() 437 local range0 = { 438 start = { line = 0, character = 0 }, 439 ['end'] = { line = 0, character = 0 }, 440 } 441 local items = { 442 { 443 detail = 'abc', 444 filterText = 'abc', 445 kind = 7, 446 label = 'abc', 447 sortText = 'abc', 448 textEdit = { 449 newText = 'abc: Abc', 450 range = range0, 451 }, 452 }, 453 } 454 eq({ { abbr = 'abc', word = 'abc: Abc' } }, extract_word_abbr(complete('|', items).items)) 455 end) 456 457 it('prefers wordlike components for snippets', function() 458 -- There are two goals here: 459 -- 460 -- 1. The `word` should match what the user started typing, so that vim.fn.complete() doesn't 461 -- filter it away, preventing snippet expansion 462 -- 463 -- For example, if they type `items@ins`, luals returns `table.insert(items, $0)` as 464 -- textEdit.newText and `insert` as label. 465 -- There would be no prefix match if textEdit.newText is used as `word` 466 -- 467 -- 2. If users do not expand a snippet, but continue typing, they should see a somewhat reasonable 468 -- `word` getting inserted. 469 -- 470 -- For example in: 471 -- 472 -- insertText: "testSuites ${1:Env}" 473 -- label: "testSuites" 474 -- 475 -- "testSuites" should have priority as `word`, as long as the full snippet gets expanded on accept (<c-y>) 476 local range0 = { 477 start = { line = 0, character = 0 }, 478 ['end'] = { line = 0, character = 0 }, 479 } 480 local completion_list = { 481 -- luals postfix snippet (typed text: items@ins|) 482 { 483 label = 'insert', 484 insertTextFormat = 2, 485 textEdit = { 486 newText = 'table.insert(items, $0)', 487 range = range0, 488 }, 489 }, 490 -- eclipse.jdt.ls `new` snippet 491 { 492 label = 'new', 493 insertTextFormat = 2, 494 textEdit = { 495 newText = '${1:Object} ${2:foo} = new ${1}(${3});\n${0}', 496 range = range0, 497 }, 498 textEditText = '${1:Object} ${2:foo} = new ${1}(${3});\n${0}', 499 }, 500 -- eclipse.jdt.ls `List.copyO` function call completion 501 { 502 label = 'copyOf(Collection<? extends E> coll) : List<E>', 503 insertTextFormat = 2, 504 insertText = 'copyOf', 505 textEdit = { 506 newText = 'copyOf(${1:coll})', 507 range = range0, 508 }, 509 }, 510 -- luals for snippet 511 { 512 insertText = 'for ${1:index}, ${2:value} in ipairs(${3:t}) do\n\t$0\nend', 513 insertTextFormat = 2, 514 kind = 15, 515 label = 'for .. ipairs', 516 }, 517 } 518 local expected = { 519 { abbr = 'copyOf(Collection<? extends E> coll) : List<E>', word = 'copyOf' }, 520 { abbr = 'for .. ipairs', word = 'for .. ipairs' }, 521 { abbr = 'insert', word = 'insert' }, 522 { abbr = 'new', word = 'new' }, 523 } 524 eq(expected, extract_word_abbr(complete('|', completion_list).items)) 525 end) 526 527 it('uses correct start boundary', function() 528 local completion_list = { 529 isIncomplete = false, 530 items = { 531 { 532 filterText = 'this_thread', 533 insertText = 'this_thread', 534 insertTextFormat = 1, 535 kind = 9, 536 label = ' this_thread', 537 score = 1.3205767869949, 538 sortText = '4056f757this_thread', 539 textEdit = { 540 newText = 'this_thread', 541 range = { 542 start = { line = 0, character = 7 }, 543 ['end'] = { line = 0, character = 11 }, 544 }, 545 }, 546 }, 547 }, 548 } 549 local expected = { 550 { 551 abbr = ' this_thread', 552 dup = 1, 553 empty = 1, 554 icase = 1, 555 info = '', 556 kind = 'Module', 557 menu = '', 558 abbr_hlgroup = '', 559 word = 'this_thread', 560 }, 561 } 562 local result = complete(' std::this|', completion_list) 563 eq(7, result.server_start_boundary) 564 for _, item in ipairs(result.items) do 565 item.user_data = nil 566 end 567 eq(expected, result.items) 568 end) 569 570 it('should search from start boundary to cursor position', function() 571 local completion_list = { 572 isIncomplete = false, 573 items = { 574 { 575 filterText = 'this_thread', 576 insertText = 'this_thread', 577 insertTextFormat = 1, 578 kind = 9, 579 label = ' this_thread', 580 score = 1.3205767869949, 581 sortText = '4056f757this_thread', 582 textEdit = { 583 newText = 'this_thread', 584 range = { 585 start = { line = 0, character = 7 }, 586 ['end'] = { line = 0, character = 11 }, 587 }, 588 }, 589 }, 590 { 591 filterText = 'no_match', 592 insertText = 'notthis_thread', 593 insertTextFormat = 1, 594 kind = 9, 595 label = ' notthis_thread', 596 score = 1.3205767869949, 597 sortText = '4056f757this_thread', 598 textEdit = { 599 newText = 'notthis_thread', 600 range = { 601 start = { line = 0, character = 7 }, 602 ['end'] = { line = 0, character = 11 }, 603 }, 604 }, 605 }, 606 }, 607 } 608 local expected = { 609 abbr = ' this_thread', 610 dup = 1, 611 empty = 1, 612 icase = 1, 613 info = '', 614 kind = 'Module', 615 menu = '', 616 abbr_hlgroup = '', 617 word = 'this_thread', 618 } 619 local result = complete(' std::this|is', completion_list) 620 eq(1, #result.items) 621 local item = result.items[1] 622 item.user_data = nil 623 eq(expected, item) 624 end) 625 626 it('uses defaults from itemDefaults', function() 627 --- @type lsp.CompletionList 628 local completion_list = { 629 isIncomplete = false, 630 itemDefaults = { 631 editRange = { 632 start = { line = 1, character = 1 }, 633 ['end'] = { line = 1, character = 4 }, 634 }, 635 insertTextFormat = 2, 636 data = 'foobar', 637 }, 638 items = { 639 { 640 label = 'hello', 641 data = 'item-property-has-priority', 642 textEditText = 'hello', 643 }, 644 }, 645 } 646 local result = complete('|', completion_list) 647 eq(1, #result.items) 648 local item = result.items[1].user_data.nvim.lsp.completion_item --- @type lsp.CompletionItem 649 eq(2, item.insertTextFormat) 650 eq('item-property-has-priority', item.data) 651 eq({ line = 1, character = 1 }, item.textEdit.range.start) 652 end) 653 654 it( 655 'uses insertText as textEdit.newText if there are editRange defaults but no textEditText', 656 function() 657 --- @type lsp.CompletionList 658 local completion_list = { 659 isIncomplete = false, 660 itemDefaults = { 661 editRange = { 662 start = { line = 1, character = 1 }, 663 ['end'] = { line = 1, character = 4 }, 664 }, 665 insertTextFormat = 2, 666 data = 'foobar', 667 }, 668 items = { 669 { 670 insertText = 'the-insertText', 671 label = 'hello', 672 data = 'item-property-has-priority', 673 }, 674 }, 675 } 676 local result = complete('|', completion_list) 677 eq(1, #result.items) 678 eq('the-insertText', result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText) 679 end 680 ) 681 682 it( 683 'defaults to label as textEdit.newText if insertText or textEditText are not present', 684 function() 685 local completion_list = { 686 isIncomplete = false, 687 itemDefaults = { 688 editRange = { 689 start = { line = 1, character = 1 }, 690 ['end'] = { line = 1, character = 4 }, 691 }, 692 insertTextFormat = 2, 693 data = 'foobar', 694 }, 695 items = { 696 { 697 label = 'hello', 698 data = 'item-property-has-priority', 699 }, 700 }, 701 } 702 local result = complete('|', completion_list) 703 eq(1, #result.items) 704 eq('hello', result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText) 705 end 706 ) 707 708 it('uses the start boundary from an insertReplace response', function() 709 local completion_list = { 710 isIncomplete = false, 711 items = { 712 { 713 data = { cacheId = 1 }, 714 kind = 2, 715 label = 'foobar', 716 sortText = '11', 717 textEdit = { 718 insert = { 719 start = { character = 4, line = 4 }, 720 ['end'] = { character = 8, line = 4 }, 721 }, 722 newText = 'foobar', 723 replace = { 724 start = { character = 4, line = 4 }, 725 ['end'] = { character = 8, line = 4 }, 726 }, 727 }, 728 }, 729 { 730 data = { cacheId = 2 }, 731 kind = 2, 732 label = 'bazqux', 733 sortText = '11', 734 textEdit = { 735 insert = { 736 start = { character = 4, line = 4 }, 737 ['end'] = { character = 5, line = 4 }, 738 }, 739 newText = 'bazqux', 740 replace = { 741 start = { character = 4, line = 4 }, 742 ['end'] = { character = 5, line = 4 }, 743 }, 744 }, 745 }, 746 }, 747 } 748 749 local result = complete('foo.f|', completion_list) 750 eq(1, #result.items) 751 eq('foobar', result.items[1].user_data.nvim.lsp.completion_item.textEdit.newText) 752 end) 753 end) 754 755 --- @param name string 756 --- @param completion_result vim.lsp.CompletionResult 757 --- @param opts? {trigger_chars?: string[], resolve_result?: lsp.CompletionItem, delay?: integer, cmp?: string} 758 --- @return integer 759 local function create_server(name, completion_result, opts) 760 opts = opts or {} 761 return exec_lua(function() 762 local server = _G._create_server({ 763 capabilities = { 764 completionProvider = { 765 triggerCharacters = opts.trigger_chars or { '.' }, 766 resolveProvider = opts.resolve_result ~= nil, 767 }, 768 }, 769 handlers = { 770 ['textDocument/completion'] = function(_, _, callback) 771 if opts.delay then 772 -- simulate delay in completion request, needed for some of these tests 773 vim.defer_fn(function() 774 callback(nil, completion_result) 775 end, opts.delay) 776 else 777 callback(nil, completion_result) 778 end 779 end, 780 ['completionItem/resolve'] = function(_, _, callback) 781 callback(nil, opts.resolve_result) 782 end, 783 }, 784 }) 785 786 local bufnr = vim.api.nvim_get_current_buf() 787 vim.api.nvim_win_set_buf(0, bufnr) 788 local cmp_fn 789 if opts.cmp then 790 cmp_fn = assert(loadstring(opts.cmp)) 791 end 792 return vim.lsp.start({ 793 name = name, 794 cmd = server.cmd, 795 on_attach = function(client, bufnr0) 796 vim.lsp.completion.enable(true, client.id, bufnr0, { 797 autotrigger = opts.trigger_chars ~= nil, 798 convert = function(item) 799 return { abbr = item.label:gsub('%b()', '') } 800 end, 801 cmp = cmp_fn, 802 }) 803 end, 804 }) 805 end) 806 end 807 808 describe('vim.lsp.completion: protocol', function() 809 before_each(function() 810 clear() 811 exec_lua(create_server_definition) 812 exec_lua(function() 813 _G.capture = {} 814 --- @diagnostic disable-next-line:duplicate-set-field 815 vim.fn.complete = function(col, matches) 816 _G.capture.col = col 817 _G.capture.matches = matches 818 end 819 end) 820 end) 821 822 local function assert_matches(fn) 823 retry(nil, nil, function() 824 fn(exec_lua('return _G.capture.matches')) 825 end) 826 end 827 828 --- @param pos [integer, integer] 829 local function trigger_at_pos(pos) 830 exec_lua(function() 831 local win = vim.api.nvim_get_current_win() 832 vim.api.nvim_win_set_cursor(win, pos) 833 vim.lsp.completion.get() 834 end) 835 836 retry(nil, nil, function() 837 neq(nil, exec_lua('return _G.capture.col')) 838 end) 839 end 840 841 it('fetches completions and shows them using complete on trigger', function() 842 create_server('dummy', { 843 isIncomplete = false, 844 items = { 845 { label = 'hello' }, 846 { label = 'hercules', tags = { 1 } }, -- 1 represents Deprecated tag 847 { label = 'hero', deprecated = true }, 848 }, 849 }) 850 851 feed('ih') 852 trigger_at_pos({ 1, 1 }) 853 854 assert_matches(function(matches) 855 eq({ 856 { 857 abbr = 'hello', 858 dup = 1, 859 empty = 1, 860 icase = 1, 861 info = '', 862 kind = 'Unknown', 863 menu = '', 864 abbr_hlgroup = '', 865 user_data = { 866 nvim = { 867 lsp = { client_id = 1, completion_item = { label = 'hello' } }, 868 }, 869 }, 870 word = 'hello', 871 }, 872 { 873 abbr = 'hercules', 874 dup = 1, 875 empty = 1, 876 icase = 1, 877 info = '', 878 kind = 'Unknown', 879 menu = '', 880 abbr_hlgroup = 'DiagnosticDeprecated', 881 user_data = { 882 nvim = { 883 lsp = { 884 client_id = 1, 885 completion_item = { label = 'hercules', tags = { 1 } }, 886 }, 887 }, 888 }, 889 word = 'hercules', 890 }, 891 { 892 abbr = 'hero', 893 dup = 1, 894 empty = 1, 895 icase = 1, 896 info = '', 897 kind = 'Unknown', 898 menu = '', 899 abbr_hlgroup = 'DiagnosticDeprecated', 900 user_data = { 901 nvim = { 902 lsp = { 903 client_id = 1, 904 completion_item = { label = 'hero', deprecated = true }, 905 }, 906 }, 907 }, 908 word = 'hero', 909 }, 910 }, matches) 911 end) 912 end) 913 914 it('merges results from multiple clients', function() 915 create_server('dummy1', { isIncomplete = false, items = { { label = 'hello' } } }) 916 create_server('dummy2', { isIncomplete = false, items = { { label = 'hallo' } } }) 917 create_server('dummy3', { { label = 'hallo' } }) 918 919 feed('ih') 920 trigger_at_pos({ 1, 1 }) 921 922 assert_matches(function(matches) 923 eq(3, #matches) 924 eq('hello', matches[1].word) 925 eq('hallo', matches[2].word) 926 eq('hallo', matches[3].word) 927 end) 928 end) 929 930 it('insert char triggers clients matching trigger characters', function() 931 create_server('dummy1', { 932 isIncomplete = false, 933 items = { { label = 'hello' } }, 934 }, { trigger_chars = { 'e' } }) 935 create_server('dummy2', { 936 isIncomplete = false, 937 items = { { label = 'hallo' } }, 938 }, { trigger_chars = { 'h' } }) 939 940 feed('h') 941 exec_lua(function() 942 vim.v.char = 'h' 943 vim.cmd.startinsert() 944 vim.api.nvim_exec_autocmds('InsertCharPre', {}) 945 end) 946 947 assert_matches(function(matches) 948 eq(1, #matches) 949 eq('hallo', matches[1].word) 950 end) 951 end) 952 953 it('treats 2-triggers-at-once as "last char wins"', function() 954 create_server('dummy1', { 955 isIncomplete = false, 956 items = { { label = 'first' } }, 957 }, { trigger_chars = { '-' } }) 958 create_server('dummy2', { 959 isIncomplete = false, 960 items = { { label = 'second' } }, 961 }, { trigger_chars = { '>' } }) 962 963 feed('i->') 964 965 assert_matches(function(matches) 966 eq(1, #matches) 967 eq('second', matches[1].word) 968 end) 969 end) 970 971 it('executes commands', function() 972 local completion_list = { 973 isIncomplete = false, 974 items = { 975 { 976 label = 'hello', 977 command = { arguments = { '1', '0' }, command = 'dummy', title = '' }, 978 }, 979 }, 980 } 981 local client_id = create_server('dummy', completion_list) 982 983 exec_lua(function() 984 _G.called = false 985 local client = assert(vim.lsp.get_client_by_id(client_id)) 986 client.commands.dummy = function() 987 _G.called = true 988 end 989 end) 990 991 feed('ih') 992 trigger_at_pos({ 1, 1 }) 993 994 local item = completion_list.items[1] 995 exec_lua(function() 996 vim.v.completed_item = { 997 user_data = { 998 nvim = { 999 lsp = { client_id = client_id, completion_item = item }, 1000 }, 1001 }, 1002 } 1003 end) 1004 1005 feed('<C-x><C-o><C-y>') 1006 1007 assert_matches(function(matches) 1008 eq(1, #matches) 1009 eq('hello', matches[1].word) 1010 eq(true, exec_lua('return _G.called')) 1011 end) 1012 end) 1013 1014 it('resolves and executes commands', function() 1015 local completion_list = { 1016 isIncomplete = false, 1017 items = { { label = 'hello' } }, 1018 } 1019 local client_id = create_server('dummy', completion_list, { 1020 resolve_result = { 1021 label = 'hello', 1022 command = { arguments = { '1', '0' }, command = 'dummy', title = '' }, 1023 }, 1024 }) 1025 exec_lua(function() 1026 _G.called = false 1027 local client = assert(vim.lsp.get_client_by_id(client_id)) 1028 client.commands.dummy = function() 1029 _G.called = true 1030 end 1031 end) 1032 1033 feed('ih') 1034 trigger_at_pos({ 1, 1 }) 1035 1036 local item = completion_list.items[1] 1037 exec_lua(function() 1038 vim.v.completed_item = { 1039 user_data = { 1040 nvim = { 1041 lsp = { client_id = client_id, completion_item = item }, 1042 }, 1043 }, 1044 } 1045 end) 1046 1047 feed('<C-x><C-o><C-y>') 1048 1049 assert_matches(function(matches) 1050 eq(1, #matches) 1051 eq('hello', matches[1].word) 1052 eq(true, exec_lua('return _G.called')) 1053 end) 1054 end) 1055 1056 it('enable(…,{convert=fn}) custom word/abbr format', function() 1057 create_server('dummy', { 1058 isIncomplete = false, 1059 items = { { label = 'foo(bar)' } }, 1060 }) 1061 1062 feed('ifo') 1063 trigger_at_pos({ 1, 1 }) 1064 assert_matches(function(matches) 1065 eq('foo', matches[1].abbr) 1066 end) 1067 end) 1068 1069 it('enable(…,{cmp=fn}) custom sort order', function() 1070 create_server('dummy', { 1071 isIncomplete = false, 1072 items = { 1073 { label = 'zzz', sortText = 'a' }, 1074 { label = 'aaa', sortText = 'z' }, 1075 { label = 'mmm', sortText = 'm' }, 1076 }, 1077 }, { 1078 cmp = string.dump(function(a, b) 1079 return a.abbr < b.abbr 1080 end), 1081 }) 1082 feed('i') 1083 trigger_at_pos({ 1, 0 }) 1084 assert_matches(function(matches) 1085 eq(3, #matches) 1086 eq('aaa', matches[1].abbr) 1087 eq('mmm', matches[2].abbr) 1088 eq('zzz', matches[3].abbr) 1089 end) 1090 end) 1091 1092 it('sends completion context when invoked', function() 1093 local params = exec_lua(function() 1094 local params 1095 local server = _G._create_server({ 1096 capabilities = { completionProvider = true }, 1097 handlers = { 1098 ['textDocument/completion'] = function(_, params0, callback) 1099 params = params0 1100 callback(nil, nil) 1101 end, 1102 }, 1103 }) 1104 1105 local bufnr = vim.api.nvim_get_current_buf() 1106 vim.api.nvim_win_set_buf(0, bufnr) 1107 vim.lsp.start({ 1108 name = 'dummy', 1109 cmd = server.cmd, 1110 on_attach = function(client, bufnr0) 1111 vim.lsp.completion.enable(true, client.id, bufnr0) 1112 end, 1113 }) 1114 1115 vim.lsp.completion.get() 1116 1117 return params 1118 end) 1119 1120 eq({ triggerKind = 1 }, params.context) 1121 end) 1122 1123 it('sends completion context with trigger characters', function() 1124 exec_lua(function() 1125 local server = _G._create_server({ 1126 capabilities = { 1127 completionProvider = { triggerCharacters = { 'h' } }, 1128 }, 1129 handlers = { 1130 ['textDocument/completion'] = function(_, params, callback) 1131 _G.params = params 1132 callback(nil, { isIncomplete = false, items = { label = 'hello' } }) 1133 end, 1134 }, 1135 }) 1136 1137 local bufnr = vim.api.nvim_get_current_buf() 1138 vim.api.nvim_win_set_buf(0, bufnr) 1139 vim.lsp.start({ 1140 name = 'dummy', 1141 cmd = server.cmd, 1142 on_attach = function(client, bufnr0) 1143 vim.lsp.completion.enable(true, client.id, bufnr0, { autotrigger = true }) 1144 end, 1145 }) 1146 end) 1147 1148 feed('ih') 1149 1150 retry(100, nil, function() 1151 eq({ triggerKind = 2, triggerCharacter = 'h' }, exec_lua('return _G.params.context')) 1152 end) 1153 end) 1154 end) 1155 1156 describe('vim.lsp.completion: integration', function() 1157 before_each(function() 1158 clear() 1159 exec_lua(create_server_definition) 1160 exec_lua(function() 1161 vim.fn.complete = vim.schedule_wrap(vim.fn.complete) 1162 end) 1163 end) 1164 1165 it('puts cursor at the end of completed word', function() 1166 local completion_list = { 1167 isIncomplete = false, 1168 items = { 1169 { 1170 label = 'hello', 1171 insertText = '${1:hello} friends', 1172 insertTextFormat = 2, 1173 }, 1174 }, 1175 } 1176 exec_lua(function() 1177 vim.o.completeopt = 'menuone,noselect' 1178 end) 1179 local client_id = create_server('dummy', completion_list) 1180 feed('i world<esc>0ih<c-x><c-o>') 1181 wait_for_pum() 1182 feed('<C-n><C-y>') 1183 eq( 1184 { true, { 'hello friends world' } }, 1185 exec_lua(function() 1186 return { 1187 vim.snippet.active({ direction = 1 }), 1188 vim.api.nvim_buf_get_lines(0, 0, -1, true), 1189 } 1190 end) 1191 ) 1192 exec_lua(function() 1193 vim.snippet.jump(1) 1194 end) 1195 eq( 1196 #'hello friends', 1197 exec_lua(function() 1198 return vim.api.nvim_win_get_cursor(0)[2] 1199 end) 1200 ) 1201 assert_cleanup_after_detach(client_id) 1202 end) 1203 1204 it('clear multiple-lines word', function() 1205 local completion_list = { 1206 isIncomplete = false, 1207 items = { 1208 { 1209 label = 'then...end', 1210 sortText = '0001', 1211 insertText = 'then\n\t$0\nend', 1212 kind = 15, 1213 insertTextFormat = 2, 1214 }, 1215 }, 1216 } 1217 exec_lua(function() 1218 vim.o.completeopt = 'menuone,noselect' 1219 end) 1220 local client_id = create_server('dummy', completion_list) 1221 feed('Sif true <C-X><C-O>') 1222 wait_for_pum() 1223 feed('<C-n><C-y>') 1224 eq( 1225 { false, { 'if true then', '\t', 'end' } }, 1226 exec_lua(function() 1227 return { 1228 vim.snippet.active({ direction = 1 }), 1229 vim.api.nvim_buf_get_lines(0, 0, -1, true), 1230 } 1231 end) 1232 ) 1233 assert_cleanup_after_detach(client_id) 1234 end) 1235 1236 it('prepends prefix for items with different start positions', function() 1237 local completion_list = { 1238 isIncomplete = false, 1239 items = { 1240 { 1241 label = 'div.foo', 1242 insertTextFormat = 2, 1243 textEdit = { 1244 newText = '<div class="foo">$0</div>', 1245 range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 7 } }, 1246 }, 1247 }, 1248 }, 1249 } 1250 exec_lua(function() 1251 vim.o.completeopt = 'menu,menuone,noinsert' 1252 end) 1253 local client_id = create_server('dummy', completion_list) 1254 feed('Adiv.foo<C-x><C-O>') 1255 wait_for_pum() 1256 feed('<C-Y>') 1257 eq('<div class="foo"></div>', n.api.nvim_get_current_line()) 1258 eq({ 1, 17 }, n.api.nvim_win_get_cursor(0)) 1259 assert_cleanup_after_detach(client_id) 1260 end) 1261 1262 it('does not empty server start boundary', function() 1263 local completion_list = { 1264 isIncomplete = false, 1265 items = { 1266 { 1267 label = 'div.foo', 1268 insertTextFormat = 2, 1269 textEdit = { 1270 newText = '<div class="foo">$0</div>', 1271 range = { 1272 start = { line = 0, character = 0 }, 1273 ['end'] = { line = 0, character = 7 }, 1274 }, 1275 }, 1276 }, 1277 }, 1278 } 1279 local completion_list2 = { 1280 isIncomplete = false, 1281 items = { { insertTextFormat = 1, label = 'foo' } }, 1282 } 1283 exec_lua(function() 1284 vim.o.completeopt = 'menu,menuone,noinsert' 1285 end) 1286 create_server('dummy', completion_list) 1287 create_server('dummy2', completion_list2) 1288 create_server('dummy3', { isIncomplete = false, items = {} }) 1289 feed('Adiv.foo<C-x><C-O>') 1290 wait_for_pum() 1291 feed('<C-Y>') 1292 eq('<div class="foo"></div>', n.api.nvim_get_current_line()) 1293 eq({ 1, 17 }, n.api.nvim_win_get_cursor(0)) 1294 end) 1295 1296 it('sorts items when fuzzy is enabled and prefix not empty #33610', function() 1297 local completion_list = { 1298 isIncomplete = false, 1299 items = { 1300 { 1301 kind = 21, 1302 label = '-row-end-1', 1303 sortText = '0327', 1304 textEdit = { 1305 newText = '-row-end-1', 1306 range = { 1307 ['end'] = { character = 1, line = 0 }, 1308 start = { character = 0, line = 0 }, 1309 }, 1310 }, 1311 }, 1312 { 1313 kind = 21, 1314 label = 'w-1/2', 1315 sortText = '3052', 1316 textEdit = { 1317 newText = 'w-1/2', 1318 range = { 1319 ['end'] = { character = 1, line = 0 }, 1320 start = { character = 0, line = 0 }, 1321 }, 1322 }, 1323 }, 1324 }, 1325 } 1326 exec_lua(function() 1327 vim.o.completeopt = 'menuone,fuzzy' 1328 end) 1329 create_server('dummy', completion_list, { trigger_chars = { '-' } }) 1330 feed('Sw-') 1331 wait_for_pum() 1332 feed('<C-y>') 1333 eq('w-1/2', n.api.nvim_get_current_line()) 1334 end) 1335 end) 1336 1337 describe("vim.lsp.completion: omnifunc + 'autocomplete'", function() 1338 before_each(function() 1339 clear() 1340 exec_lua(create_server_definition) 1341 exec_lua(function() 1342 -- enable buffer and omnifunc autocompletion 1343 -- omnifunc will be the lsp omnifunc 1344 vim.o.complete = '.,o' 1345 vim.o.autocomplete = true 1346 end) 1347 1348 local completion_list = { 1349 isIncomplete = false, 1350 items = { 1351 { label = 'hello' }, 1352 { label = 'hallo' }, 1353 }, 1354 } 1355 create_server('dummy', completion_list, { delay = 50 }) 1356 end) 1357 1358 local function assert_matches(expected) 1359 retry(nil, nil, function() 1360 local matches = vim.tbl_map(function(m) 1361 return m.word 1362 end, exec_lua('return vim.fn.complete_info({ "items" })').items) 1363 eq(expected, matches) 1364 end) 1365 end 1366 1367 it('merges with other completions', function() 1368 feed('ihillo<cr><esc>ih') 1369 assert_matches({ 'hillo', 'hallo', 'hello' }) 1370 end) 1371 1372 it('fuzzy matches without duplication', function() 1373 -- wait for one completion request to start and then request another before 1374 -- the first one finishes, then wait for both to finish 1375 feed('ihillo<cr>h') 1376 vim.uv.sleep(1) 1377 feed('e') 1378 1379 assert_matches({ 'hello' }) 1380 end) 1381 end)