Skip to main content

kaish_kernel/
arithmetic.rs

1//! `$(( ))` — checked 64-bit integer arithmetic and another-base number
2//! reading.
3//!
4//! Three stages, kept separate so each can be tested on its own:
5//! `tokenize` (text → `Tok`), `parse` (`Tok` → `ArithExpr`), and
6//! evaluation (`eval_sync` for a scope with no `$(...)` reachable, and
7//! `Kernel::eval_arith_async` in `kernel.rs` for the general case).
8//!
9//! Supports: decimal/hex/`base#digits` literals (base 2..=36), the full C
10//! precedence table down through `?:`, `$name`/`${...}`/`$(...)`/nested
11//! `$((...))` as operands, and bare `(( expr ))` as a condition (see
12//! `Stmt::Arith`/`Expr::Arith` in `ast/types.rs`).
13//!
14//! Diverges from bash on purpose: overflow is an error, never a wrap; a
15//! leading-zero numeral is refused, never read as octal; an unset or empty
16//! operand is an error, never 0; `$(...)` on the unselected side of
17//! `&&`/`||`/`?:` never runs.
18
19use crate::ast::{Stmt, Value, VarPath};
20use crate::interpreter::{value_defaults_on_emptiness, value_to_string, PathError, Scope};
21use std::ops::Range;
22
23/// An error from tokenizing, parsing, or evaluating `$(( ))`. `message` is
24/// the full, final text shown to the caller; `span` is the byte range in the
25/// arithmetic source the error concerns, when one exists (evaluation errors
26/// over already-resolved values carry `0..0` — the message already names
27/// the values).
28#[derive(Debug, Clone, PartialEq)]
29pub struct ArithError {
30    pub message: String,
31    pub span: Range<usize>,
32}
33
34impl ArithError {
35    fn new(message: impl Into<String>, span: Range<usize>) -> Self {
36        Self { message: message.into(), span }
37    }
38}
39
40impl std::fmt::Display for ArithError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(&self.message)
43    }
44}
45
46impl std::error::Error for ArithError {}
47
48const MAX_DEPTH: usize = 256;
49
50/// The decimal a leading-zero numeral was probably meant to be — `010`
51/// becomes `10`, `-007` becomes `-7`. `None` when the text is not one.
52///
53/// Two callers, two shapes of `text`: `value_to_num` (`interpreter/eval.rs`)
54/// hands this a full resolved value, sign and all (`test $x -eq -7` with
55/// `x` holding `-007`) — for that caller, the sign in `text` IS the fix,
56/// and must survive the trim. `Tokenizer::lex_number` hands this only the
57/// unsigned digits (the tokenizer already split a source-level `-`/`+`
58/// into its own token before `007` was ever scanned) and prepends any sign
59/// itself, via `Tokenizer::leading_unary_sign` — for that caller this
60/// function's own sign handling is simply inert, never wrong.
61pub(crate) fn leading_zero_decimal(text: &str) -> Option<String> {
62    if !crate::lexer::is_leading_zero_numeral(text) {
63        return None;
64    }
65    let sign = if text.starts_with('-') { "-" } else { "" };
66    let digits = text.trim_start_matches('-').trim_start_matches('0');
67    Some(format!("{sign}{}", if digits.is_empty() { "0" } else { digits }))
68}
69
70// ═══════════════════════════════════════════════════════════════════
71// Tokens
72// ═══════════════════════════════════════════════════════════════════
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub(crate) enum BinOp {
76    Add, Sub, Mul, Div, Rem, Pow, Shl, Shr,
77    Lt, Le, Gt, Ge, Eq, Ne,
78    BitAnd, BitXor, BitOr, And, Or,
79}
80
81impl BinOp {
82    fn symbol(self) -> &'static str {
83        match self {
84            BinOp::Add => "+", BinOp::Sub => "-", BinOp::Mul => "*",
85            BinOp::Div => "/", BinOp::Rem => "%", BinOp::Pow => "**",
86            BinOp::Shl => "<<", BinOp::Shr => ">>",
87            BinOp::Lt => "<", BinOp::Le => "<=", BinOp::Gt => ">", BinOp::Ge => ">=",
88            BinOp::Eq => "==", BinOp::Ne => "!=",
89            BinOp::BitAnd => "&", BinOp::BitXor => "^", BinOp::BitOr => "|",
90            BinOp::And => "&&", BinOp::Or => "||",
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub(crate) enum UnOp {
97    Neg,
98    Not,
99    BitNot,
100}
101
102/// A `$(...)`/`${...}`/`$name`/`$?`/`$$`/`$((...))` operand, still
103/// unresolved. Evaluating one is the only place `$(( ))` needs the async
104/// evaluator — everything else in `ArithExpr` is pure.
105#[derive(Debug, Clone, PartialEq)]
106pub(crate) enum Expansion {
107    /// Bare `x` or `$x` — the whole value.
108    Var(String),
109    /// `${root[...]...}` — a literal-key subscript path (the interpolation
110    /// reading: brackets hold a KEY, not an expression).
111    BracedPath { root: String, brackets: String },
112    /// `${root[...]:-default}` — `default` is itself arithmetic source,
113    /// evaluated only when `root` is unset or null.
114    BracedDefault { root: String, brackets: String, default: String },
115    /// `$?`
116    LastExitCode,
117    /// `$$`
118    CurrentPid,
119    /// `$(...)` — pre-parsed; running it needs the async evaluator.
120    CommandSubst(Vec<Stmt>),
121    /// `$((...))` — a nested arithmetic form, evaluated recursively.
122    Nested(Box<ArithExpr>),
123}
124
125#[derive(Debug, Clone, PartialEq)]
126pub(crate) enum ArithExpr {
127    Int(i64),
128    Expansion(Expansion),
129    /// `xs[i]`, `xs[i][j]` — Decision B: each bracket's contents is a
130    /// numeric expression (the opposite of `${xs[i]}`'s literal key).
131    Subscript { root: String, indices: Vec<ArithExpr> },
132    /// `base#<expansion>` — the expansion's rendered text is read as digits
133    /// in `base` (`2#$BITS`, `10#$(date +%m)`).
134    BasedExpansion { base: u32, expansion: Box<Expansion> },
135    Unary { op: UnOp, operand: Box<ArithExpr> },
136    Binary { op: BinOp, left: Box<ArithExpr>, right: Box<ArithExpr> },
137    Ternary { cond: Box<ArithExpr>, then_branch: Box<ArithExpr>, else_branch: Box<ArithExpr> },
138}
139
140impl ArithExpr {
141    /// True when some reachable node is a `$(...)` — used by callers that
142    /// want the sync fast path when it is safe.
143    pub(crate) fn contains_command_subst(&self) -> bool {
144        fn expansion_has(e: &Expansion) -> bool {
145            match e {
146                Expansion::CommandSubst(_) => true,
147                Expansion::Nested(inner) => inner.contains_command_subst(),
148                // The default is unparsed text at this point (parsing
149                // happens only if it is actually reached, at eval time) —
150                // parse it here just to answer the question. A parse
151                // failure changes nothing: eval will hit the identical
152                // parse error on either path.
153                Expansion::BracedDefault { default, .. } => parse(default)
154                    .map(|parsed| parsed.contains_command_subst())
155                    .unwrap_or(false),
156                Expansion::Var(_)
157                | Expansion::BracedPath { .. }
158                | Expansion::LastExitCode
159                | Expansion::CurrentPid => false,
160            }
161        }
162        match self {
163            ArithExpr::Int(_) => false,
164            ArithExpr::Expansion(e) => expansion_has(e),
165            ArithExpr::Subscript { indices, .. } => {
166                indices.iter().any(ArithExpr::contains_command_subst)
167            }
168            ArithExpr::BasedExpansion { expansion, .. } => expansion_has(expansion),
169            ArithExpr::Unary { operand, .. } => operand.contains_command_subst(),
170            ArithExpr::Binary { left, right, .. } => {
171                left.contains_command_subst() || right.contains_command_subst()
172            }
173            ArithExpr::Ternary { cond, then_branch, else_branch } => {
174                cond.contains_command_subst()
175                    || then_branch.contains_command_subst()
176                    || else_branch.contains_command_subst()
177            }
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq)]
183enum TokKind {
184    Number(u64),
185    BasedExpansion { base: u32, expansion: Box<Expansion> },
186    Ident(String),
187    Expansion(Expansion),
188    LParen,
189    RParen,
190    LBracket,
191    RBracket,
192    Question,
193    Colon,
194    Op(BinOp),
195    Bang,
196    Tilde,
197}
198
199impl std::fmt::Display for TokKind {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            TokKind::Number(n) => write!(f, "{n}"),
203            TokKind::BasedExpansion { base, .. } => write!(f, "{base}#..."),
204            TokKind::Ident(name) => write!(f, "{name}"),
205            TokKind::Expansion(_) => write!(f, "$..."),
206            TokKind::LParen => write!(f, "("),
207            TokKind::RParen => write!(f, ")"),
208            TokKind::LBracket => write!(f, "["),
209            TokKind::RBracket => write!(f, "]"),
210            TokKind::Question => write!(f, "?"),
211            TokKind::Colon => write!(f, ":"),
212            TokKind::Op(op) => write!(f, "{}", op.symbol()),
213            TokKind::Bang => write!(f, "!"),
214            TokKind::Tilde => write!(f, "~"),
215        }
216    }
217}
218
219#[derive(Debug, Clone, PartialEq)]
220struct Tok {
221    kind: TokKind,
222    span: Range<usize>,
223}
224
225// ═══════════════════════════════════════════════════════════════════
226// Tokenizer
227// ═══════════════════════════════════════════════════════════════════
228
229struct Tokenizer<'a> {
230    text: &'a str,
231    chars: Vec<(usize, char)>,
232    pos: usize,
233}
234
235impl<'a> Tokenizer<'a> {
236    fn new(text: &'a str) -> Self {
237        Self { text, chars: text.char_indices().collect(), pos: 0 }
238    }
239
240    fn byte_pos(&self) -> usize {
241        self.byte_at(self.pos)
242    }
243
244    /// Byte offset for a CHAR index. `self.pos` (and every local `start`
245    /// derived from it) counts chars, not bytes — a multi-byte character
246    /// anywhere before the index makes the two diverge. Every span handed
247    /// to `ArithError::new` must go through this (or `byte_pos()` for the
248    /// current position), never a bare char index.
249    fn byte_at(&self, char_idx: usize) -> usize {
250        self.chars.get(char_idx).map(|(b, _)| *b).unwrap_or(self.text.len())
251    }
252
253    fn peek(&self) -> Option<char> {
254        self.chars.get(self.pos).map(|(_, c)| *c)
255    }
256
257    fn peek_at(&self, n: usize) -> Option<char> {
258        self.chars.get(self.pos + n).map(|(_, c)| *c)
259    }
260
261    fn advance(&mut self) -> Option<char> {
262        let c = self.peek();
263        if c.is_some() {
264            self.pos += 1;
265        }
266        c
267    }
268
269    fn slice(&self, start: usize, end: usize) -> &'a str {
270        let end_byte = self.chars.get(end).map(|(b, _)| *b).unwrap_or(self.text.len());
271        let start_byte = self.chars.get(start).map(|(b, _)| *b).unwrap_or(self.text.len());
272        &self.text[start_byte..end_byte]
273    }
274
275    /// End of the numeral run starting at `from` — digits, letters, and `_`.
276    /// An error quotes the literal the user wrote, not the prefix scanned so
277    /// far, so `1_000` is not reported as `1_`.
278    fn numeral_run_end(&self, from: usize) -> usize {
279        let mut end = from;
280        while self.chars.get(end).is_some_and(|(_, c)| c.is_ascii_alphanumeric() || *c == '_') {
281            end += 1;
282        }
283        end
284    }
285
286    fn skip_ws(&mut self) {
287        while matches!(self.peek(), Some(c) if c.is_whitespace()) {
288            self.pos += 1;
289        }
290    }
291
292    fn tokenize(mut self) -> Result<Vec<Tok>, ArithError> {
293        let mut out = Vec::new();
294        loop {
295            self.skip_ws();
296            let Some(c) = self.peek() else { break };
297            let start_byte = self.byte_pos();
298            let kind = match c {
299                '0'..='9' => self.lex_number(&out)?,
300                '$' => self.lex_dollar()?,
301                c if c.is_ascii_alphabetic() || c == '_' => self.lex_ident(),
302                '(' => { self.advance(); TokKind::LParen }
303                ')' => { self.advance(); TokKind::RParen }
304                '[' => { self.advance(); TokKind::LBracket }
305                ']' => { self.advance(); TokKind::RBracket }
306                '?' => { self.advance(); TokKind::Question }
307                ':' => { self.advance(); TokKind::Colon }
308                '~' => { self.advance(); TokKind::Tilde }
309                '!' => {
310                    self.advance();
311                    if self.peek() == Some('=') {
312                        self.advance();
313                        TokKind::Op(BinOp::Ne)
314                    } else {
315                        TokKind::Bang
316                    }
317                }
318                '+' => {
319                    self.advance();
320                    self.reject_compound_or('+', start_byte, &out)?;
321                    TokKind::Op(BinOp::Add)
322                }
323                '-' => {
324                    self.advance();
325                    self.reject_compound_or('-', start_byte, &out)?;
326                    TokKind::Op(BinOp::Sub)
327                }
328                '*' => {
329                    self.advance();
330                    if self.peek() == Some('*') {
331                        self.advance();
332                        TokKind::Op(BinOp::Pow)
333                    } else {
334                        TokKind::Op(BinOp::Mul)
335                    }
336                }
337                '/' => { self.advance(); TokKind::Op(BinOp::Div) }
338                '%' => { self.advance(); TokKind::Op(BinOp::Rem) }
339                '<' => {
340                    self.advance();
341                    if self.peek() == Some('<') {
342                        self.advance();
343                        if self.peek() == Some('<') {
344                            return Err(ArithError::new(
345                                "`<<<` is a here-string, not an operator; write `<<` to shift",
346                                start_byte..self.byte_at(self.pos + 1),
347                            ));
348                        }
349                        TokKind::Op(BinOp::Shl)
350                    } else if self.peek() == Some('=') {
351                        self.advance();
352                        TokKind::Op(BinOp::Le)
353                    } else {
354                        TokKind::Op(BinOp::Lt)
355                    }
356                }
357                '>' => {
358                    self.advance();
359                    if self.peek() == Some('>') {
360                        self.advance();
361                        if self.peek() == Some('>') {
362                            return Err(ArithError::new(
363                                "`>>>` is not an operator; write `>>`",
364                                start_byte..self.byte_at(self.pos + 1),
365                            ));
366                        }
367                        TokKind::Op(BinOp::Shr)
368                    } else if self.peek() == Some('=') {
369                        self.advance();
370                        TokKind::Op(BinOp::Ge)
371                    } else {
372                        TokKind::Op(BinOp::Gt)
373                    }
374                }
375                '=' => {
376                    self.advance();
377                    if self.peek() == Some('=') {
378                        self.advance();
379                        TokKind::Op(BinOp::Eq)
380                    } else {
381                        let end = self.text.len();
382                        let rhs = self.text[self.byte_pos()..].trim();
383                        if let Some((name, name_start)) = Self::preceding_name(&out) {
384                            let source = &self.text[name_start..end];
385                            return Err(ArithError::new(
386                                format!(
387                                    "`{source}` assigns inside `$(( ))`; write `{name}={rhs}`, or `==` to compare"
388                                ),
389                                name_start..end,
390                            ));
391                        }
392                        let source = &self.text[start_byte..end];
393                        return Err(ArithError::new(
394                            format!(
395                                "`{source}` assigns inside `$(( ))`; write `name=rhs`, or `==` to compare"
396                            ),
397                            start_byte..end,
398                        ));
399                    }
400                }
401                '&' => {
402                    self.advance();
403                    if self.peek() == Some('&') {
404                        self.advance();
405                        TokKind::Op(BinOp::And)
406                    } else {
407                        TokKind::Op(BinOp::BitAnd)
408                    }
409                }
410                '|' => {
411                    self.advance();
412                    if self.peek() == Some('|') {
413                        self.advance();
414                        TokKind::Op(BinOp::Or)
415                    } else {
416                        TokKind::Op(BinOp::BitOr)
417                    }
418                }
419                '^' => { self.advance(); TokKind::Op(BinOp::BitXor) }
420                ',' => {
421                    return Err(ArithError::new(
422                        "`,` is not an operator; one expression per `$(( ))`",
423                        start_byte..self.byte_at(self.pos + 1),
424                    ));
425                }
426                other => {
427                    return Err(ArithError::new(
428                        format!("`{other}` cannot start a value"),
429                        start_byte..self.byte_at(self.pos + 1),
430                    ));
431                }
432            };
433            out.push(Tok { kind, span: start_byte..self.byte_pos() });
434        }
435        Ok(out)
436    }
437
438    /// The identifier that ends immediately before `op_start_byte` — the
439    /// `x` in `x++`/`x += 2`/`x = 2`, read from the token already emitted
440    /// (the operator has not been pushed onto `out` yet).
441    fn preceding_name(out: &[Tok]) -> Option<(String, usize)> {
442        match out.last() {
443            Some(Tok { kind: TokKind::Ident(name), span }) => Some((name.clone(), span.start)),
444            _ => None,
445        }
446    }
447
448    /// The `+`/`-` immediately before the numeral about to be lexed, IF it
449    /// is acting as a unary sign on that numeral rather than a binary
450    /// operator between two operands (`5 - 007`'s `-` is binary; `-007`'s
451    /// is unary). `out.last()` is the candidate sign; the token before
452    /// that decides which — a `+`/`-` is unary except right after
453    /// something that can itself END an expression. Mirrors the
454    /// grammatical position `parse_unary`/`parse_additive` compute later,
455    /// without building a parser: `tokenize` has already refused by the
456    /// time a numeral like `007` would reach the parser at all, so the
457    /// sign has to be read back from the flat token stream here instead.
458    fn leading_unary_sign(out: &[Tok]) -> Option<char> {
459        let sign = match out.last() {
460            Some(Tok { kind: TokKind::Op(BinOp::Sub), .. }) => '-',
461            Some(Tok { kind: TokKind::Op(BinOp::Add), .. }) => '+',
462            _ => return None,
463        };
464        let ends_an_expression = out
465            .len()
466            .checked_sub(2)
467            .and_then(|i| out.get(i))
468            .is_some_and(|tok| {
469                matches!(
470                    tok.kind,
471                    TokKind::Number(_)
472                        | TokKind::Ident(_)
473                        | TokKind::Expansion(_)
474                        | TokKind::BasedExpansion { .. }
475                        | TokKind::RParen
476                        | TokKind::RBracket
477                )
478            });
479        if ends_an_expression { None } else { Some(sign) }
480    }
481
482    /// The identifier starting at the current position — the `x` in
483    /// `++x`/`--x`, where the operator precedes the name. Consumes it:
484    /// this is only called on a path that is about to return `Err`, so
485    /// leaving `self.pos` past it does not affect anything further.
486    fn consume_following_name(&mut self) -> Option<(String, usize)> {
487        let start = self.pos;
488        if !matches!(self.peek(), Some(c) if c.is_ascii_alphabetic() || c == '_') {
489            return None;
490        }
491        while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric() || c == '_') {
492            self.pos += 1;
493        }
494        Some((self.slice(start, self.pos).to_string(), self.byte_pos()))
495    }
496
497    /// After consuming `+`/`-`, refuse `++`/`--`/`+=`/`-=` outright — kaish
498    /// has no assignment or increment inside `$(( ))`. Names the real
499    /// identifier — from the token just emitted for `x++`/`x+=` (postfix),
500    /// or scanned forward for `++x` (prefix) — rather than a placeholder.
501    fn reject_compound_or(&mut self, sym: char, op_start_byte: usize, out: &[Tok]) -> Result<(), ArithError> {
502        let step = if sym == '+' { "+ 1" } else { "- 1" };
503        if self.peek() == Some(sym) {
504            self.advance();
505            let end = self.byte_pos();
506            if let Some((name, name_start)) = Self::preceding_name(out) {
507                let source = &self.text[name_start..end];
508                return Err(ArithError::new(
509                    format!("`{source}` assigns inside `$(( ))`; write `{name}=$(({name} {step}))`"),
510                    name_start..end,
511                ));
512            }
513            if let Some((name, name_end)) = self.consume_following_name() {
514                let source = &self.text[op_start_byte..name_end];
515                return Err(ArithError::new(
516                    format!("`{source}` assigns inside `$(( ))`; write `{name}=$(({name} {step}))`"),
517                    op_start_byte..name_end,
518                ));
519            }
520            let source = &self.text[op_start_byte..end];
521            return Err(ArithError::new(
522                format!("`{source}` assigns inside `$(( ))`; write `name=$((name {step}))`"),
523                op_start_byte..end,
524            ));
525        }
526        if self.peek() == Some('=') {
527            self.advance();
528            let end = self.text.len();
529            let rhs = self.text[self.byte_pos()..].trim();
530            if let Some((name, name_start)) = Self::preceding_name(out) {
531                let source = &self.text[name_start..end];
532                return Err(ArithError::new(
533                    format!("`{source}` assigns inside `$(( ))`; write `{name}=$(({name} {sym} {rhs}))`"),
534                    name_start..end,
535                ));
536            }
537            let source = &self.text[op_start_byte..end];
538            return Err(ArithError::new(
539                format!("`{source}` assigns inside `$(( ))`; write `name=$((name {sym} rhs))`"),
540                op_start_byte..end,
541            ));
542        }
543        Ok(())
544    }
545
546    fn lex_ident(&mut self) -> TokKind {
547        let start = self.pos;
548        while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric() || c == '_') {
549            self.pos += 1;
550        }
551        TokKind::Ident(self.slice(start, self.pos).to_string())
552    }
553
554    /// Consume a run of base-`base` digits (case-insensitive letters past
555    /// `9`), erroring loud on `_` or a digit too large for `base`. Returns
556    /// the checked magnitude and the run's end index (== start if empty).
557    fn consume_digits(&mut self, base: u32, lit_start: usize) -> Result<(u64, usize), ArithError> {
558        let digits_start = self.pos;
559        let mut mag: u64 = 0;
560        while let Some(c) = self.peek() {
561            if c == '_' {
562                return Err(ArithError::new(
563                    format!("`{}` contains `_`; remove it", self.slice(lit_start, self.numeral_run_end(self.pos))),
564                    self.byte_at(lit_start)..self.byte_pos() + c.len_utf8(),
565                ));
566            }
567            if !c.is_ascii_alphanumeric() {
568                break;
569            }
570            let digit_val = match c {
571                '0'..='9' => c as u32 - '0' as u32,
572                'a'..='z' => c as u32 - 'a' as u32 + 10,
573                'A'..='Z' => c as u32 - 'A' as u32 + 10,
574                _ => unreachable!("ascii_alphanumeric"),
575            };
576            if digit_val >= base {
577                self.pos += 1;
578                return Err(ArithError::new(
579                    format!(
580                        "`{c}` is not a digit in `{}`; use digits valid for base {base}",
581                        self.slice(lit_start, self.pos)
582                    ),
583                    self.byte_at(lit_start)..self.byte_pos(),
584                ));
585            }
586            mag = mag
587                .checked_mul(base as u64)
588                .and_then(|m| m.checked_add(digit_val as u64))
589                .ok_or_else(|| {
590                    ArithError::new(
591                        format!("`{}` {INTEGER_OUT_OF_RANGE}", self.slice(lit_start, self.numeral_run_end(self.pos))),
592                        self.byte_at(lit_start)..self.byte_pos(),
593                    )
594                })?;
595            self.pos += 1;
596        }
597        Ok((mag, digits_start))
598    }
599
600    /// Consume a run of plain `0`-`9` digits, erroring loud on `_`. Unlike
601    /// `Self::consume_digits`, a non-digit letter (`e`, `x`, …) is a clean
602    /// stop, not an error — the base-10 run is used both as a full decimal
603    /// literal and as the base number before `#`, and the caller decides
604    /// what a trailing `e3`/`.5`/`#` means.
605    fn consume_decimal_digits(&mut self, lit_start: usize) -> Result<(u64, usize), ArithError> {
606        let digits_start = self.pos;
607        let mut mag: u64 = 0;
608        while let Some(c) = self.peek() {
609            if c == '_' {
610                return Err(ArithError::new(
611                    format!("`{}` contains `_`; remove it", self.slice(lit_start, self.numeral_run_end(self.pos))),
612                    self.byte_at(lit_start)..self.byte_pos() + c.len_utf8(),
613                ));
614            }
615            if !c.is_ascii_digit() {
616                break;
617            }
618            let digit_val = c as u64 - '0' as u64;
619            mag = mag.checked_mul(10).and_then(|m| m.checked_add(digit_val)).ok_or_else(|| {
620                ArithError::new(
621                    format!("`{}` {INTEGER_OUT_OF_RANGE}", self.slice(lit_start, self.numeral_run_end(self.pos))),
622                    self.byte_at(lit_start)..self.byte_pos(),
623                )
624            })?;
625            self.pos += 1;
626        }
627        Ok((mag, digits_start))
628    }
629
630    fn lex_number(&mut self, out: &[Tok]) -> Result<TokKind, ArithError> {
631        let start = self.pos;
632
633        // `0x` / `0X` hex.
634        if self.peek() == Some('0') && matches!(self.peek_at(1), Some('x' | 'X')) {
635            self.pos += 2;
636            let prefix = self.slice(start, self.pos).to_string();
637            let (mag, digits_start) = self.consume_digits(16, start)?;
638            if digits_start == self.pos {
639                return Err(ArithError::new(
640                    format!("`{prefix}` has no digits; add digits after `{prefix}`"),
641                    self.byte_at(start)..self.byte_pos(),
642                ));
643            }
644            return Ok(TokKind::Number(mag));
645        }
646
647        // `0b` / `0o` — not a kaish base spelling.
648        if self.peek() == Some('0') && matches!(self.peek_at(1), Some('b' | 'B' | 'o' | 'O')) {
649            let kind_char = self.peek_at(1).unwrap_or('b');
650            self.pos += 2;
651            let digits_start = self.pos;
652            while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric()) {
653                self.pos += 1;
654            }
655            let digits = self.slice(digits_start, self.pos);
656            let full = self.slice(start, self.pos);
657            let (base, word) = if matches!(kind_char, 'b' | 'B') { (2, "binary") } else { (8, "octal") };
658            // The `-`/`+` above this numeral, if any, was already emitted
659            // as its own token before `lex_number` ran — fold it into the
660            // suggestion here, or `-0b101` would suggest `2#101` and
661            // silently flip the sign.
662            let sign = Self::leading_unary_sign(out).map(String::from).unwrap_or_default();
663            return Err(ArithError::new(
664                format!("`{full}` is not a kaish base spelling; write `{sign}{base}#{digits}` for {word}"),
665                self.byte_at(start)..self.byte_pos(),
666            ));
667        }
668
669        // Plain decimal run — either a bare decimal literal, or the base
670        // number before `#`.
671        let (base_mag, digits_start) = self.consume_decimal_digits(start)?;
672        debug_assert!(digits_start == start);
673
674        // Float/exponent shape (`1.5`, `1e3`, `1E-3`): not a kaish spelling
675        // — checked before `#` and leading-zero, since a numeral can't be
676        // both a based prefix and a float.
677        let looks_like_float = (self.peek() == Some('.')
678            && matches!(self.peek_at(1), Some(c) if c.is_ascii_digit()))
679            || (matches!(self.peek(), Some('e' | 'E'))
680                && (matches!(self.peek_at(1), Some(c) if c.is_ascii_digit())
681                    || (matches!(self.peek_at(1), Some('+' | '-'))
682                        && matches!(self.peek_at(2), Some(c) if c.is_ascii_digit()))));
683        if looks_like_float {
684            if self.peek() == Some('.') {
685                self.pos += 1;
686                while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
687                    self.pos += 1;
688                }
689            }
690            if matches!(self.peek(), Some('e' | 'E')) {
691                self.pos += 1;
692                if matches!(self.peek(), Some('+' | '-')) {
693                    self.pos += 1;
694                }
695                while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
696                    self.pos += 1;
697                }
698            }
699            let text = self.slice(start, self.pos);
700            return Err(ArithError::new(
701                format!("`{text}` is not an integer; arithmetic is integer-only"),
702                self.byte_at(start)..self.byte_pos(),
703            ));
704        }
705
706        if self.peek() == Some('#') {
707            let base_text = self.slice(start, self.pos);
708            if base_text.len() > 1 && base_text.starts_with('0') {
709                return Err(ArithError::new(
710                    format!("`{base_text}` is not a base spelling; write the base without a leading zero"),
711                    self.byte_at(start)..self.byte_pos(),
712                ));
713            }
714            self.advance(); // consume '#'
715            // Range-check the full u64 before narrowing: `as u32` on
716            // k*2^32 + b (b in 2..=36) truncates to b and passes the
717            // check, silently evaluating in base b instead of refusing.
718            if !(2..=36).contains(&base_mag) {
719                return Err(ArithError::new(
720                    format!("base `{base_mag}` is outside 2..=36"),
721                    self.byte_at(start)..self.byte_pos(),
722                ));
723            }
724            let base = base_mag as u32;
725            if let Some(sign @ ('+' | '-')) = self.peek() {
726                let sign_start = self.pos;
727                self.pos += 1;
728                while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric()) {
729                    self.pos += 1;
730                }
731                let lit = self.slice(start, self.pos);
732                return Err(ArithError::new(
733                    format!("`{lit}` puts `{sign}` after `#`; write `{sign}{base}#{}`", self.slice(sign_start + 1, self.pos)),
734                    self.byte_at(start)..self.byte_pos(),
735                ));
736            }
737            if self.peek() == Some('$') {
738                let expansion = self.lex_expansion_body()?;
739                return Ok(TokKind::BasedExpansion { base, expansion: Box::new(expansion) });
740            }
741            let prefix = self.slice(start, self.pos).to_string();
742            let (mag, bdigits_start) = self.consume_digits(base, start)?;
743            if bdigits_start == self.pos {
744                return Err(ArithError::new(
745                    format!("`{prefix}` has no digits; add digits after `{prefix}`"),
746                    self.byte_at(start)..self.byte_pos(),
747                ));
748            }
749            return Ok(TokKind::Number(mag));
750        }
751
752        let text = self.slice(start, self.pos);
753        if crate::lexer::is_leading_zero_numeral(text) {
754            // Same reasoning as the `0b`/`0o` refusal above: the sign, if
755            // any, is a separate token already emitted before this
756            // numeral was lexed — fold it into both suggestions, or
757            // `-007` would suggest positive `8#7`/`7` and flip the sign.
758            let sign_str = Self::leading_unary_sign(out).map(String::from).unwrap_or_default();
759            let decimal = leading_zero_decimal(text).unwrap_or_else(|| "0".to_string());
760            return Err(ArithError::new(
761                format!(
762                    "`{text}` has a leading zero — kaish reads no octal; write `{sign_str}8#{}` for octal or `{sign_str}{decimal}` for decimal",
763                    text.trim_start_matches('0')
764                ),
765                self.byte_at(start)..self.byte_pos(),
766            ));
767        }
768        Ok(TokKind::Number(base_mag))
769    }
770
771    /// `$name`, `$?`, `$$`, `${...}`, `$(...)`, `$((...))` starting at the
772    /// current `$`.
773    fn lex_dollar(&mut self) -> Result<TokKind, ArithError> {
774        Ok(TokKind::Expansion(self.lex_expansion_body()?))
775    }
776
777    /// Same as `Self::lex_dollar` but returning the bare `Expansion`,
778    /// for `base#$name` and `base#$(...)`.
779    fn lex_expansion_body(&mut self) -> Result<Expansion, ArithError> {
780        let dollar_start = self.pos;
781        self.advance(); // consume '$'
782        match self.peek() {
783            Some('?') => { self.advance(); Ok(Expansion::LastExitCode) }
784            Some('$') => { self.advance(); Ok(Expansion::CurrentPid) }
785            Some('(') if self.peek_at(1) == Some('(') => {
786                self.pos += 2; // consume both '(' after the '$'
787                let inner_start = self.pos;
788                let close = self.skip_group(')', true, dollar_start, false)?;
789                let inner_text = self.slice(inner_start, close).to_string();
790                let inner = parse(&inner_text)?;
791                Ok(Expansion::Nested(Box::new(inner)))
792            }
793            Some('(') => {
794                self.advance(); // consume '('
795                // `skip_group` finds the close; the general lexer can't
796                // re-tokenize arithmetic syntax here. `comments = true`.
797                let cmd_start = self.pos;
798                let close = self.skip_group(')', false, dollar_start, true)?;
799                let cmd_text = self.slice(cmd_start, close).to_string();
800                match crate::parser::parse(&cmd_text) {
801                    Ok(program) => Ok(Expansion::CommandSubst(program.statements)),
802                    Err(_) => Err(ArithError::new(
803                        format!("syntax error in command substitution: $({cmd_text})"),
804                        self.byte_at(dollar_start)..self.byte_pos(),
805                    )),
806                }
807            }
808            Some('{') => {
809                self.advance(); // consume '{'
810                let body_start = self.pos;
811                let close = self.skip_group('}', false, dollar_start, false)?;
812                let body = self.slice(body_start, close).to_string();
813                parse_braced_body(&body, self.byte_at(dollar_start)..self.byte_pos())
814            }
815            // `$1`, `$2`, … — positional parameters. A leading digit is
816            // otherwise not a valid identifier start, so it is unambiguous
817            // here: bash allows only a single digit unbraced, but kaish
818            // reads the whole run (`${10}` still works too).
819            Some(c) if c.is_ascii_alphabetic() || c == '_' || c.is_ascii_digit() => {
820                let start = self.pos;
821                while matches!(self.peek(), Some(c) if c.is_ascii_alphanumeric() || c == '_') {
822                    self.pos += 1;
823                }
824                Ok(Expansion::Var(self.slice(start, self.pos).to_string()))
825            }
826            _ => Err(ArithError::new(
827                format!("`{}` cannot start a value", self.slice(dollar_start, self.pos + 1)),
828                self.byte_at(dollar_start)..self.byte_at(self.pos + 1),
829            )),
830        }
831    }
832
833    /// Scan a balanced group from just past its opener (`$(`, `$((`, `${`, or
834    /// a bare `(`) to its `close`, quote/escape-aware and recursing into
835    /// nested `$(…)`, `$((…))`, `${…}`, and `(…)`. Returns the char index of
836    /// the close and leaves `self.pos` past it; the body is
837    /// `slice(body_start, close)`. `double` closes on two `close` chars
838    /// (`$((…))`).
839    ///
840    /// `comments` true treats `#` as a comment to EOL and `<<[-]delimiter`
841    /// as a heredoc introducer whose body — through the closing delimiter
842    /// line, via [`crate::lexer::skip_heredoc_body`] — is skipped whole;
843    /// both only apply to command-substitution bodies, matching ordinary
844    /// `$(...)`. `comments` false leaves `#` as the base separator
845    /// (`$((…))`) or a literal (`${…}`), where kaish has no heredoc
846    /// grammar either. The word boundary reuses `lexer::opens_a_word`.
847    /// `group_start` is the error span for an unterminated group.
848    fn skip_group(
849        &mut self,
850        close: char,
851        double: bool,
852        group_start: usize,
853        comments: bool,
854    ) -> Result<usize, ArithError> {
855        let open: char = if close == '}' { '{' } else { '(' };
856        let mut depth = 1i32;
857        loop {
858            match self.peek() {
859                None => {
860                    let close_str = if double { "))" } else if close == '}' { "}" } else { ")" };
861                    return Err(ArithError::new(
862                        format!("`{}` has no closing `{close_str}`", self.slice(group_start, self.pos)),
863                        self.byte_at(group_start)..self.byte_pos(),
864                    ));
865                }
866                Some('\\') => {
867                    self.pos += 1;
868                    if self.peek().is_some() {
869                        self.pos += 1;
870                    }
871                }
872                Some('\'') => {
873                    self.pos += 1;
874                    while matches!(self.peek(), Some(c) if c != '\'') {
875                        self.pos += 1;
876                    }
877                    if self.peek() == Some('\'') {
878                        self.pos += 1;
879                    }
880                    // unterminated → `None` errors "no closing …"
881                }
882                Some('"') => {
883                    self.pos += 1;
884                    loop {
885                        match self.peek() {
886                            None => break,
887                            Some('\\') => {
888                                self.pos += 1;
889                                if self.peek().is_some() {
890                                    self.pos += 1;
891                                }
892                            }
893                            Some('"') => {
894                                self.pos += 1;
895                                break;
896                            }
897                            Some(_) => self.pos += 1,
898                        }
899                    }
900                }
901                Some('#') => {
902                    if comments {
903                        // comment only at a word start (`opens_a_word`);
904                        // skip to EOL (`\n`/`\r`, matching the lexer)
905                        // so a `)` in it does not close.
906                        let prev = self
907                            .pos
908                            .checked_sub(1)
909                            .and_then(|i| self.chars.get(i))
910                            .map(|(_, c)| *c);
911                        if prev.is_none() || prev.is_some_and(crate::lexer::opens_a_word) {
912                            while matches!(self.peek(), Some(c) if c != '\n' && c != '\r') {
913                                self.pos += 1;
914                            }
915                        } else {
916                            self.pos += 1; // mid-word `#` is a normal char here
917                        }
918                    } else {
919                        self.pos += 1; // `#` is the base separator / literal
920                    }
921                }
922                Some('<') if comments && self.peek_at(1) == Some('<') && self.peek_at(2) != Some('<') => {
923                    // A real heredoc introducer, only where `comments`
924                    // says this is a command-substitution body — `${…}`
925                    // and `$((…))` have no heredoc grammar. `<<<`
926                    // (here-string) is excluded and falls through below
927                    // to the base `<` character-at-a-time arm.
928                    let total_len = self.text.len();
929                    crate::lexer::skip_heredoc_body(&self.chars, &mut self.pos, total_len).map_err(
930                        |e| ArithError::new(e.token.to_string(), group_start..self.byte_pos()),
931                    )?;
932                }
933                Some('$') => {
934                    let nested_start = self.pos;
935                    match self.peek_at(1) {
936                        Some('(') if self.peek_at(2) == Some('(') => {
937                            self.pos += 3;
938                            self.skip_group(')', true, nested_start, false)?;
939                        }
940                        Some('(') => {
941                            self.pos += 2;
942                            self.skip_group(')', false, nested_start, true)?;
943                        }
944                        Some('{') => {
945                            self.pos += 2;
946                            self.skip_group('}', false, nested_start, false)?;
947                        }
948                        _ => self.pos += 1, // `$name` — the `$` is plain here
949                    }
950                }
951                Some('(') if self.peek_at(1) == Some('(') => {
952                    // bare `((…))` is arithmetic, so it carries no heredoc
953                    // or comment grammar: `<<` inside it is the shift
954                    // operator. `$((` already suppresses both; this is the
955                    // other spelling of the same context.
956                    let nested_start = self.pos;
957                    self.pos += 2;
958                    self.skip_group(')', true, nested_start, false)?;
959                }
960                Some('(') => {
961                    // bare `(` subshell: recurse so its `)`/`}` does not
962                    // close the outer group. `comments` propagates, because
963                    // a subshell body is ordinary command text.
964                    let nested_start = self.pos;
965                    self.pos += 1;
966                    self.skip_group(')', false, nested_start, comments)?;
967                }
968                Some(c) if c == open => {
969                    depth += 1;
970                    self.pos += 1;
971                }
972                Some(c) if c == close => {
973                    if double {
974                        if self.peek_at(1) == Some(close) {
975                            let close_pos = self.pos;
976                            self.pos += 2;
977                            return Ok(close_pos);
978                        }
979                        // lone `)` in `$((…))`: consume, keep scanning.
980                        self.pos += 1;
981                    } else {
982                        depth -= 1;
983                        self.pos += 1;
984                        if depth == 0 {
985                            return Ok(self.pos - 1);
986                        }
987                    }
988                }
989                Some(_) => {
990                    self.pos += 1;
991                }
992            }
993        }
994    }
995}
996
997/// Message for a numeral outside i64 range — shared text with the lexer.
998use crate::lexer::INTEGER_OUT_OF_RANGE;
999
1000fn split_name_and_brackets(text: &str) -> Option<(String, String)> {
1001    let bracket_start = text.find('[').unwrap_or(text.len());
1002    let name = &text[..bracket_start];
1003    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1004        || name.chars().next().is_some_and(|c| c.is_ascii_digit())
1005    {
1006        return None;
1007    }
1008    Some((name.to_string(), text[bracket_start..].to_string()))
1009}
1010
1011fn parse_braced_body(body: &str, span: Range<usize>) -> Result<Expansion, ArithError> {
1012    if body == "?" {
1013        return Ok(Expansion::LastExitCode);
1014    }
1015    if body == "$" {
1016        return Ok(Expansion::CurrentPid);
1017    }
1018    let bytes = body.as_bytes();
1019    let mut depth = 0i32;
1020    let mut default_at = None;
1021    let mut i = 0;
1022    while i < bytes.len() {
1023        match bytes[i] {
1024            b'[' => depth += 1,
1025            b']' => depth -= 1,
1026            b':' if depth == 0 && bytes.get(i + 1) == Some(&b'-') => {
1027                default_at = Some(i);
1028                break;
1029            }
1030            _ => {}
1031        }
1032        i += 1;
1033    }
1034    if let Some(idx) = default_at {
1035        let root_and_sub = &body[..idx];
1036        let default = body[idx + 2..].to_string();
1037        let Some((root, brackets)) = split_name_and_brackets(root_and_sub) else {
1038            return Err(ArithError::new(format!("`{{{body}}}` is not valid inside `$(( ))`"), span));
1039        };
1040        return Ok(Expansion::BracedDefault { root, brackets, default });
1041    }
1042    let Some((root, brackets)) = split_name_and_brackets(body) else {
1043        return Err(ArithError::new(format!("`{{{body}}}` is not valid inside `$(( ))`"), span));
1044    };
1045    if brackets.is_empty() {
1046        Ok(Expansion::Var(root))
1047    } else {
1048        Ok(Expansion::BracedPath { root, brackets })
1049    }
1050}
1051
1052fn tokenize(text: &str) -> Result<Vec<Tok>, ArithError> {
1053    Tokenizer::new(text).tokenize()
1054}
1055
1056// ═══════════════════════════════════════════════════════════════════
1057// Parser (precedence climbing over the EBNF, high to low: unary, `**`,
1058// `* / %`, `+ -`, `<< >>`, `< <= > >=`, `== !=`, `&`, `^`, `|`, `&&`,
1059// `||`, `?:`)
1060// ═══════════════════════════════════════════════════════════════════
1061
1062struct Parser {
1063    toks: Vec<Tok>,
1064    pos: usize,
1065    depth: usize,
1066    end: usize,
1067    /// The arithmetic source, kept so a "no operand" error can quote the
1068    /// text consumed so far (`{expr}` in the spec's error table).
1069    text: String,
1070}
1071
1072impl Parser {
1073    fn new(toks: Vec<Tok>, end: usize, text: &str) -> Self {
1074        Self { toks, pos: 0, depth: 0, end, text: text.to_string() }
1075    }
1076
1077    /// `` `{op}` has no right operand in `{expr}` `` — the operator was the
1078    /// last token; `expr` is the whole source (trailing whitespace
1079    /// included, as the spec's own example shows: "1 + " — this only fires
1080    /// when the operator was the LAST token, so the whole text amounts to
1081    /// "everything through end of input").
1082    fn missing_right_operand(&self, op: &str, op_span: &Range<usize>) -> ArithError {
1083        ArithError::new(
1084            format!(
1085                "`{op}` has no right operand in `{}`; add an integer expression after `{op}`",
1086                self.text
1087            ),
1088            op_span.clone(),
1089        )
1090    }
1091
1092    /// `` `{op}` has no operand `` — a unary/power operator with nothing at
1093    /// all after it (no left operand to show, unlike the binary case).
1094    fn missing_operand(&self, op: &str, op_span: &Range<usize>) -> ArithError {
1095        ArithError::new(
1096            format!("`{op}` has no operand; add an integer expression after `{op}`"),
1097            op_span.clone(),
1098        )
1099    }
1100
1101    fn peek(&self) -> Option<&TokKind> {
1102        self.toks.get(self.pos).map(|t| &t.kind)
1103    }
1104
1105    fn peek_span(&self) -> Range<usize> {
1106        self.toks.get(self.pos).map(|t| t.span.clone()).unwrap_or(self.end..self.end)
1107    }
1108
1109    fn advance(&mut self) -> Option<Tok> {
1110        let t = self.toks.get(self.pos).cloned();
1111        if t.is_some() {
1112            self.pos += 1;
1113        }
1114        t
1115    }
1116
1117    fn enter(&mut self) -> Result<(), ArithError> {
1118        self.depth += 1;
1119        if self.depth > MAX_DEPTH {
1120            return Err(ArithError::new("more than 256 nested arithmetic forms", self.peek_span()));
1121        }
1122        Ok(())
1123    }
1124
1125    fn leave(&mut self) {
1126        self.depth -= 1;
1127    }
1128
1129    fn left_assoc(
1130        &mut self,
1131        ops: &[BinOp],
1132        mut next: impl FnMut(&mut Self) -> Result<ArithExpr, ArithError>,
1133    ) -> Result<ArithExpr, ArithError> {
1134        let mut left = next(self)?;
1135        loop {
1136            let matched = ops.iter().copied().find(|op| self.peek() == Some(&TokKind::Op(*op)));
1137            let Some(op) = matched else { break };
1138            let op_span = self.peek_span();
1139            self.pos += 1;
1140            if self.pos >= self.toks.len() {
1141                return Err(self.missing_right_operand(op.symbol(), &op_span));
1142            }
1143            let right = next(self)?;
1144            left = ArithExpr::Binary { op, left: Box::new(left), right: Box::new(right) };
1145        }
1146        Ok(left)
1147    }
1148
1149    fn parse_conditional(&mut self) -> Result<ArithExpr, ArithError> {
1150        self.enter()?;
1151        let cond = self.parse_logical_or()?;
1152        let result = if self.peek() == Some(&TokKind::Question) {
1153            self.pos += 1;
1154            let then_branch = self.parse_conditional()?;
1155            match self.peek() {
1156                Some(&TokKind::Colon) => self.pos += 1,
1157                _ => {
1158                    return Err(ArithError::new("`?` has no matching `:`", self.peek_span()));
1159                }
1160            }
1161            let else_branch = self.parse_conditional()?;
1162            ArithExpr::Ternary {
1163                cond: Box::new(cond),
1164                then_branch: Box::new(then_branch),
1165                else_branch: Box::new(else_branch),
1166            }
1167        } else {
1168            cond
1169        };
1170        self.leave();
1171        Ok(result)
1172    }
1173
1174    fn parse_logical_or(&mut self) -> Result<ArithExpr, ArithError> {
1175        self.left_assoc(&[BinOp::Or], Self::parse_logical_and)
1176    }
1177
1178    fn parse_logical_and(&mut self) -> Result<ArithExpr, ArithError> {
1179        self.left_assoc(&[BinOp::And], Self::parse_bitor)
1180    }
1181
1182    fn parse_bitor(&mut self) -> Result<ArithExpr, ArithError> {
1183        self.left_assoc(&[BinOp::BitOr], Self::parse_bitxor)
1184    }
1185
1186    fn parse_bitxor(&mut self) -> Result<ArithExpr, ArithError> {
1187        self.left_assoc(&[BinOp::BitXor], Self::parse_bitand)
1188    }
1189
1190    fn parse_bitand(&mut self) -> Result<ArithExpr, ArithError> {
1191        self.left_assoc(&[BinOp::BitAnd], Self::parse_equality)
1192    }
1193
1194    fn parse_equality(&mut self) -> Result<ArithExpr, ArithError> {
1195        self.left_assoc(&[BinOp::Eq, BinOp::Ne], Self::parse_relational)
1196    }
1197
1198    fn parse_relational(&mut self) -> Result<ArithExpr, ArithError> {
1199        self.left_assoc(&[BinOp::Le, BinOp::Ge, BinOp::Lt, BinOp::Gt], Self::parse_shift)
1200    }
1201
1202    fn parse_shift(&mut self) -> Result<ArithExpr, ArithError> {
1203        self.left_assoc(&[BinOp::Shl, BinOp::Shr], Self::parse_additive)
1204    }
1205
1206    fn parse_additive(&mut self) -> Result<ArithExpr, ArithError> {
1207        self.left_assoc(&[BinOp::Add, BinOp::Sub], Self::parse_multiplicative)
1208    }
1209
1210    fn parse_multiplicative(&mut self) -> Result<ArithExpr, ArithError> {
1211        self.left_assoc(&[BinOp::Mul, BinOp::Div, BinOp::Rem], Self::parse_power)
1212    }
1213
1214    fn parse_power(&mut self) -> Result<ArithExpr, ArithError> {
1215        let base = self.parse_unary()?;
1216        if self.peek() == Some(&TokKind::Op(BinOp::Pow)) {
1217            let op_span = self.peek_span();
1218            self.pos += 1;
1219            if self.pos >= self.toks.len() {
1220                return Err(self.missing_right_operand("**", &op_span));
1221            }
1222            self.enter()?;
1223            let exp = self.parse_power()?;
1224            self.leave();
1225            Ok(ArithExpr::Binary { op: BinOp::Pow, left: Box::new(base), right: Box::new(exp) })
1226        } else {
1227            Ok(base)
1228        }
1229    }
1230
1231    /// `i64::MIN`'s magnitude, `9223372036854775808`, has no representation
1232    /// as a positive `i64` — it is legal only as the direct operand of a
1233    /// single unary minus.
1234    const MIN_MAGNITUDE: u64 = 9_223_372_036_854_775_808;
1235
1236    fn parse_unary(&mut self) -> Result<ArithExpr, ArithError> {
1237        self.enter()?;
1238        let result = match self.peek() {
1239            Some(&TokKind::Op(BinOp::Sub)) => {
1240                let op_span = self.peek_span();
1241                self.pos += 1;
1242                if self.pos >= self.toks.len() {
1243                    self.leave();
1244                    return Err(self.missing_operand("-", &op_span));
1245                }
1246                if let Some(&TokKind::Number(mag)) = self.peek() {
1247                    if mag == Self::MIN_MAGNITUDE {
1248                        self.pos += 1;
1249                        self.leave();
1250                        return Ok(ArithExpr::Int(i64::MIN));
1251                    }
1252                }
1253                let operand = self.parse_unary()?;
1254                ArithExpr::Unary { op: UnOp::Neg, operand: Box::new(operand) }
1255            }
1256            Some(&TokKind::Op(BinOp::Add)) => {
1257                let op_span = self.peek_span();
1258                self.pos += 1;
1259                if self.pos >= self.toks.len() {
1260                    self.leave();
1261                    return Err(self.missing_operand("+", &op_span));
1262                }
1263                self.parse_unary()?
1264            }
1265            Some(&TokKind::Bang) => {
1266                let op_span = self.peek_span();
1267                self.pos += 1;
1268                if self.pos >= self.toks.len() {
1269                    self.leave();
1270                    return Err(self.missing_operand("!", &op_span));
1271                }
1272                let operand = self.parse_unary()?;
1273                ArithExpr::Unary { op: UnOp::Not, operand: Box::new(operand) }
1274            }
1275            Some(&TokKind::Tilde) => {
1276                let op_span = self.peek_span();
1277                self.pos += 1;
1278                if self.pos >= self.toks.len() {
1279                    self.leave();
1280                    return Err(self.missing_operand("~", &op_span));
1281                }
1282                let operand = self.parse_unary()?;
1283                ArithExpr::Unary { op: UnOp::BitNot, operand: Box::new(operand) }
1284            }
1285            _ => self.parse_primary()?,
1286        };
1287        self.leave();
1288        Ok(result)
1289    }
1290
1291    fn parse_primary(&mut self) -> Result<ArithExpr, ArithError> {
1292        let span = self.peek_span();
1293        match self.advance().map(|t| t.kind) {
1294            Some(TokKind::Number(mag)) => int_from_magnitude(mag, false, span),
1295            Some(TokKind::BasedExpansion { base, expansion }) => {
1296                Ok(ArithExpr::BasedExpansion { base, expansion })
1297            }
1298            Some(TokKind::Expansion(e)) => Ok(ArithExpr::Expansion(e)),
1299            Some(TokKind::Ident(name)) => {
1300                if self.peek() == Some(&TokKind::LBracket) {
1301                    let mut indices = Vec::new();
1302                    while self.peek() == Some(&TokKind::LBracket) {
1303                        self.pos += 1;
1304                        self.enter()?;
1305                        let index = self.parse_conditional()?;
1306                        self.leave();
1307                        match self.peek() {
1308                            Some(&TokKind::RBracket) => self.pos += 1,
1309                            _ => {
1310                                return Err(ArithError::new(
1311                                    "`[` has no matching `]`",
1312                                    self.peek_span(),
1313                                ));
1314                            }
1315                        }
1316                        indices.push(index);
1317                    }
1318                    Ok(ArithExpr::Subscript { root: name, indices })
1319                } else {
1320                    Ok(ArithExpr::Expansion(Expansion::Var(name)))
1321                }
1322            }
1323            Some(TokKind::LParen) => {
1324                self.enter()?;
1325                if self.peek() == Some(&TokKind::RParen) {
1326                    self.leave();
1327                    self.pos += 1;
1328                    return Err(ArithError::new("`()` has no expression", span));
1329                }
1330                let inner = self.parse_conditional()?;
1331                self.leave();
1332                match self.peek() {
1333                    Some(&TokKind::RParen) => {
1334                        self.pos += 1;
1335                        Ok(inner)
1336                    }
1337                    _ => Err(ArithError::new("`(` has no closing `)`", span)),
1338                }
1339            }
1340            Some(TokKind::RParen) => Err(ArithError::new("`)` has no matching `(`", span)),
1341            Some(other) => Err(ArithError::new(format!("`{other}` cannot start a value"), span)),
1342            None => Err(ArithError::new("`$(( ))` has no expression; write a number or an expression", span)),
1343        }
1344    }
1345}
1346
1347fn int_from_magnitude(mag: u64, negative: bool, span: Range<usize>) -> Result<ArithExpr, ArithError> {
1348    let max = if negative { Parser::MIN_MAGNITUDE } else { i64::MAX as u64 };
1349    if mag > max {
1350        return Err(ArithError::new(format!("`{mag}` {INTEGER_OUT_OF_RANGE}"), span));
1351    }
1352    if negative && mag == Parser::MIN_MAGNITUDE {
1353        return Ok(ArithExpr::Int(i64::MIN));
1354    }
1355    Ok(ArithExpr::Int(if negative { -(mag as i64) } else { mag as i64 }))
1356}
1357
1358pub(crate) fn parse(text: &str) -> Result<ArithExpr, ArithError> {
1359    let toks = tokenize(text)?;
1360    if toks.is_empty() {
1361        return Err(ArithError::new(
1362            "`$(( ))` has no expression; write a number or an expression",
1363            0..text.len(),
1364        ));
1365    }
1366    let end = text.len();
1367    let mut parser = Parser::new(toks, end, text);
1368    let expr = parser.parse_conditional()?;
1369    if let Some(extra) = parser.peek() {
1370        if extra == &TokKind::RParen {
1371            return Err(ArithError::new(
1372                format!("`)` has no matching `(` in `{text}`"),
1373                parser.peek_span(),
1374            ));
1375        }
1376        return Err(ArithError::new(format!("`{extra}` is not valid inside `$(( ))`"), parser.peek_span()));
1377    }
1378    Ok(expr)
1379}
1380
1381// ═══════════════════════════════════════════════════════════════════
1382// Pure operator evaluation — shared by the sync and async walkers
1383// ═══════════════════════════════════════════════════════════════════
1384
1385fn shift_count_error(count: i64) -> ArithError {
1386    ArithError::new(format!("shift count `{count}` is outside 0..=63"), 0..0)
1387}
1388
1389fn overflow(l: i64, op: BinOp, r: i64) -> ArithError {
1390    ArithError::new(format!("`{l} {} {r}` does not fit in a 64-bit integer", op.symbol()), 0..0)
1391}
1392
1393pub(crate) fn apply_binary(op: BinOp, l: i64, r: i64) -> Result<i64, ArithError> {
1394    match op {
1395        BinOp::Add => l.checked_add(r).ok_or_else(|| overflow(l, op, r)),
1396        BinOp::Sub => l.checked_sub(r).ok_or_else(|| overflow(l, op, r)),
1397        BinOp::Mul => l.checked_mul(r).ok_or_else(|| overflow(l, op, r)),
1398        BinOp::Div => {
1399            if r == 0 {
1400                return Err(ArithError::new(format!("`{l} / 0` divides by zero"), 0..0));
1401            }
1402            l.checked_div(r).ok_or_else(|| overflow(l, op, r))
1403        }
1404        BinOp::Rem => {
1405            if r == 0 {
1406                return Err(ArithError::new(format!("`{l} % 0` divides by zero"), 0..0));
1407            }
1408            // checked_rem returns None for MIN % -1; the answer is 0.
1409            if r == -1 {
1410                return Ok(0);
1411            }
1412            l.checked_rem(r).ok_or_else(|| overflow(l, op, r))
1413        }
1414        BinOp::Pow => {
1415            if r < 0 {
1416                return Err(ArithError::new(format!("exponent `{r}` is negative; use 0 or greater"), 0..0));
1417            }
1418            match l {
1419                0 => Ok(if r == 0 { 1 } else { 0 }),
1420                1 => Ok(1),
1421                -1 => Ok(if r % 2 == 0 { 1 } else { -1 }),
1422                _ => {
1423                    if r > u32::MAX as i64 {
1424                        return Err(overflow(l, op, r));
1425                    }
1426                    l.checked_pow(r as u32).ok_or_else(|| overflow(l, op, r))
1427                }
1428            }
1429        }
1430        BinOp::Shl => {
1431            if !(0..=63).contains(&r) {
1432                return Err(shift_count_error(r));
1433            }
1434            let factor: i128 = 1i128 << r;
1435            let result = (l as i128) * factor;
1436            i64::try_from(result).map_err(|_| overflow(l, op, r))
1437        }
1438        BinOp::Shr => {
1439            if !(0..=63).contains(&r) {
1440                return Err(shift_count_error(r));
1441            }
1442            Ok(l >> r)
1443        }
1444        BinOp::Lt => Ok((l < r) as i64),
1445        BinOp::Le => Ok((l <= r) as i64),
1446        BinOp::Gt => Ok((l > r) as i64),
1447        BinOp::Ge => Ok((l >= r) as i64),
1448        BinOp::Eq => Ok((l == r) as i64),
1449        BinOp::Ne => Ok((l != r) as i64),
1450        BinOp::BitAnd => Ok(l & r),
1451        BinOp::BitXor => Ok(l ^ r),
1452        BinOp::BitOr => Ok(l | r),
1453        BinOp::And | BinOp::Or => unreachable!("short-circuit ops are handled by the tree walk"),
1454    }
1455}
1456
1457pub(crate) fn apply_unary(op: UnOp, v: i64) -> Result<i64, ArithError> {
1458    match op {
1459        UnOp::Neg => v
1460            .checked_neg()
1461            .ok_or_else(|| ArithError::new(format!("`-{v}` does not fit in a 64-bit integer"), 0..0)),
1462        UnOp::Not => Ok(if v == 0 { 1 } else { 0 }),
1463        UnOp::BitNot => Ok(!v),
1464    }
1465}
1466
1467fn truthy(v: i64) -> bool {
1468    v != 0
1469}
1470
1471// ═══════════════════════════════════════════════════════════════════
1472// Coercion (Value → i64)
1473// ═══════════════════════════════════════════════════════════════════
1474
1475fn expression_like(s: &str) -> bool {
1476    let bytes = s.as_bytes();
1477    bytes.iter().enumerate().any(|(i, &b)| match b {
1478        b'+' | b'-' => i > 0,
1479        b'*' | b'/' | b'%' | b'<' | b'>' | b'=' | b'&' | b'|' | b'^' | b'!' | b'~' | b'?' | b':' | b'(' | b')' => true,
1480        _ => false,
1481    })
1482}
1483
1484/// What a piece of text is, as a signed numeral in decimal/hex/`base#`
1485/// spelling — the core `parse_numeric_string` and the command-output
1486/// coercion below share, so the sign/leading-zero/tokenize logic exists
1487/// once.
1488enum Numeral {
1489    Ok(i64),
1490    Empty,
1491    ExpressionLike,
1492    /// `digits` is the unsigned digit text (`neg` already carries the
1493    /// sign separately) — kept so the caller can build a sign-correct
1494    /// suggestion instead of always suggesting a positive fix.
1495    LeadingZero { neg: bool, digits: String },
1496    NotANumber,
1497    /// The tokenizer refused with a message that already names a fix
1498    /// (`0b101` → `2#101`, `1_000` → remove the `_`, `1e3` → integer-only).
1499    /// Carries that message so the caller can keep it instead of a generic
1500    /// "is not a number".
1501    NotANumberWithFix(String),
1502    OutOfRange,
1503}
1504
1505fn read_numeral(text: &str) -> Numeral {
1506    let trimmed = text.trim();
1507    if trimmed.is_empty() {
1508        return Numeral::Empty;
1509    }
1510    if expression_like(trimmed) {
1511        return Numeral::ExpressionLike;
1512    }
1513    let (neg, digits) = match trimmed.strip_prefix('-') {
1514        Some(rest) => (true, rest),
1515        None => (false, trimmed.strip_prefix('+').unwrap_or(trimmed)),
1516    };
1517    if digits.is_empty() {
1518        return Numeral::NotANumber;
1519    }
1520    if crate::lexer::is_leading_zero_numeral(digits) {
1521        return Numeral::LeadingZero { neg, digits: digits.to_string() };
1522    }
1523    match tokenize(digits) {
1524        Ok(toks) if toks.len() == 1 => match &toks[0].kind {
1525            TokKind::Number(mag) => match int_from_magnitude(*mag, neg, 0..0) {
1526                Ok(ArithExpr::Int(n)) => Numeral::Ok(n),
1527                Ok(_) => unreachable!("int_from_magnitude only returns Int"),
1528                Err(_) => Numeral::OutOfRange,
1529            },
1530            _ => Numeral::NotANumber,
1531        },
1532        Ok(_) => Numeral::NotANumber,
1533        Err(e) => Numeral::NotANumberWithFix(e.message),
1534    }
1535}
1536
1537fn parse_numeric_string(s: &str, name: &str) -> Result<i64, ArithError> {
1538    match read_numeral(s) {
1539        Numeral::Ok(n) => Ok(n),
1540        Numeral::Empty | Numeral::ExpressionLike => Err(ArithError::new(
1541            format!(
1542                "`{name}` holds `{s}`; a variable is a value, not an expression — write it inside `$(( ))`"
1543            ),
1544            0..0,
1545        )),
1546        // Unsigned: `10#$name`/`8#$name` genuinely works — `based_value`
1547        // has no sign in `$name`'s text to refuse. Signed: it never
1548        // works, sign or no — `based_value` refuses ANY sign inside the
1549        // resolved text, so `$name` itself (holding e.g. `-007`) still
1550        // refuses even from `-10#$name`. The only fix that actually
1551        // evaluates embeds the known digits literally, sign outside `#`.
1552        Numeral::LeadingZero { neg: false, .. } => Err(ArithError::new(
1553            format!(
1554                "`{name}` holds `{s}` (leading zero) — kaish reads no octal; write `10#${name}` for decimal or `8#${name}` for octal"
1555            ),
1556            0..0,
1557        )),
1558        Numeral::LeadingZero { neg: true, digits } => Err(ArithError::new(
1559            format!(
1560                "`{name}` holds `{s}` (leading zero) — kaish reads no octal; write `-10#{digits}` for decimal or `-8#{digits}` for octal"
1561            ),
1562            0..0,
1563        )),
1564        Numeral::NotANumber => {
1565            Err(ArithError::new(format!("`{name}` holds `{s}`, which is not a number"), 0..0))
1566        }
1567        Numeral::NotANumberWithFix(fix) => {
1568            Err(ArithError::new(format!("`{name}` holds `{s}`; {fix}"), 0..0))
1569        }
1570        Numeral::OutOfRange => {
1571            Err(ArithError::new(format!("`{name}` holds `{s}`, outside the 64-bit range"), 0..0))
1572        }
1573    }
1574}
1575
1576/// Coerce a `$(...)` operand's printed text — the command must print exactly
1577/// one integer.
1578pub(crate) fn parse_command_output(text: &str, cmd: &str) -> Result<i64, ArithError> {
1579    match read_numeral(text) {
1580        Numeral::Ok(n) => Ok(n),
1581        Numeral::Empty => Err(ArithError::new(
1582            format!("`{cmd}` printed nothing; the command must print one integer"),
1583            0..0,
1584        )),
1585        Numeral::ExpressionLike
1586        | Numeral::NotANumber
1587        | Numeral::NotANumberWithFix(_)
1588        | Numeral::LeadingZero { .. }
1589        | Numeral::OutOfRange => Err(ArithError::new(
1590            format!("`{cmd}` printed `{text}`; the command must print one integer"),
1591            0..0,
1592        )),
1593    }
1594}
1595
1596pub(crate) fn value_to_arith(value: &Value, name: &str) -> Result<i64, ArithError> {
1597    match value {
1598        Value::Int(n) => Ok(*n),
1599        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
1600        Value::Float(f) => {
1601            if !f.is_finite() || f.fract() != 0.0 {
1602                Err(ArithError::new(format!("`{name}` holds `{f}`; arithmetic is integer-only"), 0..0))
1603            // The upper bound is the literal 2^63, not `i64::MAX as f64`:
1604            // i64::MAX (2^63 - 1) has no exact f64 representation at this
1605            // magnitude, so casting it to f64 ALSO rounds up to 2^63 — a
1606            // strict `>` against that rounded value let `f == 2^63`
1607            // through, and the saturating `as i64` below silently
1608            // returned i64::MAX. A float this large cannot distinguish
1609            // i64::MAX from one past it, so `>=` refuses the whole
1610            // ambiguous boundary instead of guessing.
1611            } else if *f < i64::MIN as f64 || *f >= 9_223_372_036_854_775_808.0 {
1612                Err(ArithError::new(format!("`{name}` holds `{f}`, outside the 64-bit range"), 0..0))
1613            } else {
1614                Ok(*f as i64)
1615            }
1616        }
1617        Value::String(s) => parse_numeric_string(s, name),
1618        Value::Null => Err(ArithError::new(format!("`{name}` is null; set it to an integer"), 0..0)),
1619        Value::Json(serde_json::Value::Array(_)) => {
1620            Err(ArithError::new(format!("`{name}` is a list; index a number field"), 0..0))
1621        }
1622        Value::Json(serde_json::Value::Object(_)) => {
1623            Err(ArithError::new(format!("`{name}` is a record; index a number field"), 0..0))
1624        }
1625        Value::Json(_) => Err(ArithError::new(
1626            format!("`{name}` holds `{}`, which is not a number", value_to_string(value)),
1627            0..0,
1628        )),
1629        Value::Bytes(b) => {
1630            Err(ArithError::new(format!("`{name}` holds {} bytes; decode them first", b.len()), 0..0))
1631        }
1632    }
1633}
1634
1635pub(crate) fn unset_error(name: &str) -> ArithError {
1636    let message = match name {
1637        "RANDOM" => "`$RANDOM` has no value in kaish; write `$(random --max 100)`".to_string(),
1638        "SECONDS" => {
1639            "`$SECONDS` has no value in kaish; write `start=$(date +%s)` and `$(( $(date +%s) - start ))`"
1640                .to_string()
1641        }
1642        _ => format!("`{name}` is unset; set it before `$(( ))` or write `${{{name}:-0}}`"),
1643    };
1644    ArithError::new(message, 0..0)
1645}
1646
1647pub(crate) fn resolve_var_sync(scope: &Scope, name: &str) -> Result<i64, ArithError> {
1648    // `$1`, `$2`, … reach the same variable slot bash gives them: text,
1649    // coerced by the same rules as any other string operand.
1650    if let Ok(index) = name.parse::<usize>() {
1651        return match scope.get_positional(index) {
1652            Some(s) => parse_numeric_string(s, name),
1653            None => Err(unset_error(name)),
1654        };
1655    }
1656    match scope.get(name) {
1657        Some(value) => value_to_arith(value, name),
1658        None => Err(unset_error(name)),
1659    }
1660}
1661
1662pub(crate) fn braced_path_value(scope: &Scope, root: &str, brackets: &str) -> Result<Value, ArithError> {
1663    let raw = format!("${{{root}{brackets}}}");
1664    let path: VarPath = crate::parser::parse_varpath(&raw);
1665    scope.resolve_path(&path).map_err(|e| match e {
1666        crate::interpreter::PathError::UndefinedRoot(_) => unset_error(root),
1667        crate::interpreter::PathError::Absence(msg) | crate::interpreter::PathError::Shape(msg) => {
1668            ArithError::new(msg, 0..0)
1669        }
1670    })
1671}
1672
1673/// The left operand of `${root[brackets]:-default}` inside `$(( ))`, classified
1674/// the way ordinary interpolation classifies it (decision A — `resolve_default`
1675/// in `interpreter/eval.rs`): `Ok(None)` means "select the default" (an unset
1676/// root, a missing key, an out-of-bounds index, `null`, or an empty string);
1677/// `Ok(Some(v))` is a present value to use as-is; `Err` is a shape error — a
1678/// wrong-typed access — that the default must NOT suppress and whose fallback
1679/// must NOT run.
1680///
1681/// The four `BracedDefault` call sites (sync/async × arithmetic-operand/
1682/// `base#`-text) all resolve through this one function so the contract can't
1683/// drift between them the way `.ok()` let it drift before.
1684pub(crate) fn braced_default_operand(
1685    scope: &Scope,
1686    root: &str,
1687    brackets: &str,
1688) -> Result<Option<Value>, ArithError> {
1689    let resolved: Result<Value, PathError> = if brackets.is_empty() {
1690        scope
1691            .get(root)
1692            .cloned()
1693            .ok_or_else(|| PathError::UndefinedRoot(root.to_string()))
1694    } else {
1695        let raw = format!("${{{root}{brackets}}}");
1696        let path: VarPath = crate::parser::parse_varpath(&raw);
1697        scope.resolve_path(&path)
1698    };
1699    match resolved {
1700        Ok(v) if value_defaults_on_emptiness(&v) => Ok(None),
1701        Ok(v) => Ok(Some(v)),
1702        Err(PathError::UndefinedRoot(_)) | Err(PathError::Absence(_)) => Ok(None),
1703        Err(PathError::Shape(msg)) => Err(ArithError::new(msg, 0..0)),
1704    }
1705}
1706
1707fn subscript_path(root: &str, indices: &[i64]) -> VarPath {
1708    let mut raw = format!("${{{root}");
1709    for idx in indices {
1710        raw.push('[');
1711        raw.push_str(&idx.to_string());
1712        raw.push(']');
1713    }
1714    raw.push('}');
1715    crate::parser::parse_varpath(&raw)
1716}
1717
1718pub(crate) fn resolve_subscript_sync(scope: &Scope, root: &str, indices: &[i64]) -> Result<i64, ArithError> {
1719    let path = subscript_path(root, indices);
1720    let value = scope.resolve_path(&path).map_err(|e| match e {
1721        crate::interpreter::PathError::UndefinedRoot(_) => unset_error(root),
1722        crate::interpreter::PathError::Absence(msg) | crate::interpreter::PathError::Shape(msg) => {
1723            ArithError::new(msg, 0..0)
1724        }
1725    })?;
1726    value_to_arith(&value, root)
1727}
1728
1729/// The name and verb an error about `base#<expansion>`'s VALUE uses to
1730/// describe where the value came from — `` `m` holds `08` `` versus
1731/// `` `$(...)` printed `08` ``, matching the phrasing the rest of the
1732/// coercion errors already use for a variable vs. a command's output.
1733pub(crate) fn expansion_label(e: &Expansion) -> (String, &'static str) {
1734    match e {
1735        Expansion::Var(name) => (name.clone(), "holds"),
1736        Expansion::BracedPath { root, .. } | Expansion::BracedDefault { root, .. } => {
1737            (root.clone(), "holds")
1738        }
1739        Expansion::LastExitCode => ("$?".to_string(), "holds"),
1740        Expansion::CurrentPid => ("$$".to_string(), "holds"),
1741        Expansion::CommandSubst(_) => ("$(...)".to_string(), "printed"),
1742        Expansion::Nested(_) => ("$((...))".to_string(), "holds"),
1743    }
1744}
1745
1746/// Read `text` as digits in `base` — the evaluation half of `base#<expansion>`
1747/// (`2#$BITS`, `10#$(date +%m)`). `text` is the expansion's rendered VALUE,
1748/// never re-coerced through the normal numeral rules first: that coercion is
1749/// exactly what a leading-zero string (`m="08"`) needs `10#$m` to escape, so
1750/// routing through it here would defeat the form's only purpose.
1751///
1752/// A sign in `text` is refused, not applied — the same rule as the literal
1753/// form (`16#-ff` is refused, naming `-16#ff`): the digits after `#` take
1754/// no sign, whether the `#` came with the sign in source text or the sign
1755/// arrived inside an expansion's value. `label`/`verb` name where the value
1756/// came from (see `expansion_label`) for that refusal's message.
1757///
1758/// `negative` carries the unary minus that may sit above this expansion in
1759/// the tree (`-16#$digits`) into the range check itself — mirroring the
1760/// direct-literal path, where the parser special-cases `Number(mag)` at
1761/// exactly `Parser::MIN_MAGNITUDE`. `based_value` can't special-case at
1762/// parse time (the digits are only known once the expansion resolves), so
1763/// the caller passes `negative` in; evaluating positive-then-negating would
1764/// refuse `i64::MIN`'s magnitude before the minus ever applied.
1765pub(crate) fn based_value(base: u32, text: &str, label: &str, verb: &str, negative: bool) -> Result<i64, ArithError> {
1766    let trimmed = text.trim();
1767    if let Some(stripped) = trimmed.strip_prefix('-').or_else(|| trimmed.strip_prefix('+')) {
1768        let sign = &trimmed[..1];
1769        return Err(ArithError::new(
1770            format!(
1771                "`{label}` {verb} `{text}`; the digits after `#` take no sign — write `{sign}{base}#{stripped}`"
1772            ),
1773            0..0,
1774        ));
1775    }
1776    let digits = trimmed;
1777    if digits.is_empty() {
1778        return Err(ArithError::new(format!("`{text}` has no digits"), 0..0));
1779    }
1780    let mut mag: u64 = 0;
1781    for c in digits.chars() {
1782        if !c.is_ascii_alphanumeric() {
1783            return Err(ArithError::new(format!("`{c}` is not a digit in `{text}`; use digits valid for base {base}"), 0..0));
1784        }
1785        let digit_val = match c {
1786            '0'..='9' => c as u32 - '0' as u32,
1787            'a'..='z' => c as u32 - 'a' as u32 + 10,
1788            'A'..='Z' => c as u32 - 'A' as u32 + 10,
1789            _ => unreachable!(),
1790        };
1791        if digit_val >= base {
1792            return Err(ArithError::new(format!("`{c}` is not a digit in `{text}`; use digits valid for base {base}"), 0..0));
1793        }
1794        mag = mag
1795            .checked_mul(base as u64)
1796            .and_then(|m| m.checked_add(digit_val as u64))
1797            .ok_or_else(|| ArithError::new(format!("`{text}` {INTEGER_OUT_OF_RANGE}"), 0..0))?;
1798    }
1799    match int_from_magnitude(mag, negative, 0..0)? {
1800        ArithExpr::Int(n) => Ok(n),
1801        _ => unreachable!(),
1802    }
1803}
1804
1805// ═══════════════════════════════════════════════════════════════════
1806// Sync evaluator — used where no `$(...)` is reachable. Hits a
1807// `CommandSubst` leaf only if the walk actually reaches one; the caller
1808// is expected not to call this when `contains_command_subst()` is true.
1809// ═══════════════════════════════════════════════════════════════════
1810
1811/// `contains_command_subst()` routes a tree holding this to the async
1812/// walker before eval_sync ever runs, so this is reachable only when a
1813/// caller invokes the sync evaluator directly without that check — the
1814/// message matches `EvalError::NoExecutor`'s wording for the same
1815/// situation elsewhere in the interpreter, not an internal name.
1816fn needs_async(what: &str) -> ArithError {
1817    ArithError::new(
1818        format!("`{what}` must be resolved by the async evaluator before sync evaluation"),
1819        0..0,
1820    )
1821}
1822
1823fn resolve_expansion_sync(e: &Expansion, scope: &Scope) -> Result<i64, ArithError> {
1824    match e {
1825        Expansion::Var(name) => resolve_var_sync(scope, name),
1826        Expansion::BracedPath { root, brackets } => {
1827            let v = braced_path_value(scope, root, brackets)?;
1828            value_to_arith(&v, root)
1829        }
1830        Expansion::BracedDefault { root, brackets, default } => {
1831            match braced_default_operand(scope, root, brackets)? {
1832                None => {
1833                    let default_expr = parse(default)?;
1834                    eval_sync(&default_expr, scope)
1835                }
1836                Some(v) => value_to_arith(&v, root),
1837            }
1838        }
1839        Expansion::LastExitCode => Ok(scope.last_result().code),
1840        Expansion::CurrentPid => Ok(scope.pid() as i64),
1841        Expansion::CommandSubst(_) => Err(needs_async("$(...)")),
1842        Expansion::Nested(inner) => eval_sync(inner, scope),
1843    }
1844}
1845
1846/// The expansion's rendered VALUE, for `base#<expansion>` — not its
1847/// arithmetically-coerced number. A `String` value's text passes through
1848/// untouched (leading zero included); other values render through the same
1849/// `value_to_string` interpolation uses.
1850pub(crate) fn expansion_text_sync(e: &Expansion, scope: &Scope) -> Result<String, ArithError> {
1851    match e {
1852        Expansion::Var(name) => {
1853            if let Ok(index) = name.parse::<usize>() {
1854                return match scope.get_positional(index) {
1855                    Some(s) => Ok(s.to_string()),
1856                    None => Err(unset_error(name)),
1857                };
1858            }
1859            match scope.get(name) {
1860                Some(v) => Ok(value_to_string(v)),
1861                None => Err(unset_error(name)),
1862            }
1863        }
1864        Expansion::BracedPath { root, brackets } => {
1865            braced_path_value(scope, root, brackets).map(|v| value_to_string(&v))
1866        }
1867        Expansion::BracedDefault { root, brackets, default } => {
1868            match braced_default_operand(scope, root, brackets)? {
1869                // A default that is itself a single expansion (`$(cmd)`,
1870                // `$var`, …) stays in TEXT mode — `10#${m:-$(date +%m)}`
1871                // needs the same "read raw digits" treatment `10#$m` gets,
1872                // not the leading-zero refusal a full arithmetic operand
1873                // would apply. A default with real operators (`1 + 2`) is
1874                // genuinely an expression and is evaluated as one.
1875                None => match parse(default)? {
1876                    ArithExpr::Expansion(e) => expansion_text_sync(&e, scope),
1877                    default_expr => Ok(eval_sync(&default_expr, scope)?.to_string()),
1878                },
1879                Some(v) => Ok(value_to_string(&v)),
1880            }
1881        }
1882        Expansion::LastExitCode => Ok(scope.last_result().code.to_string()),
1883        Expansion::CurrentPid => Ok(scope.pid().to_string()),
1884        Expansion::CommandSubst(_) => Err(needs_async("$(...)")),
1885        Expansion::Nested(inner) => Ok(eval_sync(inner, scope)?.to_string()),
1886    }
1887}
1888
1889fn resolve_based_sync(base: u32, e: &Expansion, scope: &Scope, negative: bool) -> Result<i64, ArithError> {
1890    let text = expansion_text_sync(e, scope)?;
1891    let (label, verb) = expansion_label(e);
1892    based_value(base, &text, &label, verb, negative)
1893}
1894
1895pub(crate) fn eval_sync(expr: &ArithExpr, scope: &Scope) -> Result<i64, ArithError> {
1896    match expr {
1897        ArithExpr::Int(n) => Ok(*n),
1898        ArithExpr::Expansion(e) => resolve_expansion_sync(e, scope),
1899        ArithExpr::Subscript { root, indices } => {
1900            let mut idx_vals = Vec::with_capacity(indices.len());
1901            for idx in indices {
1902                idx_vals.push(eval_sync(idx, scope)?);
1903            }
1904            resolve_subscript_sync(scope, root, &idx_vals)
1905        }
1906        ArithExpr::BasedExpansion { base, expansion } => resolve_based_sync(*base, expansion, scope, false),
1907        // `-base#$expansion`: resolve with the sign folded into the range
1908        // check (see `based_value`'s doc comment) instead of evaluating
1909        // positive then negating, which can never reach i64::MIN.
1910        ArithExpr::Unary { op: UnOp::Neg, operand } if matches!(operand.as_ref(), ArithExpr::BasedExpansion { .. }) => {
1911            let ArithExpr::BasedExpansion { base, expansion } = operand.as_ref() else {
1912                unreachable!("guarded by the match arm's pattern")
1913            };
1914            resolve_based_sync(*base, expansion, scope, true)
1915        }
1916        ArithExpr::Unary { op, operand } => apply_unary(*op, eval_sync(operand, scope)?),
1917        ArithExpr::Binary { op: BinOp::And, left, right } => {
1918            let l = eval_sync(left, scope)?;
1919            if !truthy(l) { Ok(0) } else { Ok(if truthy(eval_sync(right, scope)?) { 1 } else { 0 }) }
1920        }
1921        ArithExpr::Binary { op: BinOp::Or, left, right } => {
1922            let l = eval_sync(left, scope)?;
1923            if truthy(l) { Ok(1) } else { Ok(if truthy(eval_sync(right, scope)?) { 1 } else { 0 }) }
1924        }
1925        ArithExpr::Binary { op, left, right } => {
1926            apply_binary(*op, eval_sync(left, scope)?, eval_sync(right, scope)?)
1927        }
1928        ArithExpr::Ternary { cond, then_branch, else_branch } => {
1929            if truthy(eval_sync(cond, scope)?) {
1930                eval_sync(then_branch, scope)
1931            } else {
1932                eval_sync(else_branch, scope)
1933            }
1934        }
1935    }
1936}
1937
1938/// Tokenize, parse, and evaluate `text` (the content of `$(( ))`) with no
1939/// `$(...)` support — the fast path used where an async evaluator isn't
1940/// available. A reachable `$(...)` errors loudly rather than silently
1941/// resolving to nothing.
1942pub fn eval_arithmetic(text: &str, scope: &Scope) -> Result<i64, ArithError> {
1943    let expr = parse(text)?;
1944    eval_sync(&expr, scope)
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949    use super::*;
1950
1951    fn eval(expr: &str) -> i64 {
1952        let scope = Scope::new();
1953        eval_arithmetic(expr, &scope).unwrap_or_else(|e| panic!("eval {expr:?} failed: {e}"))
1954    }
1955
1956    fn err(expr: &str) -> String {
1957        let scope = Scope::new();
1958        eval_arithmetic(expr, &scope).expect_err("expected an error").message
1959    }
1960
1961    fn eval_with(expr: &str, setup: impl FnOnce(&mut Scope)) -> i64 {
1962        let mut scope = Scope::new();
1963        setup(&mut scope);
1964        eval_arithmetic(expr, &scope).unwrap_or_else(|e| panic!("eval {expr:?} failed: {e}"))
1965    }
1966
1967    fn err_with(expr: &str, setup: impl FnOnce(&mut Scope)) -> String {
1968        let mut scope = Scope::new();
1969        setup(&mut scope);
1970        eval_arithmetic(expr, &scope).expect_err("expected an error").message
1971    }
1972
1973    // ── literals & bases ──
1974    #[test]
1975    fn decimal() {
1976        assert_eq!(eval("42"), 42);
1977        assert_eq!(eval("0"), 0);
1978    }
1979
1980    #[test]
1981    fn hex() {
1982        assert_eq!(eval("0xff"), 255);
1983        assert_eq!(eval("0XFF"), 255);
1984    }
1985
1986    #[test]
1987    fn based() {
1988        assert_eq!(eval("16#ff"), 255);
1989        assert_eq!(eval("8#17"), 15);
1990        assert_eq!(eval("2#1011"), 11);
1991        assert_eq!(eval("36#z"), 35);
1992    }
1993
1994    #[test]
1995    fn negative_hex_and_based() {
1996        assert_eq!(eval("-0xff"), -255);
1997        assert_eq!(eval("- 16#ff"), -255);
1998    }
1999
2000    #[test]
2001    fn sign_after_hash_is_an_error() {
2002        let msg = err("16#-ff");
2003        assert!(msg.contains("puts") && msg.contains('#'), "{msg}");
2004    }
2005
2006    #[test]
2007    fn leading_zero_is_an_error() {
2008        let msg = err("010");
2009        assert!(msg.contains("leading zero"), "{msg}");
2010        assert!(msg.contains("8#10"), "{msg}");
2011        assert!(msg.contains('9') || msg.contains("10"), "{msg}");
2012    }
2013
2014    #[test]
2015    fn zero_alone_is_fine() {
2016        assert_eq!(eval("0 + 1"), 1);
2017    }
2018
2019    #[test]
2020    fn zero_b_and_zero_o_are_not_kaish_spellings() {
2021        let msg = err("0b101");
2022        assert!(msg.contains("2#101"), "{msg}");
2023        let msg = err("0o17");
2024        assert!(msg.contains("8#17"), "{msg}");
2025    }
2026
2027    // ── Defect 7b: a refused leading-zero/base-spelling numeral must not
2028    // drop the unary sign above it from its suggested fixes ──
2029    //
2030    // `-007` refuses on the `007` token alone — the tokenizer sees the `-`
2031    // as an already-emitted, separate `Sub` token, not part of the numeral.
2032    // A suggestion built without checking for it is POSITIVE, and
2033    // following it silently flips the value's sign. Round-tripping the
2034    // suggested text (not just checking the message string) is the real
2035    // assertion: a well-formed but wrong-signed suggestion would still
2036    // pass a text-only check.
2037
2038    #[test]
2039    fn negative_leading_zero_names_a_signed_fix() {
2040        let msg = err("-007");
2041        assert!(msg.contains("-8#7"), "{msg}");
2042        assert!(msg.contains("-7"), "{msg}");
2043        assert_eq!(eval("-8#7"), -7);
2044        assert_eq!(eval("-7"), -7);
2045    }
2046
2047    #[test]
2048    fn positive_sign_leading_zero_names_a_signed_fix() {
2049        let msg = err("+007");
2050        assert!(msg.contains("+8#7"), "{msg}");
2051        assert!(msg.contains("+7"), "{msg}");
2052        assert_eq!(eval("+8#7"), 7);
2053        assert_eq!(eval("+7"), 7);
2054    }
2055
2056    #[test]
2057    fn unsigned_leading_zero_fix_is_unchanged() {
2058        let msg = err("007");
2059        assert!(msg.contains("`8#7`"), "{msg}");
2060        assert!(!msg.contains("-8#7") && !msg.contains("+8#7"), "{msg}");
2061    }
2062
2063    #[test]
2064    fn binary_minus_before_leading_zero_keeps_the_unsigned_fix() {
2065        // `5 - 007`: `-` is BINARY subtraction here, not a unary sign on
2066        // `007` — the suggestion must stay unsigned, or "fixing" it would
2067        // silently change `5 - 7` into `5 - -7`.
2068        let msg = err("5 - 007");
2069        assert!(msg.contains("`8#7`"), "{msg}");
2070        assert!(!msg.contains("-8#7"), "{msg}");
2071    }
2072
2073    #[test]
2074    fn negative_binary_base_spelling_names_a_signed_fix() {
2075        let msg = err("-0b101");
2076        assert!(msg.contains("-2#101"), "{msg}");
2077        assert_eq!(eval("-2#101"), -5);
2078    }
2079
2080    #[test]
2081    fn negative_octal_o_spelling_names_a_signed_fix() {
2082        let msg = err("-0o17");
2083        assert!(msg.contains("-8#17"), "{msg}");
2084        assert_eq!(eval("-8#17"), -15);
2085    }
2086
2087    #[test]
2088    fn based_expansion_holding_a_signed_leading_zero_string_names_a_working_literal() {
2089        // `10#$x`/`8#$x` (the unsigned suggestion) can never work here:
2090        // `based_value` refuses ANY sign inside the resolved text, so
2091        // prepending `-` to `$x` wouldn't help — `$x` itself still reads
2092        // as `-007`. The only working fix embeds the digits literally.
2093        let msg = err_with("x", |s| s.set("x", Value::String("-007".to_string())));
2094        assert!(msg.contains("-10#007"), "{msg}");
2095        assert!(msg.contains("-8#007"), "{msg}");
2096        assert_eq!(eval("-10#007"), -7);
2097        assert_eq!(eval("-8#007"), -7);
2098    }
2099
2100    #[test]
2101    fn based_expansion_holding_an_unsigned_leading_zero_string_is_unchanged() {
2102        let msg = err_with("x", |s| s.set("x", Value::String("007".to_string())));
2103        assert!(msg.contains("10#$x"), "{msg}");
2104        assert!(msg.contains("8#$x"), "{msg}");
2105    }
2106
2107    #[test]
2108    fn base_out_of_range() {
2109        let msg = err("1#5");
2110        assert!(msg.contains("outside 2..=36"), "{msg}");
2111        let msg = err("37#5");
2112        assert!(msg.contains("outside 2..=36"), "{msg}");
2113    }
2114
2115    #[test]
2116    fn bad_digit_for_base() {
2117        let msg = err("2#5");
2118        assert!(msg.contains("not a digit"), "{msg}");
2119    }
2120
2121    // A base of the form k*2^32 + b (b in 2..=36) used to truncate through
2122    // `as u32` BEFORE the range check, landing in range and silently
2123    // computing as base b instead of refusing. Covers the typed literal,
2124    // the `base#$VAR` expansion form, and a string variable holding the
2125    // same spelling.
2126    #[test]
2127    fn base_out_of_range_survives_u32_truncation() {
2128        for (expr, true_base) in [
2129            ("4294967298#10", "4294967298"),
2130            ("4294967299#10", "4294967299"),
2131            ("4294967330#10", "4294967330"),
2132            ("8589934594#10", "8589934594"),
2133        ] {
2134            let msg = err(expr);
2135            assert!(msg.contains("outside 2..=36"), "{expr}: {msg}");
2136            assert!(msg.contains(true_base), "{expr}: {msg}");
2137        }
2138    }
2139
2140    #[test]
2141    fn based_expansion_out_of_range_survives_u32_truncation() {
2142        let msg = err_with("4294967298#$d", |s| s.set("d", Value::String("10".to_string())));
2143        assert!(msg.contains("outside 2..=36"), "{msg}");
2144        assert!(msg.contains("4294967298"), "{msg}");
2145    }
2146
2147    #[test]
2148    fn string_variable_based_literal_base_overflow_does_not_compute() {
2149        let mut scope = Scope::new();
2150        scope.set("x", Value::String("4294967298#10".to_string()));
2151        assert!(
2152            eval_arithmetic("x", &scope).is_err(),
2153            "a u32-truncated out-of-range base must refuse, not silently compute a value"
2154        );
2155    }
2156
2157    #[test]
2158    fn no_digits_after_prefix() {
2159        let msg = err("0x");
2160        assert!(msg.contains("no digits"), "{msg}");
2161        let msg = err("16#");
2162        assert!(msg.contains("no digits"), "{msg}");
2163    }
2164
2165    #[test]
2166    fn underscore_in_literal_quotes_the_whole_literal() {
2167        // Not `1_`: the message names the literal the user wrote.
2168        assert!(err("1_000").contains("`1_000`"), "{}", err("1_000"));
2169        assert!(err("12_345_6").contains("`12_345_6`"), "{}", err("12_345_6"));
2170        assert!(err("16#f_f").contains("`16#f_f`"), "{}", err("16#f_f"));
2171    }
2172
2173    #[test]
2174    fn out_of_range_literal() {
2175        let msg = err("9223372036854775808 + 1");
2176        assert!(msg.contains("does not fit"), "{msg}");
2177    }
2178
2179    #[test]
2180    fn min_literal_only_as_direct_unary_operand() {
2181        assert_eq!(eval("-9223372036854775808"), i64::MIN);
2182        let msg = err("9223372036854775808");
2183        assert!(msg.contains("does not fit"), "{msg}");
2184        let msg = err("- -9223372036854775808");
2185        assert!(msg.contains("does not fit"), "{msg}");
2186    }
2187
2188    // ── operators & precedence ──
2189    #[test]
2190    fn basic_ops() {
2191        assert_eq!(eval("5 + 3 * 2"), 11);
2192        assert_eq!(eval("10 / 3"), 3);
2193        assert_eq!(eval("-7 % 3"), -1);
2194        assert_eq!(eval("2 ** 10"), 1024);
2195    }
2196
2197    #[test]
2198    fn precedence_examples() {
2199        assert_eq!(eval("1 << 2 + 1"), 8);
2200        assert_eq!(eval("5 & 3 == 3"), 1);
2201        assert_eq!(eval("2 ** 3 ** 2"), 512);
2202        assert_eq!(eval("-2 ** 2"), 4);
2203        assert_eq!(eval("1 ? 2 : 3 ? 4 : 5"), 2);
2204    }
2205
2206    #[test]
2207    fn comparisons_return_one_or_zero() {
2208        assert_eq!(eval("5 > 3"), 1);
2209        assert_eq!(eval("3 > 5"), 0);
2210    }
2211
2212    #[test]
2213    fn bitwise() {
2214        assert_eq!(eval("6 & 3"), 2);
2215        assert_eq!(eval("6 | 1"), 7);
2216        assert_eq!(eval("6 ^ 3"), 5);
2217        assert_eq!(eval("~0"), -1);
2218    }
2219
2220    #[test]
2221    fn shifts() {
2222        assert_eq!(eval("1 << 4"), 16);
2223        assert_eq!(eval("-8 >> 1"), -4);
2224    }
2225
2226    #[test]
2227    fn shift_count_out_of_range() {
2228        let msg = err("1 << 64");
2229        assert!(msg.contains("outside 0..=63"), "{msg}");
2230        let msg = err("1 << -1");
2231        assert!(msg.contains("outside 0..=63"), "{msg}");
2232    }
2233
2234    #[test]
2235    fn short_circuit_and_or() {
2236        assert_eq!(eval("0 && 1"), 0);
2237        assert_eq!(eval("1 && 1"), 1);
2238        assert_eq!(eval("1 || 0"), 1);
2239        assert_eq!(eval("0 || 0"), 0);
2240    }
2241
2242    #[test]
2243    fn ternary_selects_unnormalized_value() {
2244        assert_eq!(eval("1 ? 42 : 7"), 42);
2245        assert_eq!(eval("0 ? 42 : 7"), 7);
2246    }
2247
2248    // ── overflow ──
2249    #[test]
2250    fn overflow_each_op() {
2251        assert!(err("9223372036854775807 + 1").contains("does not fit"));
2252        assert!(err("-9223372036854775808 - 1").contains("does not fit"));
2253        assert!(err("9223372036854775807 * 2").contains("does not fit"));
2254        assert!(err("-9223372036854775808 / -1").contains("does not fit"));
2255        assert!(err("2 ** 63").contains("does not fit"));
2256        assert!(err("1 << 63").contains("does not fit"));
2257    }
2258
2259    #[test]
2260    fn division_and_modulo_by_zero() {
2261        assert!(err("10 / 0").contains("divides by zero"));
2262        assert!(err("10 % 0").contains("divides by zero"));
2263    }
2264
2265    #[test]
2266    fn division_truncates_toward_zero() {
2267        assert_eq!(eval("7 / 2"), 3);
2268        assert_eq!(eval("-7 / 2"), -3);
2269    }
2270
2271    #[test]
2272    fn negative_exponent() {
2273        assert!(err("2 ** -1").contains("negative"));
2274    }
2275
2276    // ── variables ──
2277    #[test]
2278    fn bare_and_dollar_variable() {
2279        assert_eq!(eval_with("count + 1", |s| s.set("count", Value::Int(4))), 5);
2280        assert_eq!(eval_with("$count + 1", |s| s.set("count", Value::Int(4))), 5);
2281    }
2282
2283    #[test]
2284    fn unset_variable_is_an_error() {
2285        let msg = err("missing + 1");
2286        assert!(msg.contains("unset"), "{msg}");
2287        assert!(msg.contains(":-0"), "{msg}");
2288    }
2289
2290    #[test]
2291    fn random_and_seconds_name_their_fix() {
2292        let msg = err("RANDOM % 10");
2293        assert!(msg.contains("random --max"), "{msg}");
2294        let msg = err("SECONDS");
2295        assert!(msg.contains("date +%s"), "{msg}");
2296    }
2297
2298    #[test]
2299    fn null_variable_is_an_error() {
2300        let msg = err_with("x", |s| s.set("x", Value::Null));
2301        assert!(msg.contains("null"), "{msg}");
2302    }
2303
2304    #[test]
2305    fn float_variable_errors() {
2306        let msg = err_with("x", |s| s.set("x", Value::Float(2.7)));
2307        assert!(msg.contains("integer-only"), "{msg}");
2308    }
2309
2310    #[test]
2311    fn integral_float_coerces() {
2312        assert_eq!(eval_with("x + 1", |s| s.set("x", Value::Float(100.0))), 101);
2313    }
2314
2315    #[test]
2316    fn float_at_2_63_is_out_of_range() {
2317        // i64::MAX has no exact f64 representation and rounds UP to 2^63
2318        // when cast — the same rounding that makes 2^63 itself look like
2319        // it fits if the bound is compared as `i64::MAX as f64`.
2320        let msg = err_with("x", |s| s.set("x", Value::Float(9223372036854775808.0)));
2321        assert!(msg.contains("64-bit"), "{msg}");
2322    }
2323
2324    #[test]
2325    fn float_at_min_still_converts() {
2326        assert_eq!(eval_with("x", |s| s.set("x", Value::Float(-9223372036854775808.0))), i64::MIN);
2327    }
2328
2329    #[test]
2330    fn negative_zero_float_converts_to_zero() {
2331        assert_eq!(eval_with("x", |s| s.set("x", Value::Float(-0.0))), 0);
2332    }
2333
2334    #[test]
2335    fn string_value_is_parsed() {
2336        assert_eq!(eval_with("x", |s| s.set("x", Value::String("0xff".to_string()))), 255);
2337        assert_eq!(eval_with("mask & 16#0f", |s| s.set("mask", Value::String("0xff".to_string()))), 15);
2338    }
2339
2340    #[test]
2341    fn string_with_leading_zero_names_the_fix() {
2342        let msg = err_with("x", |s| s.set("x", Value::String("08".to_string())));
2343        assert!(msg.contains("10#$x") || msg.contains("leading zero"), "{msg}");
2344    }
2345
2346    #[test]
2347    fn string_expression_names_the_fix() {
2348        let msg = err_with("x", |s| s.set("x", Value::String("1 + 2".to_string())));
2349        assert!(msg.contains("not an expression"), "{msg}");
2350    }
2351
2352    #[test]
2353    fn string_non_numeric_is_an_error() {
2354        let msg = err_with("x", |s| s.set("x", Value::String("abc".to_string())));
2355        assert!(msg.contains("not a number"), "{msg}");
2356    }
2357
2358    // `read_numeral` used to flatten every tokenizer `Err` to a generic
2359    // "is not a number", discarding a fix the tokenizer already named.
2360    // These three spellings each carry a real fix; `abc` above has none
2361    // and must keep the generic message.
2362    #[test]
2363    fn string_binary_spelling_names_the_fix() {
2364        let msg = err_with("x", |s| s.set("x", Value::String("0b101".to_string())));
2365        assert!(msg.contains("`x`") && msg.contains("0b101"), "{msg}");
2366        assert!(msg.contains("2#101"), "{msg}");
2367    }
2368
2369    #[test]
2370    fn string_underscore_digit_group_names_the_fix() {
2371        let msg = err_with("x", |s| s.set("x", Value::String("1_000".to_string())));
2372        assert!(msg.contains("`x`") && msg.contains("1_000"), "{msg}");
2373        assert!(msg.contains("remove it"), "{msg}");
2374    }
2375
2376    #[test]
2377    fn string_float_spelling_names_the_fix() {
2378        let msg = err_with("x", |s| s.set("x", Value::String("1e3".to_string())));
2379        assert!(msg.contains("`x`") && msg.contains("1e3"), "{msg}");
2380        assert!(msg.contains("integer-only"), "{msg}");
2381    }
2382
2383    #[test]
2384    fn list_record_and_bytes_error() {
2385        let msg = err_with("x", |s| s.set("x", Value::Json(serde_json::json!([1, 2]))));
2386        assert!(msg.contains("list"), "{msg}");
2387        let msg = err_with("x", |s| s.set("x", Value::Json(serde_json::json!({"a": 1}))));
2388        assert!(msg.contains("record"), "{msg}");
2389        let msg = err_with("x", |s| s.set("x", Value::Bytes(vec![0xff, 0xfe, 0x00, 0x01])));
2390        assert!(msg.contains("bytes"), "{msg}");
2391    }
2392
2393    #[test]
2394    fn last_exit_code_and_pid() {
2395        let mut scope = Scope::new();
2396        scope.set_last_result(crate::interpreter::ExecResult::success("x").with_code(3));
2397        assert_eq!(eval_arithmetic("$?", &scope).unwrap(), 3);
2398        assert_eq!(eval_arithmetic("$$", &scope).unwrap(), scope.pid() as i64);
2399    }
2400
2401    // ── subscripts (Decision B: bare `[...]` is an expression) ──
2402    #[test]
2403    fn bare_subscript_is_a_variable_expression() {
2404        let r = eval_with("xs[i]", |s| {
2405            s.set("xs", Value::Json(serde_json::json!([10, 20, 30])));
2406            s.set("i", Value::Int(1));
2407        });
2408        assert_eq!(r, 20);
2409    }
2410
2411    #[test]
2412    fn bare_subscript_literal_and_expression_index() {
2413        let r = eval_with("xs[0] + 1", |s| s.set("xs", Value::Json(serde_json::json!([10, 20, 30]))));
2414        assert_eq!(r, 11);
2415        let r = eval_with("xs[i + 1]", |s| {
2416            s.set("xs", Value::Json(serde_json::json!([10, 20, 30])));
2417            s.set("i", Value::Int(0));
2418        });
2419        assert_eq!(r, 20);
2420    }
2421
2422    #[test]
2423    fn braced_path_reads_a_literal_key() {
2424        let r = eval_with("${c[port]}", |s| s.set("c", Value::Json(serde_json::json!({"port": 8080}))));
2425        assert_eq!(r, 8080);
2426    }
2427
2428    // ── default expansion ──
2429    #[test]
2430    fn default_used_when_unset() {
2431        assert_eq!(eval("${limit:-0} + 1"), 1);
2432    }
2433
2434    #[test]
2435    fn default_not_used_when_set() {
2436        assert_eq!(eval_with("${limit:-0} + 1", |s| s.set("limit", Value::Int(9))), 10);
2437    }
2438
2439    // ── nested arithmetic ──
2440    #[test]
2441    fn nested_arithmetic() {
2442        assert_eq!(eval("$(( 1 + 2 )) * 4"), 12);
2443    }
2444
2445    #[test]
2446    fn newline_inside_is_whitespace() {
2447        assert_eq!(eval("1 +\n2"), 3);
2448    }
2449
2450    // ── structural errors ──
2451    #[test]
2452    fn empty_is_an_error() {
2453        let msg = err("");
2454        assert!(msg.contains("no expression"), "{msg}");
2455    }
2456
2457    #[test]
2458    fn empty_group_is_an_error() {
2459        let msg = err("()");
2460        assert!(msg.contains("no expression"), "{msg}");
2461    }
2462
2463    #[test]
2464    fn missing_close_paren() {
2465        let msg = err("(1 + 2");
2466        assert!(msg.contains("closing"), "{msg}");
2467    }
2468
2469    #[test]
2470    fn extra_close_paren() {
2471        let msg = err("1 + 2)");
2472        assert!(msg.contains("matching"), "{msg}");
2473    }
2474
2475    #[test]
2476    fn ternary_without_colon() {
2477        let msg = err("1 ? 2");
2478        assert!(msg.contains(':'), "{msg}");
2479    }
2480
2481    #[test]
2482    fn not_operators_are_diagnosed() {
2483        assert!(err("1 <<< 2").contains("here-string"));
2484        assert!(err("1 >>> 2").contains(">>"));
2485        assert!(err("x = 5").contains("assigns"));
2486        assert!(err("x += 1").contains("assigns"));
2487        assert!(err("x++").contains("assigns"));
2488        assert!(err("1, 2").contains("one expression"));
2489    }
2490
2491    #[test]
2492    fn assignment_errors_name_the_real_tokens_not_a_placeholder() {
2493        assert_eq!(err("x++"), "`x++` assigns inside `$(( ))`; write `x=$((x + 1))`");
2494        assert_eq!(err("++x"), "`++x` assigns inside `$(( ))`; write `x=$((x + 1))`");
2495        assert_eq!(err("x--"), "`x--` assigns inside `$(( ))`; write `x=$((x - 1))`");
2496        assert_eq!(err("--x"), "`--x` assigns inside `$(( ))`; write `x=$((x - 1))`");
2497        assert_eq!(err("x += 2"), "`x += 2` assigns inside `$(( ))`; write `x=$((x + 2))`");
2498        assert_eq!(err("x -= 3"), "`x -= 3` assigns inside `$(( ))`; write `x=$((x - 3))`");
2499        assert_eq!(err("x = 2"), "`x = 2` assigns inside `$(( ))`; write `x=2`, or `==` to compare");
2500    }
2501
2502    #[test]
2503    fn missing_operand_names_the_source_consumed_so_far() {
2504        assert_eq!(
2505            err("1 + "),
2506            "`+` has no right operand in `1 + `; add an integer expression after `+`"
2507        );
2508        assert_eq!(err(" + "), "`+` has no operand; add an integer expression after `+`");
2509    }
2510
2511    #[test]
2512    fn a_leading_zero_base_is_refused() {
2513        assert_eq!(err("08#17"), "`08` is not a base spelling; write the base without a leading zero");
2514        assert_eq!(err("010#5"), "`010` is not a base spelling; write the base without a leading zero");
2515    }
2516
2517    #[test]
2518    fn based_expansion_digits_take_no_sign() {
2519        let msg = err_with("16#$d", |s| s.set("d", Value::String("-ff".to_string())));
2520        assert_eq!(msg, "`d` holds `-ff`; the digits after `#` take no sign — write `-16#ff`");
2521    }
2522
2523    #[test]
2524    fn depth_cap() {
2525        let mut src = String::new();
2526        for _ in 0..300 {
2527            src.push('(');
2528        }
2529        src.push('1');
2530        for _ in 0..300 {
2531            src.push(')');
2532        }
2533        let msg = err(&src);
2534        assert!(msg.contains("256"), "{msg}");
2535    }
2536
2537    // ── Defect 7a: `ArithError.span` must be a byte range ──
2538    //
2539    // The tokenizer's `self.pos` counts CHARS (it indexes a
2540    // `Vec<(usize, char)>`), but `ArithError.span` is documented as a byte
2541    // range into the source. A raw char index used directly as a byte
2542    // offset is correct only while every preceding character is one byte;
2543    // a multi-byte character before the error point makes it wrong. The
2544    // symptom: `source.get(span)` returns `None` (a non-char-boundary) or
2545    // slices the wrong bytes — never merely "a span that looks nonempty".
2546
2547    fn parse_err(text: &str) -> ArithError {
2548        parse(text).expect_err("expected a tokenizer/parse error")
2549    }
2550
2551    /// The real assertion for defect 7a: not just that `span` is nonempty,
2552    /// but that it actually slices `source` to `expected`.
2553    fn assert_span_slices_to(source: &str, err: &ArithError, expected: &str) {
2554        let slice = source.get(err.span.clone());
2555        assert!(
2556            slice.is_some(),
2557            "span {:?} does not slice {source:?} (message: {})",
2558            err.span,
2559            err.message
2560        );
2561        assert_eq!(slice.expect("checked above"), expected, "source {source:?}, message {:?}", err.message);
2562    }
2563
2564    // Control: pure ASCII before the error. A fix that zeroes every span
2565    // (rather than converting it) would pass the multi-byte tests below by
2566    // accident; this one only passes if the span is still real.
2567    #[test]
2568    fn control_ascii_before_leading_zero_span_is_byte_correct() {
2569        let source = "$(echo x) + 008";
2570        let err = parse_err(source);
2571        assert!(err.message.contains("leading zero"), "{}", err.message);
2572        assert_span_slices_to(source, &err, "008");
2573    }
2574
2575    #[test]
2576    fn ideographic_space_before_leading_zero_span_is_byte_correct() {
2577        // U+3000 IDEOGRAPHIC SPACE is 3 bytes, 1 char — `skip_ws` treats it
2578        // as whitespace, so `self.pos` (chars) undercounts the true byte
2579        // offset of everything after it by 2.
2580        let source = "\u{3000}008";
2581        let err = parse_err(source);
2582        assert!(err.message.contains("leading zero"), "{}", err.message);
2583        assert_span_slices_to(source, &err, "008");
2584    }
2585
2586    #[test]
2587    fn full_width_digit_before_leading_zero_span_is_byte_correct() {
2588        // U+FF10 FULLWIDTH DIGIT ZERO is 3 bytes, 1 char, consumed inside
2589        // `skip_group`'s generic `Some(_) => self.pos += 1` — same
2590        // char/byte divergence, reached through a different path.
2591        let source = "$(echo \u{ff10}) + 008";
2592        let err = parse_err(source);
2593        assert!(err.message.contains("leading zero"), "{}", err.message);
2594        assert_span_slices_to(source, &err, "008");
2595    }
2596
2597    #[test]
2598    fn accented_letter_before_leading_zero_span_is_byte_correct() {
2599        // U+00E9 'é' is 2 bytes, 1 char.
2600        let source = "$(echo caf\u{e9}) + 008";
2601        let err = parse_err(source);
2602        assert!(err.message.contains("leading zero"), "{}", err.message);
2603        assert_span_slices_to(source, &err, "008");
2604    }
2605
2606    #[test]
2607    fn accented_letter_before_unterminated_brace_group_span_is_byte_correct() {
2608        let source = "\u{3000}${caf\u{e9}";
2609        let err = parse_err(source);
2610        assert!(err.message.contains("no closing"), "{}", err.message);
2611        assert_span_slices_to(source, &err, "${caf\u{e9}");
2612    }
2613}
2614