Skip to main content

zsh/ported/
math.rs

1//! Mathematical expression evaluation for zshrs
2//!
3//! Direct port from zsh/Src/math.c
4//!
5//! Supports:
6//! - Integer and floating point arithmetic
7//! - All C operators (+, -, *, /, %, <<, >>, &, |, ^, etc.)
8//! - Zsh ** power operator
9//! - Comparison operators (<, >, <=, >=, ==, !=)
10//! - Logical operators (&&, ||, !)
11//! - Ternary operator (? :)
12//! - Assignment operators (=, +=, -=, *=, /=, etc.)
13//! - Pre/post increment/decrement (++, --)
14//! - Base conversion (`16#FF`, `2#1010`, `[16]FF`)
15//! - Special values (Inf, NaN)
16//! - Variable references and assignment
17
18use std::cell::{Cell, RefCell};
19use std::collections::HashMap;
20
21use crate::ported::options::opt_state_set;
22use crate::ported::params::{convbase, getsparam, unsetparam};
23use crate::ported::utils::zerr;
24/// Re-export of `mnumber` (defined in zsh_h.rs as the Src/zsh.h:95 port).
25pub use crate::ported::zsh_h::{mnumber, Nularg, MN_FLOAT, MN_INTEGER, MN_UNSET};
26use crate::zsh_h::{PM_ARRAY, PM_EFLOAT, PM_FFLOAT, PM_HASHED, PM_INTEGER, PM_TYPE};
27
28/// Re-export of `MN_FLOAT` (defined in zsh_h.rs as the Src/zsh.h:104 port).
29/// Re-export of `MN_INTEGER` (defined in zsh_h.rs as the Src/zsh.h:103 port).
30/// Re-export of `MN_UNSET` (defined in zsh_h.rs as the Src/zsh.h:105 port).
31
32/// Port of `struct mathvalue` from `Src/math.c`:
33///
34/// ```c
35/// struct mathvalue {
36///     char *lval;     /* lvalue string for variable write-back  */
37///     Value pval;     /* resolved variable handle (or NULL)     */
38///     mnumber val;    /* current numeric value                  */
39/// };
40/// ```
41#[derive(Clone)]
42pub(crate) struct mathvalue {
43    pub val: mnumber,
44    pub lval: Option<String>,
45    /// `Value pval` slot from the C source (c:314-318 — "If this is not
46    /// zero, we've retrieved a variable and this stores a reference to
47    /// it"). C caches the resolved `Value` here so `getmathparam` and
48    /// `setmathvar` share ONE subscript resolution (c:334-336 "Try to be
49    /// clever about reusing subscripts by caching the Value structure").
50    ///
51    /// !!! WARNING: RUST-ONLY SHAPE !!! zshrs's paramtab exposes no `Value`
52    /// handle, so the port caches the RESOLVED LVALUE STRING instead —
53    /// `array[++x]` collapsed to `array[1]` — which gives the same
54    /// evaluate-the-subscript-once contract. `None` = not yet resolved,
55    /// matching C's NULL. Populated by `op` (see the note there).
56    pub pval: Option<String>,
57}
58
59/// Operator associativity and type flags
60const LR: u16 = 0x0000; // left-to-right
61const RL: u16 = 0x0001; // right-to-left
62const BOOL: u16 = 0x0002; // short-circuit boolean
63
64const OP_A2: u16 = 0x0004; // 2 arguments
65const OP_A2IR: u16 = 0x0008; // 2 args, return int
66const OP_A2IO: u16 = 0x0010; // 2 args, must be int
67const OP_E2: u16 = 0x0020; // 2 args with assignment
68const OP_E2IO: u16 = 0x0040; // 2 args assign, must be int
69const OP_OP: u16 = 0x0080; // expecting operator position
70const OP_OPF: u16 = 0x0100; // followed by operator (after this, next is operator)
71
72/// Math tokens — direct port of Src/math.c:109-162. C uses bare
73/// `#define`s; the Rust port mirrors as `pub const` ints so
74/// `static int mtok` (math.c:305) can be a plain `i32` and the
75/// C precedence/type tables index by the literal numbers.
76pub const M_INPAR: i32 = 0; // c:109  '('
77/// `M_OUTPAR` constant.
78pub const M_OUTPAR: i32 = 1; // c:110  ')'
79/// `NOT` constant.
80pub const NOT: i32 = 2; // c:111  '!'
81/// `COMP` constant.
82pub const COMP: i32 = 3; // c:112  '~'
83/// `POSTPLUS` constant.
84pub const POSTPLUS: i32 = 4; // c:113  x++
85/// `POSTMINUS` constant.
86pub const POSTMINUS: i32 = 5; // c:114  x--
87/// `UPLUS` constant.
88pub const UPLUS: i32 = 6; // c:115  +x
89/// `UMINUS` constant.
90pub const UMINUS: i32 = 7; // c:116  -x
91/// `AND` constant.
92pub const AND: i32 = 8; // c:117  &
93/// `XOR` constant.
94pub const XOR: i32 = 9; // c:118  ^
95/// `OR` constant.
96pub const OR: i32 = 10; // c:119  |
97/// `MUL` constant.
98pub const MUL: i32 = 11; // c:120  *
99/// `DIV` constant.
100pub const DIV: i32 = 12; // c:121  /
101/// `MOD` constant.
102pub const MOD: i32 = 13; // c:122  %
103/// `PLUS` constant.
104pub const PLUS: i32 = 14; // c:123  +
105/// `MINUS` constant.
106pub const MINUS: i32 = 15; // c:124  -
107/// `SHLEFT` constant.
108pub const SHLEFT: i32 = 16; // c:125  <<
109/// `SHRIGHT` constant.
110pub const SHRIGHT: i32 = 17; // c:126  >>
111/// `LES` constant.
112pub const LES: i32 = 18; // c:127  <
113/// `LEQ` constant.
114pub const LEQ: i32 = 19; // c:128  <=
115/// `GRE` constant.
116pub const GRE: i32 = 20; // c:129  >
117/// `GEQ` constant.
118pub const GEQ: i32 = 21; // c:130  >=
119/// `DEQ` constant.
120pub const DEQ: i32 = 22; // c:131  ==
121/// `NEQ` constant.
122pub const NEQ: i32 = 23; // c:132  !=
123/// `DAND` constant.
124pub const DAND: i32 = 24; // c:133  &&
125/// `DOR` constant.
126pub const DOR: i32 = 25; // c:134  ||
127/// `DXOR` constant.
128pub const DXOR: i32 = 26; // c:135  ^^
129/// `QUEST` constant.
130pub const QUEST: i32 = 27; // c:136  ? (ternary)
131/// `COLON` constant.
132pub const COLON: i32 = 28; // c:137  :
133/// `EQ` constant.
134pub const EQ: i32 = 29; // c:138  =
135/// `PLUSEQ` constant.
136pub const PLUSEQ: i32 = 30; // c:139  +=
137/// `MINUSEQ` constant.
138pub const MINUSEQ: i32 = 31; // c:140  -=
139/// `MULEQ` constant.
140pub const MULEQ: i32 = 32; // c:141  *=
141/// `DIVEQ` constant.
142pub const DIVEQ: i32 = 33; // c:142  /=
143/// `MODEQ` constant.
144pub const MODEQ: i32 = 34; // c:143  %=
145/// `ANDEQ` constant.
146pub const ANDEQ: i32 = 35; // c:144  &=
147/// `XOREQ` constant.
148pub const XOREQ: i32 = 36; // c:145  ^=
149/// `OREQ` constant.
150pub const OREQ: i32 = 37; // c:146  |=
151/// `SHLEFTEQ` constant.
152pub const SHLEFTEQ: i32 = 38; // c:147  <<=
153/// `SHRIGHTEQ` constant.
154pub const SHRIGHTEQ: i32 = 39; // c:148  >>=
155/// `DANDEQ` constant.
156pub const DANDEQ: i32 = 40; // c:149  &&=
157/// `DOREQ` constant.
158pub const DOREQ: i32 = 41; // c:150  ||=
159/// `DXOREQ` constant.
160pub const DXOREQ: i32 = 42; // c:151  ^^=
161/// `COMMA` constant.
162pub const COMMA: i32 = 43; // c:152  ,
163/// `EOI` constant.
164pub const EOI: i32 = 44; // c:153  end of input
165/// `PREPLUS` constant.
166pub const PREPLUS: i32 = 45; // c:154  ++x
167/// `PREMINUS` constant.
168pub const PREMINUS: i32 = 46; // c:155  --x
169/// `NUM` constant.
170pub const NUM: i32 = 47; // c:156  number literal
171/// `ID` constant.
172pub const ID: i32 = 48; // c:157  identifier
173/// `POWER` constant.
174pub const POWER: i32 = 49; // c:158  **
175/// `CID` constant.
176pub const CID: i32 = 50; // c:159  #identifier (char value)
177/// `POWEREQ` constant.
178pub const POWEREQ: i32 = 51; // c:160  **=
179/// `FUNC` constant.
180pub const FUNC: i32 = 52; // c:161  function call
181/// Total token count — Src/math.c:162 `#define TOKCOUNT 53`. The
182/// `c_prec`/`z_prec`/`type` arrays are sized by this.
183pub const TOKCOUNT: usize = 53;
184
185/// Port of `enum prec_type` from `Src/math.c`. `mathevall()` (line
186/// 367) uses this to differentiate top-level expression evaluation
187/// (`(())`, `$(())`) from function-argument evaluation
188/// (`func(arg, arg, …)`) — argument-mode terminates parsing on
189/// the first comma encountered at the top level.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191#[allow(non_camel_case_types)]
192pub enum prec_type {
193    MPREC_TOP,
194    MPREC_ARG,
195}
196
197/// Port of `getmathparam()` from `Src/math.c:337` — C decl `getmathparam(struct mathvalue *mptr)`.
198///
199/// Look up a parameter by name from inside math context. zsh
200/// auto-typesets a missing-but-referenced name (its mathparam
201/// flag), but the Rust port keeps the variables map separate from
202/// the param table so a miss returns `Integer(0)` and skips the
203/// type-coercion. Indirect-string mode (`a="3+2"; $((a))`) is
204/// handled by recursively evaluating the string value.
205/// WARNING: param names don't match C — Rust=() vs C=(mptr)
206pub(crate) fn getmathparam(name: &str) -> mnumber {
207    // c:Src/math.c:358-362 — after reading a parameter's value, FORCEFLOAT
208    // coerces an INTEGER result to float, so `integer a=3 b=4; setopt
209    // force_float; $((a/b))` does float division (0.75) not integer (0).
210    // The literal/operator paths already honor force_float; only this
211    // param-read path dropped it. The read logic is wrapped in an
212    // immediately-invoked closure (NOT a named inner fn — the name-parity
213    // build gate requires every `fn` to have a C counterpart, and there is
214    // only one C `getmathparam`).
215    let __raw = (|| -> mnumber {
216        // Strip array subscript if present
217        let base_name = if let Some(bracket) = name.find('[') {
218            &name[..bracket]
219        } else {
220            name
221        };
222        if let Some(v) = m_variables_get(base_name) {
223            return v;
224        }
225        // c:Src/math.c:337 getmathparam — falls back to `getvalue(s)`
226        // which parses the full subscript syntax (params.c:2180).
227        // The Rust port previously required callers to seed
228        // `with_string_variables` (a pre-populate pattern that
229        // diverged from C). Read paramtab + array subscripts here
230        // so matheval works without seeding.
231        if let Some(bracket) = name.find('[') {
232            let close = name.rfind(']').unwrap_or(name.len());
233            let arr_name = &name[..bracket];
234            let idx_str = &name[bracket + 1..close];
235
236            // c:Src/math.c:341-358 — a subscript-FLAG form `(flags)pat` goes
237            // through `getvalue(mptr->pval, &s, 1)` and `getnumvalue`, which
238            // is where getarg/getindex implement every flag (`b:N:`, `e`,
239            // `n:N:`, `w`, …) and where a positional `argv` resolves. The
240            // earlier emulation read `pm.u_arr` and knew only i/I/e/n: it
241            // ignored `b:2:` and read the stale storage of `argv`, so
242            // `(( minus = argv[(ib:2:)-] ))` (_sequence sh:39) gave 0 and
243            // `(( argv[(i)-] ))` gave 1 where zsh gives 2 for both.
244            if idx_str.starts_with('(') {
245                let mut vbuf = crate::ported::zsh_h::value {
246                    pm: None,
247                    arr: Vec::new(),
248                    scanflags: 0,
249                    valflags: 0,
250                    start: 0,
251                    end: -1,
252                };
253                let mut s: &str = name;
254                // getvalue's subscript math and getnumvalue both re-enter
255                // matheval; C saves the evaluator's statics around that
256                // (c:367 mathevall, the xyy* block). Same guard as the index
257                // path below, keeping any error the inner evaluation raised.
258                let saved = save_state();
259                let r = match crate::ported::params::getvalue(Some(&mut vbuf), &mut s, 1) {
260                    // c:358 — `result = getnumvalue(mptr->pval);`
261                    Some(v) => crate::ported::params::getnumvalue(Some(v)),
262                    // c:343-353 — no value: zero.
263                    None => mnumber {
264                        l: 0,
265                        d: 0.0,
266                        type_: MN_INTEGER,
267                    },
268                };
269                let inner_err = M_ERROR.with(|c| c.borrow_mut().take());
270                restore_state(saved);
271                if let Some(e) = inner_err {
272                    m_error_set(e);
273                }
274                return r;
275            }
276
277            // c:Src/params.c:2048-2053 getindex — a `[@]` / `[*]` subscript
278            // selects the whole array (`v->start = 0; v->end = -1`, scanflags
279            // set), and getnumvalue (c:2626-2631) `sepjoin`s it and
280            // `matheval`s the result. `arr=(x y); (( arr[@] ))` therefore
281            // evaluates `x y` and fails with "operator expected at `y'"; the
282            // index path below would read `@` as the number 0.
283            if idx_str == "@" || idx_str == "*" {
284                let elems: Option<Vec<String>> = crate::ported::params::paramtab()
285                    .read()
286                    .ok()
287                    .and_then(|t| t.get(arr_name).and_then(|pm| pm.u_arr.clone()))
288                    .or_else(|| {
289                        crate::ported::params::paramtab_hashed_storage()
290                            .lock()
291                            .ok()
292                            .and_then(|m| m.get(arr_name).map(|h| h.values().cloned().collect()))
293                    });
294                if let Some(elems) = elems {
295                    let joined = crate::ported::utils::sepjoin(&elems, None); // c:2629
296                    let saved = save_state();
297                    let r = matheval(&joined); // c:2630
298                    restore_state(saved);
299                    return match r {
300                        Ok(n) => n,
301                        Err(e) => {
302                            m_error_set(e);
303                            mnumber {
304                                l: 0,
305                                d: 0.0,
306                                type_: MN_INTEGER,
307                            }
308                        }
309                    };
310                }
311            }
312
313            // c:337-358 — `getvalue(mptr->pval, &s, 1)` then
314            // `getnumvalue(mptr->pval)`: getindex evaluates the subscript,
315            // getstrvalue selects the array element or cuts the scalar, and
316            // getnumvalue re-evaluates that string (c:2639), so `b=12;
317            // $(( b[2] ))` is 2 and `a=(1+2); $(( a[1] ))` is 3. A name
318            // getvalue cannot resolve is c:345-346's nounset error. The
319            // store readers below remain for what this Value path does not
320            // model: associations (whose per-key gsu such as
321            // `compstate[nmatches]` lives in `assoc_key_hit`), module
322            // specials (PARTAB), and --bash's 0-based indexing.
323            let is_hash_or_special = crate::ported::params::paramtab()
324                .read()
325                .ok()
326                .and_then(|t| t.get(arr_name).map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED))
327                .unwrap_or(false)
328                || crate::ported::params::paramtab_hashed_storage()
329                    .lock()
330                    .map(|m| m.contains_key(arr_name))
331                    .unwrap_or(false)
332                || crate::ported::modules::parameter::PARTAB
333                    .iter()
334                    .any(|e_| e_.name == arr_name);
335            if !is_hash_or_special && !crate::dash_mode::bash_mode() {
336                let mut vbuf = crate::ported::zsh_h::value {
337                    pm: None,
338                    arr: Vec::new(),
339                    scanflags: 0,
340                    valflags: 0,
341                    start: 0,
342                    end: -1,
343                };
344                let mut s: &str = name;
345                // Same evaluator save/restore as the flag path above (c:367).
346                let saved = save_state();
347                let r = match crate::ported::params::getvalue(Some(&mut vbuf), &mut s, 1) {
348                    Some(v) => Some(crate::ported::params::getnumvalue(Some(v))), // c:358
349                    None => None,
350                };
351                let inner_err = M_ERROR.with(|c| c.borrow_mut().take());
352                restore_state(saved);
353                if let Some(e) = inner_err {
354                    m_error_set(e);
355                }
356                return match r {
357                    Some(n) => n,
358                    None => {
359                        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::UNSET) {
360                            // c:345-346
361                            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
362                        }
363                        mnumber {
364                            l: 0,
365                            d: 0.0,
366                            type_: MN_INTEGER,
367                        } // c:353
368                    }
369                };
370            }
371
372            // Recursively eval the index (so a[i+1], h[$k], etc work).
373            // CRITICAL: save/restore evaluator state around the recursive
374            // matheval — without this, the inner call's `push(idx_value)`
375            // contaminates the OUTER expression's operand stack. Bug
376            // manifested as `$((1 + arr[1]))` returning 10 (just arr[1])
377            // because the outer NUM(1) got popped by the inner eval's
378            // op() during `op(PLUS)` (which sees [NUM(1), ID(arr[1]),
379            // NUM(1_from_idx_eval)] instead of [NUM(1), ID(arr[1])]).
380            // C mathevall at math.c:367 does the same xyy* save/restore
381            // around recursive entry.
382            let saved = save_state();
383            // c:Src/math.c:382 — a recursion-limit bail is FATAL in C: the
384            // guard calls `zerr`, which sets errflag and unwinds the whole
385            // evaluation. Collapsing it into `unwrap_or(0)` here made it
386            // survivable: getmathparam carried on with index 0, the caller
387            // re-entered, and the MAX_MLEVEL cap was defeated on this path
388            // while still working for a direct self-reference (`x=x`). A
389            // p10k widget doing subscript arithmetic recursed ~1800 deep,
390            // each level re-copying the variable map in
391            // `m_string_variables_set`, and pegged a core. Report the bail
392            // through the same in-band channel the rest of getmathparam
393            // uses (`m_error_set`, drained by mathevall at the `m_error_take`
394            // below) so it propagates instead of being discarded. An
395            // ordinary unevaluable index still yields 0, as before.
396            let idx_val = match matheval(idx_str) {
397                Ok(n) => {
398                    if n.type_ == MN_FLOAT {
399                        crate::zlong_cast::zlong_from_double(n.d)
400                    } else {
401                        n.l
402                    }
403                }
404                Err(e) => {
405                    if M_LEVEL.with(|c| c.get()) > MAX_MLEVEL {
406                        m_error_set(e);
407                    }
408                    0
409                }
410            };
411            restore_state(saved);
412            // Read paramtab directly: PM_ARRAY → u_arr indexed by 1-based pos.
413            if let Ok(tab) = crate::ported::params::paramtab().read() {
414                if let Some(pm) = tab.get(arr_name) {
415                    if let Some(arr) = &pm.u_arr {
416                        let len = arr.len() as i64;
417                        // !!! BASH-MODE GATE (no C counterpart) !!! bash
418                        // indexed arrays are 0-based in arithmetic too
419                        // (`$(( a[1] ))` is the SECOND element), so skip the
420                        // zsh 1-based `-1`. Negative indices count from the
421                        // end identically in both, so only the non-negative
422                        // arm differs. Mirrors the param-expansion 0-based
423                        // subscript already applied in --bash.
424                        let pos = if idx_val < 0 {
425                            len + idx_val
426                        } else if crate::dash_mode::bash_mode() {
427                            idx_val
428                        } else {
429                            idx_val - 1
430                        };
431                        if pos >= 0 && (pos as usize) < arr.len() {
432                            let raw = &arr[pos as usize];
433                            if let Ok(n) = raw.parse::<i64>() {
434                                return mnumber {
435                                    l: n,
436                                    d: 0.0,
437                                    type_: MN_INTEGER,
438                                };
439                            }
440                            if let Ok(f) = raw.parse::<f64>() {
441                                return mnumber {
442                                    l: 0,
443                                    d: f,
444                                    type_: MN_FLOAT,
445                                };
446                            }
447                        }
448                    }
449                }
450            }
451            // c:Src/math.c:337 getmathparam → `getvalue(&vbuf, &s, 1)`
452            // (params.c:2180). C resolves the subscript through the
453            // PARAMETER'S OWN gsu, so a hash whose individual keys are
454            // live gsu cells returns the COMPUTED value in arithmetic
455            // context exactly as it does in string context. The single
456            // case that matters is `compstate[nmatches]`, whose getter
457            // is `get_nmatches` (Src/Zle/complete.c:1411 —
458            // `permmatches(0) ? 0 : nmatches`) and which is never
459            // stored data.
460            //
461            // The port read `paramtab_hashed_storage()` directly here,
462            // which never holds that key, so arithmetic always saw 0
463            // while `${compstate[nmatches]}` (subst.rs:6814) saw the
464            // live count. The compsys idiom
465            //   nm="$compstate[nmatches]" … [[ nm -ne compstate[nmatches] ]]
466            // (`_alternative` sh:63, `_arguments`, `_describe`,
467            // `_parameters`) therefore ALWAYS concluded "this completer
468            // added nothing": `_alternative` returned 1 after adding
469            // 52k matches, so `_complete` returned 1, `_megacomplete`
470            // returned 1, and `_main_complete` re-ran the whole
471            // completer for the next matcher-list entry — doubling the
472            // work on every `_alternative`-based completion.
473            //
474            // `assoc_key_hit` (vm_helper.rs:113) is the existing shared
475            // accessor that already applies C's gsu semantics for this
476            // hash; route through it rather than adding a fourth copy
477            // of the special-case. It falls back to the same store read
478            // below when it declines (name shadowed by a `local`).
479            // c:Src/params.c:2639 — `return matheval(getstrvalue(v));`: an
480            // element's value is itself an arithmetic expression, so
481            // `h[x]=2+3; (( h[x] ))` is 5. Evaluated under the same state
482            // save as the other nested reads (c:367); an error reaches the
483            // outer frame through m_error_set.
484            let element_value = |val: &str| -> mnumber {
485                let saved = save_state();
486                let r = matheval(val);
487                restore_state(saved);
488                match r {
489                    Ok(n) => n,
490                    Err(e) => {
491                        m_error_set(e);
492                        mnumber {
493                            l: 0,
494                            d: 0.0,
495                            type_: MN_INTEGER,
496                        }
497                    }
498                }
499            };
500            if let Some((_, Some(v))) = crate::vm_helper::assoc_key_hit(arr_name, idx_str) {
501                return element_value(&v);
502            }
503            // PM_HASHED via paramtab_hashed_storage. The value is copied out
504            // so the store's lock is not held across the evaluation.
505            let stored = crate::ported::params::paramtab_hashed_storage()
506                .lock()
507                .ok()
508                .and_then(|m| m.get(arr_name).and_then(|map| map.get(idx_str).cloned()));
509            if let Some(v) = stored {
510                return element_value(&v);
511            }
512            // c:Src/math.c:337 getmathparam → getvalue: magic-assoc special
513            // parameters (`sysparams`, `errnos`, `commands`, …) don't live in
514            // paramtab — their per-key value comes from a module getfn, the
515            // same PARTAB dispatch string context uses (subst.rs:8102). Route
516            // through it so `(( sysparams[pid] ))` yields the pid instead of 0.
517            // gitstatus_start relies on this (plugin line 649 / 593), so the
518            // whole zsh/system-backed init path (p10k's git prompt) was dead.
519            if let Some(e_) = crate::ported::modules::parameter::PARTAB
520                .iter()
521                .find(|e_| e_.name == arr_name)
522            {
523                let module_ok = match e_.module {
524                    Some(m_) => crate::ported::module::MODULESTAB
525                        .lock()
526                        .map(|t| t.is_loaded(m_))
527                        .unwrap_or(false),
528                    None => true,
529                };
530                if module_ok && !crate::vm_helper::magic_special_shadowed(arr_name) {
531                    // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
532                    // an arithmetic read of `special[key]` resolves the CONTAINER
533                    // name, clearing its PM_AUTOLOAD; the subscript is irrelevant.
534                    crate::vm_helper::mark_module_param_used(arr_name);
535                    if let Some(v) =
536                        (e_.getfn)(std::ptr::null_mut(), idx_str).and_then(|p_| p_.u_str)
537                    {
538                        if let Ok(n) = v.parse::<i64>() {
539                            return mnumber {
540                                l: n,
541                                d: 0.0,
542                                type_: MN_INTEGER,
543                            };
544                        }
545                        if let Ok(f) = v.parse::<f64>() {
546                            return mnumber {
547                                l: 0,
548                                d: f,
549                                type_: MN_FLOAT,
550                            };
551                        }
552                    }
553                }
554            }
555            return mnumber {
556                l: 0,
557                d: 0.0,
558                type_: MN_INTEGER,
559            };
560        }
561        // c:Src/params.c:2641-2645 getnumvalue — a param whose TYPE is
562        // integer or float is read through its typed getter
563        // (`v->pm->gsu.i->getfn(v->pm)` / `gsu.f->getfn`) and returned as
564        // an mnumber directly. Only c:2646-2647's default arm stringifies
565        // (`matheval(getstrvalue(v))`), which is the sole path where a
566        // scalar holding `0xff` or `3+2` gets re-lexed.
567        //
568        // The port inverted that: EVERY read went through `getsparam`,
569        // which for PM_INTEGER formats the value with `convbase`
570        // (params.rs:5930-5933) — so `typeset -i 16 x=255` produced the
571        // string "16#FF", which `parse::<i64>()` and `parse::<f64>()`
572        // both reject, and the value then went through a full recursive
573        // `matheval` of its own printed form. `pm->base` is an OUTPUT
574        // property in C (c:2370-2371, inside getstrvalue); it has no
575        // business in a numeric read, and `typeset -L`/`-Z` padding has
576        // none either.
577        //
578        // The gates below are getsparam's own, in getsparam's order, so
579        // this arm answers only for the plain visible param C's c:2641
580        // would have reached with an already-resolved Value; everything
581        // else falls through to the string path unchanged. They are
582        // predicates only — `lookup_special_var` is never called
583        // speculatively, because reading RANDOM advances its seed.
584        if let Ok(tab) = crate::ported::params::paramtab().read() {
585            if let Some(pm) = tab.get(base_name) {
586                let flags = pm.node.flags as u32;
587                // PM_SPECIAL: getsparam answers these from
588                // `lookup_special_var` (params.rs:5824), a name-keyed
589                // string dispatch with no integer counterpart.
590                // PM_NAMEREF: c:570-575 resolves the chain first
591                // (params.rs:5834). PM_UNSET: c:2335 reads as empty
592                // (params.rs:5913).
593                let plain = (flags
594                    & (crate::ported::zsh_h::PM_SPECIAL
595                        | crate::ported::zsh_h::PM_NAMEREF
596                        | crate::ported::zsh_h::PM_UNSET))
597                    == 0
598                    // c:Src/Modules/param_private.c:678 — a private param
599                    // belonging to an outer scope is invisible from a
600                    // deeper one; getsparam returns None (params.rs:5899).
601                    && !crate::ported::modules::param_private::getprivatenode(
602                        &**pm as *const crate::ported::zsh_h::param,
603                    )
604                    .is_null();
605                if plain {
606                    let t = PM_TYPE(flags);
607                    if t == PM_INTEGER {
608                        return mnumber {
609                            l: crate::ported::params::intgetfn(pm), // c:2642
610                            d: 0.0,
611                            type_: MN_INTEGER,
612                        };
613                    }
614                    if t == PM_EFLOAT || t == PM_FFLOAT {
615                        return mnumber {
616                            l: 0,
617                            d: crate::ported::params::floatgetfn(pm), // c:2645
618                            type_: MN_FLOAT,
619                        };
620                    }
621                }
622            }
623        }
624        if let Some(raw) = getsparam(base_name) {
625            // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash reads a variable in
626            // arithmetic with strtoimax over the WHOLE value (decimal, `0x` hex or
627            // `0` octal, blanks around it allowed); anything else, `1+2` included,
628            // is a fatal "Illegal number".
629            if crate::dash_mode::dash_faithful() {
630                return match crate::dash_mode::dash_arith_var(&raw) {
631                    Some(n) => mnumber { l: n, d: 0.0, type_: MN_INTEGER },
632                    None => {
633                        m_error_set(format!("Illegal number: {raw}"));
634                        mnumber { l: 0, d: 0.0, type_: MN_INTEGER }
635                    }
636                };
637            }
638            // c:Src/params.c:2639 matheval(getstrvalue(v)) — under OCTALZEROES a
639            // value with a leading zero (`010`) lexes as octal (c:489-512), so the
640            // decimal fast paths below must not claim it; matheval decides.
641            let octal_lead = m_octal_zeroes() && {
642                let t = raw.trim_start().trim_start_matches(['+', '-']);
643                t.len() > 1 && t.starts_with('0')
644            };
645            if !octal_lead {
646                if let Ok(n) = raw.parse::<i64>() {
647                    return mnumber {
648                        l: n,
649                        d: 0.0,
650                        type_: MN_INTEGER,
651                    };
652                }
653                if let Ok(f) = raw.parse::<f64>() {
654                    return mnumber {
655                        l: 0,
656                        d: f,
657                        type_: MN_FLOAT,
658                    };
659                }
660            }
661            // c:Src/math.c:337 getmathparam — falls back to recursively
662            // evaluating the raw string as an arith expression. zsh: a
663            // scalar holding `0xff` / `0b101` / `3+2` / `1e3` all evaluate
664            // when used in arith context. Direct path through `matheval`
665            // gives lexconstant + parser its full integer-base + float
666            // handling.
667            let saved = save_state();
668            let inherited_strs = saved.string_variables.clone();
669            new(&raw);
670            m_variables_set(saved.variables.clone());
671            let mut strs = inherited_strs;
672            strs.remove(base_name);
673            m_string_variables_set(strs);
674            m_prec_set(saved.prec);
675            m_c_precedences_set(saved.c_precedences);
676            let result = mathevall(prec_type::MPREC_TOP);
677            // c:Src/math.c::matheval — when the recursive eval errors
678            // (e.g. raw is "42xyz" with trailing junk), preserve the error
679            // message so it propagates to the outer arith caller instead
680            // of being clobbered by restore_state. zsh: `a="42xyz";
681            // $((a+1))` → "bad math expression: operator expected at
682            // `xyz'" rc=1. zshrs previously swallowed the error and
683            // returned 0 (then +1 = 1) silently. Bug #494.
684            let err_to_propagate = match &result {
685                Err(msg) => Some(msg.clone()),
686                Ok(_) => None,
687            };
688            restore_state(saved);
689            if let Ok(r) = result {
690                return r;
691            }
692            if let Some(msg) = err_to_propagate {
693                // c:2639 `return matheval(getstrvalue(v));` — the nested
694                // mathevall reports its own error with zerr (c:420 and friends)
695                // and sets errflag at the point of the read. Inside a running
696                // evaluation (`$(( x ))`, `let`) the port hands the message to
697                // the outer frame, which reports it. The compiled `(( x ))`
698                // path reads the operand through BUILTIN_GET_MATH_VAR with no
699                // evaluation open (mlevel 0), so nothing ever reported it and
700                // the statement returned 1 instead of 2.
701                if M_LEVEL.with(|c| c.get()) == 0 {
702                    crate::ported::utils::zerr(&msg);
703                } else {
704                    m_error_set(msg);
705                }
706            }
707            // Non-numeric and non-evaluable string: fall through.
708        }
709        // Recursive eval: if the var holds a non-numeric string, evaluate
710        // it AS an arith expression. zsh: `a="3+2"; $((a))` → 5. Bound
711        // to one level of indirection — fresh evaluator each call so we
712        // don't accidentally pollute s.variables.
713        if let Some(raw) = m_string_variables_get(base_name) {
714            // Save parent's eval state — `new(&raw)` resets thread_locals
715            // for the sub-eval, which would otherwise clobber the parent.
716            // Mirrors C `mathevall()` xyy* save/restore pattern (math.c:367).
717            let saved = save_state();
718            // Inherit caller's variables/string_variables/prec into the
719            // sub-eval, with `base_name` removed from the indirect map to
720            // prevent infinite recursion on `a="$a"`-style cycles.
721            let inherited_vars = saved.variables.clone();
722            let mut inherited_strs = saved.string_variables.clone();
723            inherited_strs.remove(base_name);
724            let inherited_prec = saved.prec;
725            let inherited_c_prec = saved.c_precedences;
726
727            new(&raw);
728            m_variables_set(inherited_vars);
729            m_string_variables_set(inherited_strs);
730            m_prec_set(inherited_prec);
731            m_c_precedences_set(inherited_c_prec);
732
733            let result = mathevall(prec_type::MPREC_TOP);
734            restore_state(saved);
735            if let Ok(r) = result {
736                return r;
737            }
738        }
739        // c:Src/math.c:345-346 — `if (unset(UNSET)) zerr("%s: parameter
740        // not set", mptr->lval);`. When `nounset` is set (i.e., the
741        // canonical `UNSET` option is OFF), referring to an unset
742        // parameter in arith context is an error. Bug #88 in
743        // docs/BUGS.md: zshrs silently used 0, masking typos and
744        // breaking defensive `set -u` scripts.
745        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::UNSET) {
746            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
747        }
748        mnumber {
749            l: 0,
750            d: 0.0,
751            type_: MN_INTEGER,
752        }
753    })();
754    if m_force_float() && __raw.type_ == MN_INTEGER {
755        // c:359-362 — coerce integer → float under FORCEFLOAT.
756        return mnumber {
757            l: 0,
758            d: __raw.l as f64,
759            type_: MN_FLOAT,
760        };
761    }
762    __raw
763}
764
765/// Evaluate the expression
766/// Port of `mathevall()` from `Src/math.c:367` — C decl `mathevall(char *s, enum prec_type prec_tp, char **ep)`.
767/// WARNING: param names don't match C — Rust=() vs C=(s, prec_tp, ep)
768pub(crate) fn mathevall(prec_tp: prec_type) -> Result<mnumber, String> {
769    // c:Src/math.c — matheval reads `isset(CPRECEDENCES)` / `isset(FORCEFLOAT)`
770    // / `isset(OCTALZEROES)` live at its use sites (e.g. c:348, 359, 482). The
771    // zshrs port caches them in per-eval thread-locals for speed but never
772    // populated them, so `setopt forcefloat` / `cprecedences` / `octalzeroes`
773    // had no effect inside arithmetic. Sync the caches from the live options at
774    // each eval entry (the option can't change mid-expression).
775    m_c_precedences_set(crate::ported::zsh_h::isset(
776        crate::ported::zsh_h::CPRECEDENCES,
777    ));
778    m_force_float_set(crate::ported::zsh_h::isset(
779        crate::ported::zsh_h::FORCEFLOAT,
780    ));
781    m_octal_zeroes_set(crate::ported::zsh_h::isset(
782        crate::ported::zsh_h::OCTALZEROES,
783    ));
784    m_prec_set(if m_c_precedences() { &C_PREC } else { &Z_PREC });
785
786    // c:386/446 — `if (mlevel++)` … `if (--mlevel)` bracket the evaluator.
787    // The output radix is deliberately NOT reset here: C clears it only in
788    // `matheval` and only when `mlevel` is 0 (c:1486), i.e. exactly once per
789    // TOP-LEVEL expression. `mathevall` is re-entered for every nested
790    // evaluation — including the recursive re-eval of a scalar parameter whose
791    // value is itself a math expression (`j=8#62; $(( [#36] j ))`, getmathparam
792    // below) — so resetting here wiped the outer `[#36]` and printed base 10.
793    // That is what the C comment at c:1485 means by "maintain outputradix and
794    // outputunderscore across levels of evaluation".
795    let _mlevel = MathLevel::enter();
796
797    // c:Src/math.c — bound recursive re-evaluation. A scalar whose
798    // value references itself in arithmetic — `x=x`, `x="1+x"`, or a
799    // cycle `x=y; y=x` — makes getmathparam re-enter mathevall on the
800    // same value without end (getsparam returns the self-referential
801    // string, mathevall re-parses it, getmathparam resolves the var
802    // again …). Unbounded, that recursion overruns the stack and the
803    // whole shell dies with SIGBUS/SIGABRT rather than erroring. zsh
804    // caps `mlevel` and bails with a diagnostic; match it so the eval
805    // fails cleanly (0 result) instead of crashing. thefuck's config
806    // tripped this the moment the cmd-subst deadlock that used to mask
807    // it was fixed.
808    if M_LEVEL.with(|c| c.get()) > MAX_MLEVEL {
809        let expr = m_input_clone();
810        return Err(format!("math recursion limit exceeded: {}", expr.trim()));
811    }
812
813    // Skip leading whitespace and Nularg
814    while let Some(c) = peek() {
815        if c.is_whitespace() || c == '\u{e1a1}' {
816            advance();
817        } else {
818            break;
819        }
820    }
821
822    // c:1609-1613 — mathparse's "Handle empty input" shortcut applies only at
823    // TOPPREC; an empty MPREC_ARG operand falls into checkunary instead.
824    if prec_tp == prec_type::MPREC_TOP && m_pos() >= m_input_len() {
825        return Ok(mnumber {
826            l: 0,
827            d: 0.0,
828            type_: MN_INTEGER,
829        });
830    }
831
832    // c:411 — `mathparse(prec_tp == MPREC_TOP ? TOPPREC : ARGPREC);`
833    // c:284-285 — `#define TOPPREC (prec[COMMA]+1)` / `#define ARGPREC (prec[COMMA]-1)`
834    mathparse(if prec_tp == prec_type::MPREC_TOP {
835        top_prec()
836    } else {
837        m_prec()[COMMA as usize] - 1
838    });
839
840    if let Some(err) = m_error_take() {
841        return Err(err);
842    }
843
844    // c:410-416 —
845    //   Internally, we parse the contents of parentheses at top
846    //   precedence... so we can return a parenthesis here if
847    //   there are too many at the end.
848    //   if (mtok == M_OUTPAR && !errflag)
849    //       zerr("bad math expression: unexpected ')'");
850    // The trailing-character scan below inspects the INPUT cursor, which
851    // is already past the `)` that `mathparse` lexed into `mtok`, so an
852    // unbalanced close was silently accepted (`foo="3)"; $((foo))` → 3).
853    if m_mtok() == M_OUTPAR {
854        return Err("bad math expression: unexpected ')'".to_string());
855    }
856
857    // Check for trailing characters
858    // !!! WARNING: this is matheval's c:1497-1500 `*junk` check, hoisted into
859    // mathevall because the Rust mathevall has no `char **ep` out-param. C's
860    // mathevalarg (c:1541) hands `*ss` back to getarg WITHOUT a junk check, so
861    // `${arr[1@]}` is element 1 — gate the check on MPREC_TOP to keep that.
862    while let Some(c) = peek().filter(|_| prec_tp == prec_type::MPREC_TOP) {
863        if c.is_whitespace() {
864            advance();
865        } else if c == ')' {
866            // zsh's specific wording for the unmatched-close
867            // case: `bad math expression: unexpected ')'`.
868            return Err("bad math expression: unexpected ')'".to_string());
869        } else {
870            // c:1498-1499 — `if (*junk) zerr("bad math expression:
871            // illegal character: %c", *junk);`
872            return Err(format!("bad math expression: illegal character: {}", c));
873        }
874    }
875
876    if m_stack_is_empty() {
877        return Ok(mnumber {
878            l: 0,
879            d: 0.0,
880            type_: MN_INTEGER,
881        });
882    }
883
884    let mv = m_stack_pop().unwrap();
885    let result = if (mv.val.type_ == MN_UNSET) {
886        if let Some(ref name) = mv.lval {
887            getmathparam(name)
888        } else {
889            mnumber {
890                l: 0,
891                d: 0.0,
892                type_: MN_INTEGER,
893            }
894        }
895    } else {
896        mv.val
897    };
898
899    // c:Src/math.c:425-444 — `if (errflag) { ret = 0; }` and the
900    // caller checks `errflag` externally to detect failure. The Rust
901    // port carries the error in the Result instead of a side-channel
902    // errflag, so any m_error_set inside getmathparam (e.g. the
903    // recursive-eval arm at math.rs:384 for `a="/usr/bin"; (( a ))`)
904    // must surface as Err here rather than being swallowed by the
905    // unconditional Ok(result) below. Without this check, scalar
906    // params whose values fail recursive math parsing silently
907    // resolved to 0, breaking `${(t)assoc[NAME]}` parity where C's
908    // bracket-eval relies on the substituted name's value to
909    // trigger "bad math expression".
910    if let Some(err) = m_error_take() {
911        return Err(err);
912    }
913    Ok(result)
914}
915
916/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
917/// C math.c:856 calls `getkeystring(ptr, NULL, GETKEYS_MATH, &v)` — the
918/// shared 200-line key-string decoder run in GETKEY_SINGLE_CHAR mode
919/// (decode exactly ONE char, report bytes consumed). zshrs's
920/// `getkeystring_with` loops the whole string and has no single-char
921/// mode, and the math lexer advances a char cursor (not a byte ptr), so
922/// this small adapter decodes just the one escaped char at the cursor
923/// and returns (code, chars-consumed). It mirrors the GETKEYS_MATH flag
924/// set (OCTAL_ESC | EMACS | CTRL). Allowlisted in fake_fn_allowlist.txt.
925fn decode_math_keychar(s: &str) -> Option<(i64, usize)> {
926    let cs: Vec<char> = s.chars().collect();
927    // One pass of `getkeystring`'s per-char body (c:Src/utils.c:6990-7260)
928    // for GETKEYS_MATH, minus the `\C` / `\M` / `^` modifier arms, which loop
929    // below. Returns (code, chars used); `used == 0` flags the multibyte early
930    // return (c:7198-7206), where no modifier applies and one char is consumed.
931    let decode_one = |cs: &[char]| -> Option<(i64, usize)> {
932        if cs.is_empty() {
933            return None;
934        }
935        if cs[0] != '\\' {
936            // c:Src/utils.c:7198-7207 — the wide-character arm is reached ONLY
937            // when `isset(MULTIBYTE)`; a byte above 127 otherwise falls past it.
938            // c:7209-7210 — `else if (*s == Meta) *t++ = *++s ^ 32;` decodes the
939            // escaped byte, and c:7211 takes any remaining byte as itself. So
940            // `##` answers a CHARACTER code in multibyte mode and a BYTE value in
941            // byte mode: `unsetopt multibyte; $(( ##${gr[1]} ))` is 206 (0xce),
942            // not 945 (`α`).
943            if cs[0] == char::from(crate::ported::zsh_h::Meta) {
944                if let Some(&n) = cs.get(1) {
945                    return Some((((n as u32) ^ 32) as i64, 2));
946                }
947            }
948            let mb = crate::ported::options::opt_state_get("multibyte").unwrap_or(true);
949            if !mb && (cs[0] as u32) > 127 {
950                // c:7211 — one raw byte, which for text held as UTF-8 is the
951                // lead byte of the character at the cursor.
952                let mut buf = [0u8; 4];
953                return Some((cs[0].encode_utf8(&mut buf).as_bytes()[0] as i64, 1));
954            }
955            if (cs[0] as u32) > 127 {
956                // c:7198-7206 — MULTIBYTE wide char: `*misc = wc; return`.
957                return Some((cs[0] as i64, 0));
958            }
959            return Some((cs[0] as i64, 1));
960        }
961        // `\X` escape — `\` plus at least one more char.
962        let e = match cs.get(1) {
963            Some(c) => *c,
964            None => return Some(('\\' as i64, 1)),
965        };
966        let simple = |code: i64| Some((code, 2));
967        match e {
968            'n' => simple(10),
969            't' => simple(9),
970            'r' => simple(13),
971            'e' | 'E' => simple(27),
972            'a' => simple(7),
973            'b' => simple(8),
974            'f' => simple(12),
975            'v' => simple(11),
976            '\\' => simple(92),
977            '0'..='7' => {
978                // \NNN octal (GETKEY_OCTAL_ESC), up to 3 digits.
979                let mut val: i64 = 0;
980                let mut n = 0;
981                while n < 3 {
982                    match cs.get(1 + n) {
983                        Some(c @ '0'..='7') => {
984                            val = val * 8 + (*c as i64 - '0' as i64);
985                            n += 1;
986                        }
987                        _ => break,
988                    }
989                }
990                Some((val, 1 + n))
991            }
992            'x' => {
993                // \xNN hex, up to 2 digits.
994                let mut val: i64 = 0;
995                let mut n = 0;
996                while n < 2 {
997                    match cs.get(2 + n).and_then(|c| c.to_digit(16)) {
998                        Some(d) => {
999                            val = val * 16 + d as i64;
1000                            n += 1;
1001                        }
1002                        None => break,
1003                    }
1004                }
1005                if n == 0 {
1006                    Some(('x' as i64, 2))
1007                } else {
1008                    Some((val, 2 + n))
1009                }
1010            }
1011            // c:7064-7071 `case 'c'` — GETKEYS_MATH lacks GETKEY_BACKSLASH_C,
1012            // so `goto def`, and c:7180-7185 (GETKEY_EMACS) emit the char
1013            // itself: `##\ca` is `c` followed by a stray `a`, not a control
1014            // char. `\C` / `\M` are modifiers, handled in the loop below.
1015            other => simple(other as i64),
1016        }
1017    };
1018    // c:Src/utils.c:6920 — `int meta = 0, control = 0`. `\C-`, `\M-` and a
1019    // bare `^` only set these and `continue` to the next input char; the
1020    // char that follows is decoded, then c:7261-7275 apply them in C's
1021    // order (meta-before-control when `\M` came after `^`, i.e. meta == 2).
1022    let mut meta = 0;
1023    let mut control = false;
1024    let mut i = 0;
1025    loop {
1026        let rest = cs.get(i..).unwrap_or(&[]);
1027        if rest.is_empty() {
1028            // c:7314-7317 — ran out of input: "couldn't find a character".
1029            return None;
1030        }
1031        match rest[0] {
1032            // c:7041-7052 `case 'C'` (GETKEY_EMACS) — optional dash, then
1033            // `control = 1; continue`.
1034            '\\' if rest.get(1) == Some(&'C') => {
1035                i += if rest.get(2) == Some(&'-') { 3 } else { 2 };
1036                control = true;
1037                continue;
1038            }
1039            // c:7029-7040 `case 'M'` — `meta = 1 + control` preserves the
1040            // order of `^` and meta.
1041            '\\' if rest.get(1) == Some(&'M') => {
1042                i += if rest.get(2) == Some(&'-') { 3 } else { 2 };
1043                meta = 1 + control as i32;
1044                continue;
1045            }
1046            // c:7194-7196 — `*s == '^' && !control && (how & GETKEY_CTRL)
1047            // && s[1]`: `^X` is a control char.
1048            '^' if !control && rest.len() > 1 => {
1049                i += 1;
1050                control = true;
1051                continue;
1052            }
1053            _ => {}
1054        }
1055        let (mut code, used) = decode_one(rest)?;
1056        if used == 0 {
1057            // c:7198-7206 — the multibyte arm returns straight away, so a
1058            // pending `^` / `\C` / `\M` never reaches a wide character.
1059            return Some((code, i + 1));
1060        }
1061        i += used;
1062        if meta == 2 {
1063            code |= 0x80; // c:7261-7264
1064            meta = 0;
1065        }
1066        if control {
1067            // c:7265-7271
1068            code = if code == '?' as i64 { 0x7f } else { code & 0x9f };
1069        }
1070        if meta != 0 {
1071            code |= 0x80; // c:7272-7275
1072        }
1073        return Some((code, i));
1074    }
1075}
1076
1077/// Port of `lexconstant()` from `Src/math.c:462` — C decl `lexconstant(void)`.
1078///
1079/// Lex a numeric constant — decimal/hex/binary/octal integer or
1080/// floating-point literal. Sets `m_yyval()` and returns
1081/// `NUM`. Recognises `0x`/`0b` prefixes, base-prefix
1082/// (`16#FF`), trailing-dot float, scientific notation, and zsh's
1083/// underscore digit-grouping. Mirrors C's `zstrtol_underscore()`
1084/// for greedy base parsing (consume valid digits only, leave the
1085/// rest as the next token).
1086pub(crate) fn lexconstant() -> i32 {
1087    let _start = m_pos();
1088    let mut is_neg = false;
1089
1090    // Handle leading minus for unary context
1091    if peek() == Some('-') {
1092        is_neg = true;
1093        advance();
1094    }
1095
1096    // Check for hex/binary/octal
1097    if peek() == Some('0') {
1098        advance();
1099        match peek().map(|c| c.to_ascii_lowercase()) {
1100            Some('x') => {
1101                // Hex: 0xFF
1102                advance();
1103                let hex_start = m_pos();
1104                while let Some(c) = peek() {
1105                    if c.is_ascii_hexdigit() || c == '_' {
1106                        advance();
1107                    } else {
1108                        break;
1109                    }
1110                }
1111                // c:Src/math.c lexconstant — zsh parses every integer base via
1112                // zstrtol, which truncates on overflow with a
1113                // "number truncated after N digits" warning (utils.c:2511).
1114                // i64::from_str_radix just errored to 0 on a >63-bit hex
1115                // literal (0xFFFFFFFFFFFFFFFF). Route through the port.
1116                // c:Src/utils.c:2511 — the warning prints `inp`, the rest of the
1117                // expression from the digits on, so hand zstrtol the whole tail
1118                // (underscores are its business, c:Src/math.c lexconstant).
1119                // !!! DASH-STRICT GATE (no C counterpart) !!! `0x` with no digits is
1120                // "expecting EOF" in dash.
1121                if crate::dash_mode::dash_strict() && hex_start == m_pos() {
1122                    m_error_set("bad math expression: operand expected".to_string());
1123                    return EOI;
1124                }
1125                let val = crate::ported::utils::zstrtol_underscore(&m_input_slice_from(hex_start), 16, true).0;
1126                m_lastbase_set(16);
1127                m_yyval_set(if m_force_float() {
1128                    mnumber {
1129                        l: 0,
1130                        d: if is_neg { -(val as f64) } else { val as f64 },
1131                        type_: MN_FLOAT,
1132                    }
1133                } else {
1134                    mnumber {
1135                        l: if is_neg { val.wrapping_neg() } else { val },
1136                        d: 0.0,
1137                        type_: MN_INTEGER,
1138                    }
1139                });
1140                return NUM;
1141            }
1142            // !!! DASH-STRICT GATE !!! `0b` binary literals are a zsh/bash(4+)
1143            // extension; real dash/ash reject them (POSIX has only decimal /
1144            // `0` octal / `0x` hex). Under --dash/--ash the `b` is left
1145            // unconsumed so `0b101` errors on the stray `b`, matching dash.
1146            Some('b') if !crate::dash_mode::dash_strict() => {
1147                // Binary: 0b1010
1148                advance();
1149                // c:Src/math.c:468 — `zstrtol_underscore(ptr, &ptr, 0, 1)`; its
1150                // truncation warning quotes the rest of the expression
1151                // (c:Src/utils.c:2511), not just the digit run.
1152                // `ptr` is still at the `0`: back over the `0b` just consumed.
1153                let const_start = m_pos() - 2;
1154                let tail = m_input_slice_from(const_start);
1155                let (val, rest) = crate::ported::utils::zstrtol_underscore(&tail, 0, true);
1156                m_pos_set(const_start + tail.len() - rest.len());
1157                m_lastbase_set(2);
1158                m_yyval_set(if m_force_float() {
1159                    mnumber {
1160                        l: 0,
1161                        d: if is_neg { -(val as f64) } else { val as f64 },
1162                        type_: MN_FLOAT,
1163                    }
1164                } else {
1165                    mnumber {
1166                        l: if is_neg { val.wrapping_neg() } else { val },
1167                        d: 0.0,
1168                        type_: MN_INTEGER,
1169                    }
1170                });
1171                return NUM;
1172            }
1173            Some('o') | Some('O') => {
1174                // zsh rejects `0o…` octal-prefix (Rust/Python form).
1175                // Only `0x` (hex), `0b` (binary), and bare-leading-0
1176                // (with `setopt octalzeroes`) are recognized. Emit
1177                // the same diagnostic zsh produces — set s.error
1178                // and return a zero Num so the caller's
1179                // error-propagation path picks up the failure.
1180                m_error_set(format!(
1181                    "bad math expression: operator expected at `{}'",
1182                    m_input_slice_from(m_pos())
1183                ));
1184                m_yyval_set(mnumber {
1185                    l: 0,
1186                    d: 0.0,
1187                    type_: MN_INTEGER,
1188                });
1189                return NUM;
1190            }
1191            _ => {
1192                // Could be octal or just 0
1193                if m_octal_zeroes() {
1194                    // c:Src/math.c:489-512 — OCTALZEROES enabled.
1195                    // C scans all digits then calls
1196                    // `zstrtol_underscore(ptr, &ptr, 0, 1)` with base 0;
1197                    // strtol's base-0 octal mode stops at the first
1198                    // invalid octal digit (8 or 9), so the leftover
1199                    // digit is seen by the outer parser and produces
1200                    // "operator expected at `N'".
1201                    //
1202                    // To match: scan VALID octal digits (0-7) +
1203                    // underscores, STOP at 8/9, then emit NUM. Do NOT
1204                    // roll back over the 8/9 — it stays in the input
1205                    // for the outer parser.
1206                    //
1207                    // `.`/`e`/`E`/`#` (c:501) disqualify the whole
1208                    // number — fall through to decimal/float by
1209                    // rewinding to before the leading 0.
1210                    let oct_start = m_pos();
1211                    let mut is_float_or_base = false;
1212                    let mut hit_invalid_octal = false;
1213                    // First peek-ahead: scan all digits to detect the
1214                    // terminator type. This matches C's `for (ptr2 = nptr;
1215                    // idigit(*ptr2) || *ptr2 == '_'; ptr2++)` peek.
1216                    let mut probe = oct_start;
1217                    let input = m_input_clone();
1218                    while let Some(&b) = input.as_bytes().get(probe) {
1219                        if (b as char).is_ascii_digit() || b == b'_' {
1220                            if b == b'8' || b == b'9' {
1221                                hit_invalid_octal = true;
1222                            }
1223                            probe += 1;
1224                        } else {
1225                            if b == b'.' || b == b'e' || b == b'E' || b == b'#' {
1226                                is_float_or_base = true;
1227                            }
1228                            break;
1229                        }
1230                    }
1231                    if is_float_or_base || probe == oct_start {
1232                        // c:Src/math.c:489 — the octal branch is gated on
1233                        // `ptr2 > nptr && *ptr2 != '.'/'e'/'E'/'#'`. `nptr`
1234                        // is the char AFTER the leading `0`, so `ptr2 > nptr`
1235                        // requires at least one further digit: a bare single
1236                        // `0` (probe == oct_start) is NOT octal notation and
1237                        // must NOT set lastbase=8. Without this, `integer c=0`
1238                        // under `setopt octalzeroes` (emulate sh) displayed
1239                        // `8#0` instead of `0`. `.`/`e`/`E`/`#` after digits
1240                        // (is_float_or_base) likewise falls through to
1241                        // decimal/float.
1242                        m_pos_sub(1); // rewind over leading 0
1243                    } else {
1244                        // Octal path. Advance over valid octal digits
1245                        // only (0-7) + underscores; stop at 8/9.
1246                        while let Some(c) = peek() {
1247                            if ('0'..='7').contains(&c) || c == '_' {
1248                                advance();
1249                            } else {
1250                                break;
1251                            }
1252                        }
1253                        let oct_str: String = m_input_slice(oct_start, m_pos())
1254                            .chars()
1255                            .filter(|&c| c != '_')
1256                            .collect();
1257                        let val = if oct_str.is_empty() {
1258                            0 // c:zstrtol leading-0-only → value 0
1259                        } else {
1260                            crate::ported::utils::zstrtol(&oct_str, 8).0 // c:zstrtol base 8
1261                        };
1262                        let _ = hit_invalid_octal; // implicit via leftover digit
1263                        m_lastbase_set(8);
1264                        m_yyval_set(if m_force_float() {
1265                            mnumber {
1266                                l: 0,
1267                                d: if is_neg { -(val as f64) } else { val as f64 },
1268                                type_: MN_FLOAT,
1269                            }
1270                        } else {
1271                            mnumber {
1272                                l: if is_neg { val.wrapping_neg() } else { val },
1273                                d: 0.0,
1274                                type_: MN_INTEGER,
1275                            }
1276                        });
1277                        return NUM;
1278                    }
1279                } else {
1280                    // Put back the 0 — fall through to decimal parser.
1281                    m_pos_sub(1);
1282                }
1283            }
1284        }
1285    }
1286
1287    // Parse decimal integer or float
1288    let num_start = m_pos();
1289    while let Some(c) = peek() {
1290        if is_digit(c) || c == '_' {
1291            advance();
1292        } else {
1293            break;
1294        }
1295    }
1296
1297    // !!! BASH-MODE (no C counterpart) !!! bash arithmetic is integer-only:
1298    // a `.` after a number is "invalid arithmetic operator (error token is
1299    // ".5+1")", never a float constant.
1300    if peek() == Some('.') && crate::dash_mode::bash_mode() {
1301        let tok = m_input_slice_from(m_pos());
1302        m_error_set(format!(
1303            "arithmetic syntax error: invalid arithmetic operator (error token is \"{tok}\")"
1304        ));
1305        return EOI;
1306    }
1307    // Check for float
1308    if peek() == Some('.') || peek() == Some('e') || peek() == Some('E') {
1309        // Float
1310        if peek() == Some('.') {
1311            advance();
1312            while let Some(c) = peek() {
1313                if is_digit(c) || c == '_' {
1314                    advance();
1315                } else {
1316                    break;
1317                }
1318            }
1319        }
1320        if peek() == Some('e') || peek() == Some('E') {
1321            advance();
1322            if peek() == Some('+') || peek() == Some('-') {
1323                advance();
1324            }
1325            while let Some(c) = peek() {
1326                if is_digit(c) || c == '_' {
1327                    advance();
1328                } else {
1329                    break;
1330                }
1331            }
1332        }
1333        // c:536-548 — an `_` inside the scanned constant makes C lex on in a
1334        // copy of the input with every `_` of [ptr, nptr) chucked out:
1335        //     ptr = dupstring(ptr); for (...) if (*ptr2 == '_') chuck(ptr2);
1336        let scan_end = m_pos();
1337        let seg = m_input_slice(num_start, scan_end);
1338        if seg.contains('_') {
1339            let input = m_input_clone();
1340            let stripped: String = seg.chars().filter(|&c| c != '_').collect();
1341            m_input_set(format!("{}{}{}", &input[..num_start], stripped, &input[scan_end..]));
1342        }
1343        // c:550 — `yyval.u.d = strtod(ptr, &nptr);` strtod consumes only the
1344        // longest valid prefix: an exponent with no digits after it (`1e`,
1345        // `1.5e+`) is not part of the number, so `ptr` stops at the `e` and the
1346        // parser then reports "operator expected at `e …'". The scan above
1347        // (c:524-535) is only a bound; strtod decides.
1348        let text = m_input_slice_from(num_start);
1349        let b = text.as_bytes();
1350        let mut n = 0usize;
1351        while n < b.len() && b[n].is_ascii_digit() {
1352            n += 1;
1353        }
1354        let int_end = n;
1355        if n < b.len() && b[n] == b'.' {
1356            n += 1;
1357            while n < b.len() && b[n].is_ascii_digit() {
1358                n += 1;
1359            }
1360        }
1361        let mant_end = n;
1362        if int_end == 0 && mant_end <= 1 {
1363            n = 0; // no mantissa digit: strtod consumes nothing
1364        } else if n < b.len() && (b[n] == b'e' || b[n] == b'E') {
1365            let mut m = n + 1;
1366            if m < b.len() && (b[m] == b'+' || b[m] == b'-') {
1367                m += 1;
1368            }
1369            let exp_digits = m;
1370            while m < b.len() && b[m].is_ascii_digit() {
1371                m += 1;
1372            }
1373            if m > exp_digits {
1374                n = m;
1375            }
1376        }
1377        // c:552-559 — right after strtod:
1378        //     if (ptr == nptr || *nptr == '.') {
1379        //         zerr("bad floating point constant");
1380        //         return EOI;
1381        //     }
1382        // The `*nptr == '.'` half: a SECOND dot immediately after the
1383        // constant is fatal at LEX time, so `1.2.3` is a malformed constant,
1384        // not the float 1.2 followed by `.3`.
1385        if n == 0 || b.get(n) == Some(&b'.') {
1386            m_error_set("bad floating point constant".to_string()); // c:557
1387            return EOI; // c:558
1388        }
1389        // Rust's parser wants digits on both sides of the dot.
1390        let int_part = if int_end == 0 { "0" } else { &text[..int_end] };
1391        let frac_part = if mant_end > int_end + 1 { &text[int_end + 1..mant_end] } else { "0" };
1392        let val: f64 = format!("{}.{}{}", int_part, frac_part, &text[mant_end..n])
1393            .parse()
1394            .unwrap_or(0.0);
1395        m_pos_set(num_start + n); // c:560 `ptr = nptr;`
1396        m_yyval_set(mnumber {
1397            l: 0,
1398            d: if is_neg { -val } else { val },
1399            type_: MN_FLOAT,
1400        });
1401        return NUM;
1402    }
1403
1404    // Check for base#value syntax (e.g., 16#FF)
1405    // !!! DASH-STRICT GATE (no C counterpart) !!! real dash/ash have POSIX
1406    // arithmetic only (decimal / `0` octal / `0x` hex); `base#num` is a
1407    // zsh/bash/ksh extension they reject. Under `zshrs --dash`/`--ash` do NOT
1408    // consume `#` as a base separator — the parser then hits the stray `#` and
1409    // errors like the real shell. --sh (bash-family) and --ksh keep accepting.
1410    if peek() == Some('#') && !crate::dash_mode::dash_strict() {
1411        advance();
1412        let base_str: String = m_input_slice(num_start, m_pos() - 1)
1413            .chars()
1414            .filter(|&c| c != '_')
1415            .collect();
1416        let base: u32 = base_str.parse().unwrap_or(10);
1417        // zsh: `1#X` errors with "invalid base (must be 2 to 36 inclusive)".
1418        // i64::from_str_radix panics on out-of-range base; reject early.
1419        if !(2..=36).contains(&base) {
1420            m_error_set(format!(
1421                "invalid base (must be 2 to 36 inclusive): {}",
1422                base
1423            ));
1424            m_yyval_set(mnumber {
1425                l: 0,
1426                d: 0.0,
1427                type_: MN_INTEGER,
1428            });
1429            return NUM;
1430        }
1431        m_lastbase_set(base as i32);
1432
1433        // Mirror zsh's `zstrtol_underscore(ptr, &ptr, base, 1)`
1434        // semantics: consume ONLY chars valid for the base
1435        // (greedy), stopping at the first invalid digit.
1436        // Underscore-as-thousands-separator is allowed
1437        // mid-number. The remaining input becomes the next
1438        // token, which the parser will then trip on as
1439        // "operator expected at `<rest>'" via the regular
1440        // checkunary/parser path.
1441        //
1442        // Earlier version used Rust's `from_str_radix` which
1443        // is all-or-nothing — a single bad digit nuked the
1444        // entire literal. For `2#1011x` zsh consumes the
1445        // valid `1011` (= 11) and errors on the trailing `x`;
1446        // ours errored on the whole `1011x` as one chunk.
1447        // Same for `2#10112` (zsh: at `2`, ours: at `10112`).
1448        //
1449        // Empty-digit-sequence case (`10#`, `2#`) silently
1450        // yields 0, matching zsh's `zstrtol` returning 0 when
1451        // no valid digits follow.
1452        // c:595-596 — `yyval.u.l = zstrtol_underscore(ptr, &ptr, lastbase, 1);`
1453        // zstrtol owns the digit rules, the `_` skipping and the
1454        // "number truncated after N digits" warning (c:Src/utils.c:2511),
1455        // which a saturating accumulator never raised.
1456        let tail = m_input_slice_from(m_pos());
1457        let (val, rest) = crate::ported::utils::zstrtol_underscore(&tail, base as i32, true);
1458        m_pos_add(tail.len() - rest.len());
1459        m_yyval_set(if m_force_float() {
1460            mnumber {
1461                l: 0,
1462                d: if is_neg { -(val as f64) } else { val as f64 },
1463                type_: MN_FLOAT,
1464            }
1465        } else {
1466            mnumber {
1467                l: if is_neg { val.wrapping_neg() } else { val },
1468                d: 0.0,
1469                type_: MN_INTEGER,
1470            }
1471        });
1472        return NUM;
1473    }
1474
1475    // Plain integer
1476    let int_str: String = m_input_slice(num_start, m_pos())
1477        .chars()
1478        .filter(|&c| c != '_')
1479        .collect();
1480    // c:Src/utils.c:2466-2515 zstrtol — accept overflow with truncation and a
1481    // `"number truncated after N digits"` warning rather than silently
1482    // producing 0. The fast i64 path covers everything up to i64::MAX; past
1483    // it, DELEGATE to the faithful zstrtol port (same as the hex branch above
1484    // at c:785) instead of a hardcoded 18-digit cut. zstrtol accumulates the
1485    // magnitude in a u64 and truncates ONLY when the unsigned multiply
1486    // overflows (19 digits for a 20+-digit run), then reinterprets the retained
1487    // u64 as signed — so `99999999999999999999` wraps to `-8446744073709551617`
1488    // (19 digits), while the fit-in-u64-but-not-i64 band (`9999999999999999999`)
1489    // hits the signed-overflow special case at 18 digits. The old `[..18]` slice
1490    // was one digit short on both counts. Bug #350; mirrors builtin.rs
1491    // parse_int_arg for #258.
1492    let val: i64 = match int_str.parse::<i64>() {
1493        Ok(n) => n,
1494        // !!! BASH-MODE (no C counterpart) !!! bash accumulates decimal
1495        // literals in intmax_t with wraparound and no warning, so
1496        // `$((9223372036854775808))` is INT64_MIN and `$((-9223372036854775808))`
1497        // is INT64_MIN too (zsh truncates to 18 digits with a warning).
1498        Err(_)
1499            if crate::dash_mode::bash_mode()
1500                && !int_str.is_empty()
1501                && int_str.chars().all(|c| c.is_ascii_digit()) =>
1502        {
1503            int_str
1504                .bytes()
1505                .fold(0i64, |a, d| a.wrapping_mul(10).wrapping_add((d - b'0') as i64))
1506        }
1507        Err(_) if !int_str.is_empty() && int_str.chars().all(|c| c.is_ascii_digit()) => {
1508            // zstrtol emits the "number truncated after N digits" warning itself.
1509            // c:Src/utils.c:2511 — the warning prints `inp`, the rest of
1510            // the expression from the digits on, not just the digit run.
1511            crate::ported::utils::zstrtol_underscore(&m_input_slice_from(num_start), 10, true).0
1512        }
1513        Err(_) => 0,
1514    };
1515    m_yyval_set(if m_force_float() {
1516        mnumber {
1517            l: 0,
1518            d: if is_neg { -(val as f64) } else { val as f64 },
1519            type_: MN_FLOAT,
1520        }
1521    } else {
1522        mnumber {
1523            l: if is_neg { val.wrapping_neg() } else { val },
1524            d: 0.0,
1525            type_: MN_INTEGER,
1526        }
1527    });
1528    NUM
1529}
1530
1531// ===========================================================
1532// Small C helpers from Src/math.c used internally by the evaluator
1533// below — bodies wire to existing Rust idioms while preserving the C
1534// name + citation.
1535// ===========================================================
1536
1537/// Port of `isinf()` from `Src/math.c:588` — C decl `isinf(double x)` — IEEE +/-Infinity test.
1538/// Wraps Rust's `f64::is_infinite`.
1539/// WARNING: param names don't match C — Rust=() vs C=(x)
1540pub(crate) fn isinf(x: f64) -> bool {
1541    x.is_infinite()
1542}
1543
1544/// Port of `isnan()` from `Src/math.c:608` — C decl `isnan(double x)` — IEEE NaN test. C
1545/// implements it as `store(&x) != store(&x)` to defeat compiler
1546/// folding of the canonical `x != x` NaN test; we route through
1547/// `store` for parity, but Rust's `f64::is_nan` is the
1548/// correctness path.
1549/// WARNING: param names don't match C — Rust=() vs C=(x)
1550pub(crate) fn isnan(x: f64) -> bool {
1551    store(x) != store(x) || x.is_nan()
1552}
1553
1554/// Port of `notzero()` from `Src/math.c:1142` — C decl `notzero(mnumber a)` — error-on-zero check
1555/// used by `/` and `%` operators. Returns true when `a` is non-
1556/// zero (caller continues), false when zero (caller raises
1557/// "division by zero"). Float zero is treated as non-zero per
1558/// IEEE 754 (1/0.0 → Inf, not an error) — only integer zero
1559/// trips the check, matching math.c's `if (!a.u.l) zerr(…)`.
1560/// WARNING: param names don't match C — Rust=() vs C=(a)
1561pub(crate) fn notzero(a: mnumber) -> bool {
1562    if (a.type_ == MN_UNSET) {
1563        return false;
1564    }
1565    if (a.type_ == MN_INTEGER) {
1566        return a.l != 0;
1567    }
1568    true
1569}
1570
1571// ============================================================
1572// Module-level math statics — direct port of Src/math.c globals.
1573//
1574// math.c declares each of these at file scope:
1575//   int noeval;                         // line 40
1576//   mnumber zero_mnumber;               // line 45
1577//   mnumber lastmathval;                // line 53
1578//   int lastbase;                       // line 58
1579//   static char *ptr;                   // line 60
1580//   static mnumber yyval;               // line 62
1581//   static char *yylval;                // line 63
1582//   static int mlevel = 0;              // line 67
1583//   static int unary = 1;               // line 71
1584//   static struct mathvalue *stack;     // (math.c body)
1585//   ... and a few derived from option flags (force_float, etc.).
1586//
1587// Rust port: thread_local!<Cell|RefCell<T>> per global. `mathevall`
1588// (math.c:367) saves these to its own locals (`xyyval`, `xyylval`,
1589// `xunary`, etc.) on entry and restores on exit so recursive math
1590// calls (function-arg eval, indirect string eval) don't clobber
1591// the outer evaluator's state.
1592//
1593// Cell for Copy types (i64/i32/usize/bool/mnumber/i32/&'static
1594// slice). RefCell for owned/non-Copy (String, Vec, HashMap, Option).
1595// ============================================================
1596
1597thread_local! {
1598    /// `mnumber lastmathval` (math.c:53) — result of the most recent
1599    /// top-level `matheval`. Read by callmathfunc's MFF_USERFUNC branch
1600    /// (math.c:1115 `return lastmathval`): a `functions -M` math function
1601    /// communicates its result via the last `(( ))` in its body.
1602    pub(crate) static M_LASTMATHVAL: Cell<mnumber> = const { Cell::new(mnumber { l: 0, d: 0.0, type_: MN_INTEGER }) };
1603    /// `static char *ptr` — current input cursor. Owned String in Rust
1604    /// (vs C's caller-owned char*) so the thread_local isn't a borrow.
1605    static M_INPUT: RefCell<String> = const { RefCell::new(String::new()) };
1606    /// Byte offset into `M_INPUT` of the next char to lex.
1607    static M_POS: Cell<usize> = const { Cell::new(0) };
1608    /// Byte offset where the current token began (post-whitespace).
1609    /// Used to format zsh-style "at `<remaining>'" error pointers.
1610    static M_TOK_START: Cell<usize> = const { Cell::new(0) };
1611    /// `static mnumber yyval` (math.c:62) — value lexed by zzlex.
1612    static M_YYVAL: Cell<mnumber> = const { Cell::new(mnumber { l: 0, d: 0.0, type_: MN_INTEGER }) };
1613    /// `static char *yylval` (math.c:63) — identifier or function-call
1614    /// text lexed by zzlex (caller side reads via `M_YYLVAL.with(...)`).
1615    static M_YYLVAL: RefCell<String> = const { RefCell::new(String::new()) };
1616    /// `static struct mathvalue *stack` — operand stack for the
1617    /// shunting-yard evaluator. Mirrors C's heap-grown array.
1618    static M_STACK: RefCell<Vec<mathvalue >> = const { RefCell::new(Vec::new()) };
1619    /// `int mtok` — current token tag set by zzlex.
1620    static M_MTOK: Cell<i32> = const { Cell::new(EOI) };
1621    /// `static int unary` (math.c:71) — 1 when the parser is expecting
1622    /// an operand (so `+`/`-` mean unary plus/minus).
1623    static M_UNARY: Cell<bool> = const { Cell::new(true) };
1624    // nonzero means we are not evaluating, just parsing                    // c:37
1625    /// `int noeval` (math.c:40) — non-zero when in the parse-only side
1626    /// of `&&`/`||`/ternary; suppresses side-effects.
1627    static M_NOEVAL: Cell<i32> = const { Cell::new(0) };                    // c:40
1628    // last input base we used                                              // c:55
1629    /// `int lastbase` (math.c:58) — base of the last numeric literal
1630    /// (set by lexconstant, used by `$((…))` formatting).
1631    static M_LASTBASE: Cell<i32> = const { Cell::new(-1) };                 // c:58
1632    /// `int *prec` — active precedence table (Z_PREC or C_PREC).
1633    static M_PREC: Cell<&'static [u8; TOKCOUNT]> = const { Cell::new(&Z_PREC) };
1634    /// `setopt CPRECEDENCES` mirror.
1635    static M_C_PRECEDENCES: Cell<bool> = const { Cell::new(false) };
1636    /// `setopt FORCEFLOAT` mirror.
1637    static M_FORCE_FLOAT: Cell<bool> = const { Cell::new(false) };
1638    /// `setopt OCTALZEROES` mirror.
1639    static M_OCTAL_ZEROES: Cell<bool> = const { Cell::new(false) };
1640    /// Counterpart of C's per-`struct mathvalue` value cache,
1641    /// `mptr->pval` (math.c:340-343) — a within-expression read cache
1642    /// keyed by name, so a second reference to `i` in one expression
1643    /// does not re-walk the parameter table.
1644    ///
1645    /// NOT a store. The canonical store is the parameter table, exactly
1646    /// as in C: `getmathparam` falls through to `getsparam`/paramtab on
1647    /// a miss (c:343 `getvalue`), and `setmathvar` always writes through
1648    /// `setnparam`/`assignsparam` (c:1005).
1649    ///
1650    /// Its lifetime is ONE evaluation, because in C the mathvalues that
1651    /// hold `pval` live on `stack`, which is the local `nstack[]` for
1652    /// the duration of a `mathevall` frame (c:406) and is put back to
1653    /// the caller's on exit (c:455). `matheval` / `mathevali_noeval`
1654    /// save and restore it around their frame for that reason; nested
1655    /// evaluations get the same from `restore_state`. Widening that
1656    /// lifetime lets a reader outside any evaluation (the VM's
1657    /// BUILTIN_GET_MATH_VAR) see a stale entry in preference to the
1658    /// real parameter.
1659    static M_VARIABLES: RefCell<HashMap<String, mnumber>> = RefCell::new(HashMap::new());
1660    /// Raw string values for variables whose contents aren't a plain
1661    /// number — recursively re-eval'd by `getmathparam` for
1662    /// `a="3+2"; $((a))` semantics.
1663    static M_STRING_VARIABLES: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
1664    /// `$?` — last command exit status, used by the `?` token in
1665    /// unary position.
1666    static M_LASTVAL: Cell<i32> = const { Cell::new(0) };
1667    /// `$$` — current process ID, lexed for the `$` token.
1668    static M_PID: Cell<i64> = const { Cell::new(0) };
1669    /// Error message accumulator. zsh C uses `setjmp`/`longjmp`; the
1670    /// Rust port returns errors via this Option then `mathevall`
1671    /// surfaces it as `Result::Err`.
1672    static M_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
1673    /// `int outputradix` (math.c:580) — output base for the result.
1674    /// Set by `[#N]` (positive N, with `N#` prefix) or `[##N]`
1675    /// (negative N, bare digits). Read by subst.rs's `$((…))`
1676    /// formatter at math.c:4493-4498.
1677    static M_OUTPUTRADIX: Cell<i32> = const { Cell::new(0) };               // c:580
1678    /// `int outputunderscore` (math.c:583) — group every N digits with
1679    /// `_` for readable hex/decimal output. Set by `[#N_M]` /
1680    /// `[##N_M]` / `[#_M]`. Read alongside outputradix.
1681    static M_OUTPUTUNDERSCORE: Cell<i32> = const { Cell::new(0) };          // c:583
1682    /// `static int mlevel` (math.c:67) — count of `mathevall` frames
1683    /// currently on the stack. C brackets the evaluator body with
1684    /// `if (mlevel++)` (c:386) / `if (--mlevel)` (c:446), so mlevel is 0
1685    /// exactly when no evaluation is in progress. `matheval` reads it to
1686    /// decide whether the output radix is a fresh one (c:1486).
1687    static M_LEVEL: Cell<i32> = const { Cell::new(0) };                     // c:67
1688}
1689
1690/// c:Src/math.c:65 — `#define MAX_MLEVEL 256`, the recursion cap on nested
1691/// `mathevall` entries.
1692const MAX_MLEVEL: i32 = 256;
1693
1694/// RAII bracket for C's `mlevel++` (math.c:386) / `--mlevel` (math.c:446).
1695///
1696/// C can pair the increment and decrement by hand because `mathevall` has a
1697/// single exit. The Rust evaluator returns `Result` from many points inside the
1698/// parse loop, so the decrement rides on `Drop` — otherwise an `Err` path would
1699/// leave `mlevel` permanently raised and every later `matheval` would skip its
1700/// reset, treating an unrelated expression as a nested one.
1701struct MathLevel;
1702
1703impl MathLevel {
1704    /// c:386 — `if (mlevel++)`.
1705    fn enter() -> Self {
1706        M_LEVEL.with(|c| c.set(c.get() + 1));
1707        MathLevel
1708    }
1709}
1710
1711impl Drop for MathLevel {
1712    /// c:446 — `if (--mlevel)`.
1713    fn drop(&mut self) {
1714        M_LEVEL.with(|c| c.set(c.get() - 1));
1715    }
1716}
1717
1718/// `outputradix` accessor for subst.rs's `$((…))` formatter.
1719/// Returns 0 if no `[#…]` directive was seen during the most
1720/// recent matheval. Caller is responsible for clearing via
1721/// `set_output_format(0, 0)` if it wants per-call state.
1722/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1723/// Read accessor for the C global `int outputradix` (Src/math.c:580). C reads
1724/// the global directly; Rust needs a fn because the value lives in a
1725/// `thread_local!` Cell.
1726pub fn outputradix() -> i32 {
1727    M_OUTPUTRADIX.with(|c| c.get())
1728}
1729
1730/// `outputunderscore` accessor — see [`outputradix`].
1731/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1732/// Read accessor for the C global `int outputunderscore` (Src/math.c:583).
1733/// Same rationale as `outputradix` above.
1734pub fn outputunderscore() -> i32 {
1735    M_OUTPUTUNDERSCORE.with(|c| c.get())
1736}
1737
1738/// Reset the output-format state. Called by `mathevall` before
1739/// each evaluation so `[#16]` from a prior `$((…))` doesn't leak
1740/// into the next call.
1741/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1742/// Stands in for C's inline `outputradix = outputunderscore = 0;` in
1743/// `matheval` (Src/math.c:1487).
1744pub fn reset_output_format() {
1745    M_OUTPUTRADIX.with(|c| c.set(0));
1746    M_OUTPUTUNDERSCORE.with(|c| c.set(0));
1747}
1748
1749/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1750/// Write accessor for the C global `int outputradix` (Src/math.c:580); C
1751/// assigns it inline in `zzlex` (Src/math.c:807).
1752fn m_outputradix_set(v: i32) {
1753    M_OUTPUTRADIX.with(|c| c.set(v));
1754}
1755
1756/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1757/// Write accessor for the C global `int outputunderscore` (Src/math.c:583);
1758/// C assigns it inline in `zzlex` (Src/math.c:813).
1759fn m_outputunderscore_set(v: i32) {
1760    M_OUTPUTUNDERSCORE.with(|c| c.set(v));
1761}
1762
1763// ============================================================
1764// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1765// every `m_*` fn below is a Rust-only
1766// thread_local accessor. C dereferences the corresponding module
1767// global directly (`yyval.u.l`, `*ptr++`, etc.) without an
1768// fn-shaped wrapper. The wrappers exist solely because Rust's
1769// `thread_local!` requires `.with(|c| ...)` for any access, and
1770// scattering 600 such closures throughout the evaluator would be
1771// unreadable. Allowlisted in tests/data/fake_fn_allowlist.txt.
1772// ============================================================
1773// Accessor helpers — each thread_local reads/writes via these so the
1774// migration from `s.X` → free-fn-only access is mechanical.
1775
1776/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1777/// Read accessor for the C lexer cursor `static char *ptr` (Src/math.c:60).
1778/// C dereferences `ptr` directly; the Rust port owns the input as a `String`
1779/// in a `thread_local!`, so every touch goes through an `m_input_*` fn.
1780#[inline]
1781fn m_input_clone() -> String {
1782    M_INPUT.with(|c| c.borrow().clone())
1783}
1784/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1785/// Write accessor for `static char *ptr` (Src/math.c:60) — C's `ptr = s;` in
1786/// `mathevall` (Src/math.c:367).
1787#[inline]
1788fn m_input_set(v: String) {
1789    M_INPUT.with(|c| *c.borrow_mut() = v)
1790}
1791/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1792/// Length of the buffer behind `static char *ptr` (Src/math.c:60). C has no
1793/// length: it stops at the NUL terminator.
1794#[inline]
1795fn m_input_len() -> usize {
1796    M_INPUT.with(|c| c.borrow().len())
1797}
1798/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1799/// Single-byte read of `ptr[i]` — C writes `*ptr` / `ptr[1]` directly against
1800/// `static char *ptr` (Src/math.c:60).
1801#[inline]
1802fn m_input_byte(i: usize) -> u8 {
1803    M_INPUT.with(|c| c.borrow().as_bytes().get(i).copied().unwrap_or(0))
1804}
1805/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1806/// Tail slice of the input — C just passes the raw `ptr` onward
1807/// (`static char *ptr`, Src/math.c:60).
1808#[inline]
1809fn m_input_slice_from(start: usize) -> String {
1810    M_INPUT.with(|c| c.borrow()[start..].to_string())
1811}
1812/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1813/// Sub-slice of the input — C uses two `char *` into the same buffer
1814/// (`static char *ptr`, Src/math.c:60).
1815#[inline]
1816fn m_input_slice(start: usize, end: usize) -> String {
1817    M_INPUT.with(|c| c.borrow()[start..end].to_string())
1818}
1819
1820/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1821/// Byte offset of the lexer cursor into the input. C has no such variable:
1822/// `static char *ptr` (Src/math.c:60) IS the cursor, moved by `*ptr++`.
1823#[inline]
1824fn m_pos() -> usize {
1825    M_POS.with(|c| c.get())
1826}
1827/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1828/// Absolute cursor move — C assigns `ptr` directly (`static char *ptr`,
1829/// Src/math.c:60).
1830#[inline]
1831fn m_pos_set(v: usize) {
1832    M_POS.with(|c| c.set(v))
1833}
1834/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1835/// Cursor rewind — C writes `ptr--` / `ptr -= n` on `static char *ptr`
1836/// (Src/math.c:60).
1837#[inline]
1838fn m_pos_sub(n: usize) {
1839    M_POS.with(|c| c.set(c.get() - n))
1840}
1841/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1842/// Cursor advance — C writes `ptr++` / `ptr += n` on `static char *ptr`
1843/// (Src/math.c:60).
1844#[inline]
1845fn m_pos_add(n: usize) {
1846    M_POS.with(|c| c.set(c.get() + n))
1847}
1848
1849/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1850/// Byte offset where the current token began. Stands in for the saved `char *`
1851/// C passes as `checkunary`'s `mptr` argument (Src/math.c:1548), used to build
1852/// the `bad math expression: ... at ...` error context.
1853#[inline]
1854fn m_tok_start() -> usize {
1855    M_TOK_START.with(|c| c.get())
1856}
1857/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1858/// Write side of `m_tok_start` — stands in for the saved token-start `char *`
1859/// C hands to `checkunary` (Src/math.c:1548).
1860#[inline]
1861fn m_tok_start_set(v: usize) {
1862    M_TOK_START.with(|c| c.set(v))
1863}
1864
1865/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1866/// Read accessor for the C global `static mnumber yyval` (Src/math.c:62).
1867#[inline]
1868fn m_yyval() -> mnumber {
1869    M_YYVAL.with(|c| c.get())
1870}
1871/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1872/// Write accessor for `static mnumber yyval` (Src/math.c:62); C assigns
1873/// `yyval.u.l` / `yyval.u.d` directly throughout `zzlex` (Src/math.c:617).
1874#[inline]
1875fn m_yyval_set(v: mnumber) {
1876    M_YYVAL.with(|c| c.set(v))
1877}
1878
1879/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1880/// Read accessor for the C global `static char *yylval` (Src/math.c:63).
1881#[inline]
1882fn m_yylval_clone() -> String {
1883    M_YYLVAL.with(|c| c.borrow().clone())
1884}
1885/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1886/// Write accessor for `static char *yylval` (Src/math.c:63).
1887#[inline]
1888fn m_yylval_set(v: String) {
1889    M_YYLVAL.with(|c| *c.borrow_mut() = v)
1890}
1891
1892/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1893/// Read accessor for the C global `static int mtok` (Src/math.c:305).
1894#[inline]
1895fn m_mtok() -> i32 {
1896    M_MTOK.with(|c| c.get())
1897}
1898/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1899/// Write accessor for `static int mtok` (Src/math.c:305).
1900#[inline]
1901fn m_mtok_set(t: i32) {
1902    M_MTOK.with(|c| c.set(t))
1903}
1904
1905/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1906/// Read accessor for the C global `static int unary = 1` (Src/math.c:71).
1907#[inline]
1908fn m_unary() -> bool {
1909    M_UNARY.with(|c| c.get())
1910}
1911/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1912/// Write accessor for `static int unary = 1` (Src/math.c:71).
1913#[inline]
1914fn m_unary_set(v: bool) {
1915    M_UNARY.with(|c| c.set(v))
1916}
1917
1918/// Accessor for the math-evaluator `noeval` counter (`Src/math.c:40`
1919/// `int noeval`). C reads/writes the global directly — Rust ports
1920/// the global as a thread-local `M_NOEVAL` (so nested evaluators
1921/// stay isolated) and exposes the read via this `pub` accessor.
1922/// Used by exec.c's `execsave` / `execrestore` save/restore frame
1923/// (`Src/exec.c:6450,6486`) — the math state must round-trip across
1924/// nested sublist evaluation so a ternary-arm `noeval++/--` inside
1925/// one expression doesn't leak into outer evaluations.
1926/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1927/// Read accessor for the C global `int noeval` (Src/math.c:40).
1928#[inline]
1929pub fn m_noeval() -> i32 {
1930    M_NOEVAL.with(|c| c.get())
1931}
1932/// Setter paired with `m_noeval` — assigns the math-evaluator
1933/// `noeval` counter. C does plain `noeval = en->noeval;`; this is
1934/// the Rust thread-local equivalent.
1935/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1936/// Write accessor for `int noeval` (Src/math.c:40).
1937#[inline]
1938pub fn m_noeval_set(v: i32) {
1939    M_NOEVAL.with(|c| c.set(v))
1940}
1941/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1942/// Stands in for C's `noeval++` on `int noeval` (Src/math.c:40), written inline
1943/// by `bop` (Src/math.c:1454).
1944#[inline]
1945fn m_noeval_inc() {
1946    M_NOEVAL.with(|c| c.set(c.get() + 1))
1947}
1948/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1949/// Stands in for C's `noeval--` on `int noeval` (Src/math.c:40), written inline
1950/// by `mathparse` (Src/math.c:1594).
1951#[inline]
1952fn m_noeval_dec() {
1953    M_NOEVAL.with(|c| c.set(c.get() - 1))
1954}
1955
1956/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1957/// Write accessor for the C global `int lastbase` (Src/math.c:58); C assigns
1958/// `lastbase = -1;` in `mathevall` (Src/math.c:367) and the literal's base in
1959/// `lexconstant` (Src/math.c:462).
1960#[inline]
1961fn m_lastbase_set(v: i32) {
1962    M_LASTBASE.with(|c| c.set(v))
1963}
1964
1965/// Public getter for `lastbase` — used by `assignstrvalue` in
1966/// params.rs to inherit the input numeric base when a freshly
1967/// assigned integer parameter has none of its own.
1968/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1969/// Read accessor for the C global `int lastbase` (Src/math.c:58), exported for
1970/// the `$((...))` output formatter.
1971pub fn lastbase() -> i32 {
1972    M_LASTBASE.with(|c| c.get())
1973}
1974
1975/// Public setter for `lastbase` — used by the bytecode arith
1976/// compiler (extensions/arith_compiler.rs) to communicate the
1977/// source numeric base when a `N#NNN` or `0x..` literal is
1978/// consumed inside `(( … ))`. The canonical math.c port at
1979/// `Src/math.c::lexconstant` sets this internally; bypassing
1980/// the canonical lexer (as `arith_compiler` does) requires
1981/// poking the TLS slot directly so assignsparam's `pm.base ==
1982/// 0 ? lastbase()` inheritance path fires.
1983/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1984/// Public write accessor for the C global `int lastbase` (Src/math.c:58).
1985pub fn set_lastbase(base: i32) {
1986    m_lastbase_set(base)
1987}
1988
1989/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1990/// Read accessor for the C global `static int *prec` (Src/math.c:278) — the
1991/// active precedence table.
1992#[inline]
1993fn m_prec() -> &'static [u8; TOKCOUNT] {
1994    M_PREC.with(|c| c.get())
1995}
1996/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
1997/// Write accessor for `static int *prec` (Src/math.c:278); C assigns it in
1998/// `mathevall` (Src/math.c:405).
1999#[inline]
2000fn m_prec_set(p: &'static [u8; TOKCOUNT]) {
2001    M_PREC.with(|c| c.set(p))
2002}
2003
2004/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2005/// Cached mirror of C's `isset(CPRECEDENCES)` test, which `mathevall` evaluates
2006/// inline when choosing the precedence table (Src/math.c:405).
2007#[inline]
2008fn m_c_precedences() -> bool {
2009    M_C_PRECEDENCES.with(|c| c.get())
2010}
2011/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2012/// Write side of the `isset(CPRECEDENCES)` mirror — see `m_c_precedences`; C
2013/// re-reads the option instead (Src/math.c:405).
2014#[inline]
2015fn m_c_precedences_set(v: bool) {
2016    M_C_PRECEDENCES.with(|c| c.set(v))
2017}
2018/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2019/// Cached mirror of C's `isset(FORCEFLOAT)` test, read inline by `getmathparam`
2020/// (Src/math.c:348).
2021#[inline]
2022fn m_force_float() -> bool {
2023    M_FORCE_FLOAT.with(|c| c.get())
2024}
2025/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2026/// Write side of the `isset(FORCEFLOAT)` mirror — see `m_force_float`
2027/// (Src/math.c:348).
2028#[inline]
2029fn m_force_float_set(v: bool) {
2030    M_FORCE_FLOAT.with(|c| c.set(v))
2031}
2032/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2033/// Cached mirror of C's `isset(OCTALZEROES)` test, read inline by `lexconstant`
2034/// (Src/math.c:489).
2035#[inline]
2036fn m_octal_zeroes() -> bool {
2037    // c:Src/math.c:489 — `isset(OCTALZEROES)` is read directly at
2038    // each integer-literal parse site, not snapshotted at math-eval
2039    // entry. The thread-local cache here only honored a snapshot
2040    // pushed by arith_compile (line 1205); freshly toggled
2041    // `setopt octalzeroes` inside the same script never reached it.
2042    // Mirror C by reading the option live.
2043    if crate::ported::zsh_h::isset(crate::ported::zsh_h::OCTALZEROES) {
2044        return true;
2045    }
2046    M_OCTAL_ZEROES.with(|c| c.get())
2047}
2048/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2049/// Write side of the `isset(OCTALZEROES)` mirror — see `m_octal_zeroes`
2050/// (Src/math.c:489).
2051#[inline]
2052fn m_octal_zeroes_set(v: bool) {
2053    M_OCTAL_ZEROES.with(|c| c.set(v))
2054}
2055
2056/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2057/// Write accessor for the shell's `lastval` (`$?`) mirror, which C's `zzlex`
2058/// reads straight off the global at Src/math.c:774.
2059#[inline]
2060fn m_lastval_set(v: i32) {
2061    M_LASTVAL.with(|c| c.set(v))
2062}
2063/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2064/// Read accessor for the shell's `lastval` (`$?`) mirror — C reads the global
2065/// inline at Src/math.c:774.
2066#[inline]
2067fn m_lastval() -> i32 {
2068    M_LASTVAL.with(|c| c.get())
2069}
2070/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2071/// Read accessor for the shell's `mypid` (`$$`) mirror; C's `zzlex` reads the
2072/// global inline at Src/math.c:770.
2073#[inline]
2074fn m_pid() -> i64 {
2075    M_PID.with(|c| c.get())
2076}
2077/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2078/// Write accessor for the `mypid` (`$$`) mirror — see `m_pid` (Src/math.c:770).
2079#[inline]
2080fn m_pid_set(v: i64) {
2081    M_PID.with(|c| c.set(v))
2082}
2083
2084/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2085/// Part of the Rust error channel that replaces C's `zerr()` + `errflag` unwind
2086/// (e.g. Src/math.c:420). Takes and clears the pending message.
2087#[inline]
2088fn m_error_take() -> Option<String> {
2089    M_ERROR.with(|c| c.borrow_mut().take())
2090}
2091/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2092/// Part of the Rust error channel that replaces C's `zerr()` + `errflag` unwind
2093/// (e.g. Src/math.c:420). Reports whether an error is pending.
2094#[inline]
2095fn m_error_some() -> bool {
2096    M_ERROR.with(|c| c.borrow().is_some())
2097}
2098/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2099/// Part of the Rust error channel that replaces C's `zerr()` + `errflag` unwind
2100/// (e.g. Src/math.c:420). Records the FIRST error only, matching C's bail-out
2101/// on the first `zerr`.
2102#[inline]
2103fn m_error_set(msg: String) {
2104    M_ERROR.with(|c| {
2105        if c.borrow().is_none() {
2106            *c.borrow_mut() = Some(msg);
2107        }
2108    })
2109}
2110/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2111/// Part of the Rust error channel that replaces C's `zerr()` + `errflag` unwind
2112/// (e.g. Src/math.c:420). Overwrites any pending message.
2113#[inline]
2114fn m_error_set_force(msg: String) {
2115    M_ERROR.with(|c| *c.borrow_mut() = Some(msg))
2116}
2117/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2118/// Part of the Rust error channel that replaces C's `zerr()` + `errflag` unwind
2119/// (e.g. Src/math.c:420). Clears the pending message.
2120#[inline]
2121fn m_error_clear() {
2122    M_ERROR.with(|c| *c.borrow_mut() = None)
2123}
2124
2125// Stack helpers — mathvalue stack operations.
2126/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2127/// Push onto the operand stack. C indexes the globals `static struct mathvalue
2128/// *stack` (Src/math.c:322) and `static int sp` (Src/math.c:306) directly —
2129/// `stack[++sp]` in `push` (Src/math.c:916).
2130#[inline]
2131fn m_stack_push(v: mathvalue) {
2132    M_STACK.with(|c| c.borrow_mut().push(v))
2133}
2134/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2135/// Pop from the operand stack — C writes `stack[sp--]` against `static struct
2136/// mathvalue *stack` (Src/math.c:322) / `static int sp` (Src/math.c:306).
2137#[inline]
2138fn m_stack_pop() -> Option<mathvalue> {
2139    M_STACK.with(|c| c.borrow_mut().pop())
2140}
2141/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2142/// Operand-stack depth. C keeps it as `static int sp` (Src/math.c:306), a bare
2143/// index with no accessor.
2144#[inline]
2145fn m_stack_len() -> usize {
2146    M_STACK.with(|c| c.borrow().len())
2147}
2148/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2149/// Operand-stack empty test — C writes `sp < 0` on `static int sp`
2150/// (Src/math.c:306).
2151#[inline]
2152fn m_stack_is_empty() -> bool {
2153    M_STACK.with(|c| c.borrow().is_empty())
2154}
2155/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2156/// Peek at the operand-stack top — C writes `&stack[sp]` against `static struct
2157/// mathvalue *stack` (Src/math.c:322), e.g. in `bop` (Src/math.c:1454).
2158#[inline]
2159fn m_stack_top_clone() -> Option<mathvalue> {
2160    M_STACK.with(|c| c.borrow().last().cloned())
2161}
2162
2163// Variable map helpers.
2164/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2165/// Read of the within-expression value cache that stands in for C's
2166/// per-`struct mathvalue` `pval` field (Src/math.c:340-343). NOT a store: the
2167/// parameter table stays authoritative, exactly as in C.
2168#[inline]
2169fn m_variables_get(name: &str) -> Option<mnumber> {
2170    M_VARIABLES.with(|c| c.borrow().get(name).copied())
2171}
2172/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2173/// Write to the within-expression value cache standing in for C's `mptr->pval`
2174/// (Src/math.c:340-343).
2175#[inline]
2176fn m_variables_insert(k: String, v: mnumber) {
2177    M_VARIABLES.with(|c| {
2178        c.borrow_mut().insert(k, v);
2179    })
2180}
2181/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2182/// Snapshot of the `mptr->pval` stand-in cache (Src/math.c:340-343), taken by
2183/// `save_state` so a nested evaluation cannot see the outer frame's cached
2184/// reads — C gets the same from `stack` being swapped (Src/math.c:374).
2185#[inline]
2186pub(crate) fn m_variables_clone() -> HashMap<String, mnumber> {
2187    M_VARIABLES.with(|c| c.borrow().clone())
2188}
2189/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2190/// Restore of the `mptr->pval` stand-in cache (Src/math.c:340-343); the
2191/// counterpart of `m_variables_clone`.
2192#[inline]
2193pub(crate) fn m_variables_set(map: HashMap<String, mnumber>) {
2194    M_VARIABLES.with(|c| *c.borrow_mut() = map)
2195}
2196
2197/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2198/// Read of the raw-string side of the `mptr->pval` stand-in cache
2199/// (Src/math.c:340-343), used for C's recursive `getvalue` path where the
2200/// parameter's text is itself an expression (Src/math.c:343).
2201#[inline]
2202fn m_string_variables_get(name: &str) -> Option<String> {
2203    M_STRING_VARIABLES.with(|c| c.borrow().get(name).cloned())
2204}
2205/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2206/// Eviction from the raw-string side of the `mptr->pval` stand-in cache
2207/// (Src/math.c:340-343).
2208#[inline]
2209fn m_string_variables_remove(name: &str) {
2210    M_STRING_VARIABLES.with(|c| {
2211        c.borrow_mut().remove(name);
2212    })
2213}
2214/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2215/// Snapshot of the raw-string side of the `mptr->pval` stand-in cache
2216/// (Src/math.c:340-343), taken by `save_state`.
2217#[inline]
2218fn m_string_variables_clone() -> HashMap<String, String> {
2219    M_STRING_VARIABLES.with(|c| c.borrow().clone())
2220}
2221/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2222/// Restore of the raw-string side of the `mptr->pval` stand-in cache
2223/// (Src/math.c:340-343).
2224#[inline]
2225fn m_string_variables_set(map: HashMap<String, String>) {
2226    M_STRING_VARIABLES.with(|c| *c.borrow_mut() = map)
2227}
2228/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2229/// Write to the raw-string side of the `mptr->pval` stand-in cache
2230/// (Src/math.c:340-343).
2231#[inline]
2232fn m_string_variables_insert(k: String, v: String) {
2233    M_STRING_VARIABLES.with(|c| {
2234        c.borrow_mut().insert(k, v);
2235    })
2236}
2237
2238/// Save/restore container — mirrors C `mathevall()` (Src/math.c:367)'s
2239/// stack locals (`xyyval`, `xyylval`, `xunary`, `xnoeval`, `xptr`,
2240/// etc.). Wrap recursive math eval (`callmathfunc` arg parsing,
2241/// `getmathparam` indirect-string eval) with `save_state()` /
2242/// `restore_state()` so the parent's evaluator state survives the
2243/// inner call's thread_local mutations.
2244#[allow(non_camel_case_types)]
2245struct xyy_locals {
2246    input: String,
2247    pos: usize,
2248    tok_start: usize,
2249    yyval: mnumber,
2250    yylval: String,
2251    stack: Vec<mathvalue>,
2252    mtok: i32,
2253    unary: bool,
2254    noeval: i32,
2255    error: Option<String>,
2256    variables: HashMap<String, mnumber>,
2257    string_variables: HashMap<String, String>,
2258    prec: &'static [u8; TOKCOUNT],
2259    c_precedences: bool,
2260    force_float: bool,
2261    octal_zeroes: bool,
2262    lastbase: i32,
2263}
2264
2265// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
2266// Rust-only helper. C inlines the
2267// xyy* save/restore directly inside `mathevall()`'s body
2268// (math.c:367 onward); the Rust port factors it out because two
2269// callsites (callmathfunc arg parsing, getmathparam indirect-string
2270// eval) would each duplicate ~17 lines of save/restore code.
2271fn save_state() -> xyy_locals {
2272    xyy_locals {
2273        input: m_input_clone(),
2274        pos: m_pos(),
2275        tok_start: m_tok_start(),
2276        yyval: m_yyval(),
2277        yylval: m_yylval_clone(),
2278        stack: M_STACK.with(|c| c.borrow().clone()),
2279        mtok: m_mtok(),
2280        unary: m_unary(),
2281        noeval: m_noeval(),
2282        error: M_ERROR.with(|c| c.borrow().clone()),
2283        variables: m_variables_clone(),
2284        string_variables: m_string_variables_clone(),
2285        prec: m_prec(),
2286        c_precedences: m_c_precedences(),
2287        force_float: m_force_float(),
2288        octal_zeroes: m_octal_zeroes(),
2289        lastbase: M_LASTBASE.with(|c| c.get()),
2290    }
2291}
2292
2293/// Port of `store()` from `Src/math.c:601` — C decl `store(double *x)` — load/store a double
2294/// via a pointer to defeat compilers that mis-optimize the
2295/// canonical `x != x` NaN test. zsh only compiles this path when
2296/// `HAVE_ISNAN` is undefined; we keep it as a name-parity shim
2297/// so `isnan()` can route through it (matching the C source's
2298/// `store(&x) != store(&x)` idiom).
2299/// WARNING: param names don't match C — Rust=() vs C=(x)
2300pub(crate) fn store(x: f64) -> f64 {
2301    x
2302}
2303
2304/// Port of `getcvar()` from `Src/math.c:943` — C decl `getcvar(char *s)` — character-constant
2305/// lookup. Reads the named shell variable and returns the
2306/// codepoint of its first character. Used for `#varname` token
2307/// (CId): `x="hello"; (( y = #x ))` puts 104 (`'h'`) into y.
2308/// On miss or empty value, returns 0 (matches zsh's `*s ? *s : 0`).
2309/// WARNING: param names don't match C — Rust=() vs C=(s)
2310pub(crate) fn getcvar(name: &str) -> mnumber {
2311    // c:952-965 — the value of `#name` is the first CHARACTER of the
2312    // parameter: under MULTIBYTE `mb_metacharlenconv(t, &wc)` decodes it
2313    // (c:954-961), and when it does not decode (an invalid byte, WEOF) or
2314    // MULTIBYTE is off, the first byte with its Meta escape undone
2315    // (c:964 `(unsigned char)(*t == Meta ? t[1] ^ 32 : *t)`). The port's
2316    // String holds an undecodable byte as Meta + byte^32, so reading its
2317    // first `char` returned the Meta byte itself (131) for `\xc5`, and the
2318    // decoded character even with MULTIBYTE unset.
2319    let first_char_code = |t: &str| -> i64 {
2320        let raw = crate::ported::utils::unmetafy_str(t);
2321        if raw.is_empty() {
2322            return 0; // c:964 — `*t` is the terminating NUL
2323        }
2324        if crate::ported::options::opt_state_get("multibyte").unwrap_or(true) {
2325            // c:954
2326            if let (_, Some(wc), _) = crate::ported::utils::mb_metacharlenconv(&raw) {
2327                return wc as i64; // c:957-960
2328            }
2329        }
2330        raw[0] as i64 // c:964
2331    };
2332    if let Some(raw) = m_string_variables_get(name) {
2333        return mnumber {
2334            l: first_char_code(&raw),
2335            d: 0.0,
2336            type_: MN_INTEGER,
2337        };
2338    }
2339    // c:Src/math.c:943 — `getcvar` falls back to `getsparam` for
2340    // scalar params not already cached in math-local tables. Without
2341    // this, `a=A; (( #a ))` returned 0 instead of 65 — `m_string_
2342    // variables_get` only sees variables explicitly seeded into the
2343    // math frame.
2344    if let Some(raw) = getsparam(name) {
2345        return mnumber {
2346            l: first_char_code(&raw),
2347            d: 0.0,
2348            type_: MN_INTEGER,
2349        };
2350    }
2351    if let Some(v) = m_variables_get(name) {
2352        let s = match v.type_ {
2353            MN_INTEGER => v.l.to_string(),
2354            MN_FLOAT => {
2355                let f = v.d;
2356                if isnan(f) {
2357                    "NaN".to_string()
2358                } else if isinf(f) {
2359                    if f > 0.0 {
2360                        "Inf".to_string()
2361                    } else {
2362                        "-Inf".to_string()
2363                    }
2364                } else {
2365                    format!("{:.10}", f)
2366                }
2367            }
2368            _ => "0".to_string(),
2369        };
2370        return mnumber {
2371            l: s.chars().next().map(|c| c as i64).unwrap_or(0),
2372            d: 0.0,
2373            type_: MN_INTEGER,
2374        };
2375    }
2376    mnumber {
2377        l: 0,
2378        d: 0.0,
2379        type_: MN_INTEGER,
2380    }
2381}
2382
2383/// Port of `zzlex()` from `Src/math.c:617` — C decl `zzlex(void)`.
2384///
2385/// Main math-expression lexer — returns the next token, advancing
2386/// `m_pos()` and updating `m_yyval()` / `m_yylval_clone()` as side-effects.
2387/// Handles all operators, ident lookahead for `Func` vs `Id`,
2388/// `[base]value` / `[#base]EXPR` output-radix prefixes, char
2389/// constants (`#x`, `##varname`), and dispatches numeric literals
2390/// to `lexconstant()`.
2391pub(crate) fn zzlex() -> i32 {
2392    m_yyval_set(mnumber {
2393        l: 0,
2394        d: 0.0,
2395        type_: MN_INTEGER,
2396    });
2397
2398    loop {
2399        let pre_pos = m_pos();
2400        let c = match advance() {
2401            Some(c) => c,
2402            None => {
2403                m_tok_start_set(pre_pos);
2404                return EOI;
2405            }
2406        };
2407
2408        if matches!(c, ' ' | '\t' | '\n' | '"') {
2409            continue;
2410        }
2411        // Record where this token began (post-whitespace) so error
2412        // formatters can produce zsh-style "at `<remaining>`" messages.
2413        m_tok_start_set(pre_pos);
2414
2415        match c {
2416            '+' => {
2417                // !!! DASH-STRICT GATE (no C counterpart) !!! dash has no `++` / `--`
2418                // operators: `$((x++))` errors ("expecting primary") and `$((++x))`
2419                // is `+(+x)`, so the two signs lex separately.
2420                if peek() == Some('+') && !crate::dash_mode::dash_strict() {
2421                    advance();
2422                    return if m_unary() { PREPLUS } else { POSTPLUS };
2423                }
2424                if peek() == Some('=') {
2425                    advance();
2426                    return PLUSEQ;
2427                }
2428                return if m_unary() { UPLUS } else { PLUS };
2429            }
2430
2431            '-' => {
2432                if peek() == Some('-') && !crate::dash_mode::dash_strict() {
2433                    advance();
2434                    return if m_unary() { PREMINUS } else { POSTMINUS };
2435                }
2436                if peek() == Some('=') {
2437                    advance();
2438                    return MINUSEQ;
2439                }
2440                if m_unary() {
2441                    // c:645-647 — `if (idigit(*ptr) ||
2442                    //                  (*ptr == '.' &&
2443                    //                   (idigit(ptr[1]) || !itype_end(ptr, INAMESPC, 0))))`
2444                    // — Check if followed by digit for negative number.
2445                    // C's `!itype_end(...)` arm tests a pointer itype_end
2446                    // never returns NULL for, so it is dead there too; the
2447                    // live condition is a digit, or a `.` FOLLOWED BY a digit
2448                    // (the float literal `-.5`). Without the second-char
2449                    // test a namespace name fed its bare leading `.` to
2450                    // lexconstant, and `- -.var.f` came back as
2451                    // "bad math expression: operator expected at `var.f'".
2452                    let mut ahead = m_input_slice_from(m_pos()).chars().collect::<Vec<char>>();
2453                    ahead.truncate(2);
2454                    if let Some(&next) = ahead.first() {
2455                        if is_digit(next)
2456                            || (next == '.' && ahead.get(1).copied().is_some_and(is_digit))
2457                        {
2458                            m_pos_sub(1); // Put back the -
2459                            return lexconstant();
2460                        }
2461                    }
2462                    return UMINUS;
2463                }
2464                return MINUS;
2465            }
2466
2467            '(' => return M_INPAR,
2468            ')' => return M_OUTPAR,
2469
2470            '!' => {
2471                if peek() == Some('=') {
2472                    advance();
2473                    return NEQ;
2474                }
2475                return NOT;
2476            }
2477
2478            '~' => return COMP,
2479
2480            '&' => {
2481                if peek() == Some('&') {
2482                    advance();
2483                    if peek() == Some('=') {
2484                        advance();
2485                        return DANDEQ;
2486                    }
2487                    return DAND;
2488                }
2489                if peek() == Some('=') {
2490                    advance();
2491                    return ANDEQ;
2492                }
2493                return AND;
2494            }
2495
2496            '|' => {
2497                if peek() == Some('|') {
2498                    advance();
2499                    if peek() == Some('=') {
2500                        advance();
2501                        return DOREQ;
2502                    }
2503                    return DOR;
2504                }
2505                if peek() == Some('=') {
2506                    advance();
2507                    return OREQ;
2508                }
2509                return OR;
2510            }
2511
2512            '^' => {
2513                if peek() == Some('^') {
2514                    advance();
2515                    if peek() == Some('=') {
2516                        advance();
2517                        return DXOREQ;
2518                    }
2519                    return DXOR;
2520                }
2521                if peek() == Some('=') {
2522                    advance();
2523                    return XOREQ;
2524                }
2525                return XOR;
2526            }
2527
2528            '*' => {
2529                // !!! DASH-STRICT GATE (no C counterpart) !!!
2530                // dash arithmetic has no `**` exponentiation operator.
2531                // Skip the POWER branch under dash_strict so `2**10` lexes
2532                // as `2 * * 10` (MUL MUL) and the parser errors with
2533                // "expecting primary", matching /bin/dash.
2534                if peek() == Some('*') && !crate::dash_mode::dash_strict() {
2535                    advance();
2536                    if peek() == Some('=') {
2537                        advance();
2538                        return POWEREQ;
2539                    }
2540                    return POWER;
2541                }
2542                if peek() == Some('=') {
2543                    advance();
2544                    return MULEQ;
2545                }
2546                return MUL;
2547            }
2548
2549            '/' => {
2550                if peek() == Some('=') {
2551                    advance();
2552                    return DIVEQ;
2553                }
2554                return DIV;
2555            }
2556
2557            '%' => {
2558                if peek() == Some('=') {
2559                    advance();
2560                    return MODEQ;
2561                }
2562                return MOD;
2563            }
2564
2565            '<' => {
2566                if peek() == Some('<') {
2567                    advance();
2568                    if peek() == Some('=') {
2569                        advance();
2570                        return SHLEFTEQ;
2571                    }
2572                    return SHLEFT;
2573                }
2574                if peek() == Some('=') {
2575                    advance();
2576                    return LEQ;
2577                }
2578                return LES;
2579            }
2580
2581            '>' => {
2582                if peek() == Some('>') {
2583                    advance();
2584                    if peek() == Some('=') {
2585                        advance();
2586                        return SHRIGHTEQ;
2587                    }
2588                    return SHRIGHT;
2589                }
2590                if peek() == Some('=') {
2591                    advance();
2592                    return GEQ;
2593                }
2594                return GRE;
2595            }
2596
2597            '=' => {
2598                if peek() == Some('=') {
2599                    advance();
2600                    return DEQ;
2601                }
2602                return EQ;
2603            }
2604
2605            '$' => {
2606                // $$ = pid
2607                m_yyval_set(mnumber {
2608                    l: m_pid(),
2609                    d: 0.0,
2610                    type_: MN_INTEGER,
2611                });
2612                return NUM;
2613            }
2614
2615            '?' => {
2616                if m_unary() {
2617                    // c:Src/math.c:772-776 — `case '?': if (unary) { yyval.u.l
2618                    // = lastval; return NUM; } return QUEST;`. Read the live
2619                    // `LASTVAL` atomic (zsh C's `lastval` global). The local
2620                    // `m_lastval()` cache is unset by any matheval caller —
2621                    // it would always be 0. Bug #367.
2622                    let lv =
2623                        crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
2624                    m_yyval_set(mnumber {
2625                        l: lv as i64,
2626                        d: 0.0,
2627                        type_: MN_INTEGER,
2628                    });
2629                    return NUM;
2630                }
2631                return QUEST;
2632            }
2633
2634            ':' => return COLON,
2635            ',' => {
2636                // !!! DASH-STRICT GATE (no C counterpart) !!!
2637                // dash arithmetic has no comma operator; `$((1,2))` errors.
2638                // Flag an error and end input so the whole `$((...))` fails
2639                // with a non-zero status like /bin/dash (which reports
2640                // "expecting EOF").
2641                if crate::dash_mode::dash_strict() {
2642                    m_error_set("bad math expression: ',' operator not supported".to_string());
2643                    return EOI;
2644                }
2645                return COMMA;
2646            }
2647
2648            '[' => {
2649                // [base]value or output format [#base]
2650                if is_digit(peek().unwrap_or('\0')) {
2651                    // [base]value
2652                    let base_start = m_pos();
2653                    while let Some(c) = peek() {
2654                        if is_digit(c) {
2655                            advance();
2656                        } else {
2657                            break;
2658                        }
2659                    }
2660                    if peek() != Some(']') {
2661                        m_error_set("bad base syntax".to_string());
2662                        return EOI;
2663                    }
2664                    let base_str: String = m_input_slice(base_start, m_pos());
2665                    let base: u32 = base_str.parse().unwrap_or(10);
2666                    advance(); // skip ]
2667
2668                    if !is_digit(peek().unwrap_or('\0')) && !is_ident_start(peek().unwrap_or('\0'))
2669                    {
2670                        m_error_set("bad base syntax".to_string());
2671                        return EOI;
2672                    }
2673                    // Reject out-of-range bases; from_str_radix panics
2674                    // on bases outside [2, 36].
2675                    if !(2..=36).contains(&base) {
2676                        m_error_set(format!(
2677                            "invalid base (must be 2 to 36 inclusive): {}",
2678                            base
2679                        ));
2680                        m_yyval_set(mnumber {
2681                            l: 0,
2682                            d: 0.0,
2683                            type_: MN_INTEGER,
2684                        });
2685                        return NUM;
2686                    }
2687
2688                    let val_start = m_pos();
2689                    while let Some(c) = peek() {
2690                        if c.is_ascii_alphanumeric() {
2691                            advance();
2692                        } else {
2693                            break;
2694                        }
2695                    }
2696                    let val_str = m_input_slice(val_start, m_pos());
2697                    let val = crate::ported::utils::zstrtol(&val_str, base as i32).0; // c:zstrtol base#N
2698                    m_lastbase_set(base as i32);
2699                    m_yyval_set(mnumber {
2700                        l: val,
2701                        d: 0.0,
2702                        type_: MN_INTEGER,
2703                    });
2704                    return NUM;
2705                }
2706                // c:Src/math.c:798-832 — `[#N]` / `[##N]` / `[#_M]`
2707                // output format specifier. Set outputradix to ±N and
2708                // outputunderscore to M (digit-grouping width).
2709                //
2710                //   `[#N]`        outputradix = +N    (emit `N#` prefix)
2711                //   `[##N]`       outputradix = -N    (bare digits, no prefix)
2712                //   `[#N_M]`      ... plus underscore every M digits
2713                //   `[#_M]`       outputradix unchanged, underscore = M
2714                //   `[#_]`        outputradix unchanged, underscore = 3 (default)
2715                //
2716                // Previous Rust port matched `[#…]` and SILENTLY DROPPED
2717                // the directive, so `$(([##16] 255))` returned `255` (decimal)
2718                // instead of `FF`. p10k uses `[##16]` in glyph-code emitters
2719                // and `[#16]` in icon-byte formatting; both were broken.
2720                if peek() == Some('#') {
2721                    advance(); // c:798 — skip first `#`
2722                    let mut n: i32 = 1; // c:799
2723                    if peek() == Some('#') {
2724                        // c:800 — second `#` flips sign for "no prefix"
2725                        n = -1; // c:801
2726                        advance(); // c:802
2727                    }
2728                    let p_now = peek().unwrap_or('\0');
2729                    if !is_digit(p_now) && p_now != '_' {
2730                        // c:804-805
2731                        m_error_set("bad output format specification".to_string());
2732                        return EOI;
2733                    }
2734                    let mut checkradix = false;
2735                    if is_digit(p_now) {
2736                        // c:806-809 — `outputradix = n * zstrtol(ptr, &ptr, 10);`
2737                        let rstart = m_pos();
2738                        while let Some(c) = peek() {
2739                            if is_digit(c) {
2740                                advance();
2741                            } else {
2742                                break;
2743                            }
2744                        }
2745                        let radix_str: String = m_input_slice(rstart, m_pos());
2746                        let radix: i32 = radix_str.parse().unwrap_or(10);
2747                        m_outputradix_set(n * radix); // c:807
2748                        checkradix = true; // c:808
2749                    }
2750                    if peek() == Some('_') {
2751                        // c:810-816 — `[…_M]` underscore digit-grouping width.
2752                        advance(); // c:811
2753                        let us_now = peek().unwrap_or('\0');
2754                        if is_digit(us_now) {
2755                            let ustart = m_pos();
2756                            while let Some(c) = peek() {
2757                                if is_digit(c) {
2758                                    advance();
2759                                } else {
2760                                    break;
2761                                }
2762                            }
2763                            let us_str: String = m_input_slice(ustart, m_pos());
2764                            m_outputunderscore_set(us_str.parse().unwrap_or(3));
2765                            // c:812-813
2766                        } else {
2767                            m_outputunderscore_set(3); // c:814-815 default
2768                        }
2769                    }
2770                    if peek() != Some(']') {
2771                        // c:822-823
2772                        m_error_set("bad output format specification".to_string());
2773                        return EOI;
2774                    }
2775                    advance(); // c:832 — skip `]`
2776                    if checkradix {
2777                        // c:824-831 — validate base ∈ [2, 36].
2778                        let abs_n = M_OUTPUTRADIX.with(|c| c.get()).abs();
2779                        if !(2..=36).contains(&abs_n) {
2780                            m_error_set(format!(
2781                                "invalid base (must be 2 to 36 inclusive): {}",
2782                                M_OUTPUTRADIX.with(|c| c.get())
2783                            ));
2784                            return EOI;
2785                        }
2786                    }
2787                    // c:833 — `break;` — fall through to the next token
2788                    // (the format directive doesn't yield a NUM itself).
2789                    continue;
2790                }
2791                m_error_set("bad output format specification".to_string());
2792                return EOI;
2793            }
2794
2795            '#' => {
2796                // Character code: #\x or ##string
2797                if peek() == Some('\\') || peek() == Some('#') {
2798                    advance(); // consume the `\` / 2nd `#` marker
2799                               // c:852-854 — `ptr++; if (!*ptr) { zerr("bad math
2800                               // expression: character missing after ##"); return EOI; }`.
2801                               // `$((##))` with nothing after the marker is an error, not 0.
2802                    if peek().is_none() {
2803                        crate::ported::utils::zerr(
2804                            "bad math expression: character missing after ##",
2805                        );
2806                        return EOI;
2807                    }
2808                    // c:Src/math.c:856 — `getkeystring(ptr, NULL,
2809                    // GETKEYS_MATH, &v)` decodes the char AFTER the
2810                    // marker, honoring backslash escapes: `##\n` → 10,
2811                    // `##\e` → 27, `##A` → 65. The previous port read a
2812                    // single literal char, so `##\n` yielded 92 (`\`)
2813                    // and left `n` dangling ("operator expected").
2814                    let rest: String = m_input_slice_from(m_pos());
2815                    if let Some((code, consumed)) = decode_math_keychar(&rest) {
2816                        for _ in 0..consumed {
2817                            advance();
2818                        }
2819                        m_yyval_set(mnumber {
2820                            l: code,
2821                            d: 0.0,
2822                            type_: MN_INTEGER,
2823                        });
2824                        return NUM;
2825                    }
2826                    if let Some(ch) = advance() {
2827                        m_yyval_set(mnumber {
2828                            l: ch as i64,
2829                            d: 0.0,
2830                            type_: MN_INTEGER,
2831                        });
2832                        return NUM;
2833                    }
2834                }
2835                // #varname - get first char value
2836                let id_start = m_pos();
2837                while let Some(c) = peek() {
2838                    if is_ident(c) {
2839                        advance();
2840                    } else {
2841                        break;
2842                    }
2843                }
2844                if m_pos() > id_start {
2845                    // c:890-900 — `if (*ptr == '[' || (!cct && *ptr == '('))`:
2846                    // a `[…]` subscript (nesting counted, `\` skipping the
2847                    // next character) is part of the CID's text.
2848                    if peek() == Some('[') {
2849                        advance();
2850                        let mut l = 1; // c:894
2851                        while l > 0 {
2852                            match advance() {
2853                                Some('[') => l += 1, // c:895-896
2854                                Some(']') => l -= 1, // c:897-898
2855                                Some('\\') => {
2856                                    if peek().is_some() {
2857                                        advance(); // c:899-900
2858                                    }
2859                                }
2860                                Some(_) => {}
2861                                None => break,
2862                            }
2863                        }
2864                    }
2865                    m_yylval_set(m_input_slice(id_start, m_pos())); // c:902
2866                    return CID;
2867                }
2868                // c:Src/math.c:911-915 — bare `#` (followed by non-ident) is
2869                // `$#` (positional-parameter count): `yyval.u.l =
2870                // poundgetfn(NULL); return NUM;`. Bug #368.
2871                m_yyval_set(mnumber {
2872                    l: crate::ported::params::poundgetfn(),
2873                    d: 0.0,
2874                    type_: MN_INTEGER,
2875                });
2876                return NUM;
2877            }
2878
2879            _ => {
2880                if is_digit(c) || (c == '.' && is_digit(peek().unwrap_or('\0'))) {
2881                    m_pos_sub(c.len_utf8());
2882                    return lexconstant();
2883                }
2884
2885                // c:866 — `if ((ie = itype_end(ptr, INAMESPC, 0)) != ptr) {`
2886                // The math lexer scans identifiers with INAMESPC, so a ksh93
2887                // namespace name (`.var.d`, `k.2`) is ONE math variable —
2888                // `$(( .var.x = ++.var.d - -.var.f ))` (K02parameter). The
2889                // previous walk was `is_ident_start` + `is_ident`, which is
2890                // plain IIDENT and left the leading `.` to be reported as
2891                // "bad math expression: invalid character: .".
2892                let id_start = m_pos() - c.len_utf8();
2893                let namespc_end = crate::ported::utils::itype_end(
2894                    &m_input_slice_from(id_start),
2895                    crate::ported::ztype_h::INAMESPC,
2896                    false,
2897                );
2898                if namespc_end != 0 {
2899                    // c:870 — `p = ptr; ptr = ie;`
2900                    m_pos_set(id_start + namespc_end);
2901
2902                    let id = m_input_slice(id_start, m_pos());
2903
2904                    // c:872 — `if (ie - p == 3 && !EMULATION(EMULATE_SH))`:
2905                    // the Inf/NaN constants are a zsh extension; under sh
2906                    // emulation every identifier is a variable reference, as
2907                    // POSIX arithmetic requires (`emulate sh; inf=42;
2908                    // echo $((inf))` is 42).
2909                    let id_lower = if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_SH) {
2910                        String::new()
2911                    } else {
2912                        id.to_lowercase()
2913                    };
2914                    if id_lower == "nan" {
2915                        m_yyval_set(mnumber {
2916                            l: 0,
2917                            d: f64::NAN,
2918                            type_: MN_FLOAT,
2919                        });
2920                        return NUM;
2921                    }
2922                    if id_lower == "inf" {
2923                        m_yyval_set(mnumber {
2924                            l: 0,
2925                            d: f64::INFINITY,
2926                            type_: MN_FLOAT,
2927                        });
2928                        return NUM;
2929                    }
2930
2931                    // Check for function call
2932                    if peek() == Some('(') {
2933                        // Skip to closing paren
2934                        let func_start = id_start;
2935                        advance(); // (
2936                        let mut depth = 1;
2937                        while let Some(c) = peek() {
2938                            advance();
2939                            if c == '(' {
2940                                depth += 1;
2941                            } else if c == ')' {
2942                                depth -= 1;
2943                                if depth == 0 {
2944                                    break;
2945                                }
2946                            }
2947                        }
2948                        m_yylval_set(m_input_slice(func_start, m_pos()));
2949                        return FUNC;
2950                    }
2951
2952                    // Check for array subscript
2953                    if peek() == Some('[') {
2954                        advance(); // [
2955                        let mut depth = 1;
2956                        while let Some(c) = peek() {
2957                            advance();
2958                            if c == '[' {
2959                                depth += 1;
2960                            } else if c == ']' {
2961                                depth -= 1;
2962                                if depth == 0 {
2963                                    break;
2964                                }
2965                            }
2966                        }
2967                    }
2968
2969                    m_yylval_set(m_input_slice(id_start, m_pos()));
2970                    return ID;
2971                }
2972
2973                // c:842 — `default: if (idigit(*--ptr) ...` — the C
2974                // default case BACKS UP so an unrecognized char (e.g.
2975                // `'`) is left un-consumed; matheval's trailing-junk
2976                // check (c:1498-1499) then reports THAT char:
2977                // `$(( 'A' ))` → "illegal character: '" not ": A".
2978                m_pos_sub(c.len_utf8());
2979                return EOI;
2980            }
2981        }
2982    }
2983}
2984
2985impl Default for mathvalue {
2986    fn default() -> Self {
2987        mathvalue {
2988            val: mnumber {
2989                l: 0,
2990                d: 0.0,
2991                type_: MN_INTEGER,
2992            },
2993            lval: None,
2994            pval: None, // c:922 — `stack[sp].pval = NULL;`
2995        }
2996    }
2997}
2998
2999/// Port of `push()` from `Src/math.c:916` — C decl `push(mnumber val, char *lval, int getme)`.
3000///
3001/// Push a value onto the evaluator's operand stack, with the
3002/// optional lvalue name (set when the value came from a variable
3003/// reference; needed for `++`/`--`/assignment-op write-back).
3004/// WARNING: param names don't match C — Rust=(lval) vs C=(val, lval, getme)
3005pub(crate) fn push(val: mnumber, lval: Option<String>) {
3006    m_stack_push(mathvalue {
3007        val,
3008        lval,
3009        pval: None, // c:922 — `stack[sp].pval = NULL;`
3010    });
3011}
3012
3013/// Port of `pop()` from `Src/math.c:931` — C decl `pop(int noget)`.
3014///
3015/// Pop the top operand from the stack, resolving any pending
3016/// variable read (`mnumber { l: 0, d: 0.0, type_: MN_UNSET }` + lval set). The C source
3017/// passes a `noget` flag to skip the resolution; the Rust port
3018/// always resolves since callers that want the raw lvalue use
3019/// `pop_with_lval` instead.
3020/// WARNING: param names don't match C — Rust=() vs C=(noget)
3021pub(crate) fn pop() -> mnumber {
3022    if let Some(mv) = m_stack_pop() {
3023        if (mv.val.type_ == MN_UNSET) {
3024            if let Some(ref name) = mv.lval {
3025                return getmathparam(name);
3026            }
3027        }
3028        mv.val
3029    } else {
3030        m_error_set("stack underflow".to_string());
3031        mnumber {
3032            l: 0,
3033            d: 0.0,
3034            type_: MN_INTEGER,
3035        }
3036    }
3037}
3038
3039/// Port of `setmathvar()` from `Src/math.c:972` — C decl `setmathvar(struct mathvalue *mvp, mnumber v)`.
3040///
3041/// Write `val` to the named parameter from inside math context.
3042/// Calls `setnparam` (the canonical param-set) and returns the value
3043/// re-typed to match the parameter's type (C c:1014-1027).
3044pub(crate) fn setmathvar(name: &str, val: mnumber, pval: bool) -> mnumber {
3045    // c:972
3046    // c:996-1001 — bad-lvalue check (empty name).
3047    if name.is_empty() {
3048        zerr("bad math expression: lvalue required");
3049        return mnumber {
3050            l: 0,
3051            d: 0.0,
3052            type_: MN_INTEGER,
3053        };
3054    }
3055    // c:1002-1003 — `if (noeval) return v;`
3056    if M_NOEVAL.with(|n| n.get()) != 0 {
3057        return val;
3058    }
3059    // c:975-993 — `if (mvp->pval) { … pm = paramtab->getnode(paramtab, s);
3060    // if (pm == mvp->pval->pm) { if (noeval) return v; setnumvalue(mvp->pval,
3061    // v); return v; } … }`. An lvalue this expression already READ (`n++`,
3062    // `n += 2`) is stored through the value handle getmathparam cached: no
3063    // setnparam, so no WARN_CREATE_GLOBAL / WARN_NESTED_VAR report, and the
3064    // result keeps its own type (`integer n=1; $(( n += 2.5 ))` is 3.5).
3065    // !!! RUST-ONLY SHAPE !!! zshrs caches no Value handle (see the note at
3066    // the head of `op`), so "the handle still names this parameter" is read
3067    // as "the parameter is still set". The subscripted path below already
3068    // stores without the warning and without retyping.
3069    if pval && !name.contains('[') {
3070        let set = crate::ported::params::paramtab()
3071            .read()
3072            .ok()
3073            .and_then(|t| {
3074                t.get(name)
3075                    .map(|pm| (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) == 0)
3076            })
3077            .unwrap_or(false);
3078        if set {
3079            m_variables_insert(name.to_string(), val);
3080            let _ = crate::ported::params::assignnparam(name, val, 0); // c:990 setnumvalue
3081            return val; // c:991
3082        }
3083    }
3084    // c:1004 — `setnparam(mvp->lval, v)`. C passes the FULL lval
3085    // (including any `[subscript]`) to setnparam → assignnparam,
3086    // which calls getvalue to resolve the subscript and routes the
3087    // write via setnumvalue on the resulting Value (whose v->pm for
3088    // a hash element is the hash-element scalar shim — see
3089    // params.c:640 `foundparam` set by scanparamvals at c:664). The
3090    // previous Rust port stripped the subscript here and called
3091    // setnparam("counts", val) for `counts[apple]++` — silently
3092    // wiping the assoc/array and replacing it with a scalar.
3093    //
3094    // Until the foundparam/PM_HASHELEM scalar-shim path lands in
3095    // assignnparam, route subscripted writes through `assignsparam`
3096    // (which already handles PM_HASHED + PM_ARRAY[idx] writes at
3097    // params.rs:4880-4914). For PM_ARRAY targets pre-evaluate the
3098    // subscript body via `matheval` so `arr[i + 1]` becomes
3099    // `arr[3]` before assignsparam parses the body as i64 — same
3100    // dispatch C's getarg (params.c:1367) performs internally via
3101    // mathevalarg.
3102    if let Some(bi) = name.find('[') {
3103        let close = name.rfind(']').unwrap_or(name.len());
3104        let base = &name[..bi];
3105        let body = if close > bi { &name[bi + 1..close] } else { "" };
3106        // PM_HASHED → literal-string subscript (no math eval).
3107        // PM_ARRAY / unset → math-eval the subscript body.
3108        let is_hashed = {
3109            let tab = crate::ported::params::paramtab().read();
3110            tab.ok()
3111                .and_then(|t| {
3112                    t.get(base)
3113                        .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED)
3114                })
3115                .unwrap_or(false)
3116        };
3117        let canonical = if is_hashed {
3118            name.to_string()
3119        } else {
3120            // Save/restore evaluator state around the recursive
3121            // matheval — mirrors getmathparam at math.rs:230 and
3122            // C mathevall's xyy* save/restore pattern (math.c:367).
3123            let saved = save_state();
3124            let idx_val = matheval(body)
3125                .map(|n| if n.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(n.d) } else { n.l })
3126                .unwrap_or(0);
3127            restore_state(saved);
3128            format!("{}[{}]", base, idx_val)
3129        };
3130        // Render mnumber as decimal string for assignsparam's
3131        // numeric subscript-write path. PM_ARRAY/PM_HASHED store
3132        // strings; assignsparam writes them straight through.
3133        let val_str = if val.type_ == MN_FLOAT {
3134            crate::ported::params::convfloat_underscore(val.d, 0)
3135        } else {
3136            crate::ported::params::convbase_underscore(val.l, 10, 0)
3137        };
3138        let _ = crate::ported::params::assignsparam(&canonical, &val_str, 0);
3139        // Cache the resolved (canonical) name so a subsequent read
3140        // of the same subscript in the SAME math expression sees
3141        // the new value without a paramtab round-trip.
3142        m_variables_insert(canonical, val);
3143        return val;
3144    }
3145
3146    // Unsubscripted path — cache by name and route through setnparam
3147    // as before. The canonical paramtab write inside setnparam is
3148    // what makes the value persist beyond the current $((…)).
3149    m_variables_insert(name.to_string(), val);
3150    // c:1005 — `pm = setnparam(mvp->lval, v);`
3151    let pm = crate::ported::params::setnparam(name, val);
3152    // c:1006-1027 — re-type the return per the param's type after setnparam.
3153    if let Some(pm) = pm {
3154        let flags = pm.node.flags as u32;
3155        if flags & PM_INTEGER != 0 {
3156            let l = if val.type_ == MN_FLOAT {
3157                crate::zlong_cast::zlong_from_double(val.d)
3158            } else {
3159                val.l
3160            };
3161            return mnumber {
3162                l,
3163                d: 0.0,
3164                type_: MN_INTEGER,
3165            };
3166        }
3167        if flags & (PM_EFLOAT | PM_FFLOAT) != 0 {
3168            let d = if val.type_ == MN_INTEGER {
3169                val.l as f64
3170            } else {
3171                val.d
3172            };
3173            return mnumber {
3174                l: 0,
3175                d,
3176                type_: MN_FLOAT,
3177            };
3178        }
3179    }
3180    val
3181}
3182
3183/// Call a math function
3184/// Port of `callmathfunc()` from `Src/math.c:1037` — C decl `callmathfunc(char *o)`.
3185/// WARNING: param names don't match C — Rust=() vs C=(o)
3186pub(crate) fn callmathfunc(call: &str) -> mnumber {
3187    // Parse function name and args
3188    let paren = call.find('(').unwrap_or(call.len());
3189    let name = &call[..paren];
3190    // c:1069-1099 — the numeric argument walk: skip leading blanks
3191    // (`while (iblank(*a)) a++;`), then `while (*a)` parse ONE argument
3192    // with `mathevall(a, MPREC_ARG, &a)` — which stops at a top-level
3193    // comma and leaves `a` just past it — until `errflag || mtok !=
3194    // COMMA`; text left over is "illegal character". A nested call's own
3195    // commas (`atan(hypot(3,4),2)`) and a parenthesised comma expression
3196    // (`hypot((1,3),4)`) belong to their argument, and `sin( )` has no
3197    // arguments. `None` means an error was already raised.
3198    let eval_math_args = |a: &str| -> Option<Vec<mnumber>> {
3199        let mut rest: &str = a.trim_start_matches([' ', '\t']); // c:1070-1071
3200        let mut out = Vec::new();
3201        while !rest.is_empty() {
3202            // c:1073
3203            let saved = save_state();
3204            let inherited_vars = saved.variables.clone();
3205            new(rest);
3206            m_variables_set(inherited_vars);
3207            let result = mathevall(prec_type::MPREC_ARG); // c:1087
3208            let (pos, tok) = (m_pos(), m_mtok());
3209            restore_state(saved);
3210            match result {
3211                Ok(n) => out.push(n), // c:1088
3212                Err(msg) => {
3213                    crate::ported::utils::zerr(&msg);
3214                    return None;
3215                }
3216            }
3217            rest = &rest[pos.min(rest.len())..];
3218            if tok != COMMA {
3219                break; // c:1090-1091
3220            }
3221        }
3222        if let Some(c) = rest.chars().next() {
3223            // c:1094-1095
3224            crate::ported::utils::zerr(&format!("bad math expression: illegal character: {}", c));
3225            return None;
3226        }
3227        Some(out)
3228    };
3229    // c:Src/math.c:1108-1116 — MFF_USERFUNC branch: when the named
3230    // math function points at a user shfunc (registered via
3231    // `functions -M`), dispatch via doshfunc instead of looking it
3232    // up in mathfuncs. The body sets `lastmathval` and returns;
3233    // callmathfunc reads it back. Routed here BEFORE the module
3234    // arms below so a user-registered fn shadows a built-in name
3235    // (matching C lookup order).
3236    //
3237    // C zsh's `Src/math.c:1037-1116 callmathfunc` walks the
3238    // canonical `mathfuncs` table (Src/module.c:1258) — a shell
3239    // function with the same name as the math call is NOT
3240    // dispatched unless an MFF_USERFUNC entry was installed via
3241    // `functions -M`. Bug #360: previously zshrs dispatched ANY
3242    // matching shfunc, so unregistered `myadd() {…}; $((myadd(2,3)))`
3243    // entered doshfunc and produced a math-error rather than
3244    // "unknown function: myadd" (the zsh behavior).
3245    //
3246    // Gate the dispatch on a present MFF_USERFUNC entry whose
3247    // shfunc handler resolves to `name` (per C math.c:1108's
3248    // `if (f->flags & MFF_USERFUNC)` check).
3249    // c:1109 — `shfnam = f->module ? f->module : n`. A `functions -M`
3250    // entry can map the math name to a DIFFERENT implementing shell
3251    // function (the optional 4th arg): `functions -M addtwo 2 2 _addtwo`
3252    // dispatches to `_addtwo`. Resolve the impl name from the entry's
3253    // `module` field, falling back to the math name.
3254    // c:1108-1109 + c:1106-1107 — resolve the implementing shfunc name
3255    // AND the registered [minargs, maxargs] bounds together, so the
3256    // arg-count check below sees the same entry that doshfunc dispatches
3257    // to.
3258    let userfunc_impl: Option<(String, i32, i32, i32)> = crate::ported::module::MATHFUNCS
3259        .lock()
3260        .ok()
3261        .and_then(|tab| {
3262            tab.iter()
3263                .find(|p| p.name == name && (p.flags & crate::ported::zsh_h::MFF_USERFUNC) != 0)
3264                .map(|p| {
3265                    (
3266                        p.module.clone().unwrap_or_else(|| p.name.clone()),
3267                        p.minargs,
3268                        p.maxargs,
3269                        p.flags,
3270                    )
3271                })
3272        });
3273    if let Some((impl_name, minargs, maxargs, fflags)) = userfunc_impl {
3274        if let Some(mut shfunc) = crate::ported::utils::getshfunc(&impl_name) {
3275            // c:1059-1062 — `addlinknode(l, n)`: the FIRST positional ($0)
3276            // is the MATH function NAME (`max`/`min`), NOT the implementing
3277            // shfunc name. A shared impl (zmathfunc registers max/min/sum to
3278            // one function) switches on $0, so it must see the math name.
3279            // The body to RUN is still the impl shfunc.
3280            let mut largs: Vec<String> = vec![name.to_string()];
3281            let a = call[paren..]
3282                .strip_prefix('(')
3283                .unwrap_or(&call[paren..]);
3284            let a = a.strip_suffix(')').unwrap_or(a);
3285            let argv_str: Vec<String> = if (fflags & crate::ported::zsh_h::MFF_STR) != 0 {
3286                // c:1064-1068 — `if (!*a) { addlinknode(l, dupstring("")); argc++; }`
3287                // c:1077-1080 — otherwise `str = dupstring(a); a = "";`: the
3288                // whole raw argument text, commas included, is ONE string.
3289                vec![a.to_string()]
3290            } else {
3291                // c:1069-1071 — `while (iblank(*a)) a++;`
3292                // c:1081-1090 — each argument is `mathevall(a, MPREC_ARG, &a)`
3293                // and the value is handed to the shell function as text:
3294                // `convfloat(marg.u.d, 0, 0, NULL)` or `convbase(buf, marg.u.l, 10)`.
3295                let Some(nums) = eval_math_args(a) else {
3296                    return mnumber {
3297                        l: 0,
3298                        d: 0.0,
3299                        type_: MN_INTEGER,
3300                    };
3301                };
3302                nums.into_iter()
3303                    .map(|n| {
3304                        if (n.type_ & MN_FLOAT) != 0 {
3305                            crate::ported::params::convfloat(n.d, 0, 0) // c:1083-1084
3306                        } else {
3307                            crate::ported::params::convbase(n.l, 10) // c:1086-1088
3308                        }
3309                    })
3310                    .collect()
3311            };
3312            // c:Src/math.c:1106-1107 — `if (argc >= f->minargs &&
3313            // (f->maxargs < 0 || argc <= f->maxargs))`. The actual arg count
3314            // (NOT counting the math-fn name pushed as $0 at c:1061) must be
3315            // within the registered bounds; `maxargs < 0` means unbounded.
3316            // On mismatch C falls to c:1127 `zerr("wrong number of
3317            // arguments: %s", o)` where `o` is the original `name(args)`
3318            // call text, and aborts the math eval. Without this check zshrs
3319            // dispatched the body anyway (e.g. a 0-arg `functions -M` fn
3320            // called as `cube(3)` ran the body instead of erroring).
3321            let argc = argv_str.len() as i32;
3322            if argc < minargs || (maxargs >= 0 && argc > maxargs) {
3323                crate::ported::utils::zerr(&format!("wrong number of arguments: {}", call)); // c:1127
3324                crate::ported::utils::errflag.fetch_or(
3325                    crate::ported::zsh_h::ERRFLAG_ERROR,
3326                    std::sync::atomic::Ordering::Relaxed,
3327                );
3328                return mnumber {
3329                    l: 0,
3330                    d: 0.0,
3331                    type_: MN_INTEGER,
3332                };
3333            }
3334            largs.extend(argv_str.iter().cloned());
3335            let name_for_body = impl_name.clone();
3336            let body_args = argv_str.clone();
3337            let body_runner = move || -> i32 {
3338                crate::ported::exec::run_function_body(&name_for_body, &body_args).unwrap_or(0)
3339            };
3340            // c:1114 — `doshfunc(shfunc, l, 1)`. The body runs a nested
3341            // `(( ))` which RE-ENTERS this evaluator and clobbers the outer
3342            // parser's input/pos/stack thread-locals; save + restore them
3343            // around the call so the OUTER `$(( fn(x) ))` keeps parsing
3344            // (without this it errored "operand expected at end of string").
3345            // M_LASTMATHVAL is NOT part of save_state, so the body's last
3346            // `(( ))` result survives the restore.
3347            let saved = save_state();
3348            let _ = crate::ported::exec::doshfunc(&mut shfunc, largs, true, body_runner);
3349            restore_state(saved);
3350            // c:1115 — `return lastmathval`. The body's last arithmetic
3351            // evaluation (e.g. `(( REPLY = $1 + 2 ))`) is the function's value.
3352            return M_LASTMATHVAL.with(|c| c.get());
3353        } else {
3354            // c:Src/math.c:1110-1112 — the math function IS registered
3355            // (MFF_USERFUNC via `functions -M`), but its implementing shell
3356            // function doesn't exist: `zerr("no such function: %s", shfnam)`.
3357            // This is distinct from the `unknown function` of an
3358            // UN-registered math name (c:1131); zshrs previously fell
3359            // through to that generic message.
3360            crate::ported::utils::zerr(&format!("no such function: {}", impl_name)); // c:1112
3361            crate::ported::utils::errflag.fetch_or(
3362                crate::ported::zsh_h::ERRFLAG_ERROR,
3363                std::sync::atomic::Ordering::Relaxed,
3364            );
3365            return mnumber {
3366                l: 0,
3367                d: 0.0,
3368                type_: MN_INTEGER,
3369            };
3370        }
3371    } // close `if mathfunc_entry.is_some()`
3372
3373    // c:Src/math.c:1050 — `if ((f = getmathfunc(n, 1)))`, else c:1131
3374    // `zerr("unknown function: %s", n)`. The global `mathfuncs` list
3375    // (MATHFUNCS) is the only authority: a module's `setmathfuncs`
3376    // (c:Src/module.c:1374) adds its mftab entries on load and removes
3377    // them again for `zmodload -F MOD -f:NAME`, so a module being loaded
3378    // does not by itself make a name callable. A registered entry has
3379    // `module == NULL`; an autoload stub (`zmodload -af` / `-aF MOD
3380    // f:NAME`, c:Src/module.c:1410 add_automathfunc) carries the module
3381    // name and is resolved by getmathfunc's autol arm (c:1289-1301),
3382    // which reports "autoloading module %s failed to define math
3383    // function" itself when the load does not define the name — the
3384    // zerr below is then swallowed by errflag (c:Src/utils.c:175).
3385    // !!! EMULATION-ONLY (no C counterpart) !!! ksh93 ships its math functions
3386    // (`sqrt`, `abs`, `int`, `sin`, …) built in; zsh needs `zmodload
3387    // zsh/mathfunc`. Load it on first use under a bare `--ksh`.
3388    if crate::dash_mode::ksh93_mode() {
3389        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
3390            let _ =
3391                crate::ported::module::require_module(&mut tab, "zsh/mathfunc", None, 1, false);
3392        }
3393    }
3394    let registered = crate::ported::module::MATHFUNCS
3395        .lock()
3396        .map(|tab| tab.iter().any(|p| p.name == name && p.module.is_none()))
3397        .unwrap_or(false);
3398    if !registered {
3399        let autoloaded = crate::ported::module::MODULESTAB
3400            .lock()
3401            .ok()
3402            .map(|mut tab| crate::ported::module::getmathfunc(&mut tab, name, 1).is_some())
3403            .unwrap_or(false);
3404        if !autoloaded {
3405            crate::ported::utils::zerr(&format!("unknown function: {}", name)); // c:1131
3406            crate::ported::utils::errflag.fetch_or(
3407                crate::ported::zsh_h::ERRFLAG_ERROR,
3408                std::sync::atomic::Ordering::Relaxed,
3409            );
3410            return mnumber {
3411                l: 0,
3412                d: 0.0,
3413                type_: MN_INTEGER,
3414            };
3415        }
3416    }
3417    let args_str = if paren < call.len() {
3418        &call[paren + 1..call.len() - 1]
3419    } else {
3420        ""
3421    };
3422
3423    // c:1050 — `if ((f = getmathfunc(n, 1)))`: the entry the dispatch
3424    // below calls through. The gate above already resolved an autoload
3425    // stub, so this is the real (module == NULL) row a module's
3426    // `setmathfuncs` (c:Src/module.c:1374) put on the list.
3427    let entry = crate::ported::module::MATHFUNCS.lock().ok().and_then(|tab| {
3428        tab.iter()
3429            .find(|p| p.name == name && p.module.is_none())
3430            .map(|p| (p.flags, p.nfunc, p.sfunc, p.minargs, p.maxargs, p.funcid))
3431    });
3432    let Some((fflags, nfunc, sfunc, minargs, maxargs, funcid)) = entry else {
3433        crate::ported::utils::zerr(&format!("unknown function: {}", name)); // c:1131
3434        crate::ported::utils::errflag.fetch_or(
3435            crate::ported::zsh_h::ERRFLAG_ERROR,
3436            std::sync::atomic::Ordering::Relaxed,
3437        );
3438        return mnumber {
3439            l: 0,
3440            d: 0.0,
3441            type_: MN_INTEGER,
3442        };
3443    };
3444
3445    // c:1051-1052 — `if ((f->flags & (MFF_STR|MFF_USERFUNC)) == MFF_STR)
3446    // return f->sfunc(n, a, f->funcid);`. A pure string math function
3447    // receives the raw, UN-evaluated arg text (`rand48` gets the name of
3448    // the parameter holding the seed, zsh/example's `length` the string).
3449    if (fflags & (crate::ported::zsh_h::MFF_STR | crate::ported::zsh_h::MFF_USERFUNC))
3450        == crate::ported::zsh_h::MFF_STR
3451    {
3452        if let Some(sfunc) = sfunc {
3453            return sfunc(name, args_str, funcid); // c:1052
3454        }
3455    }
3456
3457    // c:1087-1090 — `*q = mathevall(a, MPREC_ARG, &a); addlinknode(l, q);`
3458    // one mnumber per comma-separated argument.
3459    let Some(arg_nums) = eval_math_args(args_str) else {
3460        return mnumber {
3461            l: 0,
3462            d: 0.0,
3463            type_: MN_INTEGER,
3464        };
3465    };
3466    // c:1104-1105 — `if (argc >= f->minargs && (f->maxargs < 0 ||
3467    // argc <= f->maxargs))`, else c:1127 `zerr("wrong number of
3468    // arguments: %s", o)`.
3469    let argc = arg_nums.len() as i32;
3470    if argc < minargs || (maxargs >= 0 && argc > maxargs) {
3471        crate::ported::utils::zerr(&format!("wrong number of arguments: {}", call)); // c:1127
3472        crate::ported::utils::errflag.fetch_or(
3473            crate::ported::zsh_h::ERRFLAG_ERROR,
3474            std::sync::atomic::Ordering::Relaxed,
3475        );
3476        return mnumber {
3477            l: 0,
3478            d: 0.0,
3479            type_: MN_INTEGER,
3480        };
3481    }
3482    // c:1117-1123 — `return f->nfunc(n, argc, argv, f->funcid);`. The
3483    // module's own handler does the work (zsh/mathfunc's `math_func`,
3484    // zsh/system's `math_systell`, zsh/example's `math_sum`).
3485    match nfunc {
3486        Some(nfunc) => nfunc(name, argc, &arg_nums, funcid), // c:1123
3487        None => mnumber {
3488            l: 0,
3489            d: 0.0,
3490            type_: MN_INTEGER,
3491        },
3492    }
3493}
3494
3495// `MS_RAND48` and `math_string` are ports of `Src/Modules/mathfunc.c`
3496// (c:91 and c:439). They live in the Rust mirror of that file,
3497// `src/ported/modules/mathfunc.rs`; math.c's `callmathfunc`
3498// (c:Src/math.c:1051-1052) dispatches into them the same way C's
3499// `f->sfunc(n, a, f->funcid)` reaches mathfunc.c through the
3500// `MathFunc` table. The duplicate bodies that used to sit here were
3501// a second, less complete copy (they lacked the `errflag` bail at
3502// c:495-496).
3503
3504/// Port of `op()` from `Src/math.c:1154` — C decl `op(int what)`.
3505///
3506/// Apply a binary or unary operator to the operand stack. Pops
3507/// 1-2 values, applies the operation (with type coercion), and
3508/// pushes the result. Handles assignment (`OP_E2*` flag) by
3509/// writing through `setmathvar` and pushing the new value back
3510/// with the same lvalue so chained assigns work.
3511/// WARNING: param names don't match C — Rust=() vs C=(what)
3512pub(crate) fn op(what: i32) {
3513    if m_error_some() {
3514        return;
3515    }
3516
3517    let tp = OP_TYPE[what as usize];
3518
3519    // c:Src/math.c:308-320 —
3520    //   struct mathvalue {
3521    //       /*
3522    //        * If we need to get a variable, this is the string to be passed
3523    //        * to the parameter code.  It may include a subscript.
3524    //        */
3525    //       char *lval;
3526    //       /*
3527    //        * If this is not zero, we've retrieved a variable and this
3528    //        * stores a reference to it.
3529    //        */
3530    //       Value pval;
3531    //   };
3532    // c:334-336 —
3533    //   Get a number from a variable.
3534    //   Try to be clever about reusing subscripts by caching the Value
3535    //   structure.
3536    //
3537    // `getmathparam` fills `mptr->pval` on the first read (c:341-343) and
3538    // `setmathvar` writes back THROUGH that cached Value (c:976-993) rather
3539    // than re-resolving `lval`, so an operand's subscript is evaluated
3540    // EXACTLY ONCE however many times the operator touches it. That is what
3541    // makes `(( array[++x]++ ))` bump `x` once (C01arith.ztst "no double
3542    // increment for subscript").
3543    //
3544    // !!! WARNING: RUST-ONLY SHAPE !!! zshrs's paramtab hands back no
3545    // `Value` handle to cache, so the port caches the RESOLVED LVALUE
3546    // instead — `array[++x]` collapsed to `array[1]` — and passes that same
3547    // string to both `getmathparam` and `setmathvar`. Two subscript forms
3548    // are left untouched because they are not arithmetic and re-resolving
3549    // them is side-effect free: an associative-array key (matching the
3550    // PM_HASHED test `setmathvar` itself uses) and a `(flags)pat` search
3551    // subscript, which `getmathparam` resolves with its own scanner.
3552    let resolve_lval = |lv: &Option<String>| -> Option<String> {
3553        let name = lv.as_ref()?;
3554        let Some(bi) = name.find('[') else {
3555            return Some(name.clone());
3556        };
3557        let close = name.rfind(']').unwrap_or(name.len());
3558        let base = &name[..bi];
3559        let body = if close > bi { &name[bi + 1..close] } else { "" };
3560        if body.starts_with('(') {
3561            return Some(name.clone());
3562        }
3563        let is_hashed = crate::ported::params::paramtab()
3564            .read()
3565            .ok()
3566            .and_then(|t| {
3567                t.get(base)
3568                    .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED)
3569            })
3570            .unwrap_or(false);
3571        if is_hashed {
3572            return Some(name.clone());
3573        }
3574        // Save/restore evaluator state around the recursive matheval —
3575        // mirrors `setmathvar` (math.rs) and C `mathevall`'s xyy* pattern
3576        // (c:367).
3577        let saved = save_state();
3578        let idx = matheval(body).map(|n| if n.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(n.d) } else { n.l });
3579        restore_state(saved);
3580        match idx {
3581            Ok(i) => Some(format!("{}[{}]", base, i)),
3582            Err(_) => Some(name.clone()),
3583        }
3584    };
3585
3586    // Binary operators
3587    if (tp & (OP_A2 | OP_A2IR | OP_A2IO | OP_E2 | OP_E2IO)) != 0 {
3588        if m_stack_len() < 2 {
3589            // zsh's exact wording for the same condition is
3590            // `bad math expression: operand expected at end of
3591            // string`. Matching it here means `let "1+"` and
3592            // `$((5+))` produce the same diagnostic shape that
3593            // scripts grep for.
3594            m_error_set("bad math expression: operand expected at end of string".to_string());
3595            return;
3596        }
3597
3598        let b = pop(); // c:1170 — `b = pop(0);`
3599        let mut mv_a = pop_with_lval();
3600        // c:1365-1367 — the write-back reuses `stack + sp + 1`, i.e. the very
3601        // mathvalue just popped, so a compound assignment (`arr[++i] += 1`)
3602        // resolves its subscript once. Cache the resolved lvalue in `pval`
3603        // (see the note at the head of `op`) before either half runs.
3604        if (tp & (OP_E2 | OP_E2IO)) != 0 {
3605            mv_a.pval = resolve_lval(&mv_a.lval);
3606            if mv_a.pval.is_some() {
3607                mv_a.lval = mv_a.pval.clone();
3608            }
3609        }
3610        let mv_a = mv_a;
3611        // c:1171 — `a = pop(what == EQ);`. `pop`'s `noget` argument
3612        // (c:930-940) suppresses the deferred variable read, so a PLAIN
3613        // assignment NEVER fetches the lvalue's current value: `a` stays
3614        // MN_UNSET. Two behaviours depend on it — `x=/bar; (( x = 32 ))`
3615        // must not try to evaluate `/bar` as an expression, and
3616        // `(( x = 3.5 )); (( x = 4 ))` must store the integer 4 (not 4.0)
3617        // even though `x` still holds a float-looking string.
3618        let a = if (mv_a.val.type_ == MN_UNSET) && what != EQ {
3619            if let Some(ref name) = mv_a.lval {
3620                getmathparam(name)
3621            } else {
3622                mnumber {
3623                    l: 0,
3624                    d: 0.0,
3625                    type_: MN_INTEGER,
3626                }
3627            }
3628        } else {
3629            mv_a.val
3630        };
3631
3632        // Coerce types
3633        let (a, b) = if (tp & (OP_A2IO | OP_E2IO)) != 0 {
3634            // Must be integers
3635            (
3636                mnumber {
3637                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }),
3638                    d: 0.0,
3639                    type_: MN_INTEGER,
3640                },
3641                mnumber {
3642                    l: (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }),
3643                    d: 0.0,
3644                    type_: MN_INTEGER,
3645                },
3646            )
3647        } else if (a.type_ == MN_FLOAT) != (b.type_ == MN_FLOAT)
3648            && what != COMMA
3649            // c:1183 — `(a.type != MN_UNSET || what != EQ)`. A plain
3650            // assignment left `a` unresolved above, so there is nothing to
3651            // coerce; without this guard the MN_UNSET lvalue counted as an
3652            // integer and dragged an integer RHS into float.
3653            && (a.type_ != MN_UNSET || what != EQ)
3654        {
3655            // c:1184-1192 —
3656            //   Different types, so coerce to float.
3657            //   It may happen during an assignment that the LHS
3658            //   variable is actually an integer, but there's still
3659            //   no harm in doing the arithmetic in floating point;
3660            //   the assignment will do the correct conversion.
3661            //   This way, if the parameter is actually a scalar, but
3662            //   used to contain an integer, we can write a float into it.
3663            (
3664                mnumber {
3665                    l: 0,
3666                    d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 }),
3667                    type_: MN_FLOAT,
3668                },
3669                mnumber {
3670                    l: 0,
3671                    d: (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3672                    type_: MN_FLOAT,
3673                },
3674            )
3675        } else {
3676            (a, b)
3677        };
3678
3679        let result = if m_noeval() > 0 {
3680            mnumber {
3681                l: 0,
3682                d: 0.0,
3683                type_: MN_INTEGER,
3684            }
3685        } else {
3686            let is_float = (a.type_ == MN_FLOAT);
3687            match what {
3688                AND | ANDEQ => mnumber {
3689                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3690                        & (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }),
3691                    d: 0.0,
3692                    type_: MN_INTEGER,
3693                },
3694                XOR | XOREQ => mnumber {
3695                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3696                        ^ (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }),
3697                    d: 0.0,
3698                    type_: MN_INTEGER,
3699                },
3700                OR | OREQ => mnumber {
3701                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3702                        | (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }),
3703                    d: 0.0,
3704                    type_: MN_INTEGER,
3705                },
3706
3707                MUL | MULEQ => {
3708                    if is_float {
3709                        mnumber {
3710                            l: 0,
3711                            d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3712                                * (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3713                            type_: MN_FLOAT,
3714                        }
3715                    } else {
3716                        mnumber {
3717                            l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3718                                .wrapping_mul((if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l })),
3719                            d: 0.0,
3720                            type_: MN_INTEGER,
3721                        }
3722                    }
3723                }
3724
3725                DIV | DIVEQ => {
3726                    // Float div-by-zero is NOT an error in zsh —
3727                    // it produces IEEE Inf/-Inf/NaN per IEEE 754.
3728                    // Only INTEGER div-by-zero raises the error.
3729                    // Without this gate `1/0.0` errored out instead
3730                    // of returning `Inf`.
3731                    if is_float {
3732                        // Let f64 semantics handle 0.0, -0.0, NaN.
3733                        mnumber {
3734                            l: 0,
3735                            d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3736                                / (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3737                            type_: MN_FLOAT,
3738                        }
3739                    } else {
3740                        if !notzero(b) {
3741                            m_error_set("division by zero".to_string());
3742                            return;
3743                        }
3744                        let bi = (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l });
3745                        if bi == -1 {
3746                            mnumber {
3747                                l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3748                                    .wrapping_neg(),
3749                                d: 0.0,
3750                                type_: MN_INTEGER,
3751                            }
3752                        } else {
3753                            mnumber {
3754                                l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }) / bi,
3755                                d: 0.0,
3756                                type_: MN_INTEGER,
3757                            }
3758                        }
3759                    }
3760                }
3761
3762                MOD | MODEQ => {
3763                    if is_float {
3764                        // float % 0.0 → NaN per IEEE; let it fall
3765                        // through to f64 semantics rather than
3766                        // raising the integer-only error.
3767                        mnumber {
3768                            l: 0,
3769                            d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3770                                % (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3771                            type_: MN_FLOAT,
3772                        }
3773                    } else if !notzero(b) {
3774                        m_error_set("division by zero".to_string());
3775                        return;
3776                    } else {
3777                        let bi = (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l });
3778                        if bi == -1 {
3779                            mnumber {
3780                                l: 0,
3781                                d: 0.0,
3782                                type_: MN_INTEGER,
3783                            }
3784                        } else {
3785                            mnumber {
3786                                l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }) % bi,
3787                                d: 0.0,
3788                                type_: MN_INTEGER,
3789                            }
3790                        }
3791                    }
3792                }
3793
3794                PLUS | PLUSEQ => {
3795                    if is_float {
3796                        mnumber {
3797                            l: 0,
3798                            d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3799                                + (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3800                            type_: MN_FLOAT,
3801                        }
3802                    } else {
3803                        mnumber {
3804                            l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3805                                .wrapping_add((if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l })),
3806                            d: 0.0,
3807                            type_: MN_INTEGER,
3808                        }
3809                    }
3810                }
3811
3812                MINUS | MINUSEQ => {
3813                    if is_float {
3814                        mnumber {
3815                            l: 0,
3816                            d: (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3817                                - (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }),
3818                            type_: MN_FLOAT,
3819                        }
3820                    } else {
3821                        mnumber {
3822                            l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3823                                .wrapping_sub((if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l })),
3824                            d: 0.0,
3825                            type_: MN_INTEGER,
3826                        }
3827                    }
3828                }
3829
3830                SHLEFT | SHLEFTEQ => mnumber {
3831                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3832                        << ((if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }) as u32 & 63),
3833                    d: 0.0,
3834                    type_: MN_INTEGER,
3835                },
3836                SHRIGHT | SHRIGHTEQ => mnumber {
3837                    l: (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3838                        >> ((if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }) as u32 & 63),
3839                    d: 0.0,
3840                    type_: MN_INTEGER,
3841                },
3842
3843                LES => mnumber {
3844                    l: if is_float {
3845                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3846                            < (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3847                            as i64
3848                    } else {
3849                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3850                            < (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3851                            as i64
3852                    },
3853                    d: 0.0,
3854                    type_: MN_INTEGER,
3855                },
3856                LEQ => mnumber {
3857                    l: if is_float {
3858                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3859                            <= (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3860                            as i64
3861                    } else {
3862                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3863                            <= (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3864                            as i64
3865                    },
3866                    d: 0.0,
3867                    type_: MN_INTEGER,
3868                },
3869                GRE => mnumber {
3870                    l: if is_float {
3871                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3872                            > (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3873                            as i64
3874                    } else {
3875                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3876                            > (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3877                            as i64
3878                    },
3879                    d: 0.0,
3880                    type_: MN_INTEGER,
3881                },
3882                GEQ => mnumber {
3883                    l: if is_float {
3884                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3885                            >= (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3886                            as i64
3887                    } else {
3888                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3889                            >= (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3890                            as i64
3891                    },
3892                    d: 0.0,
3893                    type_: MN_INTEGER,
3894                },
3895                DEQ => mnumber {
3896                    l: if is_float {
3897                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3898                            == (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3899                            as i64
3900                    } else {
3901                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3902                            == (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3903                            as i64
3904                    },
3905                    d: 0.0,
3906                    type_: MN_INTEGER,
3907                },
3908                NEQ => mnumber {
3909                    l: if is_float {
3910                        ((if a.type_ == MN_FLOAT { a.d } else { a.l as f64 })
3911                            != (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 }))
3912                            as i64
3913                    } else {
3914                        ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l })
3915                            != (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }))
3916                            as i64
3917                    },
3918                    d: 0.0,
3919                    type_: MN_INTEGER,
3920                },
3921
3922                DAND | DANDEQ => mnumber {
3923                    l: ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }) != 0
3924                        && (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }) != 0)
3925                        as i64,
3926                    d: 0.0,
3927                    type_: MN_INTEGER,
3928                },
3929                DOR | DOREQ => mnumber {
3930                    l: ((if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }) != 0
3931                        || (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }) != 0)
3932                        as i64,
3933                    d: 0.0,
3934                    type_: MN_INTEGER,
3935                },
3936                DXOR | DXOREQ => {
3937                    let ai = (if a.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(a.d) } else { a.l }) != 0;
3938                    let bi = (if b.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(b.d) } else { b.l }) != 0;
3939                    mnumber {
3940                        l: (ai != bi) as i64,
3941                        d: 0.0,
3942                        type_: MN_INTEGER,
3943                    }
3944                }
3945
3946                POWER | POWEREQ => {
3947                    // c:1335 — POWER '**'
3948                    let mut a = a;
3949                    let mut b = b;
3950                    let mut cf = is_float; // c.type == MN_FLOAT
3951                                           // c:1337 — integer base with a negative integer exponent
3952                                           // "produces a real result, so cast to real." The cast of a
3953                                           // to float MUST happen before the zero check below: notzero
3954                                           // never faults on a float zero, so the all-integer
3955                                           // `0 ** -n` becomes pow(0.0,-n)=Inf rather than an error.
3956                    // !!! BASH-MODE (no C counterpart) !!! bash arithmetic is integer-only:
3957                    // a negative exponent is an error, not a real result.
3958                    if !cf && b.l < 0 && crate::dash_mode::bash_mode() {
3959                        m_error_set("exponent less than 0".to_string());
3960                        return;
3961                    }
3962                    if !cf && b.l < 0 {
3963                        a = mnumber {
3964                            l: 0,
3965                            d: a.l as f64,
3966                            type_: MN_FLOAT,
3967                        }; // c:1340
3968                        b = mnumber {
3969                            l: 0,
3970                            d: b.l as f64,
3971                            type_: MN_FLOAT,
3972                        }; // c:1341
3973                        cf = true; // c:1339 (a.type = b.type = c.type = MN_FLOAT)
3974                    }
3975                    if !cf {
3976                        // c:1344 — for (c.u.l = 1; b.u.l--; c.u.l *= a.u.l).
3977                        // zsh's naive O(e) loop times out on a pathological
3978                        // exponent (`0 ** 4.6e9` loops billions of times).
3979                        // zshrs computes the IDENTICAL value via
3980                        // exponentiation-by-squaring in O(log e): multiplication
3981                        // mod 2^64 is associative, so the wrapped product is
3982                        // bit-identical to the repeated-multiply result for every
3983                        // base/exponent (verified against zsh across overflow
3984                        // cases). b.l is >= 0 here (negative exponents were cast
3985                        // to float above).
3986                        let base = a.l;
3987                        let mut e = b.l;
3988                        let mut result = 1i64;
3989                        let mut acc = base;
3990                        while e > 0 {
3991                            if e & 1 == 1 {
3992                                result = result.wrapping_mul(acc);
3993                            }
3994                            e >>= 1;
3995                            if e > 0 {
3996                                acc = acc.wrapping_mul(acc);
3997                            }
3998                        }
3999                        mnumber {
4000                            l: result,
4001                            d: 0.0,
4002                            type_: MN_INTEGER,
4003                        }
4004                    } else {
4005                        let af = (if a.type_ == MN_FLOAT { a.d } else { a.l as f64 });
4006                        let bf = (if b.type_ == MN_FLOAT { b.d } else { b.l as f64 });
4007                        // c:1346 — `if (b.u.d <= 0 && !notzero(a)) return;`
4008                        // notzero faults (division by zero) only on an INTEGER
4009                        // zero, so a base that was cast to float above slips
4010                        // through and yields Inf; a genuine integer-zero base
4011                        // (e.g. `0 ** -4.0`, no cast) still errors.
4012                        if bf <= 0.0 && !notzero(a) {
4013                            m_error_set("division by zero".to_string());
4014                            return;
4015                        }
4016                        // c:1348 — (-num ** b) with non-integer b is imaginary
4017                        if af < 0.0 && bf != bf.trunc() {
4018                            m_error_set("bad math expression: imaginary power".to_string()); // c:1350
4019                            return;
4020                        }
4021                        mnumber {
4022                            l: 0,
4023                            d: af.powf(bf), // c:1356
4024                            type_: MN_FLOAT,
4025                        }
4026                    }
4027                }
4028
4029                COMMA => b,
4030                EQ => b,
4031
4032                _ => mnumber {
4033                    l: 0,
4034                    d: 0.0,
4035                    type_: MN_INTEGER,
4036                },
4037            }
4038        };
4039
4040        // Handle assignment
4041        if (tp & (OP_E2 | OP_E2IO)) != 0 {
4042            if let Some(ref name) = mv_a.lval {
4043                let final_val = setmathvar(name, result, what != EQ); // c:1171 pop(what == EQ)
4044                push(final_val, Some(name.clone()));
4045            } else {
4046                // c:Src/math.c:997 — `zerr("bad math expression: lvalue
4047                // required")`. The prefix was missing here (unlike the sibling
4048                // sites at getvar/setvar), so `(( 1 = 2 ))` printed
4049                // `lvalue required` instead of `bad math expression: lvalue
4050                // required`. Bug #1025.
4051                m_error_set("bad math expression: lvalue required".to_string());
4052                push(
4053                    mnumber {
4054                        l: 0,
4055                        d: 0.0,
4056                        type_: MN_INTEGER,
4057                    },
4058                    None,
4059                );
4060            }
4061        } else {
4062            push(result, None);
4063        }
4064        return;
4065    }
4066
4067    // Unary operators
4068    if m_stack_is_empty() {
4069        // zsh: unary op with empty stack -> `bad math
4070        // expression: operand expected at end of string`.
4071        // zshrs's bare `stack empty` had no match for scripts
4072        // grepping zsh's canonical wording.
4073        m_error_set("bad math expression: operand expected at end of string".to_string());
4074        return;
4075    }
4076
4077    let mut mv = pop_with_lval();
4078    // c:1375 + c:1400/1408/1436/1443 — `getmathparam(stack + sp)` and
4079    // `setmathvar(stack + sp, …)` operate on the SAME mathvalue, so the
4080    // ++/-- operators resolve their operand's subscript once. See the note
4081    // at the head of `op`.
4082    if matches!(what, POSTPLUS | POSTMINUS | PREPLUS | PREMINUS) {
4083        mv.pval = resolve_lval(&mv.lval);
4084        if mv.pval.is_some() {
4085            mv.lval = mv.pval.clone();
4086        }
4087    }
4088    let mv = mv;
4089    let val = if (mv.val.type_ == MN_UNSET) {
4090        if let Some(ref name) = mv.lval {
4091            getmathparam(name)
4092        } else {
4093            mnumber {
4094                l: 0,
4095                d: 0.0,
4096                type_: MN_INTEGER,
4097            }
4098        }
4099    } else {
4100        mv.val
4101    };
4102
4103    match what {
4104        NOT => {
4105            let result = mnumber {
4106                l: if ((val.type_ == MN_INTEGER && val.l == 0)
4107                    || (val.type_ == MN_FLOAT && val.d == 0.0)
4108                    || val.type_ == MN_UNSET)
4109                {
4110                    1
4111                } else {
4112                    0
4113                },
4114                d: 0.0,
4115                type_: MN_INTEGER,
4116            };
4117            push(result, None);
4118        }
4119        COMP => {
4120            let result = mnumber {
4121                l: !(if val.type_ == MN_FLOAT {
4122                    crate::zlong_cast::zlong_from_double(val.d)
4123                } else {
4124                    val.l
4125                }),
4126                d: 0.0,
4127                type_: MN_INTEGER,
4128            };
4129            push(result, None);
4130        }
4131        UPLUS => {
4132            push(val, None);
4133        }
4134        UMINUS => {
4135            let result = if (val.type_ == MN_FLOAT) {
4136                mnumber {
4137                    l: 0,
4138                    d: -(if val.type_ == MN_FLOAT {
4139                        val.d
4140                    } else {
4141                        val.l as f64
4142                    }),
4143                    type_: MN_FLOAT,
4144                }
4145            } else {
4146                // c:Src/math.c UMINUS — negating INT_MIN is UB in
4147                // C but in two's complement wraps to INT_MIN. zsh
4148                // prints \`-9223372036854775808\` for \`\$((-(2**63)))\`.
4149                // Rust's plain unary `-` panics in debug builds on
4150                // i64::MIN, so use wrapping_neg.
4151                let v = if val.type_ == MN_FLOAT {
4152                    crate::zlong_cast::zlong_from_double(val.d)
4153                } else {
4154                    val.l
4155                };
4156                mnumber {
4157                    l: v.wrapping_neg(),
4158                    d: 0.0,
4159                    type_: MN_INTEGER,
4160                }
4161            };
4162            push(result, None);
4163        }
4164        POSTPLUS => {
4165            // ++/-- on a literal (`5++`, `--5`) is a zsh error:
4166            // "bad math expression: lvalue required". Without the
4167            // mv.lval guard, zshrs silently incremented the
4168            // literal value and returned it, masking the bug.
4169            if mv.lval.is_none() {
4170                m_error_set("bad math expression: lvalue required".to_string());
4171                return;
4172            }
4173            let name = mv.lval.as_ref().unwrap();
4174            let new_val = if (val.type_ == MN_FLOAT) {
4175                mnumber {
4176                    l: 0,
4177                    d: (if val.type_ == MN_FLOAT {
4178                        val.d
4179                    } else {
4180                        val.l as f64
4181                    }) + 1.0,
4182                    type_: MN_FLOAT,
4183                }
4184            } else {
4185                mnumber {
4186                    l: (if val.type_ == MN_FLOAT {
4187                        crate::zlong_cast::zlong_from_double(val.d)
4188                    } else {
4189                        val.l
4190                    }) + 1,
4191                    d: 0.0,
4192                    type_: MN_INTEGER,
4193                }
4194            };
4195            setmathvar(name, new_val, true); // c:1375 getmathparam read it
4196            push(val, None); // Return original value
4197        }
4198        POSTMINUS => {
4199            if mv.lval.is_none() {
4200                m_error_set("bad math expression: lvalue required".to_string());
4201                return;
4202            }
4203            let name = mv.lval.as_ref().unwrap();
4204            let new_val = if (val.type_ == MN_FLOAT) {
4205                mnumber {
4206                    l: 0,
4207                    d: (if val.type_ == MN_FLOAT {
4208                        val.d
4209                    } else {
4210                        val.l as f64
4211                    }) - 1.0,
4212                    type_: MN_FLOAT,
4213                }
4214            } else {
4215                mnumber {
4216                    l: (if val.type_ == MN_FLOAT {
4217                        crate::zlong_cast::zlong_from_double(val.d)
4218                    } else {
4219                        val.l
4220                    }) - 1,
4221                    d: 0.0,
4222                    type_: MN_INTEGER,
4223                }
4224            };
4225            setmathvar(name, new_val, true); // c:1375 getmathparam read it
4226            push(val, None);
4227        }
4228        PREPLUS => {
4229            if mv.lval.is_none() {
4230                m_error_set("bad math expression: lvalue required".to_string());
4231                return;
4232            }
4233            let name = mv.lval.as_ref().unwrap();
4234            let new_val = if (val.type_ == MN_FLOAT) {
4235                mnumber {
4236                    l: 0,
4237                    d: (if val.type_ == MN_FLOAT {
4238                        val.d
4239                    } else {
4240                        val.l as f64
4241                    }) + 1.0,
4242                    type_: MN_FLOAT,
4243                }
4244            } else {
4245                mnumber {
4246                    l: (if val.type_ == MN_FLOAT {
4247                        crate::zlong_cast::zlong_from_double(val.d)
4248                    } else {
4249                        val.l
4250                    }) + 1,
4251                    d: 0.0,
4252                    type_: MN_INTEGER,
4253                }
4254            };
4255            setmathvar(name, new_val, true); // c:1375 getmathparam read it
4256            push(new_val, mv.lval);
4257        }
4258        PREMINUS => {
4259            if mv.lval.is_none() {
4260                m_error_set("bad math expression: lvalue required".to_string());
4261                return;
4262            }
4263            let name = mv.lval.as_ref().unwrap();
4264            let new_val = if (val.type_ == MN_FLOAT) {
4265                mnumber {
4266                    l: 0,
4267                    d: (if val.type_ == MN_FLOAT {
4268                        val.d
4269                    } else {
4270                        val.l as f64
4271                    }) - 1.0,
4272                    type_: MN_FLOAT,
4273                }
4274            } else {
4275                mnumber {
4276                    l: (if val.type_ == MN_FLOAT {
4277                        crate::zlong_cast::zlong_from_double(val.d)
4278                    } else {
4279                        val.l
4280                    }) - 1,
4281                    d: 0.0,
4282                    type_: MN_INTEGER,
4283                }
4284            };
4285            setmathvar(name, new_val, true); // c:1375 getmathparam read it
4286            push(new_val, mv.lval);
4287        }
4288        QUEST => {
4289            // Ternary: stack has [cond, true_val, false_val]
4290            // val already popped = false_val
4291            // Need to pop true_val and cond
4292            if m_stack_len() < 2 {
4293                m_error_set("?: needs 3 operands".to_string());
4294                return;
4295            }
4296            let false_val = val;
4297            let true_val = pop();
4298            let cond = pop();
4299            let result = if !((cond.type_ == MN_INTEGER && cond.l == 0)
4300                || (cond.type_ == MN_FLOAT && cond.d == 0.0)
4301                || cond.type_ == MN_UNSET)
4302            {
4303                true_val
4304            } else {
4305                false_val
4306            };
4307            push(result, None);
4308        }
4309        COLON => {
4310            m_error_set("bad math expression: ':' without '?'".to_string()); // c:1427
4311        }
4312        _ => {
4313            m_error_set("unknown operator".to_string());
4314        }
4315    }
4316}
4317
4318/// Port of `bop()` from `Src/math.c:1454` — C decl `bop(int tk)`.
4319///
4320/// Short-circuit boolean prologue. Inspects (without popping) the
4321/// top of stack and bumps `m_noeval()` for the parse-only side of
4322/// `&&` / `||` / their assignment forms. The matching decrement
4323/// happens after `mathparse` recurses for the RHS.
4324/// WARNING: param names don't match C — Rust=() vs C=(tk)
4325pub(crate) fn bop(tk: i32) {
4326    if m_stack_is_empty() {
4327        return;
4328    }
4329    let mv = m_stack_top_clone().unwrap();
4330    let val = if (mv.val.type_ == MN_UNSET) {
4331        if let Some(ref name) = mv.lval {
4332            getmathparam(name)
4333        } else {
4334            mnumber {
4335                l: 0,
4336                d: 0.0,
4337                type_: MN_INTEGER,
4338            }
4339        }
4340    } else {
4341        mv.val
4342    };
4343
4344    // c:Src/math.c:1461 — `tst = (spval->type & MN_FLOAT) ? (zlong)spval->u.d
4345    // : spval->u.l;`. A FLOAT operand is TRUNCATED to integer for the
4346    // short-circuit truth test (`(zlong)0.5` == 0 → falsy), NOT compared
4347    // against 0.0. zsh's `&&`/`||` therefore treat a fractional float like
4348    // 0.5 as false. The prior `val.d == 0.0` test made 0.5 spuriously TRUE,
4349    // so `0.5 || (2+3)` short-circuited on the truthy 0.5 and set noeval for
4350    // the RHS; a COMPOUND RHS under noeval collapses to a dummy 0, and the
4351    // `||` operator then combined truncated-0 with that 0 → wrong result 0
4352    // (zsh evaluates the RHS and yields 1). A bare-literal RHS masked the
4353    // bug because its value survives noeval.
4354    let tst = if val.type_ & MN_FLOAT != 0 {
4355        (crate::zlong_cast::zlong_from_double(val.d)) != 0
4356    } else {
4357        val.l != 0
4358    };
4359    match tk {
4360        DAND | DANDEQ if !tst => {
4361            m_noeval_inc();
4362        }
4363        DOR | DOREQ if tst => {
4364            m_noeval_inc();
4365        }
4366        _ => {}
4367    }
4368}
4369
4370/// Port of `matheval()` from `Src/math.c:1480` — C decl `mnumber matheval(char *s)`.
4371///
4372/// C body (c:1481-1500):
4373/// ```c
4374/// char *junk;
4375/// mnumber x;
4376/// int xmtok = mtok;
4377/// /* maintain outputradix and outputunderscore across levels of evaluation */
4378/// if (!mlevel)
4379///     outputradix = outputunderscore = 0;
4380///
4381/// if (*s == Nularg)
4382///     s++;
4383/// if (!*s) {
4384///     x.type = MN_INTEGER;
4385///     x.u.l = 0;
4386///     return x;
4387/// }
4388/// x = mathevall(s, MPREC_TOP, &junk);
4389/// mtok = xmtok;
4390/// if (*junk)
4391///     zerr("bad math expression: invalid character: %c", *junk);
4392/// return x;
4393/// ```
4394///
4395/// Three divergences in the previous Rust port:
4396///   1. Missing Nularg-byte skip at c:1489-1490 — `$(())` lexes
4397///      with a leading Nularg sentinel; without the skip, the math
4398///      evaluator chokes on the 0xa1 byte instead of evaluating
4399///      the empty expression as 0.
4400///   2. Missing empty-input fast path at c:1491-1495 — empty
4401///      string returned MN_INTEGER 0 in C; Rust port tried to
4402///      evaluate via mathevall and produced a parse error.
4403///   3. Missing mtok save/restore around mathevall (c:1483, c:1496) —
4404///      recursive math calls (e.g. `$((f($((g)))))`) overwrote the
4405///      outer call's mtok mid-parse.
4406pub fn matheval(s: &str) -> Result<mnumber, String> {
4407    // c:1480
4408    // c:1483 — `int xmtok = mtok;` save.
4409    let xmtok = M_MTOK.with(|c| c.get()); // c:1483
4410
4411    // c:1489-1490 — `if (*s == Nularg) s++;`. The 0xa1 sentinel byte
4412    // can prefix expressions emerging from the parser; skip it.
4413    let s = if let Some(rest) = s.strip_prefix(Nularg) {
4414        // c:1489
4415        rest
4416    } else {
4417        s
4418    };
4419    // c:1486-1487 — `if (!mlevel) outputradix = outputunderscore = 0;`
4420    //
4421    // Only a TOP-LEVEL evaluation starts from a clean radix. When `mlevel` is
4422    // already nonzero this `matheval` is running underneath an in-flight one
4423    // (a `functions -M` math function whose body runs `(( … ))`), and C leaves
4424    // the radix alone so the outer `[#16]` still governs the printed result.
4425    if M_LEVEL.with(|c| c.get()) == 0 {
4426        // c:1486
4427        reset_output_format(); // c:1487
4428    }
4429
4430    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's `$(( ))` is an error:
4431    // "expecting primary".
4432    if crate::dash_mode::dash_faithful() && s.trim().is_empty() {
4433        return Err("bad math expression: expression expected".to_string());
4434    }
4435    // c:1491-1495 — empty expression returns MN_INTEGER 0.
4436    if s.is_empty() {
4437        // c:1491
4438        return Ok(mnumber {
4439            l: 0,
4440            d: 0.0,
4441            type_: MN_INTEGER,
4442        }); // c:1493-1494
4443    }
4444    // c:Src/math.c:406 `stack = nstack;` / c:455 `stack = xstack;`.
4445    //
4446    // C's per-name value cache is `mptr->pval` (c:340-343), and every
4447    // `struct mathvalue` holding one lives on `stack`, which at
4448    // c:406 is pointed at the LOCAL array `nstack[STACKSZ]` (c:374) and at
4449    // c:455 is put back to the caller's `xstack` (saved at c:395). So the
4450    // lifetime is exactly one `mathevall` frame — after the evaluation
4451    // returns there is nothing left to consult but the parameter table
4452    // (`getvalue`, c:343 / `setnparam`, c:1005).
4453    //
4454    // The Rust port's counterpart of `mptr->pval` is the `M_VARIABLES`
4455    // (+ `M_STRING_VARIABLES`) thread_local, which is NOT a local: it
4456    // outlived the evaluation, because it was only ever cleared lazily
4457    // by the NEXT evaluation's `new()` (c.f. `new`, below). Anything
4458    // calling `getmathparam` outside a `mathevall` — the VM's
4459    // BUILTIN_GET_MATH_VAR does exactly that — therefore read a stale
4460    // cache entry in preference to the real parameter, and the two
4461    // stores could disagree for an unbounded window (`i=0; (( i=5 ));`
4462    // then a slot-compiled `for ((i=1; i<=3; i++))` saw 5, not 1).
4463    // Nested evaluations never had the problem because
4464    // `restore_state` (Rust-only, mirroring c:446-457) already puts the
4465    // caller's map back. Give the top-level evaluation the same
4466    // discipline so the cache dies with its frame, as `nstack` does.
4467    //
4468    // Safe to drop on exit because `setmathvar` (c:1005) writes the
4469    // canonical store — `setnparam` / `assignsparam` — on every
4470    // assignment; the map entry it also fills is only a within-
4471    // expression read cache (see setmathvar's `m_variables_insert`).
4472    let xvariables = m_variables_clone(); // c:395 `xstack = stack;`
4473    let xstring_variables = m_string_variables_clone();
4474    new(s);
4475    // c:1496-1498 (54603) — `zsh_eval_context_push("math"); x = mathevall(…);
4476    // zsh_eval_context_pop();`
4477    let math_ctx = crate::ported::exec::EvalContextFrame::push("math");
4478    let result = mathevall(prec_type::MPREC_TOP);
4479    drop(math_ctx);
4480    // c:455 — `stack = xstack;`, the cache goes out of scope with the
4481    // frame that owns it. At top level both maps were empty on entry,
4482    // so this is a clear; under a nested `matheval` it restores the
4483    // parent's, exactly as C restores the parent's `stack`.
4484    m_variables_set(xvariables);
4485    m_string_variables_set(xstring_variables);
4486    // c:1496 — `mtok = xmtok;` restore. Done even on error path.
4487    M_MTOK.with(|c| c.set(xmtok)); // c:1496
4488                                   // c:Src/math.c:1500 — `lastmathval = z;` records the result of this
4489                                   // top-level eval so callmathfunc's MFF_USERFUNC branch can return it.
4490    if let Ok(ref n) = result {
4491        M_LASTMATHVAL.with(|c| c.set(*n));
4492    }
4493    result
4494}
4495
4496/// Port of `mathevali()` from `Src/math.c:1505` — C decl
4497/// `mod_export zlong mathevali(char *s)`; the integer-coerce front-end for `matheval`.
4498///
4499/// C body (c:1505-1509):
4500/// ```c
4501/// mnumber x = matheval(s);
4502/// return (x.type & MN_FLOAT) ? (zlong)x.u.d : x.u.l;
4503/// ```
4504///
4505/// Uses bitwise AND against MN_FLOAT — `x.type` is a bitfield holding
4506/// MN_INTEGER (1), MN_FLOAT (2), MN_UNSET (4). The previous Rust port
4507/// did `n.type_ == MN_FLOAT` (strict equality) — which misclassifies
4508/// any result where MN_FLOAT is set alongside another flag (e.g. an
4509/// uninitialized-then-set result might carry MN_FLOAT|MN_UNSET = 6).
4510pub fn mathevali(s: &str) -> Result<i64, String> {
4511    // c:1505
4512    matheval(s).map(|n|                                                      // c:1506
4513        if (n.type_ & MN_FLOAT) != 0 { crate::zlong_cast::zlong_from_double(n.d) } else { n.l }) // c:1508
4514}
4515
4516/// Variant of `mathevali` that runs in NOEVAL mode — parses and
4517/// type-checks but does NOT execute side effects (assignments to
4518/// paramtab via setmathvar's c:1002-1003 noeval gate). Used by the
4519/// compile-time pre-check at compile_zsh.rs to validate `(( expr ))`
4520/// without polluting the param table. Bug #617.
4521/// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
4522/// A NOEVAL-mode variant of `mathevali` (Src/math.c:1505). C has no such entry
4523/// point — it reaches parse-only mode by raising the `noeval` global
4524/// (Src/math.c:40) from inside `bop` (Src/math.c:1454). zshrs needs a public
4525/// front door for the compile-time `(( expr ))` pre-check, which must not
4526/// write the parameter table.
4527pub fn mathevali_noeval(s: &str) -> Result<i64, String> {
4528    // new() inside matheval resets noeval to 0; we work around that
4529    // by intercepting at matheval's entry. Run matheval, but bump
4530    // noeval AFTER new() has reset it — by hooking via the mathevall
4531    // path with noeval pre-set wouldn't work because new() also resets.
4532    //
4533    // Solution: replicate matheval's setup but set noeval manually
4534    // before mathevall. mathevall itself respects noeval inside the
4535    // op() dispatch — setmathvar checks at c:1002.
4536    let xmtok = M_MTOK.with(|c| c.get());
4537    let s_skip = if let Some(rest) = s.strip_prefix(Nularg) {
4538        rest
4539    } else {
4540        s
4541    };
4542    if s_skip.is_empty() {
4543        return Ok(0);
4544    }
4545    // Same `stack = nstack` / `stack = xstack` cache lifetime as
4546    // `matheval` above (c:406 / c:455) — this entry point runs a full
4547    // evaluator frame too, so its value cache must not outlive it.
4548    let xvariables = m_variables_clone();
4549    let xstring_variables = m_string_variables_clone();
4550    new(s_skip);
4551    m_noeval_set(1); // bump AFTER new() reset
4552    let result = mathevall(prec_type::MPREC_TOP);
4553    m_noeval_set(0);
4554    m_variables_set(xvariables); // c:455
4555    m_string_variables_set(xstring_variables);
4556    M_MTOK.with(|c| c.set(xmtok));
4557    result.map(|n| {
4558        if (n.type_ & MN_FLOAT) != 0 {
4559            crate::zlong_cast::zlong_from_double(n.d)
4560        } else {
4561            n.l
4562        }
4563    })
4564}
4565
4566/// Port of `mathevalarg()` from `Src/math.c:1514` — C decl `zlong mathevalarg(char *s, char **ss)` (body c:1514-1539).
4567///
4568/// C body (c:1517-1538):
4569/// ```c
4570/// mnumber x;
4571/// int xmtok = mtok;
4572/// /* At this entry point we don't allow an empty expression,
4573///  * whereas we do with matheval(). */
4574/// if (*s == Nularg)
4575///     s++;
4576/// if (!*s) {
4577///     zerr("bad math expression: empty string");
4578///     return (zlong)0;
4579/// }
4580/// x = mathevall(s, MPREC_ARG, ss);
4581/// if (mtok == COMMA)
4582///     (*ss)--;
4583/// mtok = xmtok;
4584/// return (x.type & MN_FLOAT) ? (zlong)x.u.d : x.u.l;
4585/// ```
4586///
4587/// Two key differences from `matheval`:
4588///   1. Empty input is an ERROR (zerr + return 0), NOT silent 0.
4589///      C's comment: `$array[$ind]` where `$ind` is unset should
4590///      produce an error, not silently index 0.
4591///   2. Uses `MPREC_ARG` precedence so the parser stops at the
4592///      end-of-arg boundary (comma, close-paren) rather than
4593///      consuming everything as one top-level expression.
4594///      (Rust mathevall doesn't yet thread the prec_tp arg;
4595///      flagged for follow-up.)
4596pub(crate) fn mathevalarg(expr: &str) -> i64 {
4597    // c:1514
4598    // c:1517 — `int xmtok = mtok;` save.
4599    let xmtok = M_MTOK.with(|c| c.get()); // c:1517
4600                                          // c:1528-1529 — `if (*s == Nularg) s++;`. Skip the parser sentinel.
4601    let s = if let Some(rest) = expr.strip_prefix(Nularg) {
4602        // c:1528
4603        rest
4604    } else {
4605        expr
4606    };
4607    // c:1530-1532 — empty after Nularg-skip is a HARD error here.
4608    if s.is_empty() {
4609        // c:1530
4610        zerr("bad math expression: empty string"); // c:1531
4611        return 0; // c:1532
4612    }
4613    // c:1540-1542 — `zsh_eval_context_push("math"); x = mathevall(s, MPREC_ARG, ss);`
4614    //
4615    // ARGPREC, not TOPPREC: mathparse's empty-input shortcut (c:1609-1613) is
4616    // skipped, so an operand the lexer cannot start (`@`, zzlex c:907 EOI)
4617    // reaches checkunary and reports "operand expected at `@'" (c:1592). The
4618    // previous body called `matheval`, which parsed at TOPPREC, returned early
4619    // on that EOI and let the c:1497 junk check say "illegal character: @".
4620    // The per-frame variable cache is scoped exactly as in `matheval` above
4621    // (c:395 `xstack = stack;` / c:455 `stack = xstack;`).
4622    let xvariables = m_variables_clone();
4623    let xstring_variables = m_string_variables_clone();
4624    new(s);
4625    let math_ctx = crate::ported::exec::EvalContextFrame::push("math"); // c:1540
4626    let result = mathevall(prec_type::MPREC_ARG); // c:1541
4627    drop(math_ctx); // c:1542 zsh_eval_context_pop()
4628    m_variables_set(xvariables);
4629    m_string_variables_set(xstring_variables);
4630    // c:1545 — `mtok = xmtok;` restore.
4631    M_MTOK.with(|c| c.set(xmtok)); // c:1545
4632    match result {
4633        // c:1546 — `(x.type & MN_FLOAT) ? (zlong)x.u.d : x.u.l`. Bitwise
4634        // check against MN_FLOAT; strict equality `== MN_FLOAT` misclassifies
4635        // composite type bitfields (e.g. MN_FLOAT|MN_UNSET).
4636        Ok(n) => {
4637            if (n.type_ & MN_FLOAT) != 0 {
4638                crate::zlong_cast::zlong_from_double(n.d)
4639            } else {
4640                n.l
4641            }
4642        }
4643        // C's parser zerrs in place (checkunary c:1589-1592, division by zero,
4644        // …) and mathevall returns the zero mnumber (c:432-433). The Rust
4645        // parser carries the message in Err; raise it here with the same text.
4646        // The previous body dropped it (`unwrap_or(0)`), so `arr=(x y);
4647        // k='@'; print $arr[$k]` failed silently where zsh reports it.
4648        Err(msg) => {
4649            zerr(&msg);
4650            0
4651        }
4652    }
4653}
4654
4655/// Port of `checkunary()` from `Src/math.c:1548` — C decl `checkunary(int mtokc, char *mptr)`.
4656///
4657/// Two roles. (1) Validate that the just-lexed token (`m_mtok()`)
4658/// matches the parser's expectation: an operand was wanted but an
4659/// operator (`OP_*` flags) showed up, or vice versa. Mismatch
4660/// emits zsh's `bad math expression: <kind> expected at <ctx>`
4661/// with `<kind>` being `operator` or `operand` and `<ctx>` taken
4662/// from the input pointer at the start of the bad token. (2)
4663/// Update `m_unary()` for the next iteration based on `OP_OPF`.
4664/// WARNING: param names don't match C — Rust=() vs C=(mtokc, mptr)
4665pub(crate) fn checkunary() {
4666    // Direct port of zsh math.c checkunary() (line 1548).
4667    // Two roles:
4668    //   1. Validate that the just-lexed token (`m_mtok()`)
4669    //      matches the parser's expectation (operator vs
4670    //      operand). Mismatch emits zsh's
4671    //      "bad math expression: <kind> expected at <ctx>"
4672    //      with `<kind>` = `operator` (errmsg=2) or `operand`
4673    //      (errmsg=1). zshrs previously only did step 2,
4674    //      which left e.g. `let "5 5"` and `$((2#1011x))`
4675    //      silently accepting bogus input.
4676    //   2. Update `m_unary()` for the next iteration.
4677    let tp = OP_TYPE[m_mtok() as usize];
4678    let is_op_token = (tp & (OP_A2 | OP_A2IR | OP_A2IO | OP_E2 | OP_E2IO | OP_OP)) != 0;
4679    let errmsg = if is_op_token {
4680        if m_unary() {
4681            1
4682        } else {
4683            0
4684        }
4685    } else if !m_unary() {
4686        2
4687    } else {
4688        0
4689    };
4690    if errmsg != 0 && !m_error_some() {
4691        let errtype = if errmsg == 2 { "operator" } else { "operand" };
4692        // zsh's `mptr` is the input position BEFORE zzlex
4693        // consumed the bad token. We track the same via
4694        // `tok_start` which zzlex updates after whitespace
4695        // skip. Walk forward past whitespace (mirrors zsh's
4696        // `inblank` skip) so the error context starts at
4697        // the first visible char.
4698        let input_owned = m_input_clone();
4699        let bytes = input_owned.as_bytes();
4700        let mut start = m_tok_start();
4701        while start < bytes.len() && matches!(bytes[start], b' ' | b'\t' | b'\n') {
4702            start += 1;
4703        }
4704        // zsh truncates after 10 chars and appends `...` if
4705        // there's more remaining (the over flag in the C
4706        // source). Mirror that to keep error messages
4707        // bounded for long bogus expressions.
4708        let remaining = m_input_slice_from(start);
4709        let (ctx, over) = if remaining.chars().count() > 10 {
4710            let truncated: String = remaining.chars().take(10).collect();
4711            (truncated, true)
4712        } else {
4713            (remaining.to_string(), false)
4714        };
4715        if ctx.is_empty() {
4716            m_error_set(format!(
4717                "bad math expression: {} expected at end of string",
4718                errtype
4719            ));
4720        } else {
4721            m_error_set(format!(
4722                "bad math expression: {} expected at `{}{}'",
4723                errtype,
4724                ctx,
4725                if over { "..." } else { "" }
4726            ));
4727        }
4728    }
4729    m_unary_set((tp & OP_OPF) == 0);
4730}
4731
4732/// Operator-precedence parser - closely follows zsh math.c mathparse()
4733/// Port of `mathparse()` from `Src/math.c:1594` — C decl `mathparse(int pc)`.
4734/// WARNING: param names don't match C — Rust=() vs C=(pc)
4735pub(crate) fn mathparse(pc: u8) {
4736    if m_error_some() {
4737        return;
4738    }
4739
4740    m_mtok_set(zzlex());
4741
4742    // Handle empty input
4743    if pc == top_prec() && m_mtok() == EOI {
4744        return;
4745    }
4746
4747    checkunary();
4748
4749    while m_prec()[m_mtok() as usize] <= pc {
4750        if m_error_some() {
4751            return;
4752        }
4753
4754        match m_mtok() {
4755            NUM => {
4756                push(m_yyval(), None);
4757            }
4758            ID => {
4759                let lval = m_yylval_clone();
4760                if m_noeval() > 0 {
4761                    push(
4762                        mnumber {
4763                            l: 0,
4764                            d: 0.0,
4765                            type_: MN_INTEGER,
4766                        },
4767                        Some(lval),
4768                    );
4769                } else {
4770                    push(
4771                        mnumber {
4772                            l: 0,
4773                            d: 0.0,
4774                            type_: MN_UNSET,
4775                        },
4776                        Some(lval),
4777                    );
4778                }
4779            }
4780            CID => {
4781                let lval = m_yylval_clone();
4782                let val = if m_noeval() > 0 {
4783                    mnumber {
4784                        l: 0,
4785                        d: 0.0,
4786                        type_: MN_INTEGER,
4787                    }
4788                } else {
4789                    getcvar(&lval)
4790                };
4791                push(val, Some(lval));
4792            }
4793            FUNC => {
4794                let func_call = m_yylval_clone();
4795                let val = if m_noeval() > 0 {
4796                    mnumber {
4797                        l: 0,
4798                        d: 0.0,
4799                        type_: MN_INTEGER,
4800                    }
4801                } else {
4802                    callmathfunc(&func_call)
4803                };
4804                push(val, None);
4805            }
4806            M_INPAR => {
4807                mathparse(top_prec());
4808                if m_mtok() != M_OUTPAR {
4809                    if !m_error_some() {
4810                        // Match zsh's `bad math expression: ')'
4811                        // expected` so error diagnostics align.
4812                        m_error_set("bad math expression: ')' expected".to_string());
4813                    }
4814                    return;
4815                }
4816            }
4817            QUEST => {
4818                // Ternary operator
4819                if m_stack_is_empty() {
4820                    m_error_set("bad math expression".to_string());
4821                    return;
4822                }
4823                let mv = m_stack_top_clone().unwrap();
4824                let cond = get_value(&mv);
4825
4826                let q = !((cond.type_ == MN_INTEGER && cond.l == 0)
4827                    || (cond.type_ == MN_FLOAT && cond.d == 0.0)
4828                    || cond.type_ == MN_UNSET);
4829                if !q {
4830                    m_noeval_inc();
4831                }
4832                let colon_prec = m_prec()[COLON as usize];
4833                let stack_before = m_stack_len();
4834                mathparse(colon_prec - 1);
4835                if !q {
4836                    m_noeval_dec();
4837                }
4838
4839                if m_mtok() != COLON {
4840                    if !m_error_some() {
4841                        // Distinguish whether the inner parse
4842                        // produced an operand: stack grew →
4843                        // colon expected; stack same → operand
4844                        // missing (input ran out at end of
4845                        // string after `?`).
4846                        if m_stack_len() > stack_before {
4847                            m_error_set("bad math expression: ':' expected".to_string());
4848                        } else {
4849                            m_error_set(
4850                                "bad math expression: operand expected at end of string"
4851                                    .to_string(),
4852                            );
4853                        }
4854                    }
4855                    return;
4856                }
4857
4858                if q {
4859                    m_noeval_inc();
4860                }
4861                let quest_prec = m_prec()[QUEST as usize];
4862                mathparse(quest_prec);
4863                if q {
4864                    m_noeval_dec();
4865                }
4866
4867                op(QUEST);
4868                continue;
4869            }
4870            _ => {
4871                // Binary/unary operator
4872                let otok = m_mtok();
4873                let onoeval = m_noeval();
4874                let tp = OP_TYPE[otok as usize];
4875                // Orphan binary at start: `let "*"`, `let "*5"`,
4876                // `let "/"`. zsh keeps its input pointer at the
4877                // start of the bad operator and emits `operand
4878                // expected at \`<remaining>'`. zshrs previously
4879                // collapsed every operand-missing case into "at
4880                // end of string" which lost the operator
4881                // location for orphan-at-start expressions.
4882                let is_binary = (tp & (OP_A2 | OP_A2IR | OP_A2IO | OP_E2 | OP_E2IO)) != 0;
4883                if m_stack_is_empty() && is_binary {
4884                    let remaining = m_input_slice_from(m_tok_start());
4885                    m_error_set(format!(
4886                        "bad math expression: operand expected at `{}'",
4887                        remaining
4888                    ));
4889                    return;
4890                }
4891                if (tp & 0x03) == BOOL {
4892                    bop(otok);
4893                }
4894                let otok_prec = m_prec()[otok as usize];
4895                // Right-to-left gets same prec, left-to-right gets prec-1
4896                let adjust = if (tp & 0x01) != RL { 1 } else { 0 };
4897                mathparse(otok_prec - adjust);
4898                m_noeval_set(onoeval);
4899                op(otok);
4900                continue;
4901            }
4902        }
4903
4904        // After operand (Num, Id, Func, InPar), get next token
4905        m_mtok_set(zzlex());
4906        checkunary();
4907    }
4908}
4909/// Zsh precedence table (default)
4910static Z_PREC: [u8; TOKCOUNT] = [
4911    1, 137, 2, 2, 2, // InPar OutPar Not Comp PostPlus
4912    2, 2, 2, 4, 5, // PostMinus UPlus UMinus And Xor
4913    6, 8, 8, 8, 9, // Or Mul Div Mod Plus
4914    9, 3, 3, 10, 10, // Minus ShLeft ShRight Les Leq
4915    10, 10, 11, 11, 12, // Gre Geq Deq Neq DAnd
4916    13, 13, 14, 15, 16, // DOr DXor Quest Colon Eq
4917    16, 16, 16, 16, 16, // PlusEq MinusEq MulEq DivEq ModEq
4918    16, 16, 16, 16, 16, // AndEq XorEq OrEq ShLeftEq ShRightEq
4919    16, 16, 16, 17, 200, // DAndEq DOrEq DXorEq Comma Eoi
4920    2, 2, 0, 0, 7, // PrePlus PreMinus Num Id Power
4921    0, 16, 0, // CId PowerEq Func
4922];
4923
4924/// C precedence table (used with C_PRECEDENCES option)
4925static C_PREC: [u8; TOKCOUNT] = [
4926    1, 137, 2, 2, 2, 2, 2, 2, 9, 10, 11, 4, 4, 4, 5, 5, 6, 6, 7, 7, 7, 7, 8, 8, 12, 14, 13, 15, 16,
4927    17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 18, 200, 2, 2, 0, 0, 3, 0, 17, 0,
4928];
4929
4930/// Operator type table (matches C math.c type[] array)
4931static OP_TYPE: [u16; TOKCOUNT] = [
4932    // InPar, OutPar, Not, Comp, PostPlus
4933    LR,
4934    LR | OP_OP | OP_OPF,
4935    RL,
4936    RL,
4937    RL | OP_OP | OP_OPF,
4938    // PostMinus, UPlus, UMinus, And, Xor
4939    RL | OP_OP | OP_OPF,
4940    RL,
4941    RL,
4942    LR | OP_A2IO,
4943    LR | OP_A2IO,
4944    // Or, Mul, Div, Mod, Plus
4945    LR | OP_A2IO,
4946    LR | OP_A2,
4947    LR | OP_A2,
4948    LR | OP_A2,
4949    LR | OP_A2,
4950    // Minus, ShLeft, ShRight, Les, Leq
4951    LR | OP_A2,
4952    LR | OP_A2IO,
4953    LR | OP_A2IO,
4954    LR | OP_A2IR,
4955    LR | OP_A2IR,
4956    // Gre, Geq, Deq, Neq, DAnd
4957    LR | OP_A2IR,
4958    LR | OP_A2IR,
4959    LR | OP_A2IR,
4960    LR | OP_A2IR,
4961    BOOL | OP_A2IO,
4962    // DOr, DXor, Quest, Colon, Eq
4963    BOOL | OP_A2IO,
4964    LR | OP_A2IO,
4965    RL | OP_OP,
4966    RL | OP_OP,
4967    RL | OP_E2,
4968    // PlusEq, MinusEq, MulEq, DivEq, ModEq
4969    RL | OP_E2,
4970    RL | OP_E2,
4971    RL | OP_E2,
4972    RL | OP_E2,
4973    RL | OP_E2,
4974    // AndEq, XorEq, OrEq, ShLeftEq, ShRightEq
4975    RL | OP_E2IO,
4976    RL | OP_E2IO,
4977    RL | OP_E2IO,
4978    RL | OP_E2IO,
4979    RL | OP_E2IO,
4980    // DAndEq, DOrEq, DXorEq, Comma, Eoi
4981    BOOL | OP_E2IO,
4982    BOOL | OP_E2IO,
4983    RL | OP_A2IO,
4984    RL | OP_A2,
4985    RL | OP_OP,
4986    // PrePlus, PreMinus, Num, Id, Power
4987    RL,
4988    RL,
4989    LR | OP_OPF,
4990    LR | OP_OPF,
4991    RL | OP_A2,
4992    // CId, PowerEq, Func
4993    LR | OP_OPF,
4994    RL | OP_E2,
4995    LR | OP_OPF,
4996];
4997
4998// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
4999// Rust-only helper. See save_state above.
5000fn restore_state(saved: xyy_locals) {
5001    m_input_set(saved.input);
5002    m_pos_set(saved.pos);
5003    m_tok_start_set(saved.tok_start);
5004    m_yyval_set(saved.yyval);
5005    m_yylval_set(saved.yylval);
5006    M_STACK.with(|c| *c.borrow_mut() = saved.stack);
5007    m_mtok_set(saved.mtok);
5008    m_unary_set(saved.unary);
5009    m_noeval_set(saved.noeval);
5010    M_ERROR.with(|c| *c.borrow_mut() = saved.error);
5011    m_variables_set(saved.variables);
5012    m_string_variables_set(saved.string_variables);
5013    m_prec_set(saved.prec);
5014    m_c_precedences_set(saved.c_precedences);
5015    m_force_float_set(saved.force_float);
5016    m_octal_zeroes_set(saved.octal_zeroes);
5017    M_LASTBASE.with(|c| c.set(saved.lastbase));
5018}
5019
5020// MathState struct DELETED — state now lives in M_* thread_locals
5021// (matching C math.c's module statics + mathevall's xyy* save/restore).
5022
5023// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5024// Rust-only initializer. C `mathevall()`
5025// (math.c:367) takes the input as a parameter and seeds the module
5026// statics inline at function entry; Rust port factors that seeding
5027// out so call sites can chain `with_*` setters before invoking
5028// `mathevall()`.
5029/// Initialize thread_local math state from a fresh input string.
5030/// Mirrors the entry-side state setup in C `mathevall()` (math.c:367).
5031pub(crate) fn new(input: &str) {
5032    m_input_set(input.to_string());
5033    m_pos_set(0);
5034    m_tok_start_set(0);
5035    m_yyval_set(mnumber {
5036        l: 0,
5037        d: 0.0,
5038        type_: MN_INTEGER,
5039    });
5040    m_yylval_set(String::new());
5041    M_STACK.with(|c| {
5042        c.borrow_mut().clear();
5043    });
5044    m_mtok_set(EOI);
5045    m_unary_set(true);
5046    m_noeval_set(0);
5047    m_lastbase_set(-1);
5048    m_prec_set(&Z_PREC);
5049    m_c_precedences_set(false);
5050    m_force_float_set(false);
5051    m_octal_zeroes_set(false);
5052    m_variables_set(HashMap::new());
5053    m_string_variables_set(HashMap::new());
5054    m_lastval_set(0);
5055    m_pid_set(crate::ported::params::mypid.load(std::sync::atomic::Ordering::Relaxed));
5056    m_error_clear();
5057}
5058
5059// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5060// Rust-only setter. zsh C reads parameters
5061// directly from the global param table on demand; the Rust port
5062// caller seeds an in-memory map up front via this fn.
5063pub(crate) fn with_variables(vars: HashMap<String, mnumber>) {
5064    m_variables_set(vars);
5065}
5066
5067// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5068// Rust-only setter. Parses each value as
5069// numeric → `mnumber` if possible, otherwise stores the raw string
5070// for `getmathparam`'s recursive-eval path (e.g. `a="3+2"; $((a))`).
5071/// Inject variables from string->string mapping (for shell integration)
5072pub(crate) fn with_string_variables(vars: &HashMap<String, String>) {
5073    for (k, v) in vars {
5074        if let Ok(i) = v.parse::<i64>() {
5075            m_variables_insert(
5076                k.clone(),
5077                mnumber {
5078                    l: i,
5079                    d: 0.0,
5080                    type_: MN_INTEGER,
5081                },
5082            );
5083        } else if let Ok(f) = v.parse::<f64>() {
5084            m_variables_insert(
5085                k.clone(),
5086                mnumber {
5087                    l: 0,
5088                    d: f,
5089                    type_: MN_FLOAT,
5090                },
5091            );
5092        } else if !v.is_empty() {
5093            // Non-numeric string — keep raw so getmathparam can
5094            // recursively evaluate it as an arith expression.
5095            // zsh: `a="3+2"; $((a))` returns 5.
5096            m_string_variables_insert(k.clone(), v.clone());
5097        }
5098    }
5099}
5100
5101// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5102// Rust-only accessor. zsh C writes back
5103// to the global param table during evaluation; ShellExecutor
5104// integration uses this to harvest the post-eval variables map and
5105// merge it into its own `variables` table.
5106/// Extract modified variables as string->string mapping (for shell integration)
5107pub(crate) fn extract_string_variables() -> HashMap<String, String> {
5108    M_VARIABLES.with(|c| {
5109        c.borrow()
5110            .iter()
5111            .map(|(k, v)| {
5112                (
5113                    k.clone(),
5114                    match v.type_ {
5115                        MN_INTEGER => v.l.to_string(),
5116                        MN_FLOAT => {
5117                            let f = v.d;
5118                            if isnan(f) {
5119                                "NaN".to_string()
5120                            } else if isinf(f) {
5121                                if f > 0.0 {
5122                                    "Inf".to_string()
5123                                } else {
5124                                    "-Inf".to_string()
5125                                }
5126                            } else {
5127                                format!("{:.10}", f)
5128                            }
5129                        }
5130                        _ => "0".to_string(),
5131                    },
5132                )
5133            })
5134            .collect()
5135    })
5136}
5137
5138// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5139// Rust-only setopt mirror. zsh C reads
5140// the option flag directly from `isset(CPRECEDENCES)` inside
5141// `mathevall()`; this setter caches the bit so the evaluator
5142// avoids re-reading the option tree on every token.
5143pub(crate) fn with_c_precedences(enable: bool) {
5144    m_c_precedences_set(enable);
5145    m_prec_set(if enable { &C_PREC } else { &Z_PREC });
5146}
5147
5148// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5149// Rust-only setopt mirror for FORCE_FLOAT.
5150pub(crate) fn with_force_float(enable: bool) {
5151    m_force_float_set(enable);
5152}
5153
5154// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5155// Rust-only setopt mirror for OCTAL_ZEROES.
5156pub(crate) fn with_octal_zeroes(enable: bool) {
5157    m_octal_zeroes_set(enable);
5158}
5159
5160// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5161// Rust-only setter for `$?` (last command
5162// status) so the `?`-token in unary position can read it. zsh C
5163// reads `lastval` directly as a global.
5164pub(crate) fn with_lastval(val: i32) {
5165    m_lastval_set(val);
5166}
5167
5168// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5169// Rust-only cursor read. C uses `*ptr`
5170// directly without an fn-shaped wrapper.
5171pub(crate) fn peek() -> Option<char> {
5172    // Read the char in place. This used to be
5173    // `m_input_clone()[m_pos()..].chars().next()`, which heap-allocates a
5174    // COPY OF THE WHOLE EXPRESSION to look at one character — and `advance()`
5175    // calls this per character, so lexing an n-char expression performed n
5176    // full-string allocations, O(n^2) bytes copied per parse. `(( … ))` is
5177    // parsed on every evaluation (compile_arith emits LoadConst(<expr text>)
5178    // + CallBuiltin(ARITH_EVAL); nothing is compiled ahead of time), so this
5179    // ran once per character per loop iteration. c:Src/math.c uses `*ptr`
5180    // on the caller's buffer — no copy at all.
5181    //
5182    // ASCII fast path. `&s[pos..]` runs a UTF-8 char-boundary assertion and
5183    // `.chars().next()` runs the multi-byte decoder; both are pure overhead
5184    // for arithmetic, whose lexemes (digits, identifiers, operators) are
5185    // ASCII by construction. A byte below 0x80 is always a char boundary and
5186    // always encodes itself, so `Some(b as char)` is bit-identical to what
5187    // the slice+decode returns. `pos >= len` yields `None`, same as slicing
5188    // an empty tail. Anything else (a genuine multi-byte lead byte, or the
5189    // Dnull/Snull \u{8x} token chars the lexer leaves in quoted operands)
5190    // falls through to the original expression unchanged — including its
5191    // panic-on-non-boundary behaviour, which this path cannot reach.
5192    M_INPUT.with(|c| {
5193        let s = c.borrow();
5194        let pos = m_pos();
5195        let b = s.as_bytes();
5196        if pos >= b.len() {
5197            return None;
5198        }
5199        if b[pos] < 0x80 {
5200            return Some(b[pos] as char);
5201        }
5202        s[pos..].chars().next()
5203    })
5204}
5205
5206// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5207// Rust-only cursor advance. C uses
5208// `*ptr++` directly.
5209pub(crate) fn advance() -> Option<char> {
5210    let c = peek()?;
5211    m_pos_add(c.len_utf8());
5212    Some(c)
5213}
5214
5215// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5216// Rust-only char classifier. C uses
5217// ctype.h `idigit()` macro directly.
5218fn is_digit(c: char) -> bool {
5219    c.is_ascii_digit()
5220}
5221
5222// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5223// Rust-only char classifier. C uses
5224// `iident()` / `isalpha()` macros directly.
5225fn is_ident_start(c: char) -> bool {
5226    c.is_ascii_alphabetic() || c == '_'
5227}
5228
5229// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5230// Rust-only char classifier. C uses
5231// `iident()` macro directly.
5232fn is_ident(c: char) -> bool {
5233    c.is_ascii_alphanumeric() || c == '_'
5234}
5235
5236// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5237// Rust-only stack helper. C inlines
5238// this inside `pop()` (math.c:931) — its `noget` flag controls
5239// whether to resolve the deferred Unset+lval read; zshrs splits
5240// the two paths into separate ported so the resolved-vs-raw choice
5241// is at the call site.
5242pub(crate) fn pop_with_lval() -> mathvalue {
5243    m_stack_pop().unwrap_or_default()
5244}
5245
5246// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5247// Rust-only value-resolver. C inlines
5248// the deferred-variable-read pattern inside `pop()` and `op()`
5249// (math.c:931, 1154); the Rust port factors it out for `bop`
5250// and `mathparse` to inspect-without-consuming.
5251pub(crate) fn get_value(mv: &mathvalue) -> mnumber {
5252    if (mv.val.type_ == MN_UNSET) {
5253        if let Some(ref name) = mv.lval {
5254            return getmathparam(name);
5255        }
5256    }
5257    mv.val
5258}
5259
5260// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5261// Rust-only helper. C inlines the
5262// expression `prec[COMMA] + 1` directly in mathparse() and
5263// mathevall() everywhere it's needed (math.c:1594, 367).
5264pub(crate) fn top_prec() -> u8 {
5265    m_prec()[COMMA as usize] + 1
5266}
5267
5268// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5269// Rust-only accessor (note plural — singular
5270// `getmathparam` IS in math.c:337). zsh C's caller reads the param
5271// table directly post-eval; this returns a snapshot of the in-memory
5272// variables map for ShellExecutor integration.
5273/// Get updated variables after evaluation
5274pub(crate) fn getmathparams() -> HashMap<String, mnumber> {
5275    m_variables_clone()
5276}
5277
5278// ===========================================================
5279// Methods moved verbatim from src/ported/vm_helper because their
5280// C counterpart's source file maps 1:1 to this Rust module.
5281// Phase: math
5282// ===========================================================
5283
5284// BEGIN moved-from-exec-rs
5285// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5286
5287// END moved-from-exec-rs
5288
5289// ===========================================================
5290// Free ported moved verbatim from src/ported/vm_helper.
5291// ===========================================================
5292// BEGIN moved-from-exec-rs (free ported)
5293/// Pop argc arguments from the VM stack into a Vec<String>.
5294///
5295/// `Value::Array` entries (produced by `${arr[@]}`, glob expansion, brace
5296/// expansion, etc.) splice into multiple argv-style args — same flattening
5297/// rule as fusevm's `Op::Exec`. Without this, a builtin like `echo
5298/// ${arr[@]}` with `arr=(x y z)` would receive a single space-joined arg
5299/// `"x y z"` instead of three separate args.
5300/// Subscript-arith parser namespace. Holds the three pre-resolve parsers
5301/// `eval_arith_expr` runs against an expression before substituting array
5302/// references — the C source's `mathexpr()` (Src/math.c) inlines this work
5303/// inside the lexer, but Rust splits it out so the assignment-target arms
5304/// don't get confused with read sites.
5305// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5306// Rust-only string parser. C `setmathvar`
5307// (math.c:972) walks the lvalue pointer left in place by zzlex,
5308// so subscripted compound assigns fall out of the lexer for free.
5309// zshrs sees `((a[i]+=v))` as raw text and must split it before
5310// pre_resolve_array_subscripts substitutes the read value in place.
5311#[inline]
5312/// Detect `name[idx]=rhs` (or `name[idx]+=rhs`, etc.) at the start of
5313/// an arith expression. Returns (name, idx_expr, rhs). Used by
5314/// `eval_arith_expr` to handle `((a[i]=expr))` — the regular pre-
5315/// resolve pass would substitute a[i] with its current value first,
5316/// turning the expression into `0=42` which is invalid.
5317/// Parse `name[idx]OP rhs?` where OP is `++`, `--`, `+=`, `-=`, etc.
5318/// Returns (name, idx_expr, op, rhs). For `++`/`--`, rhs is empty.
5319pub(crate) fn parse_compound(expr: &str) -> Option<(String, String, String, String)> {
5320    let trimmed = expr.trim();
5321    let bytes = trimmed.as_bytes();
5322    if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0].is_ascii_alphabetic()) {
5323        return None;
5324    }
5325    let mut i = 1;
5326    while i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) {
5327        i += 1;
5328    }
5329    let name = trimmed[..i].to_string();
5330    if i >= bytes.len() || bytes[i] != b'[' {
5331        return None;
5332    }
5333    let idx_start = i + 1;
5334    let mut depth = 1;
5335    let mut j = idx_start;
5336    while j < bytes.len() && depth > 0 {
5337        match bytes[j] {
5338            b'[' => depth += 1,
5339            b']' => {
5340                depth -= 1;
5341                if depth == 0 {
5342                    break;
5343                }
5344            }
5345            _ => {}
5346        }
5347        j += 1;
5348    }
5349    if j >= bytes.len() {
5350        return None;
5351    }
5352    let idx_expr = trimmed[idx_start..j].to_string();
5353    let mut k = j + 1;
5354    while k < bytes.len() && bytes[k].is_ascii_whitespace() {
5355        k += 1;
5356    }
5357    if k >= bytes.len() {
5358        return None;
5359    }
5360    let rest = &bytes[k..];
5361    // Try 3-char operators first (`<<=`, `>>=`, `**=`), then 2-char
5362    // (`++`, `--`, `+=`, `-=`, `*=`, `/=`, `%=`, `&=`, `|=`, `^=`).
5363    let (op, op_len) = match rest {
5364        [b'<', b'<', b'=', ..] => ("<<=", 3),
5365        [b'>', b'>', b'=', ..] => (">>=", 3),
5366        [b'*', b'*', b'=', ..] => ("**=", 3),
5367        [b'+', b'+', ..] => ("++", 2),
5368        [b'-', b'-', ..] => ("--", 2),
5369        [b'+', b'=', ..] => ("+=", 2),
5370        [b'-', b'=', ..] => ("-=", 2),
5371        [b'*', b'=', ..] => ("*=", 2),
5372        [b'/', b'=', ..] => ("/=", 2),
5373        [b'%', b'=', ..] => ("%=", 2),
5374        [b'&', b'=', ..] => ("&=", 2),
5375        [b'|', b'=', ..] => ("|=", 2),
5376        [b'^', b'=', ..] => ("^=", 2),
5377        _ => return None,
5378    };
5379    let rhs = trimmed[k + op_len..].trim().to_string();
5380    // For `++` / `--`, the rhs MUST be empty (anything else would be
5381    // a parse error). For `+=` etc., rhs is the value expression.
5382    if (op == "++" || op == "--") && !rhs.is_empty() {
5383        return None;
5384    }
5385    Some((name, idx_expr, op.to_string(), rhs))
5386}
5387// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5388// Rust-only string parser. C handles
5389// `++NAME[IDX]` via the lexer leaving the lvalue pointer set; the
5390// Rust port pre-parses the text. See parse_compound above.
5391/// Pre-increment/decrement on subscript: `++NAME[IDX]` / `--NAME[IDX]`.
5392/// Returns (name, idx_expr, op) where op is "++" or "--".
5393pub(crate) fn parse_pre_inc(expr: &str) -> Option<(String, String, String)> {
5394    let trimmed = expr.trim();
5395    let (after_op, pre_op) = if let Some(s) = trimmed.strip_prefix("++") {
5396        (s, "++")
5397    } else if let Some(s) = trimmed.strip_prefix("--") {
5398        (s, "--")
5399    } else {
5400        return None;
5401    };
5402    let after_op = after_op.trim_start();
5403    let bytes = after_op.as_bytes();
5404    if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0].is_ascii_alphabetic()) {
5405        return None;
5406    }
5407    let mut i = 1;
5408    while i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) {
5409        i += 1;
5410    }
5411    let name = after_op[..i].to_string();
5412    if i >= bytes.len() || bytes[i] != b'[' {
5413        return None;
5414    }
5415    let idx_start = i + 1;
5416    let mut depth = 1;
5417    let mut j = idx_start;
5418    while j < bytes.len() && depth > 0 {
5419        match bytes[j] {
5420            b'[' => depth += 1,
5421            b']' => {
5422                depth -= 1;
5423                if depth == 0 {
5424                    break;
5425                }
5426            }
5427            _ => {}
5428        }
5429        j += 1;
5430    }
5431    if j >= bytes.len() {
5432        return None;
5433    }
5434    let idx_expr = after_op[idx_start..j].to_string();
5435    // After ], must be end of input (or whitespace).
5436    let mut k = j + 1;
5437    while k < bytes.len() && bytes[k].is_ascii_whitespace() {
5438        k += 1;
5439    }
5440    if k != bytes.len() {
5441        return None;
5442    }
5443    Some((name, idx_expr, pre_op.to_string()))
5444}
5445// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5446// Rust-only string parser for `NAME[IDX]=v`.
5447// See parse_compound above for the rationale.
5448pub(crate) fn parse_assign(expr: &str) -> Option<(String, String, String)> {
5449    let trimmed = expr.trim();
5450    let bytes = trimmed.as_bytes();
5451    if bytes.is_empty() || !(bytes[0] == b'_' || bytes[0].is_ascii_alphabetic()) {
5452        return None;
5453    }
5454    let mut i = 1;
5455    while i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) {
5456        i += 1;
5457    }
5458    let name = trimmed[..i].to_string();
5459    if i >= bytes.len() || bytes[i] != b'[' {
5460        return None;
5461    }
5462    let idx_start = i + 1;
5463    let mut depth = 1;
5464    let mut j = idx_start;
5465    while j < bytes.len() && depth > 0 {
5466        match bytes[j] {
5467            b'[' => depth += 1,
5468            b']' => {
5469                depth -= 1;
5470                if depth == 0 {
5471                    break;
5472                }
5473            }
5474            _ => {}
5475        }
5476        j += 1;
5477    }
5478    if j >= bytes.len() {
5479        return None;
5480    }
5481    let idx_expr = trimmed[idx_start..j].to_string();
5482    // Skip ]
5483    let mut k = j + 1;
5484    while k < bytes.len() && bytes[k].is_ascii_whitespace() {
5485        k += 1;
5486    }
5487    if k >= bytes.len() || bytes[k] != b'=' {
5488        return None;
5489    }
5490    // Reject `==` and `=~` (comparison/regex, not assignment).
5491    if k + 1 < bytes.len() && (bytes[k + 1] == b'=' || bytes[k + 1] == b'~') {
5492        return None;
5493    }
5494    let rhs = trimmed[k + 1..].trim().to_string();
5495    Some((name, idx_expr, rhs))
5496}
5497
5498// END moved-from-exec-rs (free ported)
5499
5500// ===========================================================
5501// Numeric formatting helpers moved from src/ported/vm_helper.
5502// Mirror Src/math.c / Src/utils.c base+digit-grouping logic.
5503// ===========================================================
5504
5505// !!! WARNING: RUST-ONLY HELPER !!! — no counterpart in Src/math.c.
5506// `convbase` lives in `Src/params.c:5632`
5507// (called from math.c:1089). This file holds a duplicate that
5508// predates the params.rs port; canonical home is
5509// `convbase`. This entry is drift pending
5510// cleanup; do not add new callers — use `convbase`.
5511/// Format an integer in the given base (2-36) using zsh's
5512// `convbase` lives in params.rs (matching C: defined in params.c:5632
5513// as a 1-line delegation to `convbase_ptr` at params.c:5586). The
5514// math.rs entry that used to duplicate it is removed; callers should
5515// import `convbase` directly.
5516
5517#[cfg(test)]
5518mod tests {
5519    use super::*;
5520
5521    /// The per-name value cache (`M_VARIABLES`, the port's counterpart
5522    /// of C's `mptr->pval`, math.c:340-343) must not outlive the
5523    /// evaluation that filled it. In C it cannot: the mathvalues that
5524    /// hold `pval` live on `stack`, which is the frame-local `nstack[]`
5525    /// for the duration of a `mathevall` (c:406) and is put back to the
5526    /// caller's on exit (c:455). The Rust cache is a thread_local, so
5527    /// `matheval` has to restore it by hand.
5528    ///
5529    /// The failure this pins is NOT "the assignment did not persist" —
5530    /// it did, `setmathvar` writes through `setnparam` (c:1005). It is
5531    /// that a reader OUTSIDE any evaluation (the VM's
5532    /// BUILTIN_GET_MATH_VAR, which calls `getmathparam` directly) saw
5533    /// the leftover cache entry in preference to the parameter, so the
5534    /// two could report different values for the same name for an
5535    /// unbounded window. That froze the counter of a slot-compiled
5536    /// `for ((i=1; i<=3; i++))` after any preceding `(( i = … ))`.
5537    #[test]
5538    fn math_value_cache_does_not_outlive_its_evaluation() {
5539        let _g = crate::test_util::global_state_lock();
5540        // setnparam early-returns under `unset(EXECOPT)`.
5541        opt_state_set("exec", true);
5542        let name = "mcache_lifetime_probe";
5543        unsetparam(name);
5544
5545        // An assignment inside `(( … ))` — the shape that populated the
5546        // cache. It must reach the parameter table (c:1005).
5547        assert_eq!(mathevali(&format!("{name} = 5")).unwrap(), 5);
5548        assert_eq!(
5549            crate::ported::params::getsparam(name).as_deref(),
5550            Some("5"),
5551            "setmathvar must write through to the parameter table"
5552        );
5553
5554        // Nothing may be left behind for a later reader to find.
5555        assert!(
5556            m_variables_get(name).is_none(),
5557            "the value cache outlived its evaluation: {:?}",
5558            m_variables_get(name)
5559        );
5560
5561        // Decisive check: move the parameter WITHOUT going through the
5562        // evaluator, exactly as the loop's BUILTIN_SET_VAR does, then
5563        // read it back the way BUILTIN_GET_MATH_VAR does. A surviving
5564        // cache entry would answer 5 here and the two stores would be
5565        // visibly disagreeing.
5566        crate::ported::params::setsparam(name, "7");
5567        let seen = getmathparam(name);
5568        assert_eq!(seen.type_, MN_INTEGER);
5569        assert_eq!(
5570            seen.l, 7,
5571            "getmathparam outside an evaluation must read the parameter \
5572             table, not a leftover cache entry"
5573        );
5574
5575        unsetparam(name);
5576    }
5577
5578    /// `setmathvar` writes through to paramtab via `setnparam`.
5579    /// After the call, `getsparam(name)` should return the value.
5580    #[test]
5581    fn setmathvar_writes_to_paramtab() {
5582        let _g = crate::test_util::global_state_lock();
5583        // setnparam early-returns under `unset(EXECOPT)`. Enable it
5584        // (default in interactive shells; tests run with all opts
5585        // unset by default).
5586        opt_state_set("exec", true);
5587        // Sanity: a direct setiparam call should also work.
5588        unsetparam("mvar1_baseline");
5589        crate::ported::params::setiparam("mvar1_baseline", 42);
5590        let baseline = getsparam("mvar1_baseline");
5591        assert_eq!(
5592            baseline.as_deref(),
5593            Some("42"),
5594            "baseline setiparam path; got {:?}",
5595            baseline
5596        );
5597        unsetparam("mvar1_baseline");
5598
5599        unsetparam("mvar1");
5600        let v = mnumber {
5601            l: 42,
5602            d: 0.0,
5603            type_: MN_INTEGER,
5604        };
5605        let returned = setmathvar("mvar1", v, false);
5606        assert_eq!(returned.l, 42);
5607        let stored = getsparam("mvar1");
5608        assert_eq!(
5609            stored.as_deref(),
5610            Some("42"),
5611            "setmathvar should write through; got {:?}",
5612            stored
5613        );
5614        unsetparam("mvar1");
5615        opt_state_set("exec", false);
5616    }
5617
5618    /// `setmathvar` with empty name emits zerr and returns 0.
5619    #[test]
5620    fn setmathvar_empty_name_returns_zero() {
5621        let _g = crate::test_util::global_state_lock();
5622        let v = mnumber {
5623            l: 99,
5624            d: 0.0,
5625            type_: MN_INTEGER,
5626        };
5627        let returned = setmathvar("", v, false);
5628        assert_eq!(returned.l, 0);
5629        assert_eq!(returned.type_, MN_INTEGER);
5630    }
5631
5632    /// End-to-end round trip: `setmathvar` writes through paramtab;
5633    /// `getmathparam` reads back the same value via `getsparam`. Pins
5634    /// the full math ↔ paramtab integration that the recent setmathvar
5635    /// and getsparam-PM_INTEGER fixes enable together.
5636    #[test]
5637    fn setmathvar_getmathparam_roundtrip() {
5638        let _g = crate::test_util::global_state_lock();
5639        opt_state_set("exec", true);
5640        unsetparam("rt_int");
5641        unsetparam("rt_float");
5642
5643        // Integer roundtrip
5644        let n_in = mnumber {
5645            l: 123,
5646            d: 0.0,
5647            type_: MN_INTEGER,
5648        };
5649        setmathvar("rt_int", n_in, false);
5650        let n_out = getmathparam("rt_int");
5651        assert_eq!(n_out.type_, MN_INTEGER);
5652        assert_eq!(n_out.l, 123);
5653
5654        // Float roundtrip
5655        let f_in = mnumber {
5656            l: 0,
5657            d: 3.14,
5658            type_: MN_FLOAT,
5659        };
5660        setmathvar("rt_float", f_in, false);
5661        let f_out = getmathparam("rt_float");
5662        // Stored as paramtab PM_FFLOAT (per setnparam c:3687); read
5663        // back as MN_FLOAT.
5664        assert_eq!(f_out.type_, MN_FLOAT);
5665        assert!(
5666            (f_out.d - 3.14).abs() < 1e-9,
5667            "expected ~3.14, got {}",
5668            f_out.d
5669        );
5670
5671        unsetparam("rt_int");
5672        unsetparam("rt_float");
5673        opt_state_set("exec", false);
5674    }
5675
5676    /// `setmathvar` with subscript splits at `[` and writes to the
5677    /// base name (subscript handling is upstream).
5678    #[test]
5679    fn setmathvar_subscript_writes_to_base_name() {
5680        let _g = crate::test_util::global_state_lock();
5681        opt_state_set("exec", true);
5682        unsetparam("mvar2");
5683        let v = mnumber {
5684            l: 7,
5685            d: 0.0,
5686            type_: MN_INTEGER,
5687        };
5688        setmathvar("mvar2[5]", v, false);
5689        let stored = getsparam("mvar2");
5690        // The base "mvar2" got the value; subscript element handling
5691        // is upstream so we just confirm the param was created.
5692        assert!(stored.is_some());
5693        unsetparam("mvar2");
5694        opt_state_set("exec", false);
5695    }
5696
5697    /// `setmathvar` MUST short-circuit when `noeval` is set (c:1002-1003).
5698    /// Used by the unused branch of a math ternary: `(( cond ? a=1 : b=2 ))`
5699    /// evaluates only ONE side; the other side runs with noeval=1 to
5700    /// type-check without side effects. A regression that ignores
5701    /// noeval would assign BOTH sides, corrupting the unselected
5702    /// variable on every conditional expression.
5703    ///
5704    /// Pin: with noeval set, assigning to "ne_var" must NOT create
5705    /// the param. The return value still equals the input (so the
5706    /// arithmetic stack sees a sane value); paramtab stays unchanged.
5707    #[test]
5708    fn setmathvar_noeval_skips_paramtab_write() {
5709        let _g = crate::test_util::global_state_lock();
5710        opt_state_set("exec", true);
5711        unsetparam("ne_var");
5712
5713        // Set the math-local noeval counter.
5714        M_NOEVAL.with(|n| n.set(1));
5715
5716        let v = mnumber {
5717            l: 42,
5718            d: 0.0,
5719            type_: MN_INTEGER,
5720        };
5721        let ret = setmathvar("ne_var", v, false);
5722        assert_eq!(
5723            ret.l, 42,
5724            "c:1003 — `return v` so the stack still sees the value"
5725        );
5726        // The paramtab MUST NOT have a new entry.
5727        assert!(
5728            getsparam("ne_var").is_none(),
5729            "c:1002-1003 — noeval suppresses the paramtab write"
5730        );
5731
5732        // Restore noeval so subsequent tests aren't affected.
5733        M_NOEVAL.with(|n| n.set(0));
5734        opt_state_set("exec", false);
5735    }
5736
5737    /// `setmathvar` returns a value RE-TYPED to match the destination
5738    /// param's type (C c:1014-1027). Assigning a FLOAT to a PM_INTEGER
5739    /// param must return an integer-typed mnumber with the float
5740    /// truncated. This matches C's "assignment returns the typed
5741    /// value" contract — used by chained assignment expressions like
5742    /// `(( a = b = 3.7 ))` where `a`'s type drives the cascade.
5743    #[test]
5744    fn setmathvar_float_into_integer_coerces_return_type() {
5745        let _g = crate::test_util::global_state_lock();
5746        opt_state_set("exec", true);
5747        unsetparam("intvar");
5748        // Pre-create as PM_INTEGER so the type is fixed.
5749        crate::ported::params::setiparam("intvar", 0);
5750
5751        // Assign a float; expect integer return with truncated value.
5752        let v = mnumber {
5753            l: 0,
5754            d: 3.7,
5755            type_: MN_FLOAT,
5756        };
5757        let ret = setmathvar("intvar", v, false);
5758        assert_eq!(
5759            ret.type_, MN_INTEGER,
5760            "c:1016-1020 — PM_INTEGER target must return MN_INTEGER"
5761        );
5762        assert_eq!(
5763            ret.l, 3,
5764            "c:1018 — float→int truncates (3.7 → 3, not rounded)"
5765        );
5766
5767        unsetparam("intvar");
5768        opt_state_set("exec", false);
5769    }
5770
5771    /// Pin `mathevalarg` empty-string error path per c:1530-1532.
5772    /// Unlike `matheval` which returns MN_INTEGER 0 on empty, this
5773    /// entry point treats empty as a HARD error (emits zerr and
5774    /// returns 0). Used by callers like `$array[$ind]` where unset
5775    /// `$ind` should produce a diagnostic rather than silently index 0.
5776    #[test]
5777    fn mathevalarg_empty_emits_error_and_returns_zero() {
5778        let _g = crate::test_util::global_state_lock();
5779        // Empty input → returns 0 with error message emitted.
5780        let r = mathevalarg("");
5781        assert_eq!(r, 0, "c:1532 — empty input returns 0");
5782
5783        // Nularg-only → also empty after skip, returns 0.
5784        let nularg_only: String = "\u{e1a1}".to_string();
5785        let r = mathevalarg(&nularg_only);
5786        assert_eq!(
5787            r, 0,
5788            "c:1528-1532 — Nularg-only is empty after skip, returns 0"
5789        );
5790
5791        // Valid expression → real evaluation.
5792        let r = mathevalarg("1 + 2");
5793        assert_eq!(r, 3, "c:1534 — non-empty expression evaluates normally");
5794
5795        // Nularg-prefixed expression → skipped, then evaluated.
5796        let nularg_plus: String = "\u{e1a1}5 * 5".to_string();
5797        let r = mathevalarg(&nularg_plus);
5798        assert_eq!(
5799            r, 25,
5800            "c:1529 — Nularg skipped, then `5 * 5` evaluates to 25"
5801        );
5802    }
5803
5804    /// Pin `matheval` empty + Nularg fast paths per c:1489-1495.
5805    /// Empty input MUST return MN_INTEGER 0 without invoking the
5806    /// parser; the Nularg sentinel (0xa1) byte at the start of the
5807    /// input MUST be skipped before the empty check.
5808    #[test]
5809    fn matheval_empty_input_returns_zero_int() {
5810        let _g = crate::test_util::global_state_lock();
5811        // Empty string → MN_INTEGER 0 (c:1491-1494).
5812        let r = matheval("").expect("empty string must return 0, not error");
5813        assert_eq!(
5814            r.type_, MN_INTEGER,
5815            "c:1493 — empty input returns MN_INTEGER"
5816        );
5817        assert_eq!(r.l, 0, "c:1494 — empty input value is 0");
5818
5819        // Nularg-only string → also returns 0 (c:1489-1494).
5820        let nularg_only: String = "\u{e1a1}".to_string();
5821        let r = matheval(&nularg_only)
5822            .expect("Nularg-only must return 0 (treated as empty after skip)");
5823        assert_eq!(
5824            r.type_, MN_INTEGER,
5825            "c:1489-1493 — Nularg-only input treated as empty → MN_INTEGER"
5826        );
5827        assert_eq!(r.l, 0, "c:1494 — Nularg-only input value is 0");
5828
5829        // Nularg + expression → evaluates the expression (c:1490 skip).
5830        let nularg_plus: String = "\u{e1a1}1 + 2".to_string();
5831        let r =
5832            matheval(&nularg_plus).expect("Nularg prefix must be skipped and expression evaluated");
5833        let v = if r.type_ == MN_FLOAT { crate::zlong_cast::zlong_from_double(r.d) } else { r.l };
5834        assert_eq!(v, 3, "c:1490 — Nularg skipped, then `1 + 2` evaluates to 3");
5835    }
5836
5837    #[test]
5838    fn test_basic_arithmetic() {
5839        let _g = crate::test_util::global_state_lock();
5840        assert_eq!(mathevali("1 + 2").unwrap(), 3);
5841        assert_eq!(mathevali("10 - 3").unwrap(), 7);
5842        assert_eq!(mathevali("4 * 5").unwrap(), 20);
5843        assert_eq!(mathevali("20 / 4").unwrap(), 5);
5844        assert_eq!(mathevali("17 % 5").unwrap(), 2);
5845    }
5846
5847    #[test]
5848    fn test_precedence() {
5849        let _g = crate::test_util::global_state_lock();
5850        assert_eq!(mathevali("2 + 3 * 4").unwrap(), 14);
5851        assert_eq!(mathevali("(2 + 3) * 4").unwrap(), 20);
5852        assert_eq!(mathevali("2 ** 3 ** 2").unwrap(), 512); // Right associative
5853    }
5854
5855    #[test]
5856    fn test_comparison() {
5857        let _g = crate::test_util::global_state_lock();
5858        assert_eq!(mathevali("5 > 3").unwrap(), 1);
5859        assert_eq!(mathevali("5 < 3").unwrap(), 0);
5860        assert_eq!(mathevali("5 == 5").unwrap(), 1);
5861        assert_eq!(mathevali("5 != 3").unwrap(), 1);
5862        assert_eq!(mathevali("5 >= 5").unwrap(), 1);
5863        assert_eq!(mathevali("5 <= 5").unwrap(), 1);
5864    }
5865
5866    #[test]
5867    fn test_logical() {
5868        let _g = crate::test_util::global_state_lock();
5869        assert_eq!(mathevali("1 && 1").unwrap(), 1);
5870        assert_eq!(mathevali("1 && 0").unwrap(), 0);
5871        assert_eq!(mathevali("1 || 0").unwrap(), 1);
5872        assert_eq!(mathevali("0 || 0").unwrap(), 0);
5873        assert_eq!(mathevali("!0").unwrap(), 1);
5874        assert_eq!(mathevali("!1").unwrap(), 0);
5875    }
5876
5877    #[test]
5878    fn test_bitwise() {
5879        let _g = crate::test_util::global_state_lock();
5880        assert_eq!(mathevali("5 & 3").unwrap(), 1);
5881        assert_eq!(mathevali("5 | 3").unwrap(), 7);
5882        assert_eq!(mathevali("5 ^ 3").unwrap(), 6);
5883        assert_eq!(mathevali("~0").unwrap(), -1);
5884        assert_eq!(mathevali("1 << 4").unwrap(), 16);
5885        assert_eq!(mathevali("16 >> 2").unwrap(), 4);
5886    }
5887
5888    #[test]
5889    fn test_ternary() {
5890        let _g = crate::test_util::global_state_lock();
5891        assert_eq!(mathevali("1 ? 10 : 20").unwrap(), 10);
5892        assert_eq!(mathevali("0 ? 10 : 20").unwrap(), 20);
5893        assert_eq!(mathevali("(5 > 3) ? 100 : 200").unwrap(), 100);
5894    }
5895
5896    #[test]
5897    fn test_power() {
5898        let _g = crate::test_util::global_state_lock();
5899        assert_eq!(mathevali("2 ** 10").unwrap(), 1024);
5900        assert_eq!(mathevali("3 ** 3").unwrap(), 27);
5901        assert!(
5902            (matheval("2.0 ** 0.5")
5903                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
5904                .unwrap()
5905                - std::f64::consts::SQRT_2)
5906                .abs()
5907                < 0.0001
5908        );
5909    }
5910
5911    #[test]
5912    fn test_float() {
5913        let _g = crate::test_util::global_state_lock();
5914        assert!(
5915            (matheval("3.14 + 0.01")
5916                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
5917                .unwrap()
5918                - 3.15)
5919                .abs()
5920                < 0.0001
5921        );
5922        assert!(
5923            (matheval("1.5 * 2.0")
5924                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
5925                .unwrap()
5926                - 3.0)
5927                .abs()
5928                < 0.0001
5929        );
5930    }
5931
5932    #[test]
5933    fn test_unary() {
5934        let _g = crate::test_util::global_state_lock();
5935        assert_eq!(mathevali("-5").unwrap(), -5);
5936        assert_eq!(mathevali("- -5").unwrap(), 5); // space needed to avoid --
5937        assert_eq!(mathevali("+5").unwrap(), 5);
5938        assert_eq!(mathevali("-(-5)").unwrap(), 5);
5939    }
5940
5941    #[test]
5942    fn test_base() {
5943        let _g = crate::test_util::global_state_lock();
5944        assert_eq!(mathevali("0xFF").unwrap(), 255);
5945        assert_eq!(mathevali("0b1010").unwrap(), 10);
5946        assert_eq!(mathevali("16#FF").unwrap(), 255);
5947        assert_eq!(mathevali("2#1010").unwrap(), 10);
5948        assert_eq!(mathevali("[16]FF").unwrap(), 255);
5949    }
5950
5951    #[test]
5952    fn test_variables() {
5953        let _g = crate::test_util::global_state_lock();
5954        let mut vars = HashMap::new();
5955        vars.insert(
5956            "x".to_string(),
5957            mnumber {
5958                l: 10,
5959                d: 0.0,
5960                type_: MN_INTEGER,
5961            },
5962        );
5963        vars.insert(
5964            "y".to_string(),
5965            mnumber {
5966                l: 20,
5967                d: 0.0,
5968                type_: MN_INTEGER,
5969            },
5970        );
5971
5972        new("x + y");
5973        with_variables(vars);
5974        assert_eq!(
5975            ({
5976                let __m = mathevall(prec_type::MPREC_TOP).unwrap();
5977                if __m.type_ == MN_FLOAT {
5978                    crate::zlong_cast::zlong_from_double(__m.d)
5979                } else {
5980                    __m.l
5981                }
5982            }),
5983            30
5984        );
5985    }
5986
5987    #[test]
5988    fn test_assignment() {
5989        let _g = crate::test_util::global_state_lock();
5990        new("x = 5");
5991        mathevall(prec_type::MPREC_TOP).unwrap();
5992        assert_eq!(
5993            ({
5994                let __m = m_variables_get("x").unwrap();
5995                if __m.type_ == MN_FLOAT {
5996                    crate::zlong_cast::zlong_from_double(__m.d)
5997                } else {
5998                    __m.l
5999                }
6000            }),
6001            5
6002        );
6003
6004        new("x = 5, x += 3");
6005        let result = mathevall(prec_type::MPREC_TOP).unwrap();
6006        assert_eq!(
6007            (if result.type_ == MN_FLOAT {
6008                crate::zlong_cast::zlong_from_double(result.d)
6009            } else {
6010                result.l
6011            }),
6012            8
6013        );
6014    }
6015
6016    #[test]
6017    fn test_increment() {
6018        let _g = crate::test_util::global_state_lock();
6019        let mut vars = HashMap::new();
6020        vars.insert(
6021            "x".to_string(),
6022            mnumber {
6023                l: 5,
6024                d: 0.0,
6025                type_: MN_INTEGER,
6026            },
6027        );
6028
6029        new("++x");
6030        with_variables(vars.clone());
6031        assert_eq!(
6032            ({
6033                let __m = mathevall(prec_type::MPREC_TOP).unwrap();
6034                if __m.type_ == MN_FLOAT {
6035                    crate::zlong_cast::zlong_from_double(__m.d)
6036                } else {
6037                    __m.l
6038                }
6039            }),
6040            6
6041        );
6042        assert_eq!(
6043            ({
6044                let __m = m_variables_get("x").unwrap();
6045                if __m.type_ == MN_FLOAT {
6046                    crate::zlong_cast::zlong_from_double(__m.d)
6047                } else {
6048                    __m.l
6049                }
6050            }),
6051            6
6052        );
6053
6054        new("x++");
6055        with_variables(vars.clone());
6056        assert_eq!(
6057            ({
6058                let __m = mathevall(prec_type::MPREC_TOP).unwrap();
6059                if __m.type_ == MN_FLOAT {
6060                    crate::zlong_cast::zlong_from_double(__m.d)
6061                } else {
6062                    __m.l
6063                }
6064            }),
6065            5
6066        );
6067        assert_eq!(
6068            ({
6069                let __m = m_variables_get("x").unwrap();
6070                if __m.type_ == MN_FLOAT {
6071                    crate::zlong_cast::zlong_from_double(__m.d)
6072                } else {
6073                    __m.l
6074                }
6075            }),
6076            6
6077        );
6078    }
6079
6080    #[test]
6081    fn test_functions() {
6082        let _g = crate::test_util::global_state_lock();
6083        // c:Src/math.c:1037 — `callmathfunc` requires `zsh/mathfunc`
6084        // to be in the loaded-modules table. zsh -fc returns
6085        // "unknown function: sqrt" without `zmodload zsh/mathfunc`
6086        // — the same gating now applies in zshrs. Boot the module
6087        // here so the unit test exercises the math-function bodies
6088        // not the missing-module guard.
6089        crate::ported::module::MODULESTAB
6090            .lock()
6091            .unwrap()
6092            .load_module("zsh/mathfunc", None, false);
6093        assert!(
6094            (matheval("sqrt(4)")
6095                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6096                .unwrap()
6097                - 2.0)
6098                .abs()
6099                < 0.0001
6100        );
6101        assert!(
6102            (matheval("sin(0)")
6103                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6104                .unwrap())
6105            .abs()
6106                < 0.0001
6107        );
6108        assert!(
6109            (matheval("cos(0)")
6110                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6111                .unwrap()
6112                - 1.0)
6113                .abs()
6114                < 0.0001
6115        );
6116        assert!(
6117            (matheval("abs(-5)")
6118                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6119                .unwrap()
6120                - 5.0)
6121                .abs()
6122                < 0.0001
6123        );
6124        assert!(
6125            (matheval("floor(3.7)")
6126                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6127                .unwrap()
6128                - 3.0)
6129                .abs()
6130                < 0.0001
6131        );
6132        assert!(
6133            (matheval("ceil(3.2)")
6134                .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6135                .unwrap()
6136                - 4.0)
6137                .abs()
6138                < 0.0001
6139        );
6140    }
6141
6142    #[test]
6143    fn test_special_values() {
6144        let _g = crate::test_util::global_state_lock();
6145        assert!(matheval("Inf")
6146            .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6147            .unwrap()
6148            .is_infinite());
6149        assert!(matheval("NaN")
6150            .map(|n| (if n.type_ == MN_FLOAT { n.d } else { n.l as f64 }))
6151            .unwrap()
6152            .is_nan());
6153    }
6154
6155    #[test]
6156    fn test_errors() {
6157        let _g = crate::test_util::global_state_lock();
6158        assert!(matheval("1 / 0").is_err());
6159        assert!(matheval("1 +").is_err());
6160        // Empty arith expression is a parse error in zsh:
6161        //   $ zsh -c '(( ))'; echo $?   →   1
6162        // zsh aborts with `bad math expression: empty parentheses`.
6163        assert!(matheval("()").is_err());
6164    }
6165
6166    #[test]
6167    fn test_underscore_in_numbers() {
6168        let _g = crate::test_util::global_state_lock();
6169        assert_eq!(mathevali("1_000_000").unwrap(), 1000000);
6170        assert_eq!(mathevali("0xFF_FF").unwrap(), 65535);
6171    }
6172
6173    #[test]
6174    fn test_comma_operator() {
6175        let _g = crate::test_util::global_state_lock();
6176        assert_eq!(mathevali("1, 2, 3").unwrap(), 3);
6177        assert_eq!(mathevali("(x = 1, y = 2, x + y)").unwrap(), 3);
6178    }
6179
6180    /// c:1505 — integer divide-by-zero is a runtime error in `$(( ))`.
6181    /// A regression returning 0 silently masks programmer errors.
6182    #[test]
6183    fn mathevali_divide_by_zero_errors() {
6184        let _g = crate::test_util::global_state_lock();
6185        assert!(mathevali("1/0").is_err());
6186        assert!(mathevali("5/(2-2)").is_err());
6187    }
6188
6189    /// Bug #1025: assigning to a non-lvalue (`1 = 2`) must fail with the FULL
6190    /// "bad math expression: lvalue required" (c:Src/math.c:997), not the
6191    /// prefix-stripped "lvalue required" the assignment-operator arm emitted.
6192    #[test]
6193    fn mathevali_assign_to_nonlvalue_keeps_bad_math_prefix() {
6194        let _g = crate::test_util::global_state_lock();
6195        for expr in ["1 = 2", "5 = 3 + 2", "(1+1) = 3"] {
6196            let err = mathevali(expr).expect_err("assign to non-lvalue must error");
6197            assert_eq!(
6198                err, "bad math expression: lvalue required",
6199                "expr {expr:?} must carry the `bad math expression:` prefix"
6200            );
6201        }
6202        // A real lvalue still assigns.
6203        assert_eq!(mathevali("x = 5").unwrap(), 5);
6204    }
6205
6206    /// c:1505-1508 — `mathevali` returns `(x.type & MN_FLOAT) ?
6207    /// (zlong)x.u.d : x.u.l`. A float result rounded toward zero;
6208    /// MN_INTEGER returns x.u.l unchanged. Regression target: the
6209    /// previous Rust port used strict equality `== MN_FLOAT` which
6210    /// misclassifies any composite MN_FLOAT|MN_X bitfield.
6211    #[test]
6212    fn mathevali_truncates_float_via_bitmask_not_strict_eq() {
6213        let _g = crate::test_util::global_state_lock();
6214        // Float expression → truncated toward zero (3.7 → 3, -3.7 → -3).
6215        assert_eq!(mathevali("3.7").unwrap(), 3);
6216        assert_eq!(mathevali("-3.7").unwrap(), -3);
6217        // Pure integer expression → MN_INTEGER path, no truncation.
6218        assert_eq!(mathevali("42").unwrap(), 42);
6219    }
6220
6221    /// c:1480 — mod-by-zero is also an error (matches POSIX).
6222    #[test]
6223    fn mathevali_mod_by_zero_errors() {
6224        let _g = crate::test_util::global_state_lock();
6225        assert!(mathevali("5 % 0").is_err());
6226    }
6227
6228    /// c:1505 — operator precedence: `*` binds tighter than `+`.
6229    /// Regression flipping this would silently break every
6230    /// `$(( a + b * c ))` users compute.
6231    #[test]
6232    fn mathevali_respects_multiplicative_over_additive_precedence() {
6233        let _g = crate::test_util::global_state_lock();
6234        assert_eq!(mathevali("1 + 2 * 3").unwrap(), 7);
6235        assert_eq!(mathevali("(1 + 2) * 3").unwrap(), 9);
6236        assert_eq!(mathevali("10 - 2 * 3").unwrap(), 4);
6237    }
6238
6239    /// c:1505 — bitshift `<<` `>>` from `$(( ))` grammar. Regression
6240    /// dropping them breaks any hex-mask / bit-pack computation.
6241    #[test]
6242    fn mathevali_bitshift_operators() {
6243        let _g = crate::test_util::global_state_lock();
6244        assert_eq!(mathevali("1 << 4").unwrap(), 16);
6245        assert_eq!(mathevali("256 >> 3").unwrap(), 32);
6246    }
6247
6248    /// c:1505 — `&&` short-circuits on zero LHS. Regression that
6249    /// evaluates the RHS would surface side-effects (or divide-
6250    /// by-zero) the user expected NOT to fire.
6251    #[test]
6252    fn mathevali_logical_and_short_circuits_on_zero_lhs() {
6253        let _g = crate::test_util::global_state_lock();
6254        // If RHS evaluated, `1/0` would error. Short-circuit must skip.
6255        assert_eq!(mathevali("0 && 1/0").unwrap(), 0);
6256    }
6257
6258    /// c:1505 — `||` short-circuits on non-zero LHS. Same rationale.
6259    #[test]
6260    fn mathevali_logical_or_short_circuits_on_nonzero_lhs() {
6261        let _g = crate::test_util::global_state_lock();
6262        assert_eq!(mathevali("1 || 1/0").unwrap(), 1);
6263    }
6264
6265    /// c:1505 — ternary `cond ? a : b` evaluates ONLY the selected
6266    /// branch. Regression that evaluates both surfaces side-effects
6267    /// in the unused branch.
6268    #[test]
6269    fn mathevali_ternary_evaluates_only_selected_branch() {
6270        let _g = crate::test_util::global_state_lock();
6271        assert_eq!(mathevali("1 ? 42 : 1/0").unwrap(), 42);
6272        assert_eq!(mathevali("0 ? 1/0 : 42").unwrap(), 42);
6273    }
6274
6275    /// `Src/math.c:467-490` — `$(( ))` integer arithmetic supports
6276    /// hex (`0x`/`0X`), binary (`0b`/`0B`). Octal-via-leading-zero
6277    /// (`0777`) is OPT-IN behind the OCTALZEROES option — by default
6278    /// `0777` parses as decimal 777 (matches C's c:489 conditional).
6279    /// Pin both the hex/binary path AND the default-decimal behavior
6280    /// for leading-zero.
6281    #[test]
6282    fn mathevali_parses_hex_and_binary_literals() {
6283        let _g = crate::test_util::global_state_lock();
6284        // octalzeroes is reset to OFF by global_state_lock (test_util.rs)
6285        // so the `0777`-as-decimal pin works regardless of which test
6286        // ran first.
6287        // Hex literals at c:471 (lowchar 'x').
6288        assert_eq!(mathevali("0xff").unwrap(), 255);
6289        assert_eq!(mathevali("0x10").unwrap(), 16);
6290        assert_eq!(mathevali("0xff + 1").unwrap(), 256);
6291        // Binary literals at c:471 (lowchar 'b').
6292        assert_eq!(mathevali("0b1010").unwrap(), 10);
6293        assert_eq!(mathevali("0b11111111").unwrap(), 255);
6294        // Default-OCTALZEROES-off: `0777` is decimal 777, NOT octal 511.
6295        assert_eq!(
6296            mathevali("0777").unwrap(),
6297            777,
6298            "c:489 — leading-zero parses as decimal when OCTALZEROES off"
6299        );
6300    }
6301
6302    /// c:1505 — bitwise AND/OR/XOR. Each operator has its own
6303    /// precedence tier between shifts and logical ops. Regression
6304    /// flipping precedence between `&` and `|` would break `$(( a &
6305    /// b | c ))` (must be `(a&b) | c`, not `a & (b|c)`).
6306    #[test]
6307    fn mathevali_bitwise_operators_and_or_xor() {
6308        let _g = crate::test_util::global_state_lock();
6309        // Boolean truth-table cases.
6310        assert_eq!(mathevali("12 & 10").unwrap(), 8, "1100 & 1010 = 1000");
6311        assert_eq!(mathevali("12 | 10").unwrap(), 14, "1100 | 1010 = 1110");
6312        assert_eq!(mathevali("12 ^ 10").unwrap(), 6, "1100 ^ 1010 = 0110");
6313        // Precedence: `&` > `^` > `|`, so `a & b | c` == `(a&b) | c`.
6314        assert_eq!(
6315            mathevali("12 & 10 | 1").unwrap(),
6316            9,
6317            "c:1505 — & binds tighter than | : (12 & 10) | 1 = 8 | 1 = 9"
6318        );
6319    }
6320
6321    /// c:1505 — comparison ops produce 0 or 1 (Boolean semantics).
6322    /// Pin all six relational ops.
6323    #[test]
6324    fn mathevali_comparison_operators_return_zero_or_one() {
6325        let _g = crate::test_util::global_state_lock();
6326        assert_eq!(mathevali("1 < 2").unwrap(), 1);
6327        assert_eq!(mathevali("2 < 1").unwrap(), 0);
6328        assert_eq!(mathevali("2 > 1").unwrap(), 1);
6329        assert_eq!(mathevali("1 > 2").unwrap(), 0);
6330        assert_eq!(mathevali("2 <= 2").unwrap(), 1);
6331        assert_eq!(mathevali("2 >= 2").unwrap(), 1);
6332        assert_eq!(mathevali("2 == 2").unwrap(), 1);
6333        assert_eq!(mathevali("2 != 2").unwrap(), 0);
6334    }
6335
6336    /// c:1505 — unary minus and bitwise NOT. The C parser must
6337    /// distinguish `1 - 2` (binary) from `1 + -2` (unary).
6338    #[test]
6339    fn mathevali_unary_minus_and_bitwise_not() {
6340        let _g = crate::test_util::global_state_lock();
6341        assert_eq!(mathevali("-5").unwrap(), -5);
6342        assert_eq!(mathevali("-(2 + 3)").unwrap(), -5);
6343        assert_eq!(mathevali("1 + -2").unwrap(), -1);
6344        // Bitwise NOT (~).
6345        assert_eq!(mathevali("~0").unwrap(), -1, "two's-complement: ~0 = -1");
6346        assert_eq!(mathevali("~5").unwrap(), -6);
6347    }
6348
6349    /// c:1505 — logical NOT operator `!`. Maps 0 → 1, anything-else → 0.
6350    #[test]
6351    fn mathevali_logical_not() {
6352        let _g = crate::test_util::global_state_lock();
6353        assert_eq!(mathevali("!0").unwrap(), 1);
6354        assert_eq!(mathevali("!1").unwrap(), 0);
6355        assert_eq!(mathevali("!42").unwrap(), 0);
6356        // Double-NOT normalises to 0/1.
6357        assert_eq!(mathevali("!!42").unwrap(), 1);
6358        assert_eq!(mathevali("!!0").unwrap(), 0);
6359    }
6360
6361    /// `Src/math.c:109-161` — math token IDs are `#define`d as a
6362    /// densely-packed integer ladder used as indices into the precedence
6363    /// (`Z_PREC` / `C_PREC`) and type (`OP_TYPE`) tables. Position is
6364    /// load-bearing: shifting any value silently mis-routes every math
6365    /// expression at runtime. Pin every value individually so a reorder
6366    /// or off-by-one fails this test. QUEST=27 and COMMA=43 specifically
6367    /// were previously typed in Title-case (`Quest`/`Comma`) violating
6368    /// the C-source casing rule.
6369    #[test]
6370    fn math_token_ids_match_c_source_position_for_position() {
6371        let _g = crate::test_util::global_state_lock();
6372        let table = [
6373            ("M_INPAR", M_INPAR, 0),
6374            ("M_OUTPAR", M_OUTPAR, 1),
6375            ("NOT", NOT, 2),
6376            ("COMP", COMP, 3),
6377            ("POSTPLUS", POSTPLUS, 4),
6378            ("POSTMINUS", POSTMINUS, 5),
6379            ("UPLUS", UPLUS, 6),
6380            ("UMINUS", UMINUS, 7),
6381            ("AND", AND, 8),
6382            ("XOR", XOR, 9),
6383            ("OR", OR, 10),
6384            ("MUL", MUL, 11),
6385            ("DIV", DIV, 12),
6386            ("MOD", MOD, 13),
6387            ("PLUS", PLUS, 14),
6388            ("MINUS", MINUS, 15),
6389            ("SHLEFT", SHLEFT, 16),
6390            ("SHRIGHT", SHRIGHT, 17),
6391            ("LES", LES, 18),
6392            ("LEQ", LEQ, 19),
6393            ("GRE", GRE, 20),
6394            ("GEQ", GEQ, 21),
6395            ("DEQ", DEQ, 22),
6396            ("NEQ", NEQ, 23),
6397            ("DAND", DAND, 24),
6398            ("DOR", DOR, 25),
6399            ("DXOR", DXOR, 26),
6400            ("QUEST", QUEST, 27), // c:136 — was Title-case Quest, divergent
6401            ("COLON", COLON, 28),
6402            ("EQ", EQ, 29),
6403            ("PLUSEQ", PLUSEQ, 30),
6404            ("MINUSEQ", MINUSEQ, 31),
6405            ("MULEQ", MULEQ, 32),
6406            ("DIVEQ", DIVEQ, 33),
6407            ("MODEQ", MODEQ, 34),
6408            ("ANDEQ", ANDEQ, 35),
6409            ("XOREQ", XOREQ, 36),
6410            ("OREQ", OREQ, 37),
6411            ("SHLEFTEQ", SHLEFTEQ, 38),
6412            ("SHRIGHTEQ", SHRIGHTEQ, 39),
6413            ("DANDEQ", DANDEQ, 40),
6414            ("DOREQ", DOREQ, 41),
6415            ("DXOREQ", DXOREQ, 42),
6416            ("COMMA", COMMA, 43), // c:152 — was Title-case Comma, divergent
6417            ("EOI", EOI, 44),
6418            ("PREPLUS", PREPLUS, 45),
6419            ("PREMINUS", PREMINUS, 46),
6420            ("NUM", NUM, 47),
6421            ("ID", ID, 48),
6422            ("POWER", POWER, 49),
6423            ("CID", CID, 50),
6424            ("POWEREQ", POWEREQ, 51),
6425            ("FUNC", FUNC, 52),
6426        ];
6427        for (name, got, want) in table {
6428            assert_eq!(
6429                got, want,
6430                "c:109-161 — {} must be {} (C source value)",
6431                name, want
6432            );
6433        }
6434        // TOKCOUNT = 53 must equal the table length (no holes).
6435        assert_eq!(
6436            TOKCOUNT,
6437            table.len() + 0,
6438            "c:162 — TOKCOUNT must match the number of tokens"
6439        );
6440        // QUEST sits between DXOR and COLON; gap was 26→28 BEFORE the
6441        // QUEST=27 fix, exposing a missing index. Pin the ordering.
6442        assert_eq!(QUEST, DXOR + 1, "c:136 — QUEST immediately follows DXOR");
6443        assert_eq!(COLON, QUEST + 1, "c:137 — COLON immediately follows QUEST");
6444        assert_eq!(
6445            COMMA,
6446            DXOREQ + 1,
6447            "c:152 — COMMA immediately follows DXOREQ"
6448        );
6449        assert_eq!(EOI, COMMA + 1, "c:153 — EOI immediately follows COMMA");
6450    }
6451
6452    /// `Src/math.c:109-162` — the precedence and type tables MUST have
6453    /// length `TOKCOUNT`. Pin both lengths so a future token addition
6454    /// without table updates fails immediately.
6455    #[test]
6456    fn math_dispatch_tables_match_tokcount() {
6457        let _g = crate::test_util::global_state_lock();
6458        assert_eq!(
6459            Z_PREC.len(),
6460            TOKCOUNT,
6461            "Z_PREC must have one slot per math token"
6462        );
6463        assert_eq!(
6464            C_PREC.len(),
6465            TOKCOUNT,
6466            "C_PREC must have one slot per math token"
6467        );
6468        assert_eq!(
6469            OP_TYPE.len(),
6470            TOKCOUNT,
6471            "OP_TYPE must have one slot per math token"
6472        );
6473    }
6474
6475    // ═══════════════════════════════════════════════════════════════════
6476    // matheval / mathevali — anchored to `zsh -c 'echo $(( ... ))'`.
6477    // Each expected value verified against zsh 5.9. Where zshrs diverges
6478    // the test FAILS, exposing the bug. matheval returns mnumber; we
6479    // mostly use mathevali for integer comparisons.
6480    // ═══════════════════════════════════════════════════════════════════
6481
6482    fn mi(expr: &str) -> i64 {
6483        let _g = crate::test_util::global_state_lock();
6484        mathevali(expr).unwrap_or_else(|e| panic!("mathevali({expr:?}) → Err({e})"))
6485    }
6486
6487    // ── Literals ────────────────────────────────────────────────────
6488    /// `echo $(( 42 ))` → 42
6489    #[test]
6490    fn matheval_decimal_literal() {
6491        assert_eq!(mi("42"), 42);
6492    }
6493
6494    /// `echo $(( -7 ))` → -7
6495    #[test]
6496    fn matheval_unary_minus_literal() {
6497        assert_eq!(mi("-7"), -7);
6498    }
6499
6500    /// `echo $(( 0xff ))` → 255
6501    #[test]
6502    fn matheval_hex_literal_lowercase() {
6503        assert_eq!(mi("0xff"), 255);
6504    }
6505
6506    /// `echo $(( 0XDEAD ))` → 57005
6507    #[test]
6508    fn matheval_hex_literal_uppercase() {
6509        assert_eq!(mi("0XDEAD"), 0xDEAD);
6510    }
6511
6512    /// `echo $(( 16#FF ))` → 255 (zsh base# literal)
6513    #[test]
6514    fn matheval_base_hash_hex() {
6515        assert_eq!(mi("16#FF"), 255);
6516    }
6517
6518    /// `echo $(( 2#1010 ))` → 10
6519    #[test]
6520    fn matheval_base_hash_binary() {
6521        assert_eq!(mi("2#1010"), 10);
6522    }
6523
6524    /// `echo $(( 8#17 ))` → 15
6525    #[test]
6526    fn matheval_base_hash_octal() {
6527        assert_eq!(mi("8#17"), 15);
6528    }
6529
6530    /// `echo $(( 010 ))` → 10 (zsh default: NOT octal unless OCTAL_ZEROES set)
6531    /// Test relies on `test_util::global_state_lock()` (acquired inside `mi`)
6532    /// to reset `octalzeroes` to OFF on entry — see test_util.rs:53.
6533    #[test]
6534    fn matheval_leading_zero_is_decimal_not_octal() {
6535        assert_eq!(mi("010"), 10);
6536    }
6537
6538    // ── Binary arithmetic ──────────────────────────────────────────
6539    /// `echo $(( 1 + 2 + 3 ))` → 6
6540    #[test]
6541    fn matheval_addition_chain() {
6542        assert_eq!(mi("1 + 2 + 3"), 6);
6543    }
6544
6545    /// `echo $(( 2 * 3 + 4 ))` → 10 (precedence: * over +)
6546    #[test]
6547    fn matheval_precedence_mul_over_add() {
6548        assert_eq!(mi("2 * 3 + 4"), 10);
6549    }
6550
6551    /// `echo $(( 2 * (3 + 4) ))` → 14 (parens override)
6552    #[test]
6553    fn matheval_parens_override_precedence() {
6554        assert_eq!(mi("2 * (3 + 4)"), 14);
6555    }
6556
6557    /// `echo $(( 17 / 5 ))` → 3 (integer division, truncates toward 0)
6558    #[test]
6559    fn matheval_integer_division_truncates() {
6560        assert_eq!(mi("17 / 5"), 3);
6561    }
6562
6563    /// `echo $(( 1 / 4 ))` → 0 (integer division of small numerator)
6564    #[test]
6565    fn matheval_integer_division_below_one() {
6566        assert_eq!(mi("1 / 4"), 0);
6567    }
6568
6569    /// `echo $(( 17 % 5 ))` → 2
6570    #[test]
6571    fn matheval_modulo() {
6572        assert_eq!(mi("17 % 5"), 2);
6573    }
6574
6575    /// `echo $(( 2 ** 10 ))` → 1024
6576    #[test]
6577    fn matheval_power() {
6578        assert_eq!(mi("2 ** 10"), 1024);
6579    }
6580
6581    /// `echo $(( 3 ** 3 ))` → 27
6582    #[test]
6583    fn matheval_power_small_cubed() {
6584        assert_eq!(mi("3 ** 3"), 27);
6585    }
6586
6587    /// `echo $(( -2 ** 2 ))` → 4 (zsh: unary binds tighter than **)
6588    #[test]
6589    fn matheval_unary_binds_tighter_than_power() {
6590        assert_eq!(mi("-2 ** 2"), 4);
6591    }
6592
6593    // ── Bitwise ─────────────────────────────────────────────────────
6594    /// `echo $(( 0xff & 0x0f ))` → 15
6595    #[test]
6596    fn matheval_bitand() {
6597        assert_eq!(mi("0xff & 0x0f"), 15);
6598    }
6599
6600    /// `echo $(( 0xff | 0x100 ))` → 511
6601    #[test]
6602    fn matheval_bitor() {
6603        assert_eq!(mi("0xff | 0x100"), 511);
6604    }
6605
6606    /// `echo $(( 0xff ^ 0x0f ))` → 240
6607    #[test]
6608    fn matheval_bitxor() {
6609        assert_eq!(mi("0xff ^ 0x0f"), 240);
6610    }
6611
6612    /// `echo $(( ~0 ))` → -1 (two's-complement bitwise NOT)
6613    #[test]
6614    fn matheval_bitnot_zero_is_minus_one() {
6615        assert_eq!(mi("~0"), -1);
6616    }
6617
6618    /// `echo $(( 1 << 8 ))` → 256
6619    #[test]
6620    fn matheval_left_shift() {
6621        assert_eq!(mi("1 << 8"), 256);
6622    }
6623
6624    /// `echo $(( 256 >> 4 ))` → 16
6625    #[test]
6626    fn matheval_right_shift() {
6627        assert_eq!(mi("256 >> 4"), 16);
6628    }
6629
6630    /// `echo $(( -1 >> 1 ))` → -1 (arithmetic shift, sign-preserving)
6631    #[test]
6632    fn matheval_arithmetic_right_shift_preserves_sign() {
6633        assert_eq!(mi("-1 >> 1"), -1);
6634    }
6635
6636    // ── Comparison & logical ───────────────────────────────────────
6637    /// `echo $(( 5 == 5 ))` → 1
6638    #[test]
6639    fn matheval_eq_true() {
6640        assert_eq!(mi("5 == 5"), 1);
6641    }
6642
6643    /// `echo $(( 5 != 6 ))` → 1
6644    #[test]
6645    fn matheval_ne_true() {
6646        assert_eq!(mi("5 != 6"), 1);
6647    }
6648
6649    /// `echo $(( 3 < 5 ))` → 1
6650    #[test]
6651    fn matheval_lt_true() {
6652        assert_eq!(mi("3 < 5"), 1);
6653    }
6654
6655    /// `echo $(( 5 <= 5 ))` → 1
6656    #[test]
6657    fn matheval_le_true_on_equal() {
6658        assert_eq!(mi("5 <= 5"), 1);
6659    }
6660
6661    /// `echo $(( 1 && 1 ))` → 1
6662    #[test]
6663    fn matheval_logand_both_true() {
6664        assert_eq!(mi("1 && 1"), 1);
6665    }
6666
6667    /// `echo $(( 0 || 1 ))` → 1
6668    #[test]
6669    fn matheval_logor_one_true() {
6670        assert_eq!(mi("0 || 1"), 1);
6671    }
6672
6673    /// `echo $(( !0 ))` → 1
6674    #[test]
6675    fn matheval_lognot_false() {
6676        assert_eq!(mi("!0"), 1);
6677    }
6678
6679    /// `echo $(( !5 ))` → 0
6680    #[test]
6681    fn matheval_lognot_truthy() {
6682        assert_eq!(mi("!5"), 0);
6683    }
6684
6685    // ── Ternary ─────────────────────────────────────────────────────
6686    /// `echo $(( 1 ? 10 : 20 ))` → 10
6687    #[test]
6688    fn matheval_ternary_true_branch() {
6689        assert_eq!(mi("1 ? 10 : 20"), 10);
6690    }
6691
6692    /// `echo $(( 0 ? 10 : 20 ))` → 20
6693    #[test]
6694    fn matheval_ternary_false_branch() {
6695        assert_eq!(mi("0 ? 10 : 20"), 20);
6696    }
6697
6698    // ── Comma operator ─────────────────────────────────────────────
6699    /// `echo $(( (1,2,3) ))` → 3 (comma returns last)
6700    #[test]
6701    fn matheval_comma_returns_last() {
6702        assert_eq!(mi("(1,2,3)"), 3);
6703    }
6704
6705    // ── Floats via matheval (mnumber tag) ──────────────────────────
6706    /// `1.0 / 4` returns a float (MN_FLOAT) — pin the type discriminator.
6707    #[test]
6708    fn matheval_float_div_returns_mn_float_type() {
6709        let _g = crate::test_util::global_state_lock();
6710        let n = matheval("1.0 / 4").expect("matheval");
6711        // MN_FLOAT flag must be set on the result type.
6712        assert_ne!(
6713            n.type_ & MN_FLOAT,
6714            0,
6715            "1.0 / 4 must carry MN_FLOAT in type; got type_=0x{:x}",
6716            n.type_
6717        );
6718        // d field holds the float value; should be ~0.25.
6719        assert!(
6720            (n.d - 0.25).abs() < 1e-9,
6721            "1.0 / 4 d-field should be 0.25; got {}",
6722            n.d
6723        );
6724    }
6725
6726    /// `42` returns an integer (MN_INTEGER, not MN_FLOAT).
6727    #[test]
6728    fn matheval_integer_literal_returns_mn_integer_type() {
6729        let _g = crate::test_util::global_state_lock();
6730        let n = matheval("42").expect("matheval");
6731        assert_eq!(
6732            n.type_ & MN_FLOAT,
6733            0,
6734            "42 must NOT carry MN_FLOAT; got type_=0x{:x}",
6735            n.type_
6736        );
6737        assert_eq!(n.l, 42);
6738    }
6739
6740    /// matheval on empty string → MN_INTEGER 0 (C c:1491-1495 fast path).
6741    #[test]
6742    fn matheval_empty_input_returns_zero() {
6743        let _g = crate::test_util::global_state_lock();
6744        let n = matheval("").expect("matheval(\"\") must succeed");
6745        assert_eq!(n.l, 0, "empty input → 0");
6746        assert_eq!(
6747            n.type_, MN_INTEGER,
6748            "empty input → MN_INTEGER (c:1491-1495)"
6749        );
6750    }
6751
6752    // ── mathevali (integer-coerce front-end) ───────────────────────
6753    /// `mathevali` integer-coerces float results via `(zlong)x.u.d` — pin
6754    /// the truncation semantics (away from zero is wrong; C truncates).
6755    #[test]
6756    fn mathevali_truncates_float_toward_zero() {
6757        let _g = crate::test_util::global_state_lock();
6758        // 7.9 → truncates to 7 (NOT rounds to 8)
6759        assert_eq!(mathevali("7.9").unwrap(), 7);
6760        // -7.9 → truncates to -7 (NOT rounds to -8)
6761        assert_eq!(mathevali("-7.9").unwrap(), -7);
6762    }
6763
6764    // ═══════════════════════════════════════════════════════════════════
6765    // zsh test-corpus pins — Test/C01arith.ztst arithmetic regression
6766    // suite. Each test cites the ztst line range; pass = lock current
6767    // correct behavior, #[ignore = "ZSHRS BUG: ..."] = tracked gap.
6768    // ═══════════════════════════════════════════════════════════════════
6769
6770    /// `Test/C01arith.ztst:6-10` — basic integer literal.
6771    #[test]
6772    fn zsh_corpus_basic_integer_literal() {
6773        let _g = crate::test_util::global_state_lock();
6774        assert_eq!(matheval("42").unwrap().l, 42, "ztst:9 — int literal");
6775    }
6776
6777    /// `Test/C01arith.ztst:22-25` — `((29.1 % 13.0 * 10) + 0.5)` = 31.6
6778    /// → integer-coerced to 31.
6779    #[test]
6780    fn zsh_corpus_float_modulo_then_int_truncation() {
6781        let _g = crate::test_util::global_state_lock();
6782        let r = mathevali("(29.1 % 13.0 * 10) + 0.5");
6783        assert_eq!(r.ok(), Some(31), "ztst:25 — float % then int truncation");
6784    }
6785
6786    /// `Test/C01arith.ztst:27-29` — multi-base input:
6787    /// `0x10 + 0X01 + 2#1010` = 16 + 1 + 10 = 27.
6788    #[test]
6789    fn zsh_corpus_multi_base_input() {
6790        let _g = crate::test_util::global_state_lock();
6791        assert_eq!(
6792            mathevali("0x10 + 0X01 + 2#1010").unwrap(),
6793            27,
6794            "ztst:29 — hex + 2#binary sum",
6795        );
6796    }
6797
6798    /// `Test/C01arith.ztst:41-44` — float→int truncation:
6799    /// `(( i = 32.5 ))` then int → 32.
6800    #[test]
6801    fn zsh_corpus_float_truncates_in_integer_context() {
6802        let _g = crate::test_util::global_state_lock();
6803        assert_eq!(
6804            mathevali("32.5").unwrap(),
6805            32,
6806            "ztst:44 — truncate, not round"
6807        );
6808    }
6809
6810    /// `Test/C01arith.ztst:46-50` — operator precedence chain:
6811    /// `4 - - 3 * 7 << 1 & 7 ^ 1 | 16 ** 2` = 1591 (zsh-default
6812    /// MATH_OPS precedence, NOT C precedence).
6813    #[test]
6814    fn zsh_corpus_zsh_precedence_chain() {
6815        let _g = crate::test_util::global_state_lock();
6816        let r = mathevali("4 - - 3 * 7 << 1 & 7 ^ 1 | 16 ** 2");
6817        assert_eq!(r.ok(), Some(1591), "ztst:50 — zsh-default precedence");
6818    }
6819
6820    /// `Test/C01arith.ztst:96-97` — mixed int+float:
6821    /// `3 + 5 * 1.75` = 11.75 (float promotion).
6822    #[test]
6823    fn zsh_corpus_mixed_int_float_promotes_to_float() {
6824        let _g = crate::test_util::global_state_lock();
6825        let n = matheval("3 + 5 * 1.75").unwrap();
6826        assert!(
6827            (n.d - 11.75).abs() < 1e-9,
6828            "ztst:96 — 3+5*1.75 = 11.75 (float promotion)"
6829        );
6830    }
6831
6832    /// `Test/C01arith.ztst:62-64` — logical precedence:
6833    /// `1 < 2 || 2 < 2 && 3 > 4` = 1 (|| lower than &&).
6834    #[test]
6835    fn zsh_corpus_logical_precedence_or_low_and_high() {
6836        let _g = crate::test_util::global_state_lock();
6837        let n = mathevali("1 < 2 || 2 < 2 && 3 > 4").unwrap();
6838        assert_eq!(n, 1, "ztst:64 — || lower than &&");
6839    }
6840
6841    /// `Test/C01arith.ztst:66-68` — nested ternary right-associative:
6842    /// `1+4 ? 3+2 ? 4+3 ? 5+6 ? 4*8 : 0 : 0 : 0 : 0` = 32.
6843    #[test]
6844    fn zsh_corpus_ternary_right_associative_nested() {
6845        let _g = crate::test_util::global_state_lock();
6846        let n = mathevali("1+4 ? 3+2 ? 4+3 ? 5+6 ? 4*8 : 0 : 0 : 0 : 0").unwrap();
6847        assert_eq!(n, 32, "ztst:68 — nested ternary right-associative");
6848    }
6849
6850    /// `Test/C01arith.ztst:78-80` — comma returns last:
6851    /// `0, 4 ? 3 : 1, 5` = 5.
6852    #[test]
6853    fn zsh_corpus_comma_returns_last_value() {
6854        let _g = crate::test_util::global_state_lock();
6855        let n = mathevali("0, 4 ? 3 : 1, 5").unwrap();
6856        assert_eq!(n, 5, "ztst:80 — comma operator returns last");
6857    }
6858
6859    /// `Test/C01arith.ztst:9` — `1 + 2 * 3` = 7 (precedence).
6860    #[test]
6861    fn zsh_corpus_integer_precedence_mul_before_add() {
6862        let _g = crate::test_util::global_state_lock();
6863        let n = mathevali("1 + 2 * 3").unwrap();
6864        assert_eq!(n, 7, "ztst:9 — *,/ before +,-");
6865    }
6866
6867    /// `Test/C01arith.ztst:18` — `1.5 + 2.5` = 4.0.
6868    #[test]
6869    fn zsh_corpus_basic_float_add() {
6870        let _g = crate::test_util::global_state_lock();
6871        let n = matheval("1.5 + 2.5").unwrap();
6872        assert!((n.d - 4.0).abs() < 1e-9, "ztst:18 — 1.5+2.5=4.0");
6873    }
6874
6875    /// `Test/C01arith.ztst:24-26` — `7.5 % 2.5` = 0.0 (float modulo).
6876    #[test]
6877    fn zsh_corpus_float_modulo_exact_division() {
6878        let _g = crate::test_util::global_state_lock();
6879        let n = matheval("7.5 % 2.5").unwrap();
6880        assert!(n.d.abs() < 1e-9, "ztst:24 — 7.5%2.5=0.0");
6881    }
6882
6883    /// `Test/C01arith.ztst:46-50` — full zsh precedence chain.
6884    /// `4 - - 3 * 7 << 1 & 7 ^ 1 | 16 ** 2` = 1591 under default
6885    /// (non-C-precedence) zsh.
6886    #[test]
6887    fn zsh_corpus_full_zsh_precedence_chain() {
6888        let _g = crate::test_util::global_state_lock();
6889        let n = mathevali("4 - - 3 * 7 << 1 & 7 ^ 1 | 16 ** 2").unwrap();
6890        assert_eq!(n, 1591, "ztst:50 — default zsh precedence = 1591");
6891    }
6892
6893    // ═══════════════════════════════════════════════════════════════════
6894    // C-parity tests pinning Src/math.c output-format + lastbase
6895    // accessor helpers.
6896    // ═══════════════════════════════════════════════════════════════════
6897
6898    /// `outputradix()` returns the current `$OUTPUT_RADIX` value.
6899    /// C: reads the global `outputradix` int.
6900    #[test]
6901    fn outputradix_returns_int_no_panic() {
6902        let _g = crate::test_util::global_state_lock();
6903        let _r = outputradix();
6904    }
6905
6906    /// `outputunderscore()` returns the current digit-group setting.
6907    #[test]
6908    fn outputunderscore_returns_int_no_panic() {
6909        let _g = crate::test_util::global_state_lock();
6910        let _r = outputunderscore();
6911    }
6912
6913    /// `reset_output_format()` is a no-panic clearing call.
6914    #[test]
6915    fn reset_output_format_no_panic() {
6916        let _g = crate::test_util::global_state_lock();
6917        reset_output_format();
6918        reset_output_format();
6919    }
6920
6921    /// `lastbase()` returns the integer base of the last math
6922    /// literal parsed (C's `lastbase` global).
6923    #[test]
6924    fn lastbase_returns_int_no_panic() {
6925        let _g = crate::test_util::global_state_lock();
6926        let _r = lastbase();
6927    }
6928
6929    /// `set_lastbase(N)` then `lastbase()` returns N.
6930    #[test]
6931    fn set_lastbase_round_trips() {
6932        let _g = crate::test_util::global_state_lock();
6933        let saved = lastbase();
6934        set_lastbase(16);
6935        assert_eq!(lastbase(), 16);
6936        set_lastbase(saved);
6937    }
6938
6939    /// `m_noeval_set(0)` then `m_noeval()` returns 0.
6940    #[test]
6941    fn m_noeval_default_is_zero() {
6942        let _g = crate::test_util::global_state_lock();
6943        m_noeval_set(0);
6944        assert_eq!(m_noeval(), 0);
6945    }
6946
6947    /// `m_noeval_set(N)` round-trips through getter.
6948    #[test]
6949    fn m_noeval_set_round_trips() {
6950        let _g = crate::test_util::global_state_lock();
6951        let saved = m_noeval();
6952        m_noeval_set(3);
6953        assert_eq!(m_noeval(), 3);
6954        m_noeval_set(saved);
6955    }
6956
6957    // ═══════════════════════════════════════════════════════════════════
6958    // Additional C-parity tests for Src/math.c matheval + mathevali +
6959    // outputradix / outputunderscore / reset_output_format.
6960    // ═══════════════════════════════════════════════════════════════════
6961
6962    /// c:1491 — `matheval("")` returns MN_INTEGER 0.
6963    #[test]
6964    fn matheval_empty_returns_integer_zero() {
6965        let _g = crate::test_util::global_state_lock();
6966        let r = matheval("").expect("empty must succeed");
6967        assert_eq!(r.l, 0);
6968        assert_eq!(r.type_, MN_INTEGER);
6969    }
6970
6971    /// c:1489 — Nularg-prefixed expression is treated as the suffix.
6972    /// `Nularg + "42"` → 42.
6973    #[test]
6974    fn matheval_nularg_prefix_skipped() {
6975        let _g = crate::test_util::global_state_lock();
6976        let s = format!("{}42", Nularg);
6977        let r = matheval(&s).expect("must succeed");
6978        assert_eq!(r.l, 42, "Nularg prefix stripped, '42' evaluated");
6979    }
6980
6981    /// c:1480 — `matheval("1+2")` returns 3.
6982    #[test]
6983    fn matheval_basic_addition() {
6984        let _g = crate::test_util::global_state_lock();
6985        let r = matheval("1+2").expect("must succeed");
6986        assert_eq!(r.l, 3);
6987    }
6988
6989    /// c:1480 — `matheval("10*5")` returns 50.
6990    #[test]
6991    fn matheval_basic_multiplication() {
6992        let _g = crate::test_util::global_state_lock();
6993        let r = matheval("10*5").expect("must succeed");
6994        assert_eq!(r.l, 50);
6995    }
6996
6997    /// c:1505 — `mathevali("3+4")` returns 7 (integer-coerce).
6998    #[test]
6999    fn mathevali_integer_result() {
7000        let _g = crate::test_util::global_state_lock();
7001        let r = mathevali("3+4").expect("must succeed");
7002        assert_eq!(r, 7);
7003    }
7004
7005    /// c:1505 — `mathevali("3.7")` truncates to 3 (MN_FLOAT → i64).
7006    #[test]
7007    fn mathevali_float_truncates() {
7008        let _g = crate::test_util::global_state_lock();
7009        let r = mathevali("3.7").expect("must succeed");
7010        assert_eq!(r, 3, "float must truncate (cast to i64)");
7011    }
7012
7013    /// c:1505 — `mathevali("-2.7")` truncates toward zero → -2.
7014    #[test]
7015    fn mathevali_negative_float_truncates_toward_zero() {
7016        let _g = crate::test_util::global_state_lock();
7017        let r = mathevali("-2.7").expect("must succeed");
7018        assert_eq!(r, -2, "-2.7 truncates to -2 (toward zero, not -3)");
7019    }
7020
7021    /// c:898 — `reset_output_format()` clears both radix + underscore.
7022    #[test]
7023    fn reset_output_format_clears_both_state() {
7024        let _g = crate::test_util::global_state_lock();
7025        reset_output_format();
7026        assert_eq!(outputradix(), 0);
7027        assert_eq!(outputunderscore(), 0);
7028    }
7029
7030    /// c:889 — `outputradix` is deterministic right after reset.
7031    #[test]
7032    fn outputradix_zero_after_reset() {
7033        let _g = crate::test_util::global_state_lock();
7034        reset_output_format();
7035        for _ in 0..5 {
7036            assert_eq!(outputradix(), 0);
7037        }
7038    }
7039
7040    /// c:1049 — `lastbase` accessor returns valid base value.
7041    #[test]
7042    fn lastbase_accessor_returns_value() {
7043        let _g = crate::test_util::global_state_lock();
7044        let saved = lastbase();
7045        set_lastbase(16);
7046        assert_eq!(lastbase(), 16);
7047        set_lastbase(saved);
7048    }
7049
7050    /// c:1061 — `set_lastbase(8)` then `lastbase()` returns 8.
7051    #[test]
7052    fn set_lastbase_round_trips_pin() {
7053        let _g = crate::test_util::global_state_lock();
7054        let saved = lastbase();
7055        set_lastbase(8);
7056        assert_eq!(lastbase(), 8);
7057        set_lastbase(saved);
7058    }
7059
7060    // ═══════════════════════════════════════════════════════════════════
7061    // Additional C-parity tests for Src/math.c
7062    // c:889 outputradix / c:894 outputunderscore / c:901 reset_output_format
7063    // c:1022 m_noeval / c:1049 lastbase / c:2952 matheval / c:2995 mathevali
7064    // ═══════════════════════════════════════════════════════════════════
7065
7066    /// c:889 — `outputradix` returns i32 (compile-time type pin).
7067    #[test]
7068    fn outputradix_returns_i32_type() {
7069        let _g = crate::test_util::global_state_lock();
7070        let _: i32 = outputradix();
7071    }
7072
7073    /// c:894 — `outputunderscore` returns i32 (compile-time type pin).
7074    #[test]
7075    fn outputunderscore_returns_i32_type() {
7076        let _g = crate::test_util::global_state_lock();
7077        let _: i32 = outputunderscore();
7078    }
7079
7080    /// c:1022 — `m_noeval` returns i32 (compile-time type pin).
7081    #[test]
7082    fn m_noeval_returns_i32_type() {
7083        let _g = crate::test_util::global_state_lock();
7084        let _: i32 = m_noeval();
7085    }
7086
7087    /// c:1022 + c:1029 — `m_noeval` set/get round-trip preserves value.
7088    #[test]
7089    fn m_noeval_set_get_round_trip() {
7090        let _g = crate::test_util::global_state_lock();
7091        let saved = m_noeval();
7092        m_noeval_set(42);
7093        assert_eq!(m_noeval(), 42, "m_noeval round-trips");
7094        m_noeval_set(saved);
7095    }
7096
7097    /// c:1049 — `lastbase` returns i32 (compile-time type pin).
7098    #[test]
7099    fn lastbase_returns_i32_type() {
7100        let _g = crate::test_util::global_state_lock();
7101        let _: i32 = lastbase();
7102    }
7103
7104    /// c:2952 — `matheval("")` empty returns Result<mnumber, String> type.
7105    #[test]
7106    fn matheval_returns_result_type() {
7107        let _g = crate::test_util::global_state_lock();
7108        let _: Result<mnumber, String> = matheval("");
7109    }
7110
7111    /// c:2995 — `mathevali("")` empty returns Result<i64, String>.
7112    #[test]
7113    fn mathevali_returns_result_i64_type() {
7114        let _g = crate::test_util::global_state_lock();
7115        let _: Result<i64, String> = mathevali("");
7116    }
7117
7118    /// c:2952 — `matheval("0")` returns Ok with l=0.
7119    #[test]
7120    fn matheval_zero_returns_ok_zero() {
7121        let _g = crate::test_util::global_state_lock();
7122        let r = matheval("0").expect("0 must parse");
7123        assert_eq!(r.l, 0, "matheval('0').l = 0");
7124    }
7125
7126    /// c:2995 — `mathevali("1+1")` returns Ok(2).
7127    #[test]
7128    fn mathevali_simple_addition_returns_two() {
7129        let _g = crate::test_util::global_state_lock();
7130        let r = mathevali("1+1").expect("1+1 must parse");
7131        assert_eq!(r, 2, "1+1 must equal 2");
7132    }
7133
7134    /// c:2952 — `matheval` is pure for the same input (no side effects).
7135    #[test]
7136    fn matheval_is_pure_for_constants() {
7137        let _g = crate::test_util::global_state_lock();
7138        for s in ["0", "1", "42", "100"] {
7139            let first = matheval(s).map(|r| r.l).unwrap_or(0);
7140            for _ in 0..3 {
7141                let r = matheval(s).map(|r| r.l).unwrap_or(0);
7142                assert_eq!(r, first, "matheval({:?}) must be pure", s);
7143            }
7144        }
7145    }
7146
7147    /// c:2995 — `mathevali` accepts an undefined identifier as 0/parameter
7148    /// lookup per zsh math semantics (unset → 0); pin the deterministic
7149    /// fallback behavior rather than expecting Err. C body c:3030+
7150    /// passes unknown idents through `getmathparam` which returns 0 for
7151    /// unset names by zsh-default contract.
7152    #[test]
7153    fn mathevali_undefined_ident_deterministic() {
7154        let _g = crate::test_util::global_state_lock();
7155        let first = mathevali("__never_real_var_xyz__");
7156        for _ in 0..3 {
7157            assert_eq!(
7158                mathevali("__never_real_var_xyz__"),
7159                first,
7160                "mathevali on undefined ident must be deterministic"
7161            );
7162        }
7163    }
7164
7165    // ═══════════════════════════════════════════════════════════════════
7166    // Regression pins for setmathvar subscripted-lvalue writes.
7167    //
7168    // Prior to commit 2026-05-29, setmathvar stripped the subscript
7169    // before calling setnparam, so `(( h[k]++ ))` wrote to the bare
7170    // base name and wiped the assoc/array. C's setmathvar at
7171    // Src/math.c:1004 passes the FULL `mvp->lval` (subscript and
7172    // all) to setnparam — fixed by routing subscripted writes
7173    // through assignsparam after pre-evaluating numeric subscripts
7174    // via matheval.
7175    // ═══════════════════════════════════════════════════════════════════
7176
7177    /// Helper — read assoc element directly out of the hashed-storage
7178    /// backing map. `getsparam("h[k]")` doesn't resolve the subscript
7179    /// form in our port; the canonical hash-element read goes through
7180    /// expansion (subst), so these unit tests poke the storage map
7181    /// the same way `assignsparam`'s subscript-write path does.
7182    fn assoc_read(base: &str, key: &str) -> Option<String> {
7183        let m = crate::ported::params::paramtab_hashed_storage()
7184            .lock()
7185            .ok()?;
7186        m.get(base).and_then(|map| map.get(key).cloned())
7187    }
7188
7189    /// c:Src/math.c:1004 — `(( assoc[key]++ ))` must mutate the
7190    /// hash element, not the bare assoc param. Bug: silent no-op
7191    /// (counts[apple] stayed at 10).
7192    #[test]
7193    fn setmathvar_assoc_post_increment_mutates_hash_element() {
7194        let _g = crate::test_util::global_state_lock();
7195        crate::ported::params::unsetparam("counts");
7196        // Create assoc with apple=10 — `sethparam` is the `typeset -A`
7197        // equivalent. A bare `counts[apple]=10` on an UNSET name is not:
7198        // zsh math-evaluates the subscript of a non-assoc, so it fails
7199        // with "assignment to invalid subscript range" (verified against
7200        // /bin/zsh) and no param is created.
7201        let _ =
7202            crate::ported::params::sethparam("counts", vec!["apple".to_string(), "10".to_string()]);
7203        // (( counts[apple]++ )) → read 10, write 11.
7204        let _ = setmathvar(
7205            "counts[apple]",
7206            mnumber {
7207                l: 11,
7208                d: 0.0,
7209                type_: MN_INTEGER,
7210            },
7211            false,
7212        );
7213        assert_eq!(
7214            assoc_read("counts", "apple"),
7215            Some("11".to_string()),
7216            "(( counts[apple]++ )) must mutate the hash element",
7217        );
7218        crate::ported::params::unsetparam("counts");
7219    }
7220
7221    /// c:Src/math.c:1004 — `(( assoc[key] = N ))` on an existing
7222    /// assoc creates/updates the slot without wiping siblings.
7223    #[test]
7224    fn setmathvar_assoc_assign_creates_slot_preserving_siblings() {
7225        let _g = crate::test_util::global_state_lock();
7226        crate::ported::params::unsetparam("h");
7227        let _ = crate::ported::params::sethparam(
7228            "h",
7229            vec![
7230                "a".to_string(),
7231                "1".to_string(),
7232                "b".to_string(),
7233                "2".to_string(),
7234            ],
7235        );
7236        let _ = setmathvar(
7237            "h[c]",
7238            mnumber {
7239                l: 99,
7240                d: 0.0,
7241                type_: MN_INTEGER,
7242            },
7243            false,
7244        );
7245        assert_eq!(
7246            assoc_read("h", "a"),
7247            Some("1".to_string()),
7248            "sibling h[a] preserved",
7249        );
7250        assert_eq!(
7251            assoc_read("h", "b"),
7252            Some("2".to_string()),
7253            "sibling h[b] preserved",
7254        );
7255        assert_eq!(
7256            assoc_read("h", "c"),
7257            Some("99".to_string()),
7258            "h[c] created with assigned value",
7259        );
7260        crate::ported::params::unsetparam("h");
7261    }
7262
7263    /// c:Src/math.c:1004 — `(( arr[i] = N ))` on indexed array
7264    /// must write element i, not wipe the array and replace with
7265    /// a scalar.
7266    #[test]
7267    fn setmathvar_indexed_array_assign_writes_element() {
7268        let _g = crate::test_util::global_state_lock();
7269        crate::ported::params::unsetparam("arr");
7270        let _ = crate::ported::params::assignaparam(
7271            "arr",
7272            vec!["10".to_string(), "20".to_string(), "30".to_string()],
7273            0,
7274        );
7275        let _ = setmathvar(
7276            "arr[2]",
7277            mnumber {
7278                l: 99,
7279                d: 0.0,
7280                type_: MN_INTEGER,
7281            },
7282            false,
7283        );
7284        assert_eq!(
7285            crate::ported::params::getaparam("arr"),
7286            Some(vec!["10".to_string(), "99".to_string(), "30".to_string()]),
7287            "(( arr[2]=99 )) must write slot 2 only",
7288        );
7289        crate::ported::params::unsetparam("arr");
7290    }
7291
7292    /// c:Src/math.c:1004 + Src/params.c:1367 — `(( arr[i + 1] = N ))`
7293    /// inside math context: subscript body is a math expression and
7294    /// must be evaluated. Pinned because the previous Rust port
7295    /// passed "arr[i + 1]" verbatim and assignsparam's i64-parse on
7296    /// the body failed, auto-vivifying as PM_HASHED.
7297    #[test]
7298    fn setmathvar_array_with_math_subscript_pre_evaluates_index() {
7299        let _g = crate::test_util::global_state_lock();
7300        crate::ported::params::unsetparam("arr");
7301        crate::ported::params::unsetparam("i");
7302        let _ = crate::ported::params::setiparam("i", 2);
7303        let _ = crate::ported::params::assignaparam(
7304            "arr",
7305            vec![
7306                "a".to_string(),
7307                "b".to_string(),
7308                "c".to_string(),
7309                "d".to_string(),
7310            ],
7311            0,
7312        );
7313        // arr[i + 1] → arr[3] after matheval.
7314        let _ = setmathvar(
7315            "arr[i + 1]",
7316            mnumber {
7317                l: 77,
7318                d: 0.0,
7319                type_: MN_INTEGER,
7320            },
7321            false,
7322        );
7323        let got = crate::ported::params::getaparam("arr");
7324        assert_eq!(
7325            got.as_ref().and_then(|v| v.get(2)).map(|s| s.as_str()),
7326            Some("77"),
7327            "arr[i+1] with i=2 must write slot 3",
7328        );
7329        crate::ported::params::unsetparam("arr");
7330        crate::ported::params::unsetparam("i");
7331    }
7332
7333    /// c:Src/math.c:1004 — chained `(( h[k]++ ))` calls compound:
7334    /// three increments on a fresh slot yield 3, not 1.
7335    #[test]
7336    fn setmathvar_assoc_three_increments_compound_to_three() {
7337        let _g = crate::test_util::global_state_lock();
7338        crate::ported::params::unsetparam("hc");
7339        let _ = crate::ported::params::sethparam("hc", vec!["x".to_string(), "0".to_string()]);
7340        for _ in 0..3 {
7341            // Read current then write read+1 — like (( hc[x]++ )).
7342            let cur = assoc_read("hc", "x")
7343                .and_then(|s| s.parse::<i64>().ok())
7344                .unwrap_or(0);
7345            let _ = setmathvar(
7346                "hc[x]",
7347                mnumber {
7348                    l: cur + 1,
7349                    d: 0.0,
7350                    type_: MN_INTEGER,
7351                },
7352                false,
7353            );
7354        }
7355        assert_eq!(
7356            assoc_read("hc", "x"),
7357            Some("3".to_string()),
7358            "three (( hc[x]++ )) must compound to 3",
7359        );
7360        crate::ported::params::unsetparam("hc");
7361    }
7362
7363    /// c:Src/math.c:1004 — `(( h[fresh]++ ))` auto-vivifies the
7364    /// slot to value 1 (read NULL → 0, write 0+1).
7365    #[test]
7366    fn setmathvar_assoc_post_increment_on_unset_slot_creates_with_one() {
7367        let _g = crate::test_util::global_state_lock();
7368        crate::ported::params::unsetparam("hv");
7369        // Create the assoc first (typeset -A hv).
7370        let _ = crate::ported::params::sethparam("hv", vec!["seed".to_string(), "0".to_string()]);
7371        // (( hv[fresh]++ )) — fresh slot should become 1.
7372        let _ = setmathvar(
7373            "hv[fresh]",
7374            mnumber {
7375                l: 1,
7376                d: 0.0,
7377                type_: MN_INTEGER,
7378            },
7379            false,
7380        );
7381        assert_eq!(
7382            assoc_read("hv", "fresh"),
7383            Some("1".to_string()),
7384            "(( hv[fresh]++ )) on unset slot must create with 1",
7385        );
7386        crate::ported::params::unsetparam("hv");
7387    }
7388
7389    /// c:Src/math.c:1002 — NO_EXEC mode: setmathvar returns val
7390    /// without any param-table mutation, even for subscripted
7391    /// names. Pinned because the new subscript branch must respect
7392    /// the noeval guard added by the previous code path.
7393    #[test]
7394    fn setmathvar_subscript_respects_noeval_guard() {
7395        let _g = crate::test_util::global_state_lock();
7396        crate::ported::params::unsetparam("nev");
7397        let _ = crate::ported::params::sethparam("nev", vec!["k".to_string(), "1".to_string()]);
7398        M_NOEVAL.with(|n| n.set(1));
7399        let v = mnumber {
7400            l: 999,
7401            d: 0.0,
7402            type_: MN_INTEGER,
7403        };
7404        let ret = setmathvar("nev[k]", v, false);
7405        M_NOEVAL.with(|n| n.set(0));
7406        assert_eq!(ret.l, 999, "noeval returns val unchanged");
7407        assert_eq!(
7408            assoc_read("nev", "k"),
7409            Some("1".to_string()),
7410            "noeval must suppress the paramtab write",
7411        );
7412        crate::ported::params::unsetparam("nev");
7413    }
7414}