Repository navigation
Description
Five constructs that bash accepts and the grammar rejects, found in one sweep over a family of shell repositories. They are independent of each other, and I am glad to split them into five issues if that suits the tracker better — one report seemed the smaller imposition.
Each was reduced to the smallest failing case. Every left-hand example below is valid bash: bash -n is quiet and the code runs. All five were seen on tree-sitter-bash v0.25.1, built from source, with tree-sitter 0.25.2, and again on the same grammar inside tree_sitter_language_pack 1.4.1.
Two related rejections are already filed and are not repeated here: ${x:+word} holding an operator character is #267, and a herestring after a redirect is #232.
Why this matters past the line: the recovery can put one ERROR node over the whole file. A tool that reads comments out of the tree then gets a # inside a string as a comment and misses real ones. In one 319-line script here that is exactly what happened.
1. while with a compound condition and no ; before do
while case $x in a) true ;; esac do
echo hi
doneOne ERROR node covers the loop. Bash accepts it, because esac has already ended the compound command:
$ bash -c 'x=a; n=0; while case $x in a) ((n < 2)) ;; esac do n=$((n + 1)); done; echo "$n"'
2while if ... fi do and while { ...; } do fail the same way. A ; before do parses.
2. A ) inside a bracket expression, in every spelling bash accepts
case "a)" in *[*_\)]) echo hit ;; esac$ bash -c 'case "a)" in *[*_\)]) echo hit ;; *) echo miss ;; esac'
hitAll six of these are rejected, and bash 5.3 and bash 3.2 accept all six:
case $x in *[*_\)]) ;; esac
case $x in *[*_")"]) ;; esac
case $x in *[*_')']) ;; esac
[[ $x == *[*_\)] ]]
[[ $x == *[*_")"] ]]
[[ $x == *[*_')'] ]]A bare ) is not an alternative: bash ends the case branch at it, and inside [[ ]] answers syntax error in conditional expression: unexpected token ). So there is no written spelling that both bash and the grammar accept, and only a pattern held in a variable gets through:
CLASS='*[*_)]'
case "a)" in $CLASS) echo hit ;; esacA class with no ) parses, so the bracket expression itself is understood: case $x in *[ab]) ;; esac is fine. So is the regular-expression side, [[ "$x" =~ ^[0-9]+[.\)] ]].
3. A case pattern of a leading * and two double-quoted strings
case "$t" in *"a"*"b"*) echo hit ;; esac$ bash -c 't=xaybz; case "$t" in *"a"*"b"*) echo hit ;; *) echo miss ;; esac'
hitI could not reduce this to one rule, so here is the measurement. Every line is valid bash:
*"a"* parses
*"a"*b parses
*"a"*$x parses
*"a"*? parses
b*"a"* parses
"a"*"b"* parses
*'a'*'b'* parses
*'a'*"b"* parses
*$v*"b"* parses
*"a"b ERROR
*"a"*"b"* ERROR
*"a"*'b'* ERROR
?*"a"*"b"* ERROR
*"a b"*"c d"* ERROR
A leading * followed by a double-quoted string is where it starts, and what comes after decides. Single quotes in that leading position are fine either way.
4. A bare - inside [ ] is read as a unary operator
if [ "$f" = - ]; then echo hit; fi(program (if_statement condition: (test_command (binary_expression left: (string (simple_expansion (variable_name))) right: (unary_expression (word))) (MISSING "]")) ...))
The - became (unary_expression (word)) and swallowed the ]. Bash accepts it:
$ bash -c 'f=-; [ "$f" = - ] && echo hit'
hit[ - ] and [ -n - ] fail the same way, so the position does not matter. [ "$f" = -x ] parses, and so does [[ "$f" = - ]]. Quoting the - parses.
This is not #316, which is about a [ merging across a redirected statement. Here one test command stands alone.
5. A heredoc ends at a body line that begins with the delimiter
cat <<'MODE'
MODE=safe
MODE(program (redirected_statement body: (command ...) redirect: (heredoc_redirect (heredoc_start) (heredoc_body) (heredoc_end))) (MISSING ";") (command ...) (command ...))
The heredoc closed at MODE=safe, and what follows is read as two commands. Bash reads the body whole:
$ bash -c "cat <<'MODE'
MODE=safe
MODE"
MODE=safeAny line beginning with the delimiter does it, not only an assignment: a body line MODEL closes a <<'MODE' body too. The same for <<MODE unquoted and for <<-MODE. A longer delimiter does not help, because it is a prefix match rather than a length — MODE_BODY=safe closes a <<'MODE_BODY' body. A leading space saves the line, and xMODE is fine.
POSIX ends the body at a line containing only the delimiter, so a prefix match ends it too early.
This is not #346, which is about a heredoc whose start line continues with a ;. Here the start line is ordinary and the body closes itself.
Activity
baffalop commentedon Oct 7, 2026
Just chiming in on the bracket case: I encountered this with the following construct, an escaped
]inside condition brackets:Looks like the textmate grammar also trips up on it, but recovers. As with the
)cases, treesitter's famous error recovery here is poor. In my editor everything after this point in the file ends up mis-highlighted, including later functions. I've had to disable treesitter for bash in neovim to keep my file readable.Both lines produce ERROR nodes. Even
[[ $f == *\] ]]on its own fails.[0-9]*parses fine, and so does${tag#\[}.Reproduced with nvim-treesitter's bash parser on Neovim 0.11.4.
cwedgwood commentedon Oct 19, 2026
I reproduced item 5 using tree-sitter's C API, independently of an editor or shell-analysis host.
Pinned sources:
Minimal input:
Bash accepts this and prints the three body lines literally. The unmodified grammar instead
produces five top-level named nodes, including four extra
commandnodes, withts_node_has_error(root) == true.Changing only both delimiter occurrences from
JStoENDNODEgives one top-levelredirected_statementand no parse error. Both scripts produce identical output in Bash.The
MODE/MODE=safeexample in the issue also reproduces on these pinned sources.The likely boundary is
scan_heredoc_end_identifier:it compares the matched prefix to the delimiter without requiring the following input to be
newline or EOF.
This also affects downstream command extraction, not just highlighting: heredoc body fragments
can become apparent shell commands and cause valid scripts to be rejected by command validators.