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.
This commit is contained in:
glepnir
2025-11-19 13:38:53 +08:00
committed by GitHub
parent 2c04ae9fcc
commit c22b03c771
4 changed files with 49 additions and 11 deletions

View File

@@ -2040,6 +2040,8 @@ enable({enable}, {client_id}, {bufnr}, {opts})
`triggerCharacters`. `triggerCharacters`.
• {convert}? (`fun(item: lsp.CompletionItem): table`) • {convert}? (`fun(item: lsp.CompletionItem): table`)
Transforms an LSP CompletionItem to |complete-items|. 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()* get({opts}) *vim.lsp.completion.get()*
Triggers LSP completion once in the current buffer, if LSP completion is Triggers LSP completion once in the current buffer, if LSP completion is

View File

@@ -264,6 +264,7 @@ LSP
• The filter option of |vim.lsp.buf.code_action()| now receives the client ID as an argument. • 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 • |Client:stop()| now accepts a numerical `force` argument to be interpreted as the time to wait
before forcing the shutdown. before forcing the shutdown.
• Add cmp field to opts of |vim.lsp.completion.enable()| for custom completion ordering.
LUA LUA

View File

@@ -313,6 +313,7 @@ function M._lsp_to_complete_items(result, prefix, client_id)
local candidates = {} local candidates = {}
local bufnr = api.nvim_get_current_buf() local bufnr = api.nvim_get_current_buf()
local user_convert = vim.tbl_get(buf_handles, bufnr, 'convert') 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 for _, item in ipairs(items) do
if matches(item) then if matches(item) then
local word = get_completion_word(item, prefix, match_item_by_value) 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) table.insert(candidates, completion_item)
end end
end end
---@diagnostic disable-next-line: no-unknown if not user_cmp then
table.sort(candidates, function(a, b) ---@diagnostic disable-next-line: no-unknown
---@type lsp.CompletionItem table.sort(candidates, function(a, b)
local itema = a.user_data.nvim.lsp.completion_item ---@type lsp.CompletionItem
---@type lsp.CompletionItem local itema = a.user_data.nvim.lsp.completion_item
local itemb = b.user_data.nvim.lsp.completion_item ---@type lsp.CompletionItem
return (itema.sortText or itema.label) < (itemb.sortText or itemb.label) local itemb = b.user_data.nvim.lsp.completion_item
end) return (itema.sortText or itema.label) < (itemb.sortText or itemb.label)
end)
end
return candidates return candidates
end end
@@ -551,6 +553,10 @@ local function trigger(bufnr, clients, ctx)
end, prev_matches) end, prev_matches)
matches = vim.list_extend(prev_matches, 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 local start_col = (server_start_boundary or word_boundary) + 1
Context.cursor = { cursor_row, start_col } Context.cursor = { cursor_row, start_col }
@@ -712,6 +718,7 @@ end
--- @class vim.lsp.completion.BufferOpts --- @class vim.lsp.completion.BufferOpts
--- @field autotrigger? boolean (default: false) When true, completion triggers automatically based on the server's `triggerCharacters`. --- @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 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 client_id integer
---@param bufnr integer ---@param bufnr integer
@@ -719,7 +726,7 @@ end
local function enable_completions(client_id, bufnr, opts) local function enable_completions(client_id, bufnr, opts)
local buf_handle = buf_handles[bufnr] local buf_handle = buf_handles[bufnr]
if not buf_handle then 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 buf_handles[bufnr] = buf_handle
-- Attach to buffer events. -- Attach to buffer events.

View File

@@ -810,7 +810,7 @@ end)
--- @param name string --- @param name string
--- @param completion_result lsp.CompletionList --- @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 --- @return integer
local function create_server(name, completion_result, opts) local function create_server(name, completion_result, opts)
opts = opts or {} opts = opts or {}
@@ -841,6 +841,10 @@ local function create_server(name, completion_result, opts)
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()
vim.api.nvim_win_set_buf(0, bufnr) 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({ return vim.lsp.start({
name = name, name = name,
cmd = server.cmd, cmd = server.cmd,
@@ -850,6 +854,7 @@ local function create_server(name, completion_result, opts)
convert = function(item) convert = function(item)
return { abbr = item.label:gsub('%b()', '') } return { abbr = item.label:gsub('%b()', '') }
end, end,
cmp = cmp_fn,
}) })
end, end,
}) })
@@ -1190,6 +1195,29 @@ describe('vim.lsp.completion: protocol', function()
end) end)
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() it('sends completion context when invoked', function()
local params = exec_lua(function() local params = exec_lua(function()
local params local params