$[…] is a lexical mode, not a sublanguage
Amended 2026-10-08. The operator spellings are now a token of their own,
Token::Op(Operator), rather than words: the words rule has to tell1+1from touching atoms by token kind. The rest of the decision stands.
Inside $[…] the spellings < > <= >= != && || lex as operator
words, exactly as + and == always did; the parser reads the body with one
Pratt loop whose operands are the ordinary atoms of the value grammar; the five
operator forms are plain Ast variants; and the block itself leaves no node.
There is no Expr type. What $[…] contributes is the mode in which
operators can be written without colliding with the shell — nothing else.
What was there
The old front end had a second expression language bolted beside the first.
Ast::Expr(Box<Expr>) wrapped an Expr enum whose leaves — integer, float,
Boolean, variable, indexed variable, force — duplicated Ast’s own, with a
separate elaborator arm for each. The Pratt parser admitted only those leaves,
so $[$s == 'quit'] was a parse error although == is structural on every
value and equal already compared strings. Two of the operators could not
be lexed at all: < and > arrived as redirect tokens the Pratt parser
reinterpreted, 2>3 lexed 2> as a file descriptor and earned a diagnostic
about spacing, and &&/|| were fused from adjacent single-character tokens
by a post-pass. Meanwhile !=, <=, >= lexed as words everywhere, so
echo a >= b printed a >= b while §3.5 listed > among the word-enders.
The decision
- The lexer’s whole context is the innermost open delimiter.
DelimKindgainsExpr. A[…]makes newlines whitespace and,punctuation; a$[…]does the same and also turns the comparison and Boolean spellings into words, and makes the arithmetic characters+ - * / % =end a word, so$[1+1]is a sum with no spacing rule to remember — a numeral is read whole through its exponent sign, so1.5e+3survives. Everywhere else>begins a redirect,1+1is a word, and&,&&,||are refused by name (spawn,;,?). One stack, one predicate; the mode is restored by the next{, so$[!{wc -l < f} > 1]reads a file inside and compares outside. - Operands are atoms.
parse_expr_operandis(…), prefix-, prefixnot, orparse_atom. Which values+or<accept is the type system’s question, as it already was for$[$a + $b]on two variables; the grammar merely stopped pretending otherwise.$[$s == 'quit'],$[!{f}[k] + 1],$[[1][0] * 2]are now admitted. The price is that$[x + 1]— a dropped$— is"x" + 1; since a bare non-numeral word can never be a number,numeric_operandrefuses it under-, arithmetic and ordering at parse time, asking whether$xwas meant.==and!=are exempt: comparing to a bare-word string is legitimate. - No node for the block.
Ast::Binary,Negate,Not,And,OrcarrySpannedAstoperands;$[1.5]is the numeral1.5. The elaborator lowers five arms where it lowered twelve, andfree_refswalks one tree.
Landing beside it
The same pass removed every token that existed only to be refused
(Dollar, Ampersand) — the lexer refuses the character with the message
the parser used to give — and made a splice inside "…" the token stream the
same text has outside the string, so "!$d" is Force(Variable) like !$d
rather than the synthesised !{$d} that interpolated a block. Un-fusing
$name[k] at the lexer exposed the one place fusion was load-bearing: !
must reach over a dereference’s keys (!$p[tail] forces the field) while a
forced block is indexed after (!{cmd}[k]), and that rule now lives in
parse_bang as grammar rather than in the token shape.
Amended. “The token stream the same text has outside the string” was read
too far: the pass also let every splice swallow a following [key], and
inside a string [ is otherwise text, so "$(red)[$host]" indexed a colour
and no escape could stop it — \[ is not an escape, and "$(x)[oops" lost
its closing quote to the bracket group. A splice now ends where its own
closing delimiter does; only the undelimited $name and !$name continue
into [key], and "$[!{f}[k]]" says the rest explicitly. $(name) is again
what docs/TUTORIAL.md always called it: the mark for the end of a name.
Amended again. The amendment left $(name) meaning two things:
the end of a name inside a string, and the same as $name outside
one, where $(red)[x] still indexed. With words that touch now an
error (words), the rule is one: $(name) takes
nothing after it anywhere, and every other splice, !{…} and $[…]
included, takes [key] groups anywhere. "!{f}[k]" indexes as
!{f}[k] does, and the “wrap it in $[…]” exception is gone.
Rejected
- Keep the redirect reinterpretation and add diagnostics. It was already
one special error for
2>; every further glued spelling would want another. - Lex
&&/||as words everywhere. Outside$[…]a && bwould then runawith two arguments — the bash reflex succeeding silently, which is the one outcome worse than a bad message. - Restrict operands to a “numeric” subset of atoms. More grammar to say
what the checker already says, and
==on strings stays unwritable.