Commit: patch 9.2.0908: cannot use a {} block in a nested :autocmd

0 views
Skip to first unread message

Christian Brabandt

unread,
4:15 PM (6 hours ago) 4:15 PM
to vim...@googlegroups.com
patch 9.2.0908: cannot use a {} block in a nested :autocmd

Commit: https://github.com/vim/vim/commit/284d1b51230a577e3736f2b7a2dabc03e04d2733
Author: Hirohito Higashi <h.eas...@gmail.com>
Date: Tue Aug 4 19:50:18 2026 +0000

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) <nor...@anthropic.com>
Signed-off-by: Hirohito Higashi <h.eas...@gmail.com>
Signed-off-by: Christian Brabandt <c...@256bit.org>

diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt
index b3d1deb25..7a1df5c46 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+=<:>
/<start
}
+<This also works when the `:autocmd` is itself the command of another
+`:autocmd` or `:command`: >
+ au FileType xml au BufWinEnter * ++once {
+ setlocal matchpairs+=<:>
+ }
+<Note that the "{" must be the whole command, a command that happens to end in
+"{", such as "normal! {", does not start a block. Nesting the commands works,
+nesting the blocks themselves does not, see |:command-repl|.

The |autocmd_add()| function can be used to add a list of autocmds and autocmd
groups from a Vim script. It is preferred if you have anything that would
diff --git a/src/autocmd.c b/src/autocmd.c
index 98014cf03..7de6947c6 100644
--- a/src/autocmd.c
+++ b/src/autocmd.c
@@ -806,6 +806,56 @@ find_end_event(
return pat;
}

+/*
+ * Find the command of an ":autocmd" with argument "arg", the part after the
+ * events, the pattern and any "++once"/"++nested". Returns NULL when there is
+ * no command. Does not modify "arg" and gives no error messages.
+ */
+ char_u *
+au_find_cmd_arg(char_u *arg)
+{
+ char_u *pat;
+ char_u *cmd;
+ int group;
+
+ if (*arg == '|')
+ return NULL;
+
+ // Errors are reported when the autocommand is actually defined.
+ ++emsg_off;
+ group = au_get_grouparg(&arg);
+ pat = group == AUGROUP_ERROR
+ ? NULL : find_end_event(arg, group != AUGROUP_ALL);
+ --emsg_off;
+ if (pat == NULL)
+ return NULL;
+
+ pat = skipwhite(pat);
+ if (*pat == NUL || *pat == '|')
+ return NULL;
+
+ // White space in the pattern can be escaped with a backslash.
+ cmd = pat;
+ while (*cmd != NUL
+ && (!VIM_ISWHITE(*cmd) || (cmd > 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 b23504a85..f5771cb61 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 abff7fd41..143c731d2 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 df44d0200..299820077 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 7e36eec53..b76cec4f3 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 e505e2d4c..7f8fea421 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 b86acd071..c82c01a0f 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 412ac0ae7..9dcd0f806 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 4dddf621d..0cc9e31e3 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 5a748f499..bc604af0b 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 1a8ff15f4..01643b83d 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;
}
}
}
Reply all
Reply to author
Forward
0 new messages