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