From: Hirohito Higashi Date: Tue, 4 Aug 2026 19:50:18 +0000 (+0000) Subject: patch 9.2.0908: cannot use a {} block in a nested :autocmd X-Git-Tag: v9.2.0908^0 X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=284d1b51230a577e3736f2b7a2dabc03e04d2733;p=thirdparty%2Fvim.git patch 9.2.0908: cannot use a {} block in a nested :autocmd Problem: At script level a "{" block is only recognized when it is the whole argument of :autocmd or :command, so an :autocmd that is the command of another :autocmd cannot use a block. In a :def function a trailing "{" is accepted instead, and there any command ending in "{", such as "normal! {", is mistaken for the start of a block (lacygoill). Solution: Locate the block by following the argument of the command, descending into a nested :autocmd or :command, and use that everywhere a block needs to be recognized (Hirohito Higashi). fixes: #20918 closes: #20933 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Hirohito Higashi Signed-off-by: Christian Brabandt --- diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index b3d1deb25f..7a1df5c46a 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -1,4 +1,4 @@ -*autocmd.txt* For Vim version 9.2. Last change: 2026 May 23 +*autocmd.txt* For Vim version 9.2. Last change: 2026 Aug 04 VIM REFERENCE MANUAL by Bram Moolenaar @@ -103,6 +103,14 @@ triggered. setlocal matchpairs+=<:> / + au FileType xml au BufWinEnter * ++once { + setlocal matchpairs+=<:> + } + pat && *(cmd - 1) == '\\'))) + ++cmd; + cmd = skipwhite(cmd); + + // "++once" and "++nested" can come in any order. + for (int i = 0; i < 2; ++i) + { + if (STRNCMP(cmd, "++once", 6) == 0 && VIM_ISWHITE(cmd[6])) + cmd = skipwhite(cmd + 6); + if (STRNCMP(cmd, "++nested", 8) == 0 && VIM_ISWHITE(cmd[8])) + cmd = skipwhite(cmd + 8); + if (!in_vim9script() && STRNCMP(cmd, "nested", 6) == 0 + && VIM_ISWHITE(cmd[6])) + cmd = skipwhite(cmd + 6); + } + + return *cmd == NUL ? NULL : cmd; +} + /* * Return TRUE if "event" is included in 'eventignore(win)'. */ diff --git a/src/ex_docmd.c b/src/ex_docmd.c index b23504a85b..f5771cb615 100644 --- a/src/ex_docmd.c +++ b/src/ex_docmd.c @@ -2838,6 +2838,68 @@ checkforcmd_noparen( return checkforcmd_opt(pp, cmd, len, TRUE); } +/* + * Find the replacement text of a ":command", the part after the attributes and + * the command name. Returns NULL when there is none. Does not modify "arg" + * and gives no error messages. + */ + static char_u * +find_ucmd_repl(char_u *arg) +{ + char_u *p = arg; + + // Skip over the attributes. + while (*p == '-') + p = skipwhite(skiptowhite(p)); + + // Skip over the command name. + if (!ASCII_ISALPHA(*p)) + return NULL; + while (ASCII_ISALNUM(*p)) + ++p; + + return *p == NUL ? NULL : skipwhite(p); +} + +/* + * Find the "{" in "line" that starts a block for ":command" or ":autocmd". + * That is the case when the command argument is "{" at the end of the line, + * also when the command is nested in another ":command" or ":autocmd". + * Returns NULL when the line does not start such a block. + */ + char_u * +find_cmd_block_start(char_u *line) +{ + char_u *p = skipwhite(line); + + for (;;) + { + char_u *arg = p; + + if (*p == '{' && ends_excmd2(p, skipwhite(p + 1))) + return p; + + if (checkforcmd_noparen(&arg, "autocmd", 2)) + { + if (*arg == '!') + arg = skipwhite(arg + 1); + p = au_find_cmd_arg(arg); + } + else if (checkforcmd_noparen(&arg, "command", 3)) + { + if (*arg == '!') + arg = skipwhite(arg + 1); + p = find_ucmd_repl(arg); + } + else + return NULL; + + if (p == NULL) + return NULL; + p = skipwhite(p); + } +} + /* * Parse and skip over command modifiers: * - update eap->cmd diff --git a/src/proto/autocmd.pro b/src/proto/autocmd.pro index abff7fd414..143c731d28 100644 --- a/src/proto/autocmd.pro +++ b/src/proto/autocmd.pro @@ -5,6 +5,7 @@ void do_augroup(char_u *arg, int del_group); void autocmd_init(void); void free_all_autocmds(void); int is_aucmd_win(win_T *win); +char_u *au_find_cmd_arg(char_u *arg); int event_ignored(event_T event, char_u *ei); int check_ei(char_u *ei); char_u *au_event_disable(char *what); diff --git a/src/proto/ex_docmd.pro b/src/proto/ex_docmd.pro index df44d02004..299820077a 100644 --- a/src/proto/ex_docmd.pro +++ b/src/proto/ex_docmd.pro @@ -10,6 +10,7 @@ char *ex_errmsg(char *msg, char_u *arg); char *ex_range_without_command(exarg_T *eap); int checkforcmd(char_u **pp, char *cmd, int len); int checkforcmd_noparen(char_u **pp, char *cmd, int len); +char_u *find_cmd_block_start(char_u *line); int parse_command_modifiers(exarg_T *eap, char **errormsg, cmdmod_T *cmod, int skip_only); int has_cmdmod(cmdmod_T *cmod, int ignore_silent); int cmdmod_error(int ignore_silent); diff --git a/src/testdir/test_autocmd.vim b/src/testdir/test_autocmd.vim index 7e36eec53e..b76cec4f3f 100644 --- a/src/testdir/test_autocmd.vim +++ b/src/testdir/test_autocmd.vim @@ -4070,6 +4070,92 @@ func Test_autocmd_with_block() augroup END endfunc +" Test for a {} block at script level in an :autocmd nested in another one +func Test_autocmd_nested_block() + let lines =<< trim END + vim9script + autocmd CursorHold * autocmd BufReadPre * ++once { + g:nested_block = 'yes' + } + END + call writefile(lines, 'XautoNestedBlock', 'D') + source XautoNestedBlock + + doautocmd CursorHold + doautocmd BufReadPre + call assert_equal('yes', g:nested_block) + + unlet g:nested_block + au! CursorHold + au! BufReadPre +endfunc + +" Test for a {} block in an :autocmd nested two levels deep +func Test_autocmd_nested_block_twice() + let lines =<< trim END + vim9script + autocmd CursorHold * autocmd CursorHoldI * autocmd BufReadPre * ++once { + g:nested_twice = 'yes' + } + END + call writefile(lines, 'XautoNestedTwice', 'D') + source XautoNestedTwice + + doautocmd CursorHold + doautocmd CursorHoldI + doautocmd BufReadPre + call assert_equal('yes', g:nested_twice) + + unlet g:nested_twice + au! CursorHold + au! CursorHoldI + au! BufReadPre +endfunc + +" Only the :autocmd owning the block uses Vim9 syntax, the one it is nested in +" keeps the syntax of the script. The old "nested" is valid in legacy script +" but an error in Vim9 script, so it tells the two apart. +func Test_autocmd_nested_block_legacy_script() + let lines =<< trim END + autocmd CursorHold * autocmd BufReadPre * nested { + g:legacy_block = 'yes' + } + END + call writefile(lines, 'XautoNestedLegacy', 'D') + source XautoNestedLegacy + + doautocmd CursorHold + doautocmd BufReadPre + call assert_equal('yes', g:legacy_block) + + unlet g:legacy_block + au! CursorHold + au! BufReadPre +endfunc + +" A trailing "{" that is an argument of the command does not start a block, +" the lines after it are not swallowed +func Test_autocmd_trailing_curly_no_block() + let lines =<< trim END + vim9script + autocmd CursorHold * normal! { + g:after_autocmd = 'reached' + END + call writefile(lines, 'XautoTrailingCurly', 'D') + source XautoTrailingCurly + call assert_equal('reached', g:after_autocmd) + + new + call setline(1, ['one', '', 'two']) + call cursor(3, 1) + doautocmd CursorHold + call assert_equal(2, line('.')) + + bwipe! + unlet g:after_autocmd + au! CursorHold +endfunc + func Test_closing_autocmd_window() let lines =<< trim END edit Xa.txt diff --git a/src/testdir/test_usercommands.vim b/src/testdir/test_usercommands.vim index e505e2d4c3..7f8fea4216 100644 --- a/src/testdir/test_usercommands.vim +++ b/src/testdir/test_usercommands.vim @@ -927,6 +927,42 @@ func Test_usercmd_custom() delfunc T2 endfunc +" Test for a {} block in a command nested in :command or :autocmd +func Test_usercmd_nested_block() + command DefineIt command DoNested { + g:didnested = 'yes' + } + DefineIt + DoNested + call assert_equal('yes', g:didnested) + unlet g:didnested + delcommand DoNested + delcommand DefineIt + + " a command defined by an autocmd + autocmd CursorHold * command DoFromAu { + g:didfromau = 'yes' + } + doautocmd CursorHold + DoFromAu + call assert_equal('yes', g:didfromau) + unlet g:didfromau + delcommand DoFromAu + au! CursorHold + + " a trailing "{" that is an argument of the command is not a block + let lines =<< trim END + vim9script + command NoBlock normal! { + g:after_command = 'reached' + END + call writefile(lines, 'XcmdTrailingCurly', 'D') + source XcmdTrailingCurly + call assert_equal('reached', g:after_command) + unlet g:after_command + delcommand NoBlock +endfunc + func Test_usercmd_with_block() command DoSomething { g:didit = 'yes' # comment diff --git a/src/testdir/test_vim9_script.vim b/src/testdir/test_vim9_script.vim index b86acd071e..c82c01a0fa 100644 --- a/src/testdir/test_vim9_script.vim +++ b/src/testdir/test_vim9_script.vim @@ -485,6 +485,47 @@ def Test_command_block() unlet g:someVar enddef +" Test for a {} block in an :autocmd nested in another :autocmd +def Test_nested_autocmd_block_in_def() + au CursorHold * autocmd BufNew *.xml { + g:nestedVar = 'nested' + } + doautocmd CursorHold + split other.xml + assert_equal('nested', g:nestedVar) + + bwipe! + au! CursorHold + au! BufNew *.xml + unlet g:nestedVar +enddef + +" A trailing "{" that is an argument of the command does not start a block. +" Use a separate script, when the "{" is taken for a block the rest of this +" file would be swallowed until a line starting with "}". +def Test_autocmd_trailing_curly_no_block_in_def() + var lines =<< trim END + vim9script + def Setup() + au CursorHold * normal! { + g:afterCurly = 'reached' + enddef + Setup() + END + v9.CheckScriptSuccess(lines) + assert_equal('reached', g:afterCurly) + + new + setline(1, ['one', '', 'two']) + cursor(3, 1) + doautocmd CursorHold + assert_equal(2, line('.')) + + bwipe! + unlet g:afterCurly + au! CursorHold +enddef + " Test for using heredoc in a :command command block def Test_command_block_heredoc() var lines =<< trim CODE diff --git a/src/usercmd.c b/src/usercmd.c index 412ac0ae7a..9dcd0f8066 100644 --- a/src/usercmd.c +++ b/src/usercmd.c @@ -1322,16 +1322,16 @@ fail: } /* - * If "p" starts with "{" then read a block of commands until "}". + * If "p" starts a block of commands, read it until "}". * Used for ":command" and ":autocmd". */ char_u * may_get_cmd_block(exarg_T *eap, char_u *p, char_u **tofree, int *flags) { char_u *retp = p; + char_u *block = find_cmd_block_start(p); - if (*p == '{' && ends_excmd2(eap->arg, skipwhite(p + 1)) - && eap->ea_getline != NULL) + if (block != NULL && eap->ea_getline != NULL) { garray_T ga; char_u *line = NULL; @@ -1364,7 +1364,10 @@ may_get_cmd_block(exarg_T *eap, char_u *p, char_u **tofree, int *flags) if (retp == NULL) retp = p; ga_clear_strings(&ga); - *flags |= UC_VIM9; + // Only the command owning the block uses Vim9 syntax. A command with + // the block nested in it keeps the syntax of its script. + if (block == p) + *flags |= UC_VIM9; } return retp; } diff --git a/src/userfunc.c b/src/userfunc.c index 4dddf621d5..0cc9e31e33 100644 --- a/src/userfunc.c +++ b/src/userfunc.c @@ -1257,14 +1257,7 @@ get_function_body( --end; is_block = end > p + 2 && end[-1] == '=' && end[0] == '>'; if (!is_block) - { - char_u *s = p; - - // check for line starting with "au" for :autocmd or - // "com" for :command, these can use a {} block - is_block = checkforcmd_noparen(&s, "autocmd", 2) - || checkforcmd_noparen(&s, "command", 3); - } + is_block = find_cmd_block_start(p) != NULL; if (is_block) { diff --git a/src/version.c b/src/version.c index 5a748f4996..bc604af0b3 100644 --- a/src/version.c +++ b/src/version.c @@ -763,6 +763,8 @@ static char *(features[]) = static int included_patches[] = { /* Add new patch number below this line */ +/**/ + 908, /**/ 907, /**/ diff --git a/src/vim9cmds.c b/src/vim9cmds.c index 1a8ff15f48..01643b83d9 100644 --- a/src/vim9cmds.c +++ b/src/vim9cmds.c @@ -2345,30 +2345,18 @@ compile_exec(char_u *line_arg, exarg_T *eap, cctx_T *cctx) } else if (eap->cmdidx == CMD_command || eap->cmdidx == CMD_autocmd) { - // If there is a trailing '{' read lines until the '}' - p = eap->arg + STRLEN(eap->arg) - 1; - while (p > eap->arg && VIM_ISWHITE(*p)) - --p; - if (*p == '{') + exarg_T ea; + int flags = 0; // unused + int start_lnum = SOURCING_LNUM; + + CLEAR_FIELD(ea); + ea.arg = eap->arg; + fill_exarg_from_cctx(&ea, cctx); + p = may_get_cmd_block(&ea, line, &tofree, &flags); + if (tofree != NULL) { - exarg_T ea; - int flags = 0; // unused - int start_lnum = SOURCING_LNUM; - - CLEAR_FIELD(ea); - ea.arg = eap->arg; - fill_exarg_from_cctx(&ea, cctx); - (void)may_get_cmd_block(&ea, p, &tofree, &flags); - if (tofree != NULL) - { - *p = NUL; - line = concat_str(line, tofree); - if (line == NULL) - goto theend; - vim_free(tofree); - tofree = line; - SOURCING_LNUM = start_lnum; - } + line = p; + SOURCING_LNUM = start_lnum; } } }