neovim

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

commit c22b03c771669742e0a85d2f8193ffb6e5e84e7f
parent 2c04ae9fcc9c9a70a7336d98e469ab6a5d97c6cc
Author: glepnir <glephunter@gmail.com>
Date:   Wed, 19 Nov 2025 13:38:53 +0800

feat(lsp): user-specified sorting of lsp.completion multi-server results #36401

Problem: No way to customize completion order across multiple servers.

Solution: Add `cmp` function to `vim.lsp.completion.enable()` options
for custom sorting logic.
Diffstat:
Mruntime/doc/lsp.txt | 2++
Mruntime/doc/news.txt | 1+
Mruntime/lua/vim/lsp/completion.lua | 27+++++++++++++++++----------
Mtest/functional/plugin/lsp/completion_spec.lua | 30+++++++++++++++++++++++++++++-
4 files changed, 49 insertions(+), 11 deletions(-)

diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt @@ -2040,6 +2040,8 @@ enable({enable}, {client_id}, {bufnr}, {opts}) `triggerCharacters`. • {convert}? (`fun(item: lsp.CompletionItem): table`) Transforms an LSP CompletionItem to |complete-items|. + • {cmp}? (`fun(a: table, b: table): boolean`) Comparator + for sorting merged completion items from all servers. get({opts}) *vim.lsp.completion.get()* Triggers LSP completion once in the current buffer, if LSP completion is diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt @@ -264,6 +264,7 @@ LSP • The filter option of |vim.lsp.buf.code_action()| now receives the client ID as an argument. • |Client:stop()| now accepts a numerical `force` argument to be interpreted as the time to wait before forcing the shutdown. +• Add cmp field to opts of |vim.lsp.completion.enable()| for custom completion ordering. LUA diff --git a/runtime/lua/vim/lsp/completion.lua b/runtime/lua/vim/lsp/completion.lua @@ -313,6 +313,7 @@ function M._lsp_to_complete_items(result, prefix, client_id) local candidates = {} local bufnr = api.nvim_get_current_buf() local user_convert = vim.tbl_get(buf_handles, bufnr, 'convert') + local user_cmp = vim.tbl_get(buf_handles, bufnr, 'cmp') for _, item in ipairs(items) do if matches(item) then local word = get_completion_word(item, prefix, match_item_by_value) @@ -348,15 +349,16 @@ function M._lsp_to_complete_items(result, prefix, client_id) table.insert(candidates, completion_item) end end - ---@diagnostic disable-next-line: no-unknown - table.sort(candidates, function(a, b) - ---@type lsp.CompletionItem - local itema = a.user_data.nvim.lsp.completion_item - ---@type lsp.CompletionItem - local itemb = b.user_data.nvim.lsp.completion_item - return (itema.sortText or itema.label) < (itemb.sortText or itemb.label) - end) - + if not user_cmp then + ---@diagnostic disable-next-line: no-unknown + table.sort(candidates, function(a, b) + ---@type lsp.CompletionItem + local itema = a.user_data.nvim.lsp.completion_item + ---@type lsp.CompletionItem + local itemb = b.user_data.nvim.lsp.completion_item + return (itema.sortText or itema.label) < (itemb.sortText or itemb.label) + end) + end return candidates end @@ -551,6 +553,10 @@ local function trigger(bufnr, clients, ctx) end, prev_matches) matches = vim.list_extend(prev_matches, matches) + local user_cmp = vim.tbl_get(buf_handles, bufnr, 'cmp') + if user_cmp then + table.sort(matches, user_cmp) + end local start_col = (server_start_boundary or word_boundary) + 1 Context.cursor = { cursor_row, start_col } @@ -712,6 +718,7 @@ end --- @class vim.lsp.completion.BufferOpts --- @field autotrigger? boolean (default: false) When true, completion triggers automatically based on the server's `triggerCharacters`. --- @field convert? fun(item: lsp.CompletionItem): table Transforms an LSP CompletionItem to |complete-items|. +--- @field cmp? fun(a: table, b: table): boolean Comparator for sorting merged completion items from all servers. ---@param client_id integer ---@param bufnr integer @@ -719,7 +726,7 @@ end local function enable_completions(client_id, bufnr, opts) local buf_handle = buf_handles[bufnr] if not buf_handle then - buf_handle = { clients = {}, triggers = {}, convert = opts.convert } + buf_handle = { clients = {}, triggers = {}, convert = opts.convert, cmp = opts.cmp } buf_handles[bufnr] = buf_handle -- Attach to buffer events. diff --git a/test/functional/plugin/lsp/completion_spec.lua b/test/functional/plugin/lsp/completion_spec.lua @@ -810,7 +810,7 @@ end) --- @param name string --- @param completion_result lsp.CompletionList ---- @param opts? {trigger_chars?: string[], resolve_result?: lsp.CompletionItem, delay?: integer} +--- @param opts? {trigger_chars?: string[], resolve_result?: lsp.CompletionItem, delay?: integer, cmp?: string} --- @return integer local function create_server(name, completion_result, opts) opts = opts or {} @@ -841,6 +841,10 @@ local function create_server(name, completion_result, opts) local bufnr = vim.api.nvim_get_current_buf() vim.api.nvim_win_set_buf(0, bufnr) + local cmp_fn + if opts.cmp then + cmp_fn = assert(loadstring(opts.cmp)) + end return vim.lsp.start({ name = name, cmd = server.cmd, @@ -850,6 +854,7 @@ local function create_server(name, completion_result, opts) convert = function(item) return { abbr = item.label:gsub('%b()', '') } end, + cmp = cmp_fn, }) end, }) @@ -1190,6 +1195,29 @@ describe('vim.lsp.completion: protocol', function() end) end) + it('enable(…,{cmp=fn}) custom sort order', function() + create_server('dummy', { + isIncomplete = false, + items = { + { label = 'zzz', sortText = 'a' }, + { label = 'aaa', sortText = 'z' }, + { label = 'mmm', sortText = 'm' }, + }, + }, { + cmp = string.dump(function(a, b) + return a.abbr < b.abbr + end), + }) + feed('i') + trigger_at_pos({ 1, 0 }) + assert_matches(function(matches) + eq(3, #matches) + eq('aaa', matches[1].abbr) + eq('mmm', matches[2].abbr) + eq('zzz', matches[3].abbr) + end) + end) + it('sends completion context when invoked', function() local params = exec_lua(function() local params