Skip to main content

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}