feat(completion): completeopt=preselect, LSP CompletionItem.preselect #36613

Problem: 
LSP CompletionItem.preselect is not supported.
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionClientCapabilities

Solution:
- Add "preselect" field to complete-items and "preselect" flag
  to 'completeopt'.
- Set preselectSupport=true in LSP client capabilities.
This commit is contained in:
glepnir
2026-04-13 17:59:07 +08:00
committed by GitHub
parent 09d4eba92b
commit 53a29dce0e
12 changed files with 150 additions and 22 deletions

View File

@@ -1248,6 +1248,10 @@ items:
customization of ctermfg and guifg properties for the
completion kind
match See "matches" in |complete_info()|.
preselect when non-zero this item is selected by default in the
popup menu. Only effective when 'completeopt' contains
"preselect". If multiple items have "preselect" set,
the first one is used.
All of these except "icase", "equal", "dup" and "empty" must be a string. If
an item does not meet these requirements then an error message is given and

View File

@@ -144,6 +144,8 @@ LSP
• |vim.lsp.buf.declaration()|, |vim.lsp.buf.definition()|, |vim.lsp.buf.definition()|,
and |vim.lsp.buf.implementation()| now follows 'switchbuf'.
• Support for nested snippets.
• LSP completion now supports `CompletionItem.preselect`. Requires
'completeopt' to include "preselect".
LUA

View File

@@ -1698,8 +1698,12 @@ A jump table for the options with a short description can be found at |Q_op|.
completion in the preview window. Only works in
combination with "menu" or "menuone".
Only "fuzzy", "longest", "popup", "preinsert" and "preview" have an
effect when 'autocomplete' is enabled.
preselect Select the completion item that has the "preselect"
attribute set. If both "noselect" and "preselect" are present,
"preselect" takes precedence.
Only "fuzzy", "longest", "popup", "preinsert", "preselect" and
"preview" have an effect when 'autocomplete' is enabled.
This option does not apply to |cmdline-completion|. See 'wildoptions'
for that.

View File

@@ -396,6 +396,8 @@ Options:
- 'ambiwidth' cannot be set to empty.
- 'autoread' works in the terminal (if it supports "focus" events)
- 'background' cannot be set to empty.
- 'completeopt' flag "preselect" selects first completion item that has the
"preselect" attribute set.
- 'cpoptions' flags: |cpo-_|
- 'eadirection' cannot be set to empty.
- 'exrc' searches for ".nvim.lua", ".nvimrc", or ".exrc" files. The

View File

@@ -1218,8 +1218,12 @@ vim.go.cia = vim.go.completeitemalign
--- completion in the preview window. Only works in
--- combination with "menu" or "menuone".
---
--- Only "fuzzy", "longest", "popup", "preinsert" and "preview" have an
--- effect when 'autocomplete' is enabled.
--- preselect Select the completion item that has the "preselect"
--- attribute set. If both "noselect" and "preselect" are present,
--- "preselect" takes precedence.
---
--- Only "fuzzy", "longest", "popup", "preinsert", "preselect" and
--- "preview" have an effect when 'autocomplete' is enabled.
---
--- This option does not apply to `cmdline-completion`. See 'wildoptions'
--- for that.

View File

@@ -441,6 +441,7 @@ function M._lsp_to_complete_items(
empty = 1,
abbr_hlgroup = hl_group,
kind_hlgroup = kind_hlgroup,
preselect = item.preselect,
user_data = {
nvim = {
lsp = {

View File

@@ -474,7 +474,7 @@ function protocol.make_client_capabilities()
completionItem = {
snippetSupport = true,
commitCharactersSupport = false,
preselectSupport = false,
preselectSupport = true,
deprecatedSupport = true,
documentationFormat = { constants.MarkupKind.Markdown, constants.MarkupKind.PlainText },
insertReplaceSupport = true,

View File

@@ -173,6 +173,7 @@ struct compl_S {
///< cp_flags has CP_FREE_FNAME
int cp_flags; ///< CP_ values
int cp_number; ///< sequence number
bool cp_preselect; ///< preselect item
int cp_score; ///< fuzzy match score or proximity score
bool cp_in_match_array; ///< collected by compl_match_array
int cp_user_abbr_hlattr; ///< highlight attribute for abbr
@@ -223,6 +224,7 @@ static compl_T *compl_first_match = NULL;
static compl_T *compl_curr_match = NULL;
static compl_T *compl_shown_match = NULL;
static compl_T *compl_old_match = NULL;
static compl_T *compl_preselect_match = NULL;
/// list used to store the compl_T which have the max score
static compl_T **compl_best_matches = NULL;
@@ -847,7 +849,8 @@ int ins_compl_add_infercase(char *str_arg, int len, bool icase, char *fname, Dir
flags |= CP_ICASE;
}
int res = ins_compl_add(str, len, fname, NULL, false, NULL, dir, flags, false, NULL, score);
int res = ins_compl_add(str, len, fname, NULL, false, NULL, dir, flags, false, NULL, score,
false);
xfree(tofree);
return res;
}
@@ -916,7 +919,8 @@ bool ins_compl_preinsert_longest(void)
/// returned in case of error.
static int ins_compl_add(char *const str, int len, char *const fname, char *const *const cptext,
const bool cptext_allocated, typval_T *user_data, const Direction cdir,
int flags_arg, const bool adup, const int *user_hl, const int score)
int flags_arg, const bool adup, const int *user_hl, const int score,
bool preselect)
FUNC_ATTR_NONNULL_ARG(1)
{
compl_T *match;
@@ -966,6 +970,10 @@ static int ins_compl_add(char *const str, int len, char *const fname, char *cons
match = xcalloc(1, sizeof(compl_T));
match->cp_number = flags & CP_ORIGINAL_TEXT ? 0 : -1;
match->cp_str = cbuf_to_string(str, (size_t)len);
match->cp_preselect = preselect;
if (preselect && compl_preselect_match == NULL) {
compl_preselect_match = match;
}
// match-fname is:
// - compl_curr_match->cp_fname if it is a string equal to fname.
@@ -1214,7 +1222,7 @@ static void ins_compl_add_matches(int num_matches, char **matches, int icase)
for (int i = 0; i < num_matches && add_r != FAIL; i++) {
add_r = ins_compl_add(matches[i], -1, NULL, NULL, false, NULL, dir,
CP_FAST | (icase ? CP_ICASE : 0), false, NULL,
FUZZY_SCORE_NONE);
FUZZY_SCORE_NONE, false);
if (add_r == OK) {
// If dir was BACKWARD then honor it just once.
dir = FORWARD;
@@ -1627,6 +1635,11 @@ static int ins_compl_build_pum(void)
shown_match_ok = true;
}
}
if (comp == compl_preselect_match) {
cur = i;
compl_shown_match = comp;
shown_match_ok = true;
}
i++;
}
}
@@ -2074,6 +2087,7 @@ static void ins_compl_free(void)
} while (compl_curr_match != NULL && !is_first_match(compl_curr_match));
compl_first_match = compl_curr_match = NULL;
compl_shown_match = NULL;
compl_preselect_match = NULL;
compl_old_match = NULL;
}
@@ -3283,6 +3297,7 @@ static int ins_compl_add_tv(typval_T *const tv, const Direction dir, bool fast)
bool dup = false;
bool empty = false;
int flags = fast ? CP_FAST : 0;
bool preselect = false;
char *(cptext[CPT_COUNT]);
char *user_abbr_hlname = NULL;
char *user_kind_hlname = NULL;
@@ -3310,6 +3325,7 @@ static int ins_compl_add_tv(typval_T *const tv, const Direction dir, bool fast)
}
dup = (bool)tv_dict_get_number(tv->vval.v_dict, "dup");
empty = (bool)tv_dict_get_number(tv->vval.v_dict, "empty");
preselect = (bool)tv_dict_get_number(tv->vval.v_dict, "preselect");
if (tv_dict_get_string(tv->vval.v_dict, "equal", false) != NULL
&& tv_dict_get_number(tv->vval.v_dict, "equal")) {
flags |= CP_EQUAL;
@@ -3324,7 +3340,7 @@ static int ins_compl_add_tv(typval_T *const tv, const Direction dir, bool fast)
return FAIL;
}
int status = ins_compl_add((char *)word, -1, NULL, cptext, true,
&user_data, dir, flags, dup, user_hl, FUZZY_SCORE_NONE);
&user_data, dir, flags, dup, user_hl, FUZZY_SCORE_NONE, preselect);
if (status != OK) {
tv_clear(&user_data);
}
@@ -3420,7 +3436,7 @@ static void set_completion(colnr_T startcol, list_T *list)
}
if (ins_compl_add(compl_orig_text.data, (int)compl_orig_text.size,
NULL, NULL, false, NULL, 0,
flags | CP_FAST, false, NULL, FUZZY_SCORE_NONE) != OK) {
flags | CP_FAST, false, NULL, FUZZY_SCORE_NONE, false) != OK) {
return;
}
@@ -3436,7 +3452,10 @@ static void set_completion(colnr_T startcol, list_T *list)
compl_curr_match = compl_first_match;
bool no_select = compl_no_select || compl_longest;
if (compl_no_insert || no_select) {
if ((get_cot_flags() & kOptCotFlagPreselect) && compl_preselect_match && !no_select) {
compl_curr_match = compl_preselect_match->cp_prev;
ins_complete(Ctrl_N, false);
} else if (compl_no_insert || no_select) {
ins_complete(K_DOWN, false);
if (no_select) {
ins_complete(K_UP, false);
@@ -4117,7 +4136,7 @@ static void get_next_filename_completion(void)
int current_score = compl_fuzzy_scores[fuzzy_indices_data[i]];
if (ins_compl_add(match, -1, NULL, NULL, false, NULL, dir,
CP_FAST | ((p_fic || p_wic) ? CP_ICASE : 0),
false, NULL, current_score) == OK) {
false, NULL, current_score, false) == OK) {
dir = FORWARD;
}
@@ -4581,7 +4600,7 @@ static void get_next_bufname_token(void)
char *tail = path_tail(b->b_sfname);
if (strncmp(tail, compl_orig_text.data, compl_orig_text.size) == 0) {
ins_compl_add(tail, (int)strlen(tail), NULL, NULL, false, NULL, 0,
p_ic ? CP_ICASE : 0, false, NULL, FUZZY_SCORE_NONE);
p_ic ? CP_ICASE : 0, false, NULL, FUZZY_SCORE_NONE, false);
}
}
}
@@ -6097,7 +6116,7 @@ static int ins_compl_start(void)
}
if (ins_compl_add(compl_orig_text.data, (int)compl_orig_text.size,
NULL, NULL, false, NULL, 0,
flags, false, NULL, FUZZY_SCORE_NONE) != OK) {
flags, false, NULL, FUZZY_SCORE_NONE, false) != OK) {
API_CLEAR_STRING(compl_pattern);
API_CLEAR_STRING(compl_orig_text);
kv_destroy(compl_orig_extmarks);

View File

@@ -1603,17 +1603,18 @@ local options = {
cb = 'did_set_completeopt',
defaults = 'menu,popup',
values = {
'fuzzy',
'longest',
'menu',
'menuone',
'longest',
'preview',
'popup',
'nearest',
'noinsert',
'noselect',
'fuzzy',
'nosort',
'popup',
'preinsert',
'nearest',
'preselect',
'preview',
},
flags = true,
deny_duplicates = true,
@@ -1690,8 +1691,12 @@ local options = {
completion in the preview window. Only works in
combination with "menu" or "menuone".
Only "fuzzy", "longest", "popup", "preinsert" and "preview" have an
effect when 'autocomplete' is enabled.
preselect Select the completion item that has the "preselect"
attribute set. If both "noselect" and "preselect" are present,
"preselect" takes precedence.
Only "fuzzy", "longest", "popup", "preinsert", "preselect" and
"preview" have an effect when 'autocomplete' is enabled.
This option does not apply to |cmdline-completion|. See 'wildoptions'
for that.

View File

@@ -266,6 +266,70 @@ describe('completion', function()
feed('i<C-r>=TestComplete()<CR><ESC>')
eq(0, eval('&l:modified'))
end)
describe('"preselect"', function()
it('"preselect" selects first item with preselect attribute', function()
source([[
function! TestComplete() abort
call complete(1, [
\ {'word': 'aaa'},
\ {'word': 'bbb'},
\ {'word': 'ccc', 'preselect': v:true},
\ {'word': 'ddd'},
\ ])
return ''
endfunction
function! TestCompleteMany() abort
let items = []
for i in range(20)
call add(items, {'word': 'item' .. i})
endfor
let items[15].preselect = v:true
call complete(1, items)
return ''
endfunction
]])
command('set completeopt=menuone,preselect')
feed('i<C-r>=TestComplete()<CR>')
screen:expect([[
ccc^ |
{4:aaa }{1: }|
{4:bbb }{1: }|
{12:ccc }{1: }|
{4:ddd }{1: }|
{1:~ }|*2
{5:-- INSERT --} |
]])
-- scrolls pum when preselect item is far down
feed('<Esc>S')
command('set pumheight=5')
feed('<C-r>=TestCompleteMany()<CR>')
screen:expect([[
item15^ |
{4:item13 }{12: }{1: }|
{4:item14 }{12: }{1: }|
{12:item15 }{1: }|
{4:item16 }{101: }{1: }|
{4:item17 }{12: }{1: }|
{1:~ }|
{5:-- INSERT --} |
]])
feed('<Esc>S')
command('set completeopt=menuone,noselect,preselect pumheight=0')
feed('<C-r>=TestComplete()<CR>')
screen:expect([[
^ |
{4:aaa }{1: }|
{4:bbb }{1: }|
{12:ccc }{1: }|
{4:ddd }{1: }|
{1:~ }|*2
{5:-- INSERT --} |
]])
end)
end)
end)
describe('completeopt+=noinsert does not add blank undo items', function()

View File

@@ -1547,6 +1547,29 @@ describe('vim.lsp.completion: integration', function()
feed('<C-y>')
eq('hallo', n.api.nvim_get_current_line())
end)
it('preselect completion item', function()
local completion_list = {
isIncomplete = false,
items = {
{ label = 'aaa' },
{ label = 'zzz', preselect = true },
{ label = 'mmm' },
},
}
exec_lua(function()
vim.o.completeopt = 'menuone,noselect,preselect'
end)
create_server('dummy', completion_list)
feed('i<C-x><C-o>')
wait_for_pum()
eq(
2,
exec_lua(function()
return vim.fn.complete_info({ 'selected' }).selected
end)
)
end)
end)
describe("vim.lsp.completion: omnifunc + 'autocomplete'", function()

View File

@@ -542,7 +542,7 @@ func Test_set_completion_string_values()
call assert_match('unnamed', getcompletion('set clipboard=', 'cmdline')[0])
endif
call assert_equal('.', getcompletion('set complete=', 'cmdline')[1])
call assert_equal('menu', getcompletion('set completeopt=', 'cmdline')[1])
call assert_equal('fuzzy', getcompletion('set completeopt=', 'cmdline')[1])
" call assert_equal('keyword', getcompletion('set completefuzzycollect=', 'cmdline')[0])
if exists('+completeslash')
call assert_equal('backslash', getcompletion('set completeslash=', 'cmdline')[1])