patch 9.2.0638: cannot return matches containing spaces from a custom completion
Commit:
https://github.com/vim/vim/commit/3c76fd24878fffb7fc9285bb48026f4c5c3cee06
Author: Yasuhiro Matsumoto <
matt...@gmail.com>
Date: Sat Jun 13 19:18:55 2026 +0000
patch 9.2.0638: cannot return matches containing spaces from a custom completion
Problem: A completion function for a user command cannot return a
match containing whitespace; the argument splitter breaks it
into multiple arguments.
Solution: Add -completeopt=escape to escape spaces, tabs and
backslashes in inserted matches.
When a user command uses -complete=custom or -complete=customlist, the
completion function may return matches containing spaces or backslashes.
Without escaping, those characters end up as unescaped text in the
command line and are split into separate arguments.
The new -completeopt=escape attribute makes Vim escape spaces and
backslashes when the selected match is inserted into the command line,
while keeping the unescaped form for the popup menu, wildmenu and
getcompletion(). ArgLead passed to the completion function is also
unescaped, so the function sees the logical argument.
closes: #20239
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 38de46c65..98b381990 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 May 23
+*map.txt* For Vim version 9.2. Last change: 2026 Jun 13
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -1620,6 +1620,11 @@ Completing ":MyCmd2 two va<tab>" will complete with: >
:MyCmd2 two values
+Use -nargs=_ when the whole argument area should be taken as a single value
+as-is. When you need several arguments and an individual argument may itself
+contain spaces (so the splitter must keep running), use -nargs=+/* together
+with |:command-completeopt| (-completeopt=escape) instead.
+
Note that arguments are used as text, not as expressions. Specifically,
"s:var" will use the script-local variable in the script where the command was
@@ -1758,6 +1763,50 @@ the 'path' option: >
<
This example does not work for file names with spaces!
+ *:command-completeopt*
+For the "custom" and "customlist" types you can further opt into specific
+behaviors with the -completeopt= attribute. It takes a comma-separated list
+of option names; currently only "escape" is recognized:
+
+ -completeopt=escape
+ The completion function may return matches containing spaces,
+ tabs or backslashes. When such a match is inserted into the
+ command line each space, tab and backslash is preceded by a
+ backslash so the value is preserved as a single argument. The
+ unescaped form is used for display (popup menu / wildmenu /
+ |getcompletion()|). The ArgLead passed to the completion
+ function is also unescaped: when the user types
+ `:Cmd foo\ b<Tab>` the function is called with `foo b`, not
+ `foo\ b` . In the command's replacement text |<q-args>| and
+ |<f-args>| likewise yield the unescaped value.
+
+Example: >
+ :func MyComplete(ArgLead, CmdLine, CursorPos)
+ : return filter(['hello world', 'good morning'],
+ : \ {_, v -> stridx(v, a:ArgLead) ==# 0})
+ :endfunc
+ :com! -nargs=1 -complete=customlist,MyComplete -completeopt=escape MyCmd
+ \ echo <q-args>
+<
+After `:MyCmd <Tab>` the popup shows `hello world` and `good morning`. Typing
+`:MyCmd hello\ w<Tab>` calls MyComplete with ArgLead set to `hello w` (the
+backslash before the space is removed), the filter finds `hello world`, and
+`<Tab>` inserts it as `:MyCmd hello\ world`. `<q-args>` then evaluates to the
+logical `hello world`.
+
+Without -completeopt=escape the literal string `hello world` would be inserted
+into the command line, so Vim's argument splitter would treat it as two
+arguments.
+
+-completeopt=escape and -nargs=_ (see |:command-nargs|) target different
+shapes; they are not competing solutions for the same one:
+ "whole line as one argument, no escaping"
+ use -nargs=_
+ "multiple arguments, each may contain spaces"
+ use -nargs=+ or -nargs=* with -completeopt=escape
+Because -nargs=_ disables the argument splitter, escaping has no effect there,
+so combining -nargs=_ with -completeopt=escape is an error (E1579).
+
Range handling ~
*E177* *E178* *:command-range* *:command-count*
diff --git a/runtime/doc/tags b/runtime/doc/tags
index d056a7eed..7fbe14923 100644
--- a/runtime/doc/tags
+++ b/runtime/doc/tags
@@ -2525,6 +2525,7 @@ $quote eval.txt /*$quote*
:command-bar map.txt /*:command-bar*
:command-buffer map.txt /*:command-buffer*
:command-complete map.txt /*:command-complete*
+:command-completeopt map.txt /*:command-completeopt*
:command-completion map.txt /*:command-completion*
:command-completion-custom map.txt /*:command-completion-custom*
:command-completion-customlist map.txt /*:command-completion-customlist*
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index df216fae1..c7fb72cdd 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -52617,6 +52617,15 @@ when Vim is running in |restricted-mode|.
Using |:cscope| is no longer allowed.
+Commands ~
+--------
+- |:command-completion-customlist| can return a list of dictionaries with
+ kind/menu/info/abbr for the popup menu.
+- New argument handling for user commands |:command-nargs| using the "-nars=_"
+ attribute to handle completion of single arguments with spaces as expected.
+- New :command attribute |:command-completeopt| escapes spaces in
+ custom completion matches so they survive as a single argument.
+
Other ~
-----
- The new |xdg.vim| script for full XDG compatibility is included.
@@ -52639,13 +52648,9 @@ 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.
- |C-indenting| detects comments better.
- The |package-hlyank| can now optionally highlight the last put region as
well.
-- New argument handling for user commands |:command-nargs| using the "-nars=_"
- attribute to handle completion of single arguments with spaces as expected.
- Support %0{} in 'statusline' to insert the expression result verbatim and
not drop leading spaces |stl-%0{|.
- Generated Session and View files are written in Vim9 script, see |:mksession|,
diff --git a/src/cmdexpand.c b/src/cmdexpand.c
index 8d9c2d7b7..63b492160 100644
--- a/src/cmdexpand.c
+++ b/src/cmdexpand.c
@@ -25,6 +25,8 @@ static int expand_shellcmd(char_u *filepat, char_u ***matches, int *numMatches,
#if defined(FEAT_EVAL)
static int ExpandUserDefined(char_u *pat, expand_T *xp, regmatch_T *regmatch, char_u ***matches, int *numMatches);
static int ExpandUserList(expand_T *xp, char_u ***matches, int *numMatches);
+static char_u *apply_user_completeopt_escape(expand_T *xp, char_u *str);
+static char_u *unescape_user_completeopt_pat(expand_T *xp, char_u *src, int srclen, int *new_lenp);
#endif
static int expand_pattern_in_buf(char_u *pat, int dir, char_u ***matches, int *numMatches);
@@ -294,13 +296,24 @@ nextwild(
else
{
char_u *tmp;
+ char_u *pat_src = xp->xp_pattern;
+ int pat_len = xp->xp_pattern_len;
+#if defined(FEAT_EVAL)
+ char_u *unesc = unescape_user_completeopt_pat(xp, pat_src, pat_len,
+ &pat_len);
+ if (unesc != NULL)
+ pat_src = unesc;
+#endif
if (cmdline_fuzzy_completion_supported(xp)
|| xp->xp_context == EXPAND_PATTERN_IN_BUF)
// Don't modify the search string
- tmp = vim_strnsave(xp->xp_pattern, xp->xp_pattern_len);
+ tmp = vim_strnsave(pat_src, pat_len);
else
- tmp = addstar(xp->xp_pattern, xp->xp_pattern_len, xp->xp_context);
+ tmp = addstar(pat_src, pat_len, xp->xp_context);
+#if defined(FEAT_EVAL)
+ vim_free(unesc);
+#endif
// Translate string into pattern and expand it.
if (tmp == NULL)
@@ -805,6 +818,72 @@ win_redr_status_matches(
vim_free(buf);
}
+#if defined(FEAT_EVAL)
+/*
+ * Apply -completeopt=escape to a string about to be inserted into the command
+ * line as a completion result. If "str" is non-NULL and the active expansion
+ * context is a customlist/custom user command with UCC_ESCAPE set, free "str"
+ * and return a newly-allocated copy with spaces, tabs and backslashes prefixed
+ * by a backslash. Otherwise return "str" unchanged.
+ */
+ static char_u *
+apply_user_completeopt_escape(expand_T *xp, char_u *str)
+{
+ char_u *p;
+
+ if (str == NULL)
+ return NULL;
+ if ((xp->xp_context != EXPAND_USER_DEFINED
+ && xp->xp_context != EXPAND_USER_LIST)
+ || !(xp->xp_complete_opt & UCC_ESCAPE))
+ return str;
+ p = vim_strsave_escaped(str, (char_u *)" \");
+ if (p == NULL)
+ return str;
+ vim_free(str);
+ return p;
+}
+
+/*
+ * For -completeopt=escape on a user command, build the "logical" ArgLead by
+ * collapsing a backslash before a space, tab or backslash in the typed text.
+ * The completion function then sees "foo bar" instead of "foo\ bar".
+ * Returns a newly-allocated string and stores its length in "*new_lenp", or
+ * NULL when no unescape is applicable (caller should keep the original).
+ */
+ static char_u *
+unescape_user_completeopt_pat(
+ expand_T *xp,
+ char_u *src,
+ int srclen,
+ int *new_lenp)
+{
+ char_u *buf, *p, *d, *end;
+
+ if ((xp->xp_context != EXPAND_USER_DEFINED
+ && xp->xp_context != EXPAND_USER_LIST)
+ || !(xp->xp_complete_opt & UCC_ESCAPE))
+ return NULL;
+
+ buf = alloc(srclen + 1);
+ if (buf == NULL)
+ return NULL;
+
+ d = buf;
+ end = src + srclen;
+ for (p = src; p < end; ++p)
+ {
+ if (*p == '\' && p + 1 < end
+ && (p[1] == ' ' || p[1] == TAB || p[1] == '\'))
+ ++p;
+ *d++ = *p;
+ }
+ *d = NUL;
+ *new_lenp = (int)(d - buf);
+ return buf;
+}
+#endif
+
/*
* Get the next or prev cmdline completion match. The index of the match is set
* in "xp->xp_selected"
@@ -901,7 +980,13 @@ get_next_or_prev_match(int mode, expand_T *xp)
xp->xp_selected = findex;
// Return the original text or the selected match
- return vim_strsave(findex == -1 ? xp->xp_orig : xp->xp_files[findex]);
+ if (findex == -1)
+ return vim_strsave(xp->xp_orig);
+#if defined(FEAT_EVAL)
+ return apply_user_completeopt_escape(xp, vim_strsave(xp->xp_files[findex]));
+#else
+ return vim_strsave(xp->xp_files[findex]);
+#endif
}
/*
@@ -1101,6 +1186,12 @@ ExpandOne(
{
char_u *ss = NULL;
int orig_saved = FALSE;
+#if defined(FEAT_EVAL)
+ // ss_is_match is TRUE when ss is derived from xp_files and should be
+ // escaped per -completeopt=escape before being inserted. WILD_CANCEL
+ // and WILD_APPLY-without-selection return xp_orig unchanged.
+ int ss_is_match = FALSE;
+#endif
// first handle the case of using an old match
if (mode == WILD_NEXT || mode == WILD_PREV
@@ -1110,9 +1201,17 @@ ExpandOne(
if (mode == WILD_CANCEL)
ss = vim_strsave(xp->xp_orig ? xp->xp_orig : (char_u *)"");
else if (mode == WILD_APPLY)
- ss = vim_strsave(xp->xp_selected == -1
- ? (xp->xp_orig ? xp->xp_orig : (char_u *)"")
- : xp->xp_files[xp->xp_selected]);
+ {
+ if (xp->xp_selected == -1)
+ ss = vim_strsave(xp->xp_orig ? xp->xp_orig : (char_u *)"");
+ else
+ {
+ ss = vim_strsave(xp->xp_files[xp->xp_selected]);
+#if defined(FEAT_EVAL)
+ ss_is_match = TRUE;
+#endif
+ }
+ }
// free old names
if (xp->xp_numfiles != -1 && mode != WILD_ALL && mode != WILD_LONGEST)
@@ -1138,6 +1237,10 @@ ExpandOne(
orig_saved = TRUE;
ss = ExpandOne_start(mode, xp, str, options);
+#if defined(FEAT_EVAL)
+ if (ss != NULL)
+ ss_is_match = TRUE;
+#endif
}
// Find longest common part
@@ -1145,6 +1248,10 @@ ExpandOne(
{
ss = find_longest_match(xp, options);
xp->xp_selected = -1; // next p_wc gets first one
+#if defined(FEAT_EVAL)
+ if (ss != NULL)
+ ss_is_match = TRUE;
+#endif
}
// Concatenate all matching names. Unless interrupted, this can be slow
@@ -1156,6 +1263,38 @@ ExpandOne(
char *suffix = (options & WILD_USE_NL) ? "
" : " ";
int n = xp->xp_numfiles - 1;
int i;
+#if defined(FEAT_EVAL)
+ char_u **files = xp->xp_files;
+ char_u **escaped = NULL;
+
+ // When -completeopt=escape is set for a user command, escape each
+ // match before joining so the separator spaces stay unescaped.
+ if ((xp->xp_context == EXPAND_USER_DEFINED
+ || xp->xp_context == EXPAND_USER_LIST)
+ && (xp->xp_complete_opt & UCC_ESCAPE))
+ {
+ escaped = ALLOC_MULT(char_u *, xp->xp_numfiles);
+ if (escaped != NULL)
+ {
+ for (i = 0; i < xp->xp_numfiles; ++i)
+ {
+ escaped[i] = vim_strsave_escaped(xp->xp_files[i],
+ (char_u *)" \");
+ if (escaped[i] == NULL)
+ {
+ while (--i >= 0)
+ vim_free(escaped[i]);
+ VIM_CLEAR(escaped);
+ break;
+ }
+ }
+ if (escaped != NULL)
+ files = escaped;
+ }
+ }
+#else
+ char_u **files = xp->xp_files;
+#endif
if (xp->xp_prefix == XP_PREFIX_NO)
{
@@ -1169,7 +1308,7 @@ ExpandOne(
}
for (i = 0; i < xp->xp_numfiles; ++i)
- ss_size += STRLEN(xp->xp_files[i]) + 1; // +1 for the suffix
+ ss_size += STRLEN(files[i]) + 1; // +1 for the suffix
++ss_size; // +1 for the NUL
ss = alloc(ss_size);
@@ -1184,10 +1323,18 @@ ExpandOne(
ss_size - ss_len,
"%s%s%s",
(i > 0) ? prefix : "",
- (char *)xp->xp_files[i],
+ (char *)files[i],
(i < n) ? suffix : "");
}
}
+#if defined(FEAT_EVAL)
+ if (escaped != NULL)
+ {
+ for (i = 0; i < xp->xp_numfiles; ++i)
+ vim_free(escaped[i]);
+ vim_free(escaped);
+ }
+#endif
}
if (mode == WILD_EXPAND_FREE || mode == WILD_ALL)
@@ -1197,6 +1344,12 @@ ExpandOne(
if (!orig_saved)
vim_free(orig);
+#if defined(FEAT_EVAL)
+ // WILD_ALL already escaped its component matches in place, so don't
+ // re-escape the joined string (its separator spaces would break).
+ if (ss_is_match && mode != WILD_ALL)
+ ss = apply_user_completeopt_escape(xp, ss);
+#endif
return ss;
}
@@ -3053,11 +3206,25 @@ expand_cmdline(
// add star to file name, or convert to regexp if not exp. files.
xp->xp_pattern_len = (int)(str + col - xp->xp_pattern);
- if (cmdline_fuzzy_completion_supported(xp))
- // If fuzzy matching, don't modify the search string
- file_str = vim_strsave(xp->xp_pattern);
- else
- file_str = addstar(xp->xp_pattern, xp->xp_pattern_len, xp->xp_context);
+ {
+ char_u *pat_src = xp->xp_pattern;
+ int pat_len = xp->xp_pattern_len;
+#if defined(FEAT_EVAL)
+ char_u *unesc = unescape_user_completeopt_pat(xp, pat_src, pat_len,
+ &pat_len);
+ if (unesc != NULL)
+ pat_src = unesc;
+#endif
+
+ if (cmdline_fuzzy_completion_supported(xp))
+ // If fuzzy matching, don't modify the search string
+ file_str = vim_strnsave(pat_src, pat_len);
+ else
+ file_str = addstar(pat_src, pat_len, xp->xp_context);
+#if defined(FEAT_EVAL)
+ vim_free(unesc);
+#endif
+ }
if (file_str == NULL)
return EXPAND_UNSUCCESSFUL;
@@ -3360,6 +3527,7 @@ ExpandOther(
{EXPAND_USER_CMD_FLAGS, get_user_cmd_flags, FALSE, TRUE},
{EXPAND_USER_NARGS, get_user_cmd_nargs, FALSE, TRUE},
{EXPAND_USER_COMPLETE, get_user_cmd_complete, FALSE, TRUE},
+ {EXPAND_USER_COMPLETEOPT, get_user_cmd_completeopt, FALSE, TRUE},
#ifdef FEAT_EVAL
{EXPAND_USER_VARS, get_user_var_name, FALSE, TRUE},
{EXPAND_FUNCTIONS, get_function_name, FALSE, TRUE},
@@ -4022,7 +4190,13 @@ call_user_expand_func(
ccline->cmdbuff[ccline->cmdlen] = 0;
}
- pat = vim_strnsave(xp->xp_pattern, xp->xp_pattern_len);
+ {
+ int unesc_len;
+ pat = unescape_user_completeopt_pat(xp, xp->xp_pattern,
+ xp->xp_pattern_len, &unesc_len);
+ if (pat == NULL)
+ pat = vim_strnsave(xp->xp_pattern, xp->xp_pattern_len);
+ }
args[0].v_type = VAR_STRING;
args[0].vval.v_string = pat;
@@ -4783,11 +4957,21 @@ f_getcompletion(typval_T *argvars, typval_T *rettv)
}
}
- if (cmdline_fuzzy_completion_supported(&xpc))
- // when fuzzy matching, don't modify the search string
- pat = vim_strnsave(xpc.xp_pattern, xpc.xp_pattern_len);
- else
- pat = addstar(xpc.xp_pattern, xpc.xp_pattern_len, xpc.xp_context);
+ {
+ char_u *pat_src = xpc.xp_pattern;
+ int pat_len = xpc.xp_pattern_len;
+ char_u *unesc = unescape_user_completeopt_pat(&xpc, pat_src, pat_len,
+ &pat_len);
+ if (unesc != NULL)
+ pat_src = unesc;
+
+ if (cmdline_fuzzy_completion_supported(&xpc))
+ // when fuzzy matching, don't modify the search string
+ pat = vim_strnsave(pat_src, pat_len);
+ else
+ pat = addstar(pat_src, pat_len, xpc.xp_context);
+ vim_free(unesc);
+ }
if (rettv_list_alloc(rettv) == OK && pat != NULL)
{
diff --git a/src/errors.h b/src/errors.h
index 9dbd7ac4d..530e47c1d 100644
--- a/src/errors.h
+++ b/src/errors.h
@@ -3816,3 +3816,5 @@ EXTERN char e_invalid_format_string_single_percent_s[]
EXTERN char e_too_many_postponed_prefixes_spell[]
INIT(= N_("E1578: Too many postponed prefixes and/or compound flags"));
#endif
+EXTERN char e_completeopt_escape_cannot_be_used_with_nargs_underscore[]
+ INIT(= N_("E1579: -completeopt=escape cannot be used with -nargs=_"));
diff --git a/src/po/vim.pot b/src/po/vim.pot
index d21055030..fa6d37d69 100644
--- a/src/po/vim.pot
+++ b/src/po/vim.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Vim
"
"Report-Msgid-Bugs-To:
vim...@vim.org
"
-"POT-Creation-Date: 2026-06-13 17:59+0000
"
+"POT-Creation-Date: 2026-06-13 19:24+0000
"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE
"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>
"
"Language-Team: LANGUAGE <
L...@li.org>
"
@@ -8886,6 +8886,9 @@ msgstr ""
msgid "E1578: Too many postponed prefixes and/or compound flags"
msgstr ""
+msgid "E1579: -completeopt=escape cannot be used with -nargs=_"
+msgstr ""
+
#. type of cmdline window or 0
#. result of cmdline window or 0
#. buffer of cmdline window or NULL
diff --git a/src/proto/
usercmd.pro b/src/proto/
usercmd.pro
index 8c53f562b..fa45ad152 100644
--- a/src/proto/
usercmd.pro
+++ b/src/proto/
usercmd.pro
@@ -9,6 +9,7 @@ char_u *get_user_cmd_addr_type(expand_T *xp, int idx);
char_u *get_user_cmd_flags(expand_T *xp, int idx);
char_u *get_user_cmd_nargs(expand_T *xp, int idx);
char_u *get_user_cmd_complete(expand_T *xp, int idx);
+char_u *get_user_cmd_completeopt(expand_T *xp, int idx);
char_u *cmdcomplete_type_to_str(int expand, char_u *compl_arg);
int cmdcomplete_str_to_type(char_u *complete_str);
char *uc_fun_cmd(void);
diff --git a/src/structs.h b/src/structs.h
index 99123f309..5d6511a5a 100644
--- a/src/structs.h
+++ b/src/structs.h
@@ -665,6 +665,7 @@ typedef struct expand
xp_prefix_T xp_prefix;
#if defined(FEAT_EVAL)
char_u *xp_arg; // completion function
+ int xp_complete_opt; // UCC_ flags for user command
sctx_T xp_script_ctx; // SCTX for completion function
#endif
int xp_backslash; // one of the XP_BS_ values
diff --git a/src/testdir/test_usercommands.vim b/src/testdir/test_usercommands.vim
index 8b90a826e..e505e2d4c 100644
--- a/src/testdir/test_usercommands.vim
+++ b/src/testdir/test_usercommands.vim
@@ -373,10 +373,10 @@ endfunc
func Test_CmdCompletion()
call feedkeys(":com -\<C-A>\<C-B>\"\<CR>", 'tx')
- call assert_equal('"com -addr bang bar buffer complete count keepscript nargs range register', @:)
+ call assert_equal('"com -addr bang bar buffer complete completeopt count keepscript nargs range register', @:)
call feedkeys(":com -nargs=0 -\<C-A>\<C-B>\"\<CR>", 'tx')
- call assert_equal('"com -nargs=0 -addr bang bar buffer complete count keepscript nargs range register', @:)
+ call assert_equal('"com -nargs=0 -addr bang bar buffer complete completeopt count keepscript nargs range register', @:)
call feedkeys(":com -nargs=\<C-A>\<C-B>\"\<CR>", 'tx')
call assert_equal('"com -nargs=* + 0 1 ? _', @:)
@@ -520,6 +520,149 @@ func Test_CmdCompletion()
delcom DoCmd
endfunc
+" Test for -completeopt=escape: spaces, tabs and backslashes returned by a
+" customlist/custom completion function are backslash-escaped when inserted
+" into the command line, so the value survives as a single argument. The
+" ArgLead passed to the function and the matches shown in the popup menu
+" remain unescaped.
+func Test_command_completeopt_escape()
+ let g:EscArgLead = ''
+ func! EscOne(A, L, P)
+ let g:EscArgLead = a:A
+ return ['hello world']
+ endfunc
+ func! EscBs(A, L, P)
+ let g:EscArgLead = a:A
+ return ['foo ar']
+ endfunc
+ func! EscBoth(A, L, P)
+ let g:EscArgLead = a:A
+ return ['foo bar az']
+ endfunc
+ func! EscTab(A, L, P)
+ let g:EscArgLead = a:A
+ return ["foo bar"]
+ endfunc
+ func! EscCustom(A, L, P)
+ let g:EscArgLead = a:A
+ return "hello world"
+ endfunc
+ func! EscMulti(A, L, P)
+ let g:EscArgLead = a:A
+ return filter(['one value', 'two values', 'three values'],
+ \ {_, v -> stridx(v, a:A) == 0})
+ endfunc
+
+ " customlist + -completeopt=escape: spaces and backslashes are escaped.
+ com! -nargs=1 -complete=customlist,EscOne -completeopt=escape DoCmd
+ \ let g:EscQargs = <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"DoCmd hello\ world', @:)
+ " <q-args> yields the unescaped value.
+ let g:EscQargs = ''
+ call feedkeys(":DoCmd \<Tab>\<CR>", 'tx')
+ call assert_equal('hello world', g:EscQargs)
+ delcom DoCmd
+
+ com! -nargs=1 -complete=customlist,EscBs -completeopt=escape DoCmd echo <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"DoCmd foo\bar', @:)
+ delcom DoCmd
+
+ com! -nargs=1 -complete=customlist,EscBoth -completeopt=escape DoCmd echo <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"DoCmd foo\ bar\baz', @:)
+ delcom DoCmd
+
+ " A tab in a match is escaped like a space (the argument splitter also
+ " splits on tabs) and <q-args> yields the literal tab again.
+ com! -nargs=1 -complete=customlist,EscTab -completeopt=escape DoCmd
+ \ let g:EscQargs = <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal("\"DoCmd foo\ bar", @:)
+ " CTRL-A (insert all matches) escapes each match too.
+ call feedkeys(":DoCmd \<C-A>\<C-B>\"\<CR>", 'tx')
+ call assert_equal("\"DoCmd foo\ bar", @:)
+ let g:EscQargs = ''
+ call feedkeys(":DoCmd \<Tab>\<CR>", 'tx')
+ call assert_equal("foo bar", g:EscQargs)
+ let g:EscArgLead = ''
+ call assert_equal(["foo bar"], getcompletion("DoCmd foo\ b", 'cmdline'))
+ call assert_equal("foo b", g:EscArgLead)
+ delcom DoCmd
+ unlet g:EscQargs
+
+ " custom (newline-separated) + -completeopt=escape.
+ com! -nargs=1 -complete=custom,EscCustom -completeopt=escape DoCmd echo <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"DoCmd hello\ world', @:)
+ delcom DoCmd
+
+ " Without -completeopt=escape the literal string is inserted as-is.
+ com! -nargs=1 -complete=customlist,EscOne DoCmd echo <q-args>
+ call feedkeys(":DoCmd \<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"DoCmd hello world', @:)
+ delcom DoCmd
+
+ " getcompletion() returns the unescaped form even with -completeopt=escape,
+ " because the escape only applies at cmdline insertion, not the pum/list.
+ com! -nargs=1 -complete=customlist,EscBoth -completeopt=escape DoCmd echo <q-args>
+ call assert_equal(['foo bar az'], getcompletion('DoCmd ', 'cmdline'))
+ delcom DoCmd
+
+ " ArgLead passed to the completion function is unescaped: the user typed
+ " `foo\ b` (logical "foo b"), so the function should see "foo b", not
+ " "foo\ b", and `getcompletion()` should find the "foo bar az" match.
+ com! -nargs=1 -complete=customlist,EscBoth -completeopt=escape DoCmd echo <q-args>
+ let g:EscArgLead = ''
+ call assert_equal(['foo bar az'], getcompletion('DoCmd foo\ b', 'cmdline'))
+ call assert_equal('foo b', g:EscArgLead)
+ delcom DoCmd
+
+ " Same logic applies to custom (newline-separated): the regex used for
+ " filtering is built from the unescaped pattern, so "hello\ wo" matches
+ " "hello world".
+ com! -nargs=1 -complete=custom,EscCustom -completeopt=escape DoCmd echo <q-args>
+ let g:EscArgLead = ''
+ call assert_equal(['hello world'], getcompletion('DoCmd hello\ wo', 'cmdline'))
+ call assert_equal('hello wo', g:EscArgLead)
+ delcom DoCmd
+
+ " Multi-argument case: with -nargs=+ the argument splitter still runs, so an
+ " escaped match stays a single argument and <f-args> splits the line on the
+ " unescaped spaces. This is the scenario -nargs=_ cannot express.
+ com! -nargs=+ -complete=customlist,EscMulti -completeopt=escape DoCmd
+ \ let g:EscArgs = [<f-args>]
+ let g:EscArgs = []
+ call feedkeys(":DoCmd one\<Tab> two\<Tab>\<CR>", 'tx')
+ call assert_equal(['one value', 'two values'], g:EscArgs)
+ delcom DoCmd
+ unlet g:EscArgs
+
+ " Tab-completion on the attribute value.
+ call feedkeys(":com -completeopt=esc\<Tab>\<C-B>\"\<CR>", 'tx')
+ call assert_equal('"com -completeopt=escape', @:)
+
+ " Invalid value gives E475; empty value gives E179.
+ call assert_fails('com! -nargs=1 -complete=customlist,EscOne -completeopt=bogus DoCmd :',
+ \ 'E475:')
+ call assert_fails('com! -nargs=1 -complete=customlist,EscOne -completeopt= DoCmd :',
+ \ 'E179:')
+
+ " -completeopt=escape is meaningless with -nargs=_ (the splitter is disabled),
+ " so the combination is rejected at command-definition time.
+ call assert_fails('com! -nargs=_ -complete=customlist,EscOne -completeopt=escape DoCmd :',
+ \ 'E1579:')
+
+ delfunc EscOne
+ delfunc EscBs
+ delfunc EscBoth
+ delfunc EscTab
+ delfunc EscCustom
+ delfunc EscMulti
+ unlet g:EscArgLead
+endfunc
+
func CallExecute(A, L, P)
" Drop first '
'
return execute('echo "hi"')[1:]
diff --git a/src/usercmd.c b/src/usercmd.c
index 2d4756965..412ac0ae7 100644
--- a/src/usercmd.c
+++ b/src/usercmd.c
@@ -24,6 +24,7 @@ typedef struct ucmd
cmd_addr_T uc_addr_type; // The command's address type
sctx_T uc_script_ctx; // SCTX where the command was defined
int uc_flags; // some UC_ flags
+ int uc_compl_opt; // completion options (UCC_ flags)
#ifdef FEAT_EVAL
char_u *uc_compl_arg; // completion argument if any
#endif
@@ -211,6 +212,7 @@ find_ucmd(
if (xp != NULL)
{
xp->xp_arg = uc->uc_compl_arg;
+ xp->xp_complete_opt = uc->uc_compl_opt;
xp->xp_script_ctx = uc->uc_script_ctx;
xp->xp_script_ctx.sc_lnum += SOURCING_LNUM;
}
@@ -283,6 +285,16 @@ set_context_in_user_cmd(expand_T *xp, char_u *arg_in)
xp->xp_context = EXPAND_USER_COMPLETE;
xp->xp_pattern = p + 1;
}
+ else if (STRNICMP(arg, "completeopt", p - arg) == 0)
+ {
+ xp->xp_context = EXPAND_USER_COMPLETEOPT;
+ // xp_pattern points to the last comma-separated item being
+ // typed so that completion replaces only that item.
+ xp->xp_pattern = p + 1;
+ for (char_u *c = p + 1; *c != NUL; ++c)
+ if (*c == ',')
+ xp->xp_pattern = c + 1;
+ }
else if (STRNICMP(arg, "nargs", p - arg) == 0)
{
xp->xp_context = EXPAND_USER_NARGS;
@@ -440,7 +452,7 @@ get_user_cmd_flags(expand_T *xp UNUSED, int idx)
{
static char *user_cmd_flags[] = {
"addr", "bang", "bar", "buffer", "complete",
- "count", "nargs", "range", "register", "keepscript"
+ "completeopt", "count", "nargs", "range", "register", "keepscript"
};
if (idx < 0 || idx >= (int)ARRAY_LENGTH(user_cmd_flags))
@@ -473,6 +485,26 @@ get_user_cmd_complete(expand_T *xp UNUSED, int idx)
return command_complete_tab[idx].value.string;
}
+/*
+ * Names of options accepted by -completeopt= for :command. Keep in sync with
+ * parse_completeopt_arg() below.
+ */
+static char *user_cmd_completeopt_tab[] = {
+ "escape"
+};
+
+/*
+ * Function given to ExpandGeneric() to obtain the list of values for
+ * -completeopt.
+ */
+ char_u *
+get_user_cmd_completeopt(expand_T *xp UNUSED, int idx)
+{
+ if (idx < 0 || idx >= (int)ARRAY_LENGTH(user_cmd_completeopt_tab))
+ return NULL;
+ return (char_u *)user_cmd_completeopt_tab[idx];
+}
+
/*
* Return the row in the command_complete_tab table that contains the given key.
*/
@@ -715,6 +747,9 @@ uc_list(char_u *name, size_t name_len)
len += (int)uc_compl_arglen;
}
}
+ if (p_verbose > 0 && (cmd->uc_compl_opt & UCC_ESCAPE))
+ len += vim_snprintf((char *)IObuff + len, IOSIZE - len,
+ " -completeopt=escape");
#endif
}
@@ -911,6 +946,72 @@ parse_compl_arg(
return OK;
}
+/*
+ * Parse a -completeopt= value. "value" points to a comma-separated list of
+ * option names; on success the corresponding UCC_ flags are OR'ed into
+ * "*compl_opt".
+ * Returns FAIL on an unknown name.
+ */
+ static int
+parse_completeopt_arg(char_u *value, int vallen, int *compl_opt)
+{
+ char_u *p = value;
+ char_u *end = value + vallen;
+ int flags = 0;
+
+ if (vallen == 0)
+ {
+ semsg(_(e_argument_required_for_str), "-completeopt");
+ return FAIL;
+ }
+
+ while (p < end)
+ {
+ char_u *comma;
+ size_t itemlen;
+ int matched = FALSE;
+ int i;
+
+ comma = vim_strchr(p, ',');
+ if (comma == NULL || comma > end)
+ itemlen = (size_t)(end - p);
+ else
+ itemlen = (size_t)(comma - p);
+
+ if (itemlen == 0)
+ {
+ semsg(_(e_invalid_value_for_argument_str), "completeopt");
+ return FAIL;
+ }
+
+ for (i = 0; i < (int)ARRAY_LENGTH(user_cmd_completeopt_tab); ++i)
+ {
+ char *name = user_cmd_completeopt_tab[i];
+
+ if (STRLEN(name) == itemlen
+ && STRNCMP(p, name, itemlen) == 0)
+ {
+ if (STRCMP(name, "escape") == 0)
+ flags |= UCC_ESCAPE;
+ matched = TRUE;
+ break;
+ }
+ }
+ if (!matched)
+ {
+ semsg(_(e_invalid_value_for_argument_str), "completeopt");
+ return FAIL;
+ }
+
+ p += itemlen;
+ if (p < end && *p == ',')
+ ++p;
+ }
+
+ *compl_opt |= flags;
+ return OK;
+}
+
/*
* Scan attributes in the ":command" command.
* Return FAIL when something is wrong.
@@ -924,6 +1025,7 @@ uc_scan_attr(
int *flags,
int *complp,
char_u **compl_arg,
+ int *compl_opt,
cmd_addr_T *addr_type_arg)
{
char_u *p;
@@ -1054,6 +1156,17 @@ invalid_count:
== FAIL)
return FAIL;
}
+ else if (STRNICMP(attr, "completeopt", attrlen) == 0)
+ {
+ if (val == NULL)
+ {
+ semsg(_(e_argument_required_for_str), "-completeopt");
+ return FAIL;
+ }
+
+ if (parse_completeopt_arg(val, (int)vallen, compl_opt) == FAIL)
+ return FAIL;
+ }
else if (STRNICMP(attr, "addr", attrlen) == 0)
{
*argt |= EX_RANGE;
@@ -1093,6 +1206,7 @@ uc_add_command(
int flags,
int compl,
char_u *compl_arg UNUSED,
+ int compl_opt,
cmd_addr_T addr_type,
int force)
{
@@ -1186,6 +1300,7 @@ uc_add_command(
cmd->uc_argt = argt;
cmd->uc_def = def;
cmd->uc_compl = compl;
+ cmd->uc_compl_opt = compl_opt;
cmd->uc_script_ctx = current_sctx;
if (flags & UC_VIM9)
cmd->uc_script_ctx.sc_version = SCRIPT_VERSION_VIM9;
@@ -1268,6 +1383,7 @@ ex_command(exarg_T *eap)
int flags = 0;
int compl = EXPAND_NOTHING;
char_u *compl_arg = NULL;
+ int compl_opt = 0;
cmd_addr_T addr_type_arg = ADDR_NONE;
int has_attr = (eap->arg[0] == '-');
int name_len;
@@ -1280,7 +1396,7 @@ ex_command(exarg_T *eap)
++p;
end = skiptowhite(p);
if (uc_scan_attr(p, end - p, &argt, &def, &flags, &compl,
- &compl_arg, &addr_type_arg) == FAIL)
+ &compl_arg, &compl_opt, &addr_type_arg) == FAIL)
goto theend;
p = skipwhite(end);
}
@@ -1326,6 +1442,13 @@ ex_command(exarg_T *eap)
(char_u *)_(e_complete_used_without_allowing_arguments),
TRUE, TRUE);
}
+ else if ((compl_opt & UCC_ESCAPE) && (argt & EX_ARGSPACE))
+ {
+ // -nargs=_ disables the argument splitter, so escaping spaces in
+ // inserted matches has no effect. Reject the combination instead of
+ // silently ignoring it.
+ emsg(_(e_completeopt_escape_cannot_be_used_with_nargs_underscore));
+ }
else
{
char_u *tofree = NULL;
@@ -1333,7 +1456,7 @@ ex_command(exarg_T *eap)
p = may_get_cmd_block(eap, p, &tofree, &flags);
uc_add_command(name, end - name, p, argt, def, flags, compl, compl_arg,
- addr_type_arg, eap->forceit);
+ compl_opt, addr_type_arg, eap->forceit);
vim_free(tofree);
return; // success
@@ -1823,6 +1946,11 @@ uc_check_code(
STRCPY(buf, eap->arg);
break;
case 1: // Quote, but don't split
+ {
+ // For -completeopt=escape give <q-args> the logical value: a
+ // backslash before a space, tab or backslash is collapsed.
+ int unesc = (cmd->uc_compl_opt & UCC_ESCAPE) != 0;
+
result = STRLEN(eap->arg) + 2;
for (p = eap->arg; *p; ++p)
{
@@ -1830,6 +1958,13 @@ uc_check_code(
// DBCS can contain \ in a trail byte, skip the
// double-byte character.
++p;
+ else if (unesc && *p == '\'
+ && (VIM_ISWHITE(p[1]) || p[1] == '\'))
+ {
+ if (VIM_ISWHITE(p[1]))
+ --result;
+ ++p;
+ }
else
if (*p == '\' || *p == '"')
++result;
@@ -1844,6 +1979,13 @@ uc_check_code(
// DBCS can contain \ in a trail byte, copy the
// double-byte character to avoid escaping.
*buf++ = *p++;
+ else if (unesc && *p == '\'
+ && (VIM_ISWHITE(p[1]) || p[1] == '\'))
+ {
+ ++p; // drop the escaping backslash
+ if (*p == '\')
+ *buf++ = '\'; // re-escape for the quotes
+ }
else
if (*p == '\' || *p == '"')
*buf++ = '\';
@@ -1853,6 +1995,7 @@ uc_check_code(
}
break;
+ }
case 2: // Quote and split (<f-args>)
// This is hard, so only do it once, and cache the result
if (*split_buf == NULL)
diff --git a/src/version.c b/src/version.c
index 619d899b2..d4d8e477f 100644
--- a/src/version.c
+++ b/src/version.c
@@ -759,6 +759,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 638,
/**/
637,
/**/
diff --git a/src/vim.h b/src/vim.h
index 1f8e7e1a4..640afc62e 100644
--- a/src/vim.h
+++ b/src/vim.h
@@ -869,6 +869,7 @@ extern int (*dyn_libintl_wputenv)(const wchar_t *envstring);
#define EXPAND_FILETYPECMD 63
#define EXPAND_PATTERN_IN_BUF 64
#define EXPAND_RETAB 65
+#define EXPAND_USER_COMPLETEOPT 66
// Values for exmode_active (0 is no exmode)
@@ -3092,6 +3093,10 @@ long elapsed(DWORD start_tick);
#define UC_BUFFER 1 // -buffer: local to current buffer
#define UC_VIM9 2 // {} argument: Vim9 syntax.
+// flags for the -completeopt= attribute of :command
+#define UCC_ESCAPE 0x1 // escape spaces, tabs and backslashes in
+ // matches
+
// flags used by vim_strsave_fnameescape()
#define VSE_NONE 0
#define VSE_SHELL 1 // escape for a shell command