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

Christian Brabandt <[email protected]> Tue, 4 Aug 2026 22:15:05 +0200
Newsgroups gmane.editors.vim.devel
Message-ID <[email protected]>
patch 9.2.0908: cannot use a {} block in a nested :autocmd

Commit: https://github.com/vim/vim/commit/284d1b51230a577e3736f2b7a2dabc03e04d2733
Author: Hirohito Higashi <[email protected]>
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) <[email protected]>
    Signed-off-by: Hirohito Higashi <[email protected]>
    Signed-off-by: Christian Brabandt <[email protected]>

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;
 	    }
 	}
     }

-- 
-- 
You received this message from the "vim_dev" maillist.
Do not top-post! Type your reply below the text you are replying to.
For more information, visit http://www.vim.org/maillist.php

--- 
You received this message because you are subscribed to the Google Groups "vim_dev" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [email protected].
To view this discussion visit https://groups.google.com/d/msgid/vim_dev/E1wrLXV-00D71n-Lk%40256bit.org.