patch 9.2.0433: customlist completion cannot supply pum metadata
Commit:
https://github.com/vim/vim/commit/5c700152ae23c91b6edef3fa3e7ba06d40be0f9e
Author: Yasuhiro Matsumoto <
matt...@gmail.com>
Date: Sat May 2 16:04:38 2026 +0000
patch 9.2.0433: customlist completion cannot supply pum metadata
Problem: customlist completion cannot supply pum metadata
Solution: Allow each item returned by a customlist function to be
either a string or a Dict with keys "word", "abbr", "kind",
"menu" and "info" (Yasuhiro Matsumoto).
closes: #20100
Signed-off-by: Yasuhiro Matsumoto <
matt...@gmail.com>
Signed-off-by: Christian Brabandt <
c...@256bit.org>
diff --git a/runtime/doc/map.txt b/runtime/doc/map.txt
index 118e2d44e..a79c0388c 100644
--- a/runtime/doc/map.txt
+++ b/runtime/doc/map.txt
@@ -1,4 +1,4 @@
-*map.txt* For Vim version 9.2. Last change: 2026 Feb 14
+*map.txt* For Vim version 9.2. Last change: 2026 May 02
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -1693,7 +1693,21 @@ For the "custom" argument, the function should return the completion
candidates one per line in a newline separated string.
*E1303*
For the "customlist" argument, the function should return the completion
-candidates as a Vim List. Non-string items in the list are ignored.
+candidates as a Vim List. Each item may be either a string or a |Dictionary|.
+A Dictionary item may have the following keys:
+ word (required) the text inserted into the command line when the
+ item is selected
+ abbr alternative text shown in the popup menu in place of "word",
+ when 'wildoptions' contains "pum"; useful when the inserted
+ text and the displayed text should differ
+ kind short kind text (one or two characters), shown in the popup
+ menu when 'wildoptions' contains "pum"
+ menu extra text shown after the match in the popup menu
+ info long description shown in the info popup; the |+popupwin|
+ feature is required to display it
+Items that are neither a string nor a Dictionary, and Dictionary items without
+a "word" key, are ignored. When 'wildoptions' does not contain "pum", only
+"word" is shown.
The function arguments are:
ArgLead the leading portion of the argument currently being
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index c9ede397b..114bdf7da 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -1,4 +1,4 @@
-*version9.txt* For Vim version 9.2. Last change: 2026 May 01
+*version9.txt* For Vim version 9.2. Last change: 2026 May 02
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -52623,6 +52623,8 @@ Other ~
'completeopt' option
- Channel can handle |Blob| messages |channel-open-options|.
- Added the "u" flag to 'shortmess' to silence undo/redo messages: |shm-u|
+- |:command-completion-customlist| can return a list of dictionaries with
+ kind/menu/info/abbr for the popup menu.
Platform specific ~
-----------------
diff --git a/src/cmdexpand.c b/src/cmdexpand.c
index a4891871f..c265ecbe7 100644
--- a/src/cmdexpand.c
+++ b/src/cmdexpand.c
@@ -412,10 +412,16 @@ cmdline_pum_create(
compl_match_arraysize = numMatches;
for (int i = 0; i < numMatches; i++)
{
- compl_match_array[i].pum_text = SHOW_MATCH(i);
- compl_match_array[i].pum_info = NULL;
- compl_match_array[i].pum_extra = NULL;
- compl_match_array[i].pum_kind = NULL;
+ compl_match_array[i].pum_text = (xp->xp_files_abbr != NULL
+ && xp->xp_files_abbr[i] != NULL)
+ ? xp->xp_files_abbr[i]
+ : SHOW_MATCH(i);
+ compl_match_array[i].pum_info = xp->xp_files_info != NULL
+ ? xp->xp_files_info[i] : NULL;
+ compl_match_array[i].pum_extra = xp->xp_files_menu != NULL
+ ? xp->xp_files_menu[i] : NULL;
+ compl_match_array[i].pum_kind = xp->xp_files_kind != NULL
+ ? xp->xp_files_kind[i] : NULL;
compl_match_array[i].pum_user_abbr_hlattr = -1;
compl_match_array[i].pum_user_kind_hlattr = -1;
}
@@ -1021,6 +1027,31 @@ find_longest_match(expand_T *xp, int options)
return ss;
}
+ static void
+free_xp_files_extra(expand_T *xp, int numfiles)
+{
+ if (xp->xp_files_abbr != NULL)
+ {
+ FreeWild(numfiles, xp->xp_files_abbr);
+ xp->xp_files_abbr = NULL;
+ }
+ if (xp->xp_files_kind != NULL)
+ {
+ FreeWild(numfiles, xp->xp_files_kind);
+ xp->xp_files_kind = NULL;
+ }
+ if (xp->xp_files_menu != NULL)
+ {
+ FreeWild(numfiles, xp->xp_files_menu);
+ xp->xp_files_menu = NULL;
+ }
+ if (xp->xp_files_info != NULL)
+ {
+ FreeWild(numfiles, xp->xp_files_info);
+ xp->xp_files_info = NULL;
+ }
+}
+
/*
* Do wildcard expansion on the string "str".
* Chars that should not be expanded must be preceded with a backslash.
@@ -1087,6 +1118,7 @@ ExpandOne(
if (xp->xp_numfiles != -1 && mode != WILD_ALL && mode != WILD_LONGEST)
{
FreeWild(xp->xp_numfiles, xp->xp_files);
+ free_xp_files_extra(xp, xp->xp_numfiles);
xp->xp_numfiles = -1;
VIM_CLEAR(xp->xp_orig);
@@ -1188,6 +1220,7 @@ ExpandCleanup(expand_T *xp)
{
if (xp->xp_numfiles >= 0)
{
+ free_xp_files_extra(xp, xp->xp_numfiles);
FreeWild(xp->xp_numfiles, xp->xp_files);
xp->xp_numfiles = -1;
}
@@ -1424,7 +1457,10 @@ showmatches(
}
if (xp->xp_numfiles == -1)
+ {
FreeWild(numMatches, matches);
+ free_xp_files_extra(xp, numMatches);
+ }
return EXPAND_OK;
}
@@ -4124,6 +4160,12 @@ ExpandUserList(
list_T *retlist;
listitem_T *li;
garray_T ga;
+ garray_T ga_abbr;
+ garray_T ga_kind;
+ garray_T ga_menu;
+ garray_T ga_info;
+ int have_extra = FALSE;
+ int i;
*matches = NULL;
*numMatches = 0;
@@ -4132,31 +4174,92 @@ ExpandUserList(
return FAIL;
ga_init2(&ga, sizeof(char *), 3);
+ ga_init2(&ga_abbr, sizeof(char *), 3);
+ ga_init2(&ga_kind, sizeof(char *), 3);
+ ga_init2(&ga_menu, sizeof(char *), 3);
+ ga_init2(&ga_info, sizeof(char *), 3);
// Loop over the items in the list.
FOR_ALL_LIST_ITEMS(retlist, li)
{
- char_u *p;
+ char_u *p = NULL;
+ char_u *abbr = NULL;
+ char_u *kind = NULL;
+ char_u *menu = NULL;
+ char_u *info = NULL;
- if (li->li_tv.v_type != VAR_STRING || li->li_tv.vval.v_string == NULL)
- continue; // Skip non-string items and empty strings
-
- p = vim_strsave(li->li_tv.vval.v_string);
- if (p == NULL)
- break;
-
- if (ga_grow(&ga, 1) == FAIL)
+ if (li->li_tv.v_type == VAR_STRING)
+ {
+ if (li->li_tv.vval.v_string == NULL)
+ continue; // Skip empty strings
+ p = vim_strsave(li->li_tv.vval.v_string);
+ }
+ else if (li->li_tv.v_type == VAR_DICT
+ && li->li_tv.vval.v_dict != NULL)
+ {
+ dict_T *d = li->li_tv.vval.v_dict;
+ char_u *word = dict_get_string(d, "word", FALSE);
+
+ if (word == NULL)
+ continue; // "word" is required
+ p = vim_strsave(word);
+ abbr = dict_get_string(d, "abbr", TRUE);
+ kind = dict_get_string(d, "kind", TRUE);
+ menu = dict_get_string(d, "menu", TRUE);
+ info = dict_get_string(d, "info", TRUE);
+ if (abbr != NULL || kind != NULL || menu != NULL || info != NULL)
+ have_extra = TRUE;
+ }
+ else
+ continue; // Skip other types
+
+ if (p == NULL
+ || ga_grow(&ga, 1) == FAIL
+ || ga_grow(&ga_abbr, 1) == FAIL
+ || ga_grow(&ga_kind, 1) == FAIL
+ || ga_grow(&ga_menu, 1) == FAIL
+ || ga_grow(&ga_info, 1) == FAIL)
{
vim_free(p);
+ vim_free(abbr);
+ vim_free(kind);
+ vim_free(menu);
+ vim_free(info);
break;
}
- ((char_u **)ga.ga_data)[ga.ga_len] = p;
- ++ga.ga_len;
+ ((char_u **)ga.ga_data)[ga.ga_len++] = p;
+ ((char_u **)ga_abbr.ga_data)[ga_abbr.ga_len++] = abbr;
+ ((char_u **)ga_kind.ga_data)[ga_kind.ga_len++] = kind;
+ ((char_u **)ga_menu.ga_data)[ga_menu.ga_len++] = menu;
+ ((char_u **)ga_info.ga_data)[ga_info.ga_len++] = info;
}
list_unref(retlist);
*matches = ga.ga_data;
*numMatches = ga.ga_len;
+ if (have_extra && ga.ga_len > 0)
+ {
+ xp->xp_files_abbr = (char_u **)ga_abbr.ga_data;
+ xp->xp_files_kind = (char_u **)ga_kind.ga_data;
+ xp->xp_files_menu = (char_u **)ga_menu.ga_data;
+ xp->xp_files_info = (char_u **)ga_info.ga_data;
+ }
+ else
+ {
+ // No extra info collected; free the placeholder NULL entries.
+ for (i = 0; i < ga_abbr.ga_len; i++)
+ vim_free(((char_u **)ga_abbr.ga_data)[i]);
+ vim_free(ga_abbr.ga_data);
+ for (i = 0; i < ga_kind.ga_len; i++)
+ vim_free(((char_u **)ga_kind.ga_data)[i]);
+ vim_free(ga_kind.ga_data);
+ for (i = 0; i < ga_menu.ga_len; i++)
+ vim_free(((char_u **)ga_menu.ga_data)[i]);
+ vim_free(ga_menu.ga_data);
+ for (i = 0; i < ga_info.ga_len; i++)
+ vim_free(((char_u **)ga_info.ga_data)[i]);
+ vim_free(ga_info.ga_data);
+ }
return OK;
}
#endif
diff --git a/src/structs.h b/src/structs.h
index 8429ebe29..4d92ca75a 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -678,6 +678,17 @@ typedef struct expand
int xp_selected; // selected index in completion
char_u *xp_orig; // originally expanded string
char_u **xp_files; // list of files
+ char_u **xp_files_abbr; // optional parallel array of display
+ // strings (override xp_files for the
+ // pum text); NULL if unused
+ char_u **xp_files_kind; // optional parallel array of "kind"
+ // strings; NULL if unused
+ char_u **xp_files_menu; // optional parallel array of "menu"
+ // strings (shown after the match);
+ // NULL if unused
+ char_u **xp_files_info; // optional parallel array of "info"
+ // strings (shown in info popup);
+ // NULL if unused
char_u *xp_line; // text being completed
#define EXPAND_BUF_LEN 256
char_u xp_buf[EXPAND_BUF_LEN]; // buffer for returned match
diff --git a/src/testdir/test_cmdline.vim b/src/testdir/test_cmdline.vim
index e793292ff..efe73a1e6 100644
--- a/src/testdir/test_cmdline.vim
+++ b/src/testdir/test_cmdline.vim
@@ -4594,6 +4594,53 @@ func Test_custom_completion()
delfunc Check_customlist_completion
endfunc
+" Test that 'customlist' completion accepts dict items with extra info
+" (kind/menu/info) for display in the popup menu, and that string items still
+" work in the same list.
+func Test_customlist_dict_completion()
+ func DictComp(A, L, P)
+ return [
+ \ {'word': 'apple', 'kind': 'f', 'menu': 'fruit', 'info': 'A red fruit'},
+ \ {'word': 'banana', 'kind': 'f', 'menu': 'fruit', 'info': 'A yellow fruit'},
+ \ {'word': 'carrot', 'kind': 'v', 'menu': 'vegetable', 'info': 'An orange vegetable'},
+ \ 'plain',
+ \ ]
+ endfunc
+ command -nargs=1 -complete=customlist,DictComp DictCmd echo <q-args>
+
+ " getcompletion() returns only the "word" of each item; string items pass
+ " through unchanged.
+ call assert_equal(['apple', 'banana', 'carrot', 'plain'],
+ \ getcompletion('', 'customlist,DictComp'))
+
+ " Items missing a "word" key are silently skipped.
+ func DictCompMissingWord(A, L, P)
+ return [{'kind': 'x'}, {'word': 'ok'}]
+ endfunc
+ call assert_equal(['ok'],
+ \ getcompletion('', 'customlist,DictCompMissingWord'))
+
+ " Tab completion still selects the word.
+ call feedkeys(":DictCmd a\<Tab>\<C-B>\"\<CR>", 'xt')
+ call assert_equal('"DictCmd apple', @:)
+
+ " "abbr" overrides display only; "word" is what gets inserted.
+ func DictCompAbbr(A, L, P)
+ return [{'word': 'apple', 'abbr': 'APPLE🍎'}]
+ endfunc
+ call assert_equal(['apple'],
+ \ getcompletion('', 'customlist,DictCompAbbr'))
+ command -nargs=1 -complete=customlist,DictCompAbbr DictAbbrCmd echo <q-args>
+ call feedkeys(":DictAbbrCmd \<Tab>\<C-B>\"\<CR>", 'xt')
+ call assert_equal('"DictAbbrCmd apple', @:)
+
+ delcommand DictAbbrCmd
+ delcommand DictCmd
+ delfunc DictComp
+ delfunc DictCompMissingWord
+ delfunc DictCompAbbr
+endfunc
+
func Test_custom_completion_with_glob()
func TestGlobComplete(A, L, P)
return split(glob('Xglob*'), "
")
diff --git a/src/version.c b/src/version.c
index c75dd6a65..3a27d0c52 100644
--- a/src/version.c
+++ b/src/version.c
@@ -729,6 +729,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 433,
/**/
432,
/**/