]> git.ipfire.org Git - thirdparty/vim.git/commitdiff
patch 9.2.0638: cannot return matches containing spaces from a custom completion v9.2.0638
authorYasuhiro Matsumoto <mattn.jp@gmail.com>
Sat, 13 Jun 2026 19:18:55 +0000 (19:18 +0000)
committerChristian Brabandt <cb@256bit.org>
Sat, 13 Jun 2026 19:27:07 +0000 (19:27 +0000)
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 <mattn.jp@gmail.com>
Signed-off-by: Christian Brabandt <cb@256bit.org>
12 files changed:
runtime/doc/map.txt
runtime/doc/tags
runtime/doc/version9.txt
src/cmdexpand.c
src/errors.h
src/po/vim.pot
src/proto/usercmd.pro
src/structs.h
src/testdir/test_usercommands.vim
src/usercmd.c
src/version.c
src/vim.h

index 38de46c6579e8a98c83e64c74119ea0ae85246a2..98b38199085a14f92be316a681b937fef1aa28c0 100644 (file)
@@ -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*
index d056a7eed79118a8e1ba78efed8ac8c221e8bc26..7fbe149239232d7c47aef3d8c02b9ee417fd9f0b 100644 (file)
@@ -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*
index df216fae19cb59efac97ac032ca1786684f68906..c7fb72cdd69089175b7fa7f3aaf5a487fc48e5fb 100644 (file)
@@ -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|,
index 8d9c2d7b7f19b955b218135937d24df4e7486b86..63b4921604d11b4dafbcc764e185ea2a48341742 100644 (file)
@@ -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 *)" \t\\");
+    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) ? "\n" : " ";
        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 *)" \t\\");
+                   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)
     {
index 9dbd7ac4db1f74b5d9e6b50eedcace1e95e34bac..530e47c1d95396a95df9f4abad50a18d1ef786ce 100644 (file)
@@ -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=_"));
index d21055030849e146be731f22461badad3dfee3af..fa6d37d690cb53c9d7020c29548876d1f37bd3df 100644 (file)
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Vim\n"
 "Report-Msgid-Bugs-To: vim-dev@vim.org\n"
-"POT-Creation-Date: 2026-06-13 17:59+0000\n"
+"POT-Creation-Date: 2026-06-13 19:24+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -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
index 8c53f562b3c620256deca0b1d7e89a8b87dd4fca..fa45ad152c0009ce0dceeacea0db76a26ffb372f 100644 (file)
@@ -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);
index 99123f309acb4a6f1019c4ae355a71e140791893..5d6511a5a8e977242ca68efd3a2619d23cbd4887 100644 (file)
@@ -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
index 8b90a826e48bf2c4db7f36ba22971e64d06ae9ae..e505e2d4c31d0ddb8ced0421a47b55ca54ad1aab 100644 (file)
@@ -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\bar']
+  endfunc
+  func! EscBoth(A, L, P)
+    let g:EscArgLead = a:A
+    return ['foo bar\baz']
+  endfunc
+  func! EscTab(A, L, P)
+    let g:EscArgLead = a:A
+    return ["foo\tbar"]
+  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\\\tbar", @:)
+  " CTRL-A (insert all matches) escapes each match too.
+  call feedkeys(":DoCmd \<C-A>\<C-B>\"\<CR>", 'tx')
+  call assert_equal("\"DoCmd foo\\\tbar", @:)
+  let g:EscQargs = ''
+  call feedkeys(":DoCmd \<Tab>\<CR>", 'tx')
+  call assert_equal("foo\tbar", g:EscQargs)
+  let g:EscArgLead = ''
+  call assert_equal(["foo\tbar"], getcompletion("DoCmd foo\\\tb", 'cmdline'))
+  call assert_equal("foo\tb", 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\baz'], 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\baz" match.
+  com! -nargs=1 -complete=customlist,EscBoth -completeopt=escape DoCmd echo <q-args>
+  let g:EscArgLead = ''
+  call assert_equal(['foo bar\baz'], 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 '\n'
   return execute('echo "hi"')[1:]
index 2d47569653e15a09269e58a03ea6dd743aa4431a..412ac0ae7a4ff777f4a8a27ea32a34b9e30c303d 100644 (file)
@@ -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)
index 619d899b2d51f2bde72f6f080c5465bfe183ca12..d4d8e477fa9d1c279c8fb62304deb8ad465d0f35 100644 (file)
@@ -759,6 +759,8 @@ static char *(features[]) =
 
 static int included_patches[] =
 {   /* Add new patch number below this line */
+/**/
+    638,
 /**/
     637,
 /**/
index 1f8e7e1a42d0d4f074cdd2b6bd5fa87d03b14db1..640afc62edbc5eb1365a7b78037beea7ad35a61b 100644 (file)
--- 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