zsh/subscript_escape.rs
1//! Rust-only utility (NOT a port — lives outside `src/ported/` by design).
2//!
3//! C resolves the quoting inside a `[...]` subscript by RE-LEXING the
4//! subscript text: `getindex` (Src/params.c:2022) calls
5//! `parse_subscript(s, scanflags & SCANPM_DQUOTED, ']')` at c:2029, which
6//! untokenizes the text and pushes it back through the lexer
7//! (`dquote_parse`, Src/lex.c:1751-1769). zshrs has no equivalent step on
8//! that path — its `lex::parse_subscript` port throws away the tokenized
9//! text C copies back at c:Src/lex.c:1772, and re-entering the real lexer
10//! from inside paramsubst (and from the compiler, which resolves literal
11//! assignment keys at compile time) would mean re-entrant lexer state on
12//! the hottest expansion path.
13//!
14//! So the three C stages that decide what a backslash inside a subscript
15//! MEANS — `dquote_parse`'s backslash arm, `getarg`'s marker disposition,
16//! and the `remnulargs` / `parsestr` + `singsub` round that follows — are
17//! expressed here as the one string transform they add up to. Each rule
18//! cites the C line it comes from; see [`subscript_unescape`].
19//!
20//! Both callers are the "what key is this?" sites:
21//! * `ported::subst::paramsubst` — `${A[\[k\]]}` (read)
22//! * `extensions::compile_zsh::compile_assign` — `A[\[k\]]=v` (store)
23
24use crate::ported::zsh_h::{Bnull, Qstring, Qtick, Stringg, Tick};
25
26/// Backslash disposition inside a `[...]` subscript — the net effect of
27/// the re-lex C runs on a subscript's SOURCE text.
28///
29/// `getindex` NEVER reads the subscript the way the outer lexer left it.
30/// It calls `parse_subscript(s, scanflags & SCANPM_DQUOTED, ']')`
31/// (c:Src/params.c:2029), and `parse_subscript` untokenizes the text and
32/// re-lexes it through `dquote_parse(']', sub)`
33/// (c:Src/lex.c:1751-1769 — `untokenize(t = dupstring_wlen(s, l));
34/// inpush(t, 0, NULL); … err = dquote_parse(endchar, sub);`). That
35/// re-lex is where a backslash inside a subscript acquires its meaning:
36///
37/// ```text
38/// c:Src/lex.c:1497-1512
39/// if (c != '\n') {
40/// if (c == '$' || c == '\\' || (c == '}' && !intick && bct) ||
41/// c == endchar || c == '`' ||
42/// (endchar == ']' && (c == '[' || c == ']' ||
43/// c == '(' || c == ')' ||
44/// c == '{' || c == '}' ||
45/// (c == '"' && sub))))
46/// add(Bnull);
47/// else {
48/// /* lexstop is implicitly handled here */
49/// add('\\');
50/// goto cont;
51/// }
52/// } else if (sub || unset(CSHJUNKIEQUOTES) || endchar != '"')
53/// continue;
54/// ```
55///
56/// With `endchar == ']'` a backslash before one of ``$ \ ` ] [ ( ) { }``
57/// (plus `"` when the subscript is inside double quotes, `sub`) becomes
58/// the `Bnull` marker + the literal char; a backslash before ANY other
59/// char stays a literal backslash. That asymmetry is exactly why
60/// `A[\[k\]]` keys on `[k]` while `A[a\ b]` / `A[a\*b]` keep theirs.
61/// Backslash-newline is dropped outright (c:1513).
62///
63/// `getarg` then disposes of the markers (c:Src/params.c:1538-1551):
64///
65/// ```text
66/// if (inull(c)) {
67/// c = t[1];
68/// if (c == '[' || c == ']' || c == '(' || c == ')' ||
69/// c == '{' || c == '}') {
70/// if (ishash && i) *t = ztokens[*t - Pound];
71/// needtok = 1; ++t;
72/// } else if (c != '"')
73/// *t = ztokens[*t - Pound];
74/// continue;
75/// }
76/// ```
77///
78/// — a marker before a bracket/paren/brace (or before `"`) is KEPT and
79/// later DELETED by `remnulargs` (c:1583-1584, hash key path), so the
80/// escaped bracket reaches the hash table bare. Every other marker is
81/// untokenized back to a literal `\` (`ztokens[Bnull - Pound]` is `\`,
82/// c:Src/lex.c:38), which the `parsestr` + `singsub` round at
83/// c:1585-1593 re-marks and drops one stage later — so ``\$``, `\\` and
84/// ``\` `` also lose their backslash, just further down the pipeline.
85///
86/// zshrs has no equivalent re-lex step on this path (its
87/// `lex::parse_subscript` discards the tokenized text C copies back at
88/// c:Src/lex.c:1772), so a source-literal backslash reached the assoc
89/// key verbatim: `A[\[k\]]=v` stored the 5-char key `\[k\]` where zsh
90/// stores `[k]`. This function is that missing step, expressed as the
91/// composite string transform the three C stages add up to.
92///
93/// * `sub` — C's `SCANPM_DQUOTED`: the subscript sits inside `"…"`.
94/// * `resolve_dollar` — the caller has NO `parsestr`/`singsub` round
95/// after this call (compile-time literal key), so apply that stage's
96/// share of the work here as well.
97///
98/// Returns the rewritten text and whether an UNESCAPED `$` / `` ` ``
99/// (i.e. a live expansion, which C resolves in `singsub` at c:1592)
100/// is still present.
101pub fn subscript_unescape(s: &str, sub: bool, resolve_dollar: bool) -> (String, bool) {
102 // Fast path: no backslash and no expansion char — C's re-lex is an
103 // identity transform on such text.
104 if !s.contains('\\')
105 && !s.contains('$')
106 && !s.contains('`')
107 && !s.contains(Stringg)
108 && !s.contains(Qstring)
109 && !s.contains(Tick)
110 && !s.contains(Qtick)
111 {
112 return (s.to_string(), false);
113 }
114 let mut out = String::with_capacity(s.len());
115 let mut live = false;
116 let mut it = s.chars().peekable();
117 while let Some(c) = it.next() {
118 if c == '\\' {
119 match it.peek().copied() {
120 // c:Src/lex.c:1497 — `if (c != '\n')`; the else arm at
121 // c:1513 `continue`s for endchar != '"', dropping both.
122 Some('\n') => {
123 it.next();
124 }
125 // c:Src/lex.c:1503-1506 (the `endchar == ']'` set) +
126 // c:Src/params.c:1541-1548 (marker kept) +
127 // c:Src/params.c:1584 remnulargs (marker deleted).
128 Some(n) if matches!(n, '[' | ']' | '(' | ')' | '{' | '}') || (sub && n == '"') => {
129 out.push(n);
130 it.next();
131 }
132 // c:Src/lex.c:1501 (`$`, `\`, backtick) +
133 // c:Src/params.c:1549-1550 (marker → literal `\`) +
134 // c:Src/params.c:1585-1592 parsestr/singsub (re-marked,
135 // then dropped by prefork's remnulargs at c:169).
136 Some(n) if resolve_dollar && matches!(n, '$' | '\\' | '`') => {
137 out.push(n);
138 it.next();
139 }
140 // c:Src/lex.c:1508-1511 — `add('\\'); goto cont;`: the
141 // backslash is ordinary text and the escaped char is
142 // copied verbatim.
143 Some(n) => {
144 out.push('\\');
145 out.push(n);
146 it.next();
147 }
148 // Trailing lone backslash: C hits EOF inside
149 // dquote_parse and errors out (c:1518 lexstop). Keep the
150 // char so the caller's own error path decides.
151 None => out.push('\\'),
152 }
153 continue;
154 }
155 // c:Src/params.c:1592 singsub — an UNESCAPED `$`/backtick (in
156 // either ASCII or lexer-token spelling) is a live expansion.
157 if c == '$' || c == '`' || c == Stringg || c == Qstring || c == Tick || c == Qtick {
158 live = true;
159 }
160 out.push(c);
161 }
162 (out, live)
163}
164
165/// Same C stages as [`subscript_unescape`], stopped one step earlier and
166/// re-encoded for a caller that still has to run C's `parsestr` + `singsub`
167/// round (c:Src/params.c:1585-1592) through the word compiler.
168///
169/// [`subscript_unescape`] returns PLAIN text, which is right for a key the
170/// caller stores verbatim but wrong for a key that still holds a live
171/// expansion: its resolved `$` would be re-expanded by the word compiler and
172/// its now-bare `[` would be read as a glob. C never has that problem because
173/// its intermediate text is MARKED — `getarg` keeps the `Bnull` before a
174/// bracket (c:1541-1548) and writes a literal `\` before the others
175/// (c:1549-1550), and `parsestr` re-marks those (c:1588) before `singsub`
176/// expands what is left. zshrs's word compiler consumes the same lexer
177/// encoding, so emit the marker directly and let it do `singsub`'s job:
178///
179/// | source | C intermediate | emitted here |
180/// |--------|---------------------------------------|--------------|
181/// | `\[` `\]` `\(` `\)` `\{` `\}` (and `\"` when `sub`) | marker kept (c:1547), deleted by `remnulargs` (c:1583) | `Bnull` + char |
182/// | `\$` `\\` `` \` `` | marker → `\` (c:1550), re-marked by `parsestr` (c:1588), dropped by `singsub`'s `prefork`/`remnulargs` (c:Src/subst.c:169) | `Bnull` + char |
183/// | any other `\X` | ordinary text (c:Src/lex.c:1510 `add('\\')`) — survives BOTH re-lexes because the second one runs with `endchar == '\0'` and never marks `X` | `\` + char |
184/// | `\` + newline | dropped (c:Src/lex.c:1513) | — |
185/// | everything else | untouched | verbatim |
186///
187/// An unescaped `$` / `` ` `` is deliberately left live — that is exactly the
188/// work c:1592 `singsub` still has to do.
189///
190/// * `sub` — C's `SCANPM_DQUOTED`: the subscript sits inside `"…"`.
191pub fn subscript_escape_markers(s: &str, sub: bool) -> String {
192 if !s.contains('\\') {
193 return s.to_string();
194 }
195 let mut out = String::with_capacity(s.len());
196 let mut it = s.chars().peekable();
197 while let Some(c) = it.next() {
198 if c == '\\' {
199 match it.peek().copied() {
200 // c:Src/lex.c:1513
201 Some('\n') => {
202 it.next();
203 }
204 // c:Src/lex.c:1501-1506 — the marked set for endchar == ']'.
205 Some(n)
206 if matches!(n, '[' | ']' | '(' | ')' | '{' | '}' | '$' | '\\' | '`')
207 || (sub && n == '"') =>
208 {
209 out.push(Bnull);
210 out.push(n);
211 it.next();
212 }
213 // c:Src/lex.c:1508-1511 — `add('\\'); goto cont;`.
214 Some(n) => {
215 out.push('\\');
216 out.push(n);
217 it.next();
218 }
219 None => out.push('\\'),
220 }
221 continue;
222 }
223 out.push(c);
224 }
225 out
226}
227
228/// !!! WARNING: RUST-ONLY HELPER !!!
229/// C has no separate function here: this is the scan loop that OPENS
230/// `getarg` (c:Src/params.c:1533-1541), lifted out because the Rust
231/// paramsubst expands the whole subscript in one place and then needs
232/// to know where C would have cut it.
233///
234/// c:Src/params.c:1533-1541 —
235/// for (t = s, i = 0;
236/// (c = *t) &&
237/// ((c != Outbrack && (ishash || c != ',')) || i || inpar);
238/// t++) {
239/// /* Untokenize inull() except before brackets and double-quotes */
240/// if (inull(c)) { c = t[1]; if (c == '[' || … ) { … ++t; } … continue; }
241/// if (c == '[' || c == Inbrack) i++;
242/// else if (c == ']' || c == Outbrack) i--;
243/// if (c == '(' || c == Inpar) inpar++;
244/// else if (c == ')' || c == Outpar) inpar--;
245/// …
246/// }
247///
248/// Returns the two RAW (still unexpanded) argument texts when the scan
249/// stopped on a top-level range comma, else None (one argument only).
250/// `ishash` mirrors C's `ishash` gate: for a hash, `,` is an ordinary
251/// key byte and never terminates the argument.
252pub fn subscript_arg_split(s: &str, ishash: bool) -> Option<(String, String)> {
253 let chars: Vec<char> = s.chars().collect();
254 let mut i = 0_i32; // c:1512 — bracket nesting
255 let mut inpar = 0_i32; // c:1512
256 let mut k = 0_usize;
257 while k < chars.len() {
258 let c = chars[k];
259 // c:1543-1552 — `if (inull(c))`: the marker's NEXT char decides.
260 // Before a bracket/brace/paren the pair is skipped wholesale (c:1549
261 // `++t`), so an escaped bracket never moves the nesting counters.
262 // zshrs also sees the SOURCE-literal spelling of the escape (`\`)
263 // because it has no `parse_subscript` re-lex; treat both alike.
264 if c == crate::ported::zsh_h::Bnull || c == crate::ported::zsh_h::Bnullkeep || c == '\\' {
265 if let Some(&n) = chars.get(k + 1) {
266 if matches!(n, '[' | ']' | '(' | ')' | '{' | '}')
267 || n == crate::ported::zsh_h::Inbrack
268 || n == crate::ported::zsh_h::Outbrack
269 || n == crate::ported::zsh_h::Inpar
270 || n == crate::ported::zsh_h::Outpar
271 || n == crate::ported::zsh_h::Inbrace
272 || n == crate::ported::zsh_h::Outbrace
273 {
274 k += 2; // c:1549 `++t` plus the loop's own `t++`
275 continue;
276 }
277 }
278 k += 1; // c:1551 `continue` — the escaped char is examined next
279 continue;
280 }
281 // c:1534 — `(c != Outbrack && (ishash || c != ','))`: a top-level
282 // comma ends the argument unless the target is a hash.
283 // `Comma` (c:Src/zsh.h) is the lexer TOKEN spelling of the same byte —
284 // a nested `${…}` body reaches paramsubst tokenized, so testing only
285 // the ASCII form reported "one argument" for `${(A@)a[1,2]}` and every
286 // downstream site then treated the slice as a single element.
287 if (c == ',' || c == crate::ported::zsh_h::Comma) && !ishash && i == 0 && inpar == 0 {
288 return Some((chars[..k].iter().collect(), chars[k + 1..].iter().collect()));
289 }
290 match c {
291 '[' | crate::ported::zsh_h::Inbrack => i += 1, // c:1553
292 ']' | crate::ported::zsh_h::Outbrack => i -= 1, // c:1555
293 '(' | crate::ported::zsh_h::Inpar => inpar += 1, // c:1557
294 ')' | crate::ported::zsh_h::Outpar => inpar -= 1, // c:1559
295 _ => {}
296 }
297 k += 1;
298 }
299 None
300}
301
302/// !!! WARNING: RUST-ONLY HELPER !!!
303/// Resolve "is this subscript a range, and what are its bounds?" using
304/// the parse-time decision recorded by `subscript_arg_split` when one is
305/// available (c:Src/params.c:1533-1536 — C splits BEFORE expanding), and
306/// falling back to a depth-0 comma scan of the already-expanded text for
307/// the reference paths that do not record one.
308pub fn subscript_range_bounds(
309 sub: &str,
310 known: &Option<(String, Option<(String, String)>)>,
311) -> Option<(String, String)> {
312 // The record is only authoritative for the exact subscript text it was
313 // taken from: paramsubst reassigns `subscript` on several arms (the
314 // `${!name}` prefix form, the magic-assoc key rebuild), and a stale
315 // record must not answer for a different string.
316 if let Some((recorded, split)) = known {
317 if recorded == sub {
318 return split.clone();
319 }
320 }
321 let bs: Vec<char> = sub.chars().collect();
322 let mut depth = 0_i32;
323 for (k, &c) in bs.iter().enumerate() {
324 match c {
325 '(' | crate::ported::zsh_h::Inpar | '[' | crate::ported::zsh_h::Inbrack => depth += 1,
326 ')' | crate::ported::zsh_h::Outpar | ']' | crate::ported::zsh_h::Outbrack => {
327 if depth > 0 {
328 depth -= 1;
329 }
330 }
331 ',' if depth == 0 => {
332 return Some((bs[..k].iter().collect(), bs[k + 1..].iter().collect()));
333 }
334 c if c == crate::ported::zsh_h::Comma && depth == 0 => {
335 return Some((bs[..k].iter().collect(), bs[k + 1..].iter().collect()));
336 }
337 _ => {}
338 }
339 }
340 None
341}
342
343/// !!! WARNING: RUST-ONLY HELPER !!!
344/// Inverse of [`subscript_unescape`]'s marked set, for the one place the port
345/// has to hand an ALREADY-EXPANDED key back through a text subscript.
346///
347/// c:Src/subst.c:3312-3316 — the `${name[key]=value}` family assigns with
348/// *idend = '\0';
349/// Param pm = setsparam(idbeg, ztrdup(val));
350/// i.e. C re-parses the flat `name[key]` text too. That is sound in C because
351/// `idbeg` still holds the LEXER's spelling, where a `]` inside the key is a
352/// `Bnull`-marked byte and cannot close the subscript. zshrs's paramsubst has
353/// already resolved the subscript to plain text by then (`expand_sub_arg`), so
354/// the rebuilt string `B[\\]]` re-parsed as key `\` — the assignment landed on
355/// the wrong key and the read-back came up empty (D06subscript.ztst
356/// "Associative array substitution-assignment with reverse pattern subscript
357/// key"). Re-apply the escaping the re-parse will strip, exactly over the set
358/// c:Src/lex.c:1501-1506 marks for `endchar == ']'`, so the round trip is the
359/// identity.
360///
361/// Returns the input untouched for a FLAG-GROUP subscript (`(r)pat`), whose
362/// parentheses are structure rather than data.
363pub fn subscript_requote_for_assign(k: &str) -> std::borrow::Cow<'_, str> {
364 let trimmed = k.trim_start();
365 if trimmed.starts_with('(') || trimmed.starts_with(crate::ported::zsh_h::Inpar) {
366 return std::borrow::Cow::Borrowed(k); // flag group: structural
367 }
368 // c:Src/lex.c:1501-1506 — the set that `dquote_parse(']')` marks.
369 if !k.contains(|c| {
370 matches!(
371 c,
372 '$' | '\\' | '`' | '[' | ']' | '(' | ')' | '{' | '}' | '"'
373 )
374 }) {
375 return std::borrow::Cow::Borrowed(k);
376 }
377 let mut out = String::with_capacity(k.len() * 2);
378 for c in k.chars() {
379 if matches!(
380 c,
381 '$' | '\\' | '`' | '[' | ']' | '(' | ')' | '{' | '}' | '"'
382 ) {
383 out.push('\\');
384 }
385 out.push(c);
386 }
387 std::borrow::Cow::Owned(out)
388}
389
390/// !!! WARNING: RUST-ONLY HELPER !!!
391/// Classification of ONE subscript operand — a range bound (`${a[lo,hi]}`)
392/// or a chained subscript (`${a[lo,hi][SUB]}`) — whose text may open with a
393/// `(...)` flag group.
394///
395/// C has no such function: `getarg` (c:Src/params.c:1367) parses the flags,
396/// runs the search and returns the index all in one pass, writing its
397/// side-effects back through `Value *v` / `int *inv` out-parameters. zshrs's
398/// `ported::params::getarg` returns the matched ELEMENT for `r`/`R` and the
399/// INDEX for `i`/`I` (see `getarg_out`), and it has no `Value` to record
400/// `v->isarr |= SCANPM_WANTVALS` in — so the two facts every bound consumer
401/// needs (the match POSITION and whether WANTVALS was raised) are recovered
402/// here in one place instead of being re-derived at each call site.
403pub enum SubscriptBound {
404 /// c:Src/params.c:1729-1760 — the flag group ran a pattern SEARCH.
405 /// `.0` is getarg's 1-based match index `r` (c:1758 returns 0 for a
406 /// REVERSE miss, c:1751 `len + 1` for a FORWARD miss); `.1` records
407 /// c:1523 `v->isarr |= SCANPM_WANTVALS`, raised by `r`/`R`/`k`/`K`
408 /// when the `i`/`I` index flag (`ind`) is off.
409 Search(i64, bool),
410 /// c:Src/params.c:1597 `r = mathevalarg(s, &s)` — no search ran; the
411 /// payload is the text to evaluate as arithmetic. A recognised but
412 /// non-search flag group (`(s.X.)`, `(w)`, …) has been stripped; an
413 /// UNKNOWN group is left in place because c:1477-1483's `flagerr` arm
414 /// rewinds to before the `(` and re-reads the whole group as math.
415 Math(String),
416}
417
418/// !!! WARNING: RUST-ONLY HELPER !!!
419/// Classify one subscript operand against `arr` — see [`SubscriptBound`].
420///
421/// c:Src/params.c getindex — a bound with a search-flag subscript
422/// (`(r)pat`/`(i)pat`) yields the INDEX of the match (the `*inv`/`*w` path),
423/// not the value: `${a[(r)3,(r)5]}` slices between the matched positions.
424/// `getarg` returns the value for `r`/`R` but the index for `i`/`I`. `r` is a
425/// FORWARD first-match (c:1411 `down = 0`), `R` a REVERSE last-match (c:1416
426/// `down = 1`), so map `r`→`i` / `R`→`I` to get the matching index in the SAME
427/// direction — preserving forward/reverse for duplicate matches and the
428/// no-match returns (forward no-match → len+1, reverse → 0).
429pub fn subscript_bound_classify(t: &str, arr: &[String]) -> SubscriptBound {
430 let t = t.trim();
431 let close = match t.find(')') {
432 Some(c) if t.starts_with('(') => c,
433 _ => return SubscriptBound::Math(t.to_string()),
434 };
435 let flags = &t[1..close];
436 if flags
437 .chars()
438 .any(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K'))
439 {
440 // Search flag → matched INDEX via getarg (r/R are value-returning →
441 // map to the i/I index form in the same direction).
442 let mapped: String = flags
443 .chars()
444 .map(|c| match c {
445 'r' => 'i',
446 'R' => 'I',
447 // On a non-hash, k/K are r/R (c:1400/1405 gate only
448 // `keymatch` on ishash), so they need the same value→index
449 // remap to serve as a range BOUND. Bug #1050.
450 'k' => 'i',
451 'K' => 'I',
452 o => o,
453 })
454 .collect();
455 // c:Src/params.c:1516-1531 — the `*inv` decision. `ind` is set only
456 // by `i`/`I` (c:1420/1424); `rev` by `r`/`R`/`k`/`K`. With `ind` off
457 // and `rev` on, c:1523 raises `v->isarr |= SCANPM_WANTVALS`, and that
458 // bit rides on the Value into any CHAINED subscript (c:Src/subst.c:2896
459 // `v->isarr = isarr`, where `isarr` is the whole scanflags mask), where
460 // c:1515 `else if (v->isarr & SCANPM_WANTVALS) *inv = 0;` makes a
461 // later `(i)`/`(I)` return the ELEMENT instead of the index.
462 let ind = flags.contains('i') || flags.contains('I');
463 let wantvals = !ind;
464 let new_sub = format!("({}){}", mapped, &t[close + 1..]);
465 if let Some(crate::ported::params::getarg_out::Value(v)) =
466 crate::ported::params::getarg(&new_sub, Some(arr), None, None)
467 {
468 if let Ok(n) = v.to_str().trim().parse::<i64>() {
469 return SubscriptBound::Search(n, wantvals);
470 }
471 }
472 return SubscriptBound::Math(t.to_string());
473 }
474 if flags.chars().next().is_some_and(|c| {
475 // c:Src/params.c:1392-1476 — the flag switch's cases, verbatim.
476 matches!(
477 c,
478 'r' | 'R' | 'k' | 'K' | 'i' | 'I' | 'w' | 'f' | 'e' | 'n' | 'b' | 'p' | 's'
479 )
480 }) {
481 // Separator/word flag (`(s.X.)` etc.) is a no-op for an integer
482 // slice bound (c:#83); strip it and parse the remainder.
483 return SubscriptBound::Math(t[close + 1..].to_string());
484 }
485 // c:Src/params.c:1477-1482 — anything else is NOT a flag group. C's flag
486 // switch falls to
487 // default:
488 // flagerr:
489 // num = 1; word = rev = ind = down = keymatch = 0; sep = NULL;
490 // s = *str - 1; /* rewind */
491 // so an unknown flag char REWINDS to before the `(` and the group is
492 // re-read as MATH. That is why `${arr[(zz)1]}` reports `bad math
493 // expression` rather than a flag error.
494 //
495 // Stripping unconditionally deleted a PARENTHESISED range bound:
496 // `${arr[(x), 4]}` left the text empty, so the bound fell back to the
497 // default 1 and the slice became 1..4 (`a b c d`) where zsh gives
498 // `b c d`. Handing `(x)` to mathevali below is C's behaviour. Only the
499 // RANGE form was affected: a single `${arr[(x)]}` takes a different arm
500 // and already evaluated as math.
501 SubscriptBound::Math(t.to_string())
502}