neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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)