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