Skip to main content

zsh/extensions/
dash_mode.rs

1//! Strict-dash (Debian Almquist Shell) emulation flag — a zshrs-only
2//! extension with NO zsh C counterpart.
3//!
4//! Upstream zsh has no `dash` personality: its option system models `sh`
5//! only as a set of behavior deltas and can never *reject* a zsh syntactic
6//! extension the way real dash does. dash is behaviourally `sh` for every
7//! option (`shwordsplit`, `ksharrays`, `posix*`, …) and only ADDS
8//! rejections of zsh-only syntax. So rather than a distinct `EMULATE_DASH`
9//! bit — which would force `|| EMULATION(EMULATE_DASH)` at every one of the
10//! ~25 `EMULATION(EMULATE_SH)` call sites — `emulate dash` / `zshrs --dash`
11//! sets `EMULATION = EMULATE_SH` and raises this orthogonal flag.
12//!
13//! The lexer / parser / math / echo gates keyed off [`dash_strict`] turn
14//! the following zsh extensions into the same errors real `/bin/dash`
15//! produces:
16//!   * `$'...'` ANSI-C quoting  → literal `$` + ordinary single quote
17//!   * `<<<` here-strings       → "redirection unexpected"
18//!   * `+=` compound assignment → command word ("not found")
19//!   * `name=(...)` arrays       → "( unexpected" syntax error
20//!   * the `[[ ]]` reserved word → ordinary command ("not found")
21//!   * arith `**` / `,`          → arithmetic parse error
22//!   * non-XSI `echo`            → escapes interpreted by default
23//!
24//! This lives in `src/extensions/` (not `src/ported/`) because it has no
25//! line in zsh's C source; `src/ported/` is a faithful port only.
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
30
31/// Process-global strict-dash flag. Raised by `emulate dash` (via
32/// [`set_dash_strict`]) and cleared by any `emulate` to another
33/// personality. Read through [`dash_strict`] at each hot-path gate.
34static DASH_STRICT: AtomicBool = AtomicBool::new(false);
35
36/// Process-global "real-shell-faithful" flag for the POSIX-family drop-in
37/// modes (`zshrs --sh` / `--ksh` / `--dash`).
38///
39/// zsh's own `emulate sh`/`emulate ksh` only approximates the Bourne
40/// shells and keeps several zsh-family behaviors that the real tools do
41/// not — e.g. a trailing non-whitespace IFS separator yields a trailing
42/// empty field in zsh (`IFS=:; set -- $v` on `a:b:` → 3 args) but not in
43/// dash/ksh/bash (→ 2 args). When raised, zshrs matches the REAL shell
44/// instead of zsh's approximation, making `zshrs --sh` strictly more
45/// faithful than zsh.
46///
47/// Design: the bare drop-in flag (`--sh`/`--ksh`/`--dash`) raises this;
48/// adding `--zsh` (`zshrs --sh --zsh`) clears it, selecting zsh-style
49/// emulation instead. The runtime `emulate sh` builtin never raises it —
50/// that path is zsh's feature and keeps zsh semantics. Set only from the
51/// binary's CLI mode-application, so it defaults `false` in the library.
52static POSIX_FAITHFUL: AtomicBool = AtomicBool::new(false);
53
54/// Process-global bash drop-in flag (`zshrs --bash`). bash is a SUPERSET of
55/// POSIX sh with syntax zsh lacks: indirect `${!var}` and case-modification
56/// `${v^^}` / `${v,,}` / `${v^}` / `${v,}`. These are parsed in the subst
57/// layer only when this is set, so native zsh and the other modes are
58/// unaffected. Set from the binary's CLI mode-application; defaults `false`.
59static BASH_MODE: AtomicBool = AtomicBool::new(false);
60
61/// Process-global zsh drop-in flag (`zshrs --zsh` / `--zsh-compat`).
62///
63/// `--zsh` promises identical behaviour to `/bin/zsh`, which means the
64/// zshrs-only SYNTAX extensions have to be off — not just the caches and
65/// the daemon. A construct zsh's parser rejects must keep being rejected,
66/// with zsh's own diagnostic, or the compat-test entrypoint is measuring a
67/// different language than the one it claims to stand in for.
68///
69/// Currently gates the `intercept <kind> <pat> { … }` block body, whose
70/// raw-span capture in the lexer has no zsh counterpart: real zsh dies with
71/// "parse error near `}'" because `}` cannot be a bare argument, and under
72/// this flag zshrs does too.
73///
74/// Set only from the binary's CLI mode-application, so it defaults `false`
75/// in the library and in every embedder.
76static ZSH_DROPIN: AtomicBool = AtomicBool::new(false);
77
78/// bash `shopt -s nocasematch` — case-insensitive `[[ == ]]` / `[[ =~ ]]` /
79/// `case`. It is NOT a zsh option (opt_state can't store it), so it needs its
80/// own flag. Toggled by the `shopt` builtin; read by cond.rs / case matching.
81static NOCASEMATCH: AtomicBool = AtomicBool::new(false);
82
83/// Process-global "this Korn drop-in is the pdksh line, not ksh93" flag,
84/// raised for `zshrs --mksh` / `--pdksh`.
85///
86/// `--ksh`, `--mksh` and `--pdksh` all install the same `emulate ksh`
87/// option preset and are otherwise indistinguishable at run time, but the
88/// two lines genuinely differ where mksh inherited pdksh behavior ksh93
89/// never had. The one this currently decides is `$PIPESTATUS`:
90/// mksh(1) documents it ("PIPESTATUS: An array variable holding the exit
91/// statuses of the last pipeline"), ksh93 has no such parameter —
92/// `mksh -c 'true|false|true; print -r -- "[${PIPESTATUS[*]}]"'` → `[0 1 0]`
93/// while ksh93 prints `[]`.
94static PDKSH_FAMILY: AtomicBool = AtomicBool::new(false);
95
96/// True while the shell runs a `-c` command string (set by the binary before
97/// the string is compiled). mksh treats such a string differently from a
98/// script file: `$LINENO` is always 0 and `set -n` is ignored.
99static DASH_C_STRING: AtomicBool = AtomicBool::new(false);
100
101/// See [`DASH_C_STRING`].
102#[inline]
103pub fn dash_c_string() -> bool {
104    DASH_C_STRING.load(Ordering::Relaxed)
105}
106
107/// Record that a `-c` string is being run. See [`DASH_C_STRING`].
108#[inline]
109pub fn set_dash_c_string(on: bool) {
110    DASH_C_STRING.store(on, Ordering::Relaxed);
111}
112
113/// True for a bare `zshrs --mksh` / `--pdksh`. See [`PDKSH_FAMILY`].
114#[inline]
115pub fn pdksh_family() -> bool {
116    PDKSH_FAMILY.load(Ordering::Relaxed)
117}
118
119/// Select the pdksh/mksh line of the Korn family. Called from the binary's
120/// CLI mode application; cleared for `--ksh` and every other mode.
121#[inline]
122pub fn set_pdksh_family(on: bool) {
123    PDKSH_FAMILY.store(on, Ordering::Relaxed);
124}
125
126/// True when `shopt -s nocasematch` is active (bash case-insensitive matching).
127#[inline]
128pub fn nocasematch() -> bool {
129    NOCASEMATCH.load(Ordering::Relaxed)
130}
131
132/// Set/clear bash `nocasematch`.
133///
134/// !!! WARNING: RUST-ONLY HELPER — BASH IS THE REFERENCE, NOT zsh's C !!!
135/// bash(1), The Shopt Builtin: "nocasematch — If set, bash matches patterns
136/// in a case-insensitive fashion when performing matching while executing
137/// case or [[ conditional commands". Three consumers, two mechanisms:
138///
139///   * `[[ … == … ]]` / `[[ … != … ]]` and `case` arms case-fold both sides
140///     at their match sites, reading the [`NOCASEMATCH`] flag directly
141///     (fusevm_bridge's BUILTIN_COND_STRMATCH and `str_match`, cond.rs:462).
142///   * `[[ … =~ … ]]` goes through zsh's regex module, which already has the
143///     exact knob bash wants: `Src/Modules/regex.c:74` builds its regcomp
144///     flags as `REG_EXTENDED | (isset(CASEMATCH) ? 0 : REG_ICASE)`. Rather
145///     than bolt a second case-folding path onto the regex engine, mirror
146///     the bash flag onto zsh's CASE_MATCH with the sense inverted —
147///     `nocasematch` ON means CASE_MATCH OFF means REG_ICASE. CASE_MATCH has
148///     exactly one reader in the tree (`src/ported/modules/regex.rs:87/109`),
149///     so the mirror cannot leak into any other construct.
150///
151/// Under `--zsh` nothing calls this, so zsh's own CASE_MATCH is untouched.
152#[inline]
153pub fn set_nocasematch(on: bool) {
154    NOCASEMATCH.store(on, Ordering::Relaxed);
155    // Inverted mirror onto zsh CASE_MATCH — see the doc comment above.
156    crate::ported::options::opt_state_set_via_alias("casematch", !on);
157}
158
159/// The bash shopt names whose behavior is carried by a zsh option with the
160/// OPPOSITE sense: the shopt is ON exactly when the zsh option is OFF.
161///
162/// !!! WARNING: RUST-ONLY TABLE — BASH IS THE REFERENCE, NOT zsh's C !!!
163/// [`BASH_SHOPTS`]'s middle column can only express a same-sense mapping,
164/// and the name(s) below are negations of the zsh option that implements
165/// them, so they are resolved here instead of there.
166///
167///   * `xpg_echo` — bash(1): "If set, the echo builtin expands
168///     backslash-escape sequences by default." That is precisely zsh's
169///     `NO_BSD_ECHO`: `Src/builtin.c:4754` picks escape processing unless
170///     BSD_ECHO is set and `-e` was not given, and `--bash` boots with
171///     BSD_ECHO on (so `echo 'a\tb'` prints the backslash, as bash does).
172///     Verified against bash 5.3 for `\t`, `\n`, `\c`, `\\`, `\e`, `\0101`
173///     and for the `-e` / `-E` overrides, which win over the shopt in both
174///     shells.
175///   * `nocaseglob` is NOT here — zsh spells it `NO_CASE_GLOB` in the
176///     negative too, so `optlookup` resolves the bash spelling directly and
177///     the same-sense column already works.
178const BASH_SHOPTS_INVERTED_ZSH_OPT: &[(&str, &str)] = &[("xpg_echo", "bsdecho")];
179
180/// The `shopt` rows bash treats as READ-ONLY state, not as settable flags.
181///
182/// !!! WARNING: RUST-ONLY TABLE — BASH IS THE REFERENCE, NOT zsh's C !!!
183/// Both report a fact about how the shell was started, so bash accepts
184/// `shopt -s`/`-u` on them silently — status 0, no diagnostic — and simply
185/// does not change the value:
186///
187/// ```text
188///     $ bash -c  'shopt -s login_shell; echo rc=$?; shopt -p login_shell'
189///     rc=0
190///     shopt -u login_shell
191///     $ bash -lc 'shopt -u login_shell; shopt -p login_shell'
192///     shopt -s login_shell
193/// ```
194///
195/// zshrs let both be written, so `shopt -s login_shell` made `$BASHOPTS`
196/// claim a login shell in a non-login one. Their VALUES come from the zsh
197/// option that already carries the same state (`LOGIN_SHELL`,
198/// `src/ported/options.rs:108`; `restricted_shell` has none since zsh
199/// dropped RESTRICTED in 54181 and stays off), which is why `zshrs --bash -l`
200/// now reports `shopt -s login_shell` as bash does.
201const BASH_SHOPTS_READONLY: &[&str] = &["login_shell", "restricted_shell"];
202
203/// The zsh option carrying `name`'s behavior with an inverted sense, if any.
204fn bash_shopt_inverted_zsh_opt(name: &str) -> Option<&'static str> {
205    BASH_SHOPTS_INVERTED_ZSH_OPT
206        .iter()
207        .find(|(b, _)| *b == name)
208        .map(|(_, z)| *z)
209}
210
211/// True when the shell is running in strict-dash mode (`emulate dash` or
212/// `zshrs --dash`). Gates the zsh-extension rejections that make zshrs
213/// match `/bin/dash` byte-for-byte.
214#[inline]
215pub fn dash_strict() -> bool {
216    DASH_STRICT.load(Ordering::Relaxed)
217}
218
219/// Set (or clear) strict-dash mode. Called from `options::emulate` — set
220/// for the `dash` personality, cleared for every other so a later
221/// `emulate zsh` (etc.) fully leaves dash mode.
222#[inline]
223pub fn set_dash_strict(on: bool) {
224    DASH_STRICT.store(on, Ordering::Relaxed);
225}
226
227/// True when a POSIX-family drop-in mode should match the REAL shell
228/// rather than zsh's approximation of it. See [`POSIX_FAITHFUL`].
229#[inline]
230pub fn posix_faithful() -> bool {
231    POSIX_FAITHFUL.load(Ordering::Relaxed)
232}
233
234/// True in bash drop-in mode (`zshrs --bash`). Enables bash-only param
235/// expansion syntax (`${!var}` indirect, `${v^^}` case-mod). See [`BASH_MODE`].
236#[inline]
237pub fn bash_mode() -> bool {
238    BASH_MODE.load(Ordering::Relaxed)
239}
240
241/// The exit status a FATAL shell error leaves behind, when the emulated
242/// shell disagrees with zsh's `1`.
243///
244/// zsh's `errflag` abort ends the list and the status is whatever `lastval`
245/// held — `ERRFLAG_ERROR` is literally `1` (c:Src/zsh.h:2970), so
246/// c:Src/exec.c:3001 `lastval = errflag ? errflag : cmdoutval` yields 1.
247/// dash does not model errors that way: `sh_error()` calls
248/// `exraise(EXERROR)`, whose handler sets `exitstatus = 2` before
249/// `exitshell()`, so EVERY fatal expansion / assignment / arithmetic error
250/// leaves 2 no matter what the previous status was. Measured on
251/// dash 0.5.x and ash, all four fatal shapes:
252///
253/// ```text
254/// dash -c '(set -u; : "$nope")   2>/dev/null; printf "%d\n" $?'  → 2
255/// dash -c '(: "${nope:?msg}")    2>/dev/null; printf "%d\n" $?'  → 2
256/// dash -c '(readonly r=1; r=2)   2>/dev/null; printf "%d\n" $?'  → 2
257/// dash -c '(: $((1/0)))          2>/dev/null; printf "%d\n" $?'  → 2
258/// ```
259///
260/// bash, ksh93 and mksh all answer `1` for the same four, which is what
261/// zshrs already produces — so this returns `None` outside the dash family
262/// and every other mode is untouched. `/bin/sh` is deliberately NOT covered:
263/// on this platform it is bash 3.2, which answers `127`, while on Linux it
264/// is dash and answers `2`; encoding either would be encoding the host, not
265/// the shell.
266///
267/// Applied at the two places dash's `exitshell` would be reached — the
268/// `( … )` boundary and the end of a `-c` script — rather than at each
269/// individual `zerr` call, which is exactly where `exraise` unwinds to.
270///
271/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
272#[inline]
273pub fn fatal_error_status() -> Option<i32> {
274    // `dash_strict` alone would also fire for the zsh-STYLE leg
275    // (`--dash --zsh`), which must keep zsh's 1; `posix_faithful` is what
276    // distinguishes the drop-in from it.
277    if posix_faithful() && dash_strict() {
278        Some(2)
279    } else {
280        None
281    }
282}
283
284/// True for the bare `zshrs --dash` drop-in (not the zsh-style
285/// `--dash --zsh` leg). Gate for dash behaviors that deviate from zsh's
286/// own sh emulation.
287///
288/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
289#[inline]
290pub fn dash_faithful() -> bool {
291    posix_faithful() && dash_strict()
292}
293
294/// The option letters dash's builtin `name` accepts, where that is narrower
295/// than zsh's (`None` = keep zsh's set). dash reports any other letter as
296/// "Illegal option" (status 2). Measured on /opt/homebrew/bin/dash 0.5.13.5 and
297/// /bin/dash: `read -n/-s/-d`, `local -r/-a/-x` and `unalias -x` all fail.
298///
299/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
300pub fn dash_builtin_optstr(name: &str) -> Option<&'static [u8]> {
301    match name {
302        "read" => Some(b"rp"),
303        "local" => Some(b""),
304        "unalias" => Some(b"a"),
305        "type" => Some(b""),
306        _ => None,
307    }
308}
309
310/// Rewrite every dash `echo` `\0` octal escape to the three-digit `\NNN` form
311/// zsh's getkeystring reads. dash's `\0` introducer takes up to THREE digits
312/// after the zero (`\0101` is `A`), while zsh's consumes the zero as one of
313/// the three (`\0101` is `\010` then `1`). Escaped backslashes are skipped.
314///
315/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
316pub fn dash_echo_octal_normalize(s: &str) -> String {
317    let b: Vec<char> = s.chars().collect();
318    let mut out = String::with_capacity(s.len());
319    let mut i = 0;
320    while i < b.len() {
321        if b[i] != '\\' || i + 1 >= b.len() {
322            out.push(b[i]);
323            i += 1;
324        } else if b[i + 1] == '0' {
325            let digits: String = b[i + 2..].iter().take(3).take_while(|c| c.is_digit(8)).collect();
326            out.push_str(&format!("\\{:0>3}", digits));
327            i += 2 + digits.len();
328        } else {
329            out.push('\\');
330            out.push(b[i + 1]);
331            i += 2;
332        }
333    }
334    out
335}
336
337/// dash's arithmetic read of a variable's value: empty is 0, otherwise the whole
338/// value must be a `strtoimax(.., 0)` integer (optional sign, `0x` hex, leading-`0`
339/// octal or decimal) with only blanks around it.
340///
341/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
342pub fn dash_arith_var(raw: &str) -> Option<i64> {
343    let t = raw.trim();
344    if t.is_empty() {
345        return Some(0);
346    }
347    let (neg, body) = match t.as_bytes()[0] {
348        b'-' => (true, &t[1..]),
349        b'+' => (false, &t[1..]),
350        _ => (false, t),
351    };
352    let (radix, digits) = if let Some(h) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
353        (16, h)
354    } else if body.len() > 1 && body.starts_with('0') {
355        (8, &body[1..])
356    } else {
357        (10, body)
358    };
359    if !digits.starts_with(|c: char| c.is_ascii_alphanumeric()) {
360        return None;
361    }
362    let v = i64::from_str_radix(digits, radix).ok()?;
363    Some(if neg { -v } else { v })
364}
365
366/// dash's POSIX special builtins (parser.c `goodname` rejects them as function
367/// names; `type` words them "a special shell builtin").
368const DASH_SPECIAL_BUILTINS: &[&str] = &[
369    ":", ".", "break", "continue", "eval", "exec", "exit", "export",
370    "readonly", "return", "set", "shift", "times", "trap", "unset",
371];
372
373/// True when `name` is one of dash's POSIX special builtins.
374///
375/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
376pub fn dash_special_builtin(name: &str) -> bool {
377    DASH_SPECIAL_BUILTINS.contains(&name)
378}
379
380/// Every builtin dash itself provides (`command -v NAME` prints the bare name).
381/// Anything else zshrs registers (`let`, `print`, `typeset`, `setopt`, ...) is
382/// "not found" in dash.
383const DASH_BUILTINS: &[&str] = &[
384    ":", ".", "[", "alias", "bg", "break", "cd", "chdir", "command", "continue",
385    "echo", "eval", "exec", "exit", "export", "false", "fc", "fg", "getopts",
386    "hash", "jobs", "kill", "local", "printf", "pwd", "read", "readonly",
387    "return", "set", "shift", "test", "times", "trap", "true", "type", "ulimit",
388    "umask", "unalias", "unset", "wait",
389];
390
391/// Mark every zshrs builtin dash does not have as DISABLED, so the name falls
392/// through to PATH lookup (`let 1+1` -> "not found", 127) exactly as in dash.
393/// Called once at startup of the bare `--dash`/`--ash` drop-in.
394///
395/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
396pub fn dash_hide_foreign_builtins() {
397    let Ok(mut disabled) = crate::ported::builtin::BUILTINS_DISABLED.lock() else {
398        return;
399    };
400    for name in crate::ported::builtin::createbuiltintable().keys() {
401        if !DASH_BUILTINS.contains(&name.as_str()) {
402            disabled.insert(name.clone());
403        }
404    }
405}
406
407/// dash's `number()`: an optional `+` and decimal digits that fit an int.
408/// Anything else (`abc`, `-1`, `1+1`, `0x10`, empty) is "Illegal number".
409///
410/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
411pub fn dash_number(s: &str) -> Option<i32> {
412    let a = s.trim_start();
413    let digits = a.strip_prefix('+').unwrap_or(a);
414    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
415        return None;
416    }
417    digits.parse::<i32>().ok()
418}
419
420/// True when dash's parser rejects `name` as a function name
421/// ("Syntax error: Bad function name"): anything that is not
422/// `[A-Za-z_][A-Za-z0-9_]*` (parser.c `goodname`), or that names a POSIX
423/// special builtin (`: . break continue eval exec exit export readonly
424/// return set shift times trap unset`). Measured on /opt/homebrew/bin/dash.
425///
426/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
427pub fn dash_bad_function_name(name: &str) -> bool {
428    let mut chars = name.chars();
429    let first_ok = matches!(chars.next(), Some(c) if c == '_' || c.is_ascii_alphabetic());
430    !first_ok || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) || dash_special_builtin(name)
431}
432
433/// True in a bare Korn drop-in — `zshrs --ksh`, `--mksh` or `--pdksh`.
434///
435/// Composed rather than stored: [`posix_faithful`] is raised only by a
436/// BARE POSIX-family drop-in flag (cleared by `--zsh` and never set by the
437/// runtime `emulate` builtin), and `EMULATION(EMULATE_KSH)` picks the Korn
438/// leg out of that family. So this is false in `--zsh`, in native zshrs,
439/// under `emulate ksh` typed at a zsh prompt, and in `--sh`/`--dash`/
440/// `--bash`. Using the option/emulation bit ALONE would be wrong: a zsh
441/// user who runs `emulate ksh` must keep zsh's behavior.
442#[inline]
443pub fn korn_mode() -> bool {
444    posix_faithful() && crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH)
445}
446
447/// True for a bare `zshrs --ksh` (the ksh93 line), false for the pdksh line
448/// (`--mksh` / `--pdksh`) and for every non-Korn mode.
449#[inline]
450pub fn ksh93_mode() -> bool {
451    korn_mode() && !pdksh_family()
452}
453
454/// True when the shell being emulated has SPARSE indexed arrays — bash and
455/// the whole Korn family.
456///
457/// bash(1), Arrays: "Arrays are assigned to using compound assignments …
458/// Indexed array assignments do not require anything but *subscript*=*value*
459/// … arrays are sparse, i.e. you do not have to define all the indices."
460/// mksh(1) and ksh(1) match: `mksh -c 'a=(x y z); a[5]=q; print -r --
461/// "${!a[@]}"'` → `0 1 2 5` and `${#a[@]}` → 4, exactly like bash.
462///
463/// zsh arrays are DENSE, so `a[5]=q` pads indices 3 and 4 — hence the hole
464/// side-table in [`crate::bash_arrays`]. This predicate is the write-side
465/// gate for that table; the read side keys off whether holes exist at all,
466/// so widening this automatically widens `${#a[@]}` / `${!a[@]}` /
467/// `"${a[*]}"` / `typeset -p` with no further change.
468#[inline]
469pub fn sparse_arrays() -> bool {
470    bash_mode() || korn_mode()
471}
472
473/// True when `printf` in bash mode should treat this operand to a numeric
474/// conversion (`%d %i %o %u %x %X`) as an error (still prints 0, but exit
475/// status 1). bash — unlike zsh/ksh/dash/sh — errors on an explicitly-supplied
476/// EMPTY operand (`printf '%d' ''` → rc 1); a MISSING operand (`printf '%d'`)
477/// is NOT an error, so `arg` distinguishes them: `Some("")` → true, `None` →
478/// false. Non-empty junk ("abc"/"+"/"  ") already errors via mathevali in every
479/// mode, so only the empty-string case is handled here. Verified vs bash 5.x.
480#[inline]
481pub fn bash_printf_empty_numeric_error(arg: Option<&String>) -> bool {
482    bash_mode() && matches!(arg, Some(s) if s.is_empty())
483}
484
485/// Set (or clear) bash drop-in mode. Called from the binary's CLI mode
486/// application (raised for `--bash`, unless `--zsh` overrides).
487#[inline]
488pub fn set_bash_mode(on: bool) {
489    BASH_MODE.store(on, Ordering::Relaxed);
490}
491
492/// Values of a ksh93 brace range `{from..to[..step]}`, verified against
493/// ksh93u+m 1.0.10. Without a step the direction comes from the endpoints.
494/// With a step its SIGN is the direction (`{10..1..-4}` -> 10 6 2, `{e..a..2}`
495/// -> e): the start is always emitted and the walk stops at the first value
496/// past `to` in that direction, so a step pointing away from `to` yields
497/// just the start.
498pub fn ksh93_range_values(from: i64, to: i64, step: Option<i64>) -> Vec<i64> {
499    let step = step.unwrap_or(if from <= to { 1 } else { -1 });
500    let mut vals = vec![from];
501    let mut v = from + step;
502    while (step > 0 && v <= to) || (step < 0 && v >= to) {
503        vals.push(v);
504        v += step;
505    }
506    vals
507}
508
509/// True in zsh drop-in mode (`zshrs --zsh` / `--zsh-compat`). Gates OFF the
510/// zshrs-only syntax extensions so the compat entrypoint parses exactly what
511/// `/bin/zsh` parses. See [`ZSH_DROPIN`].
512#[inline]
513pub fn zsh_dropin() -> bool {
514    ZSH_DROPIN.load(Ordering::Relaxed)
515}
516
517/// Set (or clear) zsh drop-in mode. Called from the binary's CLI mode
518/// application (raised for `--zsh` / `--zsh-compat`).
519#[inline]
520pub fn set_zsh_dropin(on: bool) {
521    ZSH_DROPIN.store(on, Ordering::Relaxed);
522}
523
524/// Set (or clear) real-shell-faithful mode. Called from the binary's CLI
525/// mode application: raised for a bare `--sh`/`--ksh`/`--dash`, cleared
526/// when `--zsh` is also present (zsh-style emulation) or in any other mode.
527#[inline]
528pub fn set_posix_faithful(on: bool) {
529    POSIX_FAITHFUL.store(on, Ordering::Relaxed);
530}
531
532/// The bash version zshrs advertises in `--bash` mode. Scripts gate features
533/// on `${BASH_VERSINFO[0]}` (e.g. `>= 4` for assoc arrays / `${v^^}`), so a
534/// modern 5.x keeps every feature path live. Not tied to any real build.
535pub const BASH_VERSION_MAJOR: &str = "5";
536pub const BASH_VERSION_MINOR: &str = "3";
537pub const BASH_VERSION_PATCH: &str = "20";
538
539/// `$BASH_VERSION` scalar, e.g. `5.3.20(1)-release`.
540pub fn bash_version() -> String {
541    format!(
542        "{}.{}.{}(1)-release",
543        BASH_VERSION_MAJOR, BASH_VERSION_MINOR, BASH_VERSION_PATCH
544    )
545}
546
547/// `${BASH_VERSINFO[@]}` — the 6-element bash version array:
548/// `(major minor patch build release machtype)`.
549pub fn bash_versinfo() -> Vec<String> {
550    vec![
551        BASH_VERSION_MAJOR.to_string(),
552        BASH_VERSION_MINOR.to_string(),
553        BASH_VERSION_PATCH.to_string(),
554        "1".to_string(),
555        "release".to_string(),
556        format!(
557            "{}-{}-{}",
558            crate::ported::config_h::MACHTYPE,
559            crate::ported::config_h::VENDOR,
560            crate::ported::config_h::OSTYPE
561        ),
562    ]
563}
564
565/// Strip source-literal backslash escapes from a `${var/pat/REPL}` replacement
566/// under `--bash`. bash removes a `\` before ANY char in the LITERAL
567/// replacement (`\~`→`~`, `\&`→`&`, `\\`→`\`), but NOT from expanded values
568/// (`${v/p/$x}` with x=`\~` stays `\~`). This runs on the tokenized replacement
569/// BEFORE `singsub`, where source-literal backslashes are raw `\` (0x5c) while
570/// expansion-defanging escapes (`\$`/`` \` ``/`\"`) are Bnull markers (0x9f) and
571/// spliced values aren't present yet — so touching only raw `\` is exactly the
572/// bash rule. A trailing lone `\` is kept.
573pub fn strip_replacement_backslashes(s: &str) -> String {
574    if !s.contains('\\') && !s.contains('\u{e19f}') {
575        return s.to_string();
576    }
577    let mut out = String::with_capacity(s.len());
578    let mut chars = s.chars();
579    while let Some(c) = chars.next() {
580        if c == '\u{e19f}' {
581            // Bnull marker: the following char is an ALREADY-cooked literal (a
582            // `\\`/`\$`/`` \` `` the DQ lexer defanged). Keep both bytes so it
583            // survives — bash does not re-strip an already-processed backslash.
584            // bash patsub_replacement: a lexer-cooked `\&` is a literal `&`.
585            if chars.clone().next() == Some('&')
586                && bash_shopt_get("patsub_replacement") == Some(true)
587            {
588                chars.next();
589                out.push(PATSUB_LITERAL_AMP);
590                continue;
591            }
592            out.push(c);
593            if let Some(next) = chars.next() {
594                out.push(next);
595            }
596        } else if c == '\\' {
597            // Raw source-literal backslash: strip it, the next char is literal.
598            match chars.next() {
599                // bash patsub_replacement: `\&` is a literal `&` that the later
600                // `&` -> matched-text pass (`bash_patsub`) must leave alone.
601                Some('&') if bash_shopt_get("patsub_replacement") == Some(true) => {
602                    out.push(PATSUB_LITERAL_AMP)
603                }
604                Some(next) => out.push(next),
605                None => out.push('\\'),
606            }
607        } else {
608            out.push(c);
609        }
610    }
611    out
612}
613
614/// True when the shell is executing a script file or stdin rather than a
615/// `-c` string: only then does bash add the outermost `main` frame to
616/// `FUNCNAME` / `BASH_SOURCE` / `BASH_LINENO`.
617fn bash_running_script() -> bool {
618    crate::ported::params::getsparam("ZSH_EXECUTION_STRING").map_or(true, |c| c.is_empty())
619}
620
621/// The name bash reports for the top-level script (`$0` at top level).
622fn bash_script_name() -> String {
623    crate::ported::params::getsparam("ZSH_ARGZERO").unwrap_or_default()
624}
625
626/// True when the DEBUG-trap statement text is a function definition
627/// (`name () { ... }` / `function name ...`), for which bash fires no DEBUG.
628///
629/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
630pub fn is_funcdef_text(t: &str) -> bool {
631    let t = t.trim_start();
632    if t.starts_with("function ") {
633        return true;
634    }
635    let end = t
636        .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
637        .unwrap_or(t.len());
638    end > 0 && t[end..].trim_start().starts_with("()")
639}
640
641/// bash hides aliases from `type` / `command -v` / `hash` while `expand_aliases`
642/// is off (non-interactive default). True when alias lookups may succeed.
643///
644/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
645pub fn aliases_visible() -> bool {
646    !bash_mode() || crate::ported::zsh_h::isset(crate::ported::zsh_h::ALIASESOPT)
647}
648
649/// Resolve a bash special ARRAY name (`PIPESTATUS`, `FUNCNAME`,
650/// `BASH_VERSINFO`) to its value in `--bash` mode by aliasing the zsh-native
651/// special or synthesizing it. Returns `None` for any other name (or outside
652/// bash mode) so callers fall through to normal array resolution.
653pub fn bash_special_array(name: &str) -> Option<Vec<String>> {
654    // `$PIPESTATUS` is not bash's alone: mksh(1) documents it verbatim —
655    // "PIPESTATUS: An array variable holding the exit statuses of the last
656    // pipeline" — and `mksh -c 'true|false|true; print -r --
657    // "[${PIPESTATUS[*]}]"'` prints `[0 1 0]`. ksh93 has NO such parameter
658    // (same command prints `[]`), so this widens to the pdksh line only,
659    // not to `--ksh`. `FUNCNAME` and `BASH_VERSINFO` stay bash-only —
660    // neither Korn shell has them.
661    if !bash_mode() && !(name == "PIPESTATUS" && pdksh_family()) {
662        return None;
663    }
664    match name {
665        // Under the Korn emulation `pipestatus` is an ordinary parameter, not
666        // the special (zsh `emulate ksh` leaves it unset after a pipeline), so
667        // the pdksh line reads the C globals the pipeline wrote — what
668        // pipestatgetfn walks (c:Src/params.c:5269-5276).
669        "PIPESTATUS" if !bash_mode() => {
670            use crate::ported::jobs::{NUMPIPESTATS, PIPESTATS};
671            let n = NUMPIPESTATS
672                .get()
673                .and_then(|m| m.lock().ok().map(|g| *g))
674                .unwrap_or(0);
675            let stats = PIPESTATS
676                .get()
677                .and_then(|m| m.lock().ok().map(|p| p[..n].to_vec()))
678                .unwrap_or_default();
679            Some(stats.iter().map(|s| s.to_string()).collect())
680        }
681        // bash PIPESTATUS ≈ zsh pipestatus (per-stage exit codes, 0-indexed).
682        "PIPESTATUS" => Some(crate::ported::exec::array("pipestatus").unwrap_or_default()),
683        // bash FUNCNAME ≈ zsh funcstack — call stack, innermost (current) first.
684        // ...plus `main` as the outermost frame when running a script (not `-c`).
685        "FUNCNAME" => crate::ported::modules::parameter::FUNCSTACK.lock().ok().map(|f| {
686            // FUNCNAME exists only while a function runs; a sourced file is a "source" frame.
687            if !f.iter().any(|fs| fs.tp == crate::ported::zsh_h::FS_FUNC) {
688                return Vec::new();
689            }
690            let mut names: Vec<String> = f
691                .iter()
692                .rev()
693                .map(|fs| if fs.tp == crate::ported::zsh_h::FS_SOURCE { "source".to_string() } else { fs.name.clone() })
694                .collect();
695            if bash_running_script() {
696                names.push("main".to_string());
697            }
698            names
699        }),
700        // bash BASH_SOURCE: the file each frame was defined in, then the script itself.
701        "BASH_SOURCE" => crate::ported::modules::parameter::FUNCSTACK.lock().ok().map(|f| {
702            let script = bash_script_name();
703            let mut files: Vec<String> = f
704                .iter()
705                .rev()
706                .map(|fs| fs.filename.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| script.clone()))
707                .collect();
708            if bash_running_script() {
709                files.push(script);
710            }
711            files
712        }),
713        // bash BASH_LINENO: the line each frame was called from, then 0 for the script.
714        "BASH_LINENO" => crate::ported::modules::parameter::FUNCSTACK.lock().ok().map(|f| {
715            let mut lines: Vec<String> = f.iter().rev().map(|fs| fs.lineno.to_string()).collect();
716            if bash_running_script() {
717                lines.push("0".to_string());
718            }
719            lines
720        }),
721        "BASH_VERSINFO" => Some(bash_versinfo()),
722        _ => None,
723    }
724}
725
726/// bash's `shopt` option table: `(bash name, zshrs option key, bash default)`.
727///
728/// The name list and the defaults are bash 5.3's own — `bash -c shopt`
729/// prints exactly these 59 rows in this order, and `shopt` lists them
730/// alphabetically, so no caller re-sorts. bash rejects anything outside the
731/// list: `bash -c 'shopt -p zznope'` → "shopt: zznope: invalid shell option
732/// name", status 1.
733///
734/// The middle field is where the state LIVES:
735///   * `Some(opt)` — a real zsh option carries the behavior, so the flag is
736///     read and written through `opt_state`. Twelve names zsh already has
737///     under the same spelling (`optlookup` is underscore- and case-blind),
738///     plus two renames whose behavior zsh implements under a different
739///     name: bash `extglob` is zsh `kshglob` (identical `@()`/`*()`/`+()`/
740///     `?()`/`!()` ksh patterns), and bash `failglob` — "if a pattern fails
741///     to match, an error message is printed and the command is not
742///     executed" (bash(1) The Shopt Builtin) — is zsh `nomatch`.
743///   * `None` — bash-only, no zsh option behind it. zsh's option table is a
744///     faithful port and must not grow non-zsh rows (they would leak into
745///     `setopt` / `${#options}` under `--zsh`), so the state lives in
746///     [`BASH_ONLY_SHOPTS`] keyed by the bash name, seeded from the default
747///     in this table.
748///
749/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
750pub const BASH_SHOPTS: &[(&str, Option<&str>, bool)] = &[
751    ("array_expand_once", None, false),
752    ("assoc_expand_once", None, false),
753    ("autocd", Some("autocd"), false),
754    ("bash_source_fullpath", None, false),
755    ("cdable_vars", Some("cdable_vars"), false),
756    ("cdspell", None, false),
757    ("checkhash", None, false),
758    ("checkjobs", Some("checkjobs"), false),
759    ("checkwinsize", None, true),
760    ("cmdhist", None, true),
761    ("compat31", None, false),
762    ("compat32", None, false),
763    ("compat40", None, false),
764    ("compat41", None, false),
765    ("compat42", None, false),
766    ("compat43", None, false),
767    ("compat44", None, false),
768    ("complete_fullquote", None, true),
769    ("direxpand", None, false),
770    ("dirspell", None, false),
771    ("dotglob", Some("dotglob"), false),
772    ("execfail", None, false),
773    ("expand_aliases", Some("aliases"), false),
774    ("extdebug", None, false),
775    ("extglob", Some("kshglob"), false),
776    ("extquote", None, true),
777    ("failglob", Some("nomatch"), false),
778    ("force_fignore", None, true),
779    ("globasciiranges", None, true),
780    ("globskipdots", None, true),
781    ("globstar", None, false),
782    ("gnu_errfmt", None, false),
783    ("histappend", Some("histappend"), false),
784    ("histreedit", None, false),
785    ("histverify", Some("histverify"), false),
786    ("hostcomplete", None, true),
787    ("huponexit", None, false),
788    ("inherit_errexit", None, false),
789    ("interactive_comments", Some("interactive_comments"), true),
790    ("lastpipe", None, false),
791    ("lithist", None, false),
792    ("localvar_inherit", None, false),
793    ("localvar_unset", None, false),
794    // Backed by zsh's LOGINSHELL, which is what `argv[0]`'s leading dash
795    // and `-l` both set — bash(1) calls this one "not settable": it
796    // REPORTS how the shell was started rather than configuring it. It is
797    // therefore excluded from `bash_shopt_apply_defaults` below, which
798    // would otherwise stamp `false` over a real login shell's state.
799    ("login_shell", Some("loginshell"), false),
800    ("mailwarn", Some("mailwarn"), false),
801    ("no_empty_cmd_completion", None, false),
802    ("nocaseglob", Some("nocaseglob"), false),
803    ("nocasematch", Some("nocasematch"), false),
804    ("noexpand_translation", None, false),
805    ("nullglob", Some("nullglob"), false),
806    ("patsub_replacement", None, true),
807    ("progcomp", None, true),
808    ("progcomp_alias", None, false),
809    ("promptvars", Some("promptvars"), true),
810    ("restricted_shell", None, false),
811    ("shift_verbose", None, false),
812    ("sourcepath", None, true),
813    ("varredir_close", None, false),
814    ("xpg_echo", None, false),
815];
816
817thread_local! {
818    /// Live state for the `BASH_SHOPTS` rows with no zsh option behind them
819    /// (`None` in the middle column). Absent key ⇒ the table's default.
820    static BASH_ONLY_SHOPTS: RefCell<HashMap<&'static str, bool>> =
821        RefCell::new(HashMap::new());
822}
823
824/// The `BASH_SHOPTS` row for `name`, or `None` when bash would reject the
825/// name outright ("invalid shell option name", status 1).
826pub fn bash_shopt_row(name: &str) -> Option<(&'static str, Option<&'static str>, bool)> {
827    BASH_SHOPTS.iter().copied().find(|(n, _, _)| *n == name)
828}
829
830/// Read one bash `shopt` flag. `None` when the name is not a bash shopt.
831pub fn bash_shopt_get(name: &str) -> Option<bool> {
832    let (canon, zsh_opt, default_on) = bash_shopt_row(name)?;
833    // `nocasematch` is not an option at all in zsh — `opt_state` cannot
834    // store it — so it keeps its own flag (see NOCASEMATCH).
835    if canon == "nocasematch" {
836        return Some(nocasematch());
837    }
838    // Inverted-sense rows (`xpg_echo` ↔ zsh BSD_ECHO) — see
839    // BASH_SHOPTS_INVERTED_ZSH_OPT.
840    if let Some(zopt) = bash_shopt_inverted_zsh_opt(canon) {
841        return Some(!crate::ported::options::opt_state_get(zopt).unwrap_or(!default_on));
842    }
843    Some(match zsh_opt {
844        Some(opt) => crate::ported::options::opt_state_get(opt).unwrap_or(default_on),
845        None => BASH_ONLY_SHOPTS
846            .with(|m| m.borrow().get(canon).copied())
847            .unwrap_or(default_on),
848    })
849}
850
851/// Write one bash `shopt` flag. Returns false when the name is not a bash
852/// shopt (caller emits bash's "invalid shell option name" and exits 1).
853pub fn bash_shopt_set(name: &str, on: bool) -> bool {
854    let Some((canon, zsh_opt, _)) = bash_shopt_row(name) else {
855        return false;
856    };
857    // Read-only state rows — accepted and ignored, as bash does. See
858    // BASH_SHOPTS_READONLY.
859    if BASH_SHOPTS_READONLY.contains(&canon) {
860        return true;
861    }
862    if canon == "nocasematch" {
863        set_nocasematch(on);
864        return true;
865    }
866    // Inverted-sense rows (`xpg_echo` ↔ zsh BSD_ECHO) — see
867    // BASH_SHOPTS_INVERTED_ZSH_OPT.
868    if let Some(zopt) = bash_shopt_inverted_zsh_opt(canon) {
869        crate::ported::options::opt_state_set_via_alias(zopt, !on);
870        return true;
871    }
872    match zsh_opt {
873        // Through the alias-aware setter: `opt_state_get` canonicalises via
874        // `optlookup`, so a raw write under the bash spelling
875        // (`cdable_vars`) would land in a slot the read never consults
876        // (`cdablevars`) — `shopt -s cdable_vars; shopt -p cdable_vars`
877        // reported `-u`.
878        Some(opt) => {
879            crate::ported::options::opt_state_set_via_alias(opt, on);
880        }
881        None => BASH_ONLY_SHOPTS.with(|m| {
882            m.borrow_mut().insert(canon, on);
883        }),
884    }
885    true
886}
887
888/// Install bash's `shopt` defaults for every row whose state lives in a
889/// REAL zsh option.
890///
891/// The bash-only rows default correctly on their own (`BASH_ONLY_SHOPTS`
892/// falls back to the table), but the twelve zsh-backed ones inherit zsh's
893/// default, which is not always bash's: zsh's `histappend`
894/// (`APPEND_HISTORY`) is ON where bash's is OFF, so `shopt -p histappend`
895/// reported `shopt -s histappend` / status 0 against bash's
896/// `shopt -u histappend` / status 1 — and the shell really did append where
897/// bash truncates.
898///
899/// Called once from the binary's `--bash` mode application, BEFORE any user
900/// code runs, so a script's own `setopt`/`shopt` still wins.
901pub fn bash_shopt_apply_defaults() {
902    for (name, zsh_opt, default_on) in BASH_SHOPTS {
903        // Derived rows report how the shell was STARTED; they have no
904        // configurable default to stamp. `login_shell` is set from
905        // `argv[0]`'s leading dash / `-l` before any user code runs, and
906        // writing the table default here would report a real login shell
907        // as a non-login one.
908        if DERIVED_SHOPTS.contains(name) {
909            continue;
910        }
911        // Inverted rows (`xpg_echo`) live in a zsh option too, so they need
912        // the same seeding even though the middle column is `None`.
913        if zsh_opt.is_some() || bash_shopt_inverted_zsh_opt(name).is_some() {
914            // bash(1): expand_aliases is on in an interactive shell, off otherwise.
915            let on = if *name == "expand_aliases" {
916                crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
917            } else {
918                *default_on
919            };
920            bash_shopt_set(name, on);
921        }
922    }
923}
924
925/// shopt rows whose value is derived from how the shell was invoked, not
926/// from a default. bash(1) lists these under "not settable".
927const DERIVED_SHOPTS: &[&str] = &["login_shell", "restricted_shell"];
928
929/// `$BASHOPTS` — bash(1): the shopt options "valid as an argument for the
930/// -s option to shopt", colon-separated, in the table's (alphabetical)
931/// order. Only the ENABLED ones appear.
932pub fn bash_shoptsopts() -> String {
933    BASH_SHOPTS
934        .iter()
935        .filter(|(n, _, _)| bash_shopt_get(n).unwrap_or(false))
936        .map(|(n, _, _)| *n)
937        .collect::<Vec<_>>()
938        .join(":")
939}
940
941/// bash's `set -o` option table — the FIXED ~27 names bash accepts for
942/// `set -o NAME` / `set +o NAME`, lists for `set -o` / `set +o`, and joins
943/// into `$SHELLOPTS`. Each entry is `(bash name, zshrs option name)`; the
944/// order is bash's own (already alphabetical), so no caller re-sorts.
945///
946/// Six bash names have NO zsh option behind them — `errtrace`, `functrace`,
947/// `history`, `keyword`, `nolog`, `posix`. zsh's option table is a faithful
948/// port and must not grow non-zsh entries (they would leak into `setopt`,
949/// `${#options}` and `$options[…]` in `--zsh`), so their state lives in
950/// [`BASH_ONLY_OPTS`] instead, keyed by the bash name. Both halves are read
951/// back through [`bash_set_o_get`] so the listing, the query and `$SHELLOPTS`
952/// all see one state.
953///
954/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!! zsh has no bash
955/// personality; `emulate sh` only approximates it and rejects every bash-only
956/// option name outright (`Src/options.c:640` `no such option`).
957pub const BASH_SET_O: &[(&str, &str)] = &[
958    ("allexport", "allexport"),
959    ("braceexpand", "braceexpand"),
960    ("emacs", "emacs"),
961    ("errexit", "errexit"),
962    ("errtrace", "errtrace"),
963    ("functrace", "functrace"),
964    ("hashall", "hashall"),
965    ("histexpand", "histexpand"),
966    ("history", "history"),
967    ("ignoreeof", "ignoreeof"),
968    ("interactive-comments", "interactivecomments"),
969    ("keyword", "keyword"),
970    ("monitor", "monitor"),
971    ("noclobber", "noclobber"),
972    ("noexec", "noexec"),
973    ("noglob", "noglob"),
974    ("nolog", "nolog"),
975    ("notify", "notify"),
976    ("nounset", "nounset"),
977    ("onecmd", "singlecommand"),
978    ("physical", "physical"),
979    ("pipefail", "pipefail"),
980    // bash's user-facing `set -o posix` toggle (off by default); NOT
981    // zshrs's internal `posixbuiltins`, which is on in --bash.
982    ("posix", "posix"),
983    ("privileged", "privileged"),
984    ("verbose", "verbose"),
985    ("vi", "vi"),
986    ("xtrace", "xtrace"),
987];
988
989/// The bash `set -o` names with no zsh option behind them. State-only: zshrs
990/// records the flag so `set -o NAME`, the `set -o` listing and `$SHELLOPTS`
991/// agree, but no behavior hangs off them yet. Kept OUT of zsh's option table
992/// on purpose — see [`BASH_SET_O`].
993///
994/// Each defaults OFF, which is also bash's default for all six in a
995/// non-interactive `bash -c` (verified: `bash -c 'set -o'`).
996const BASH_ONLY_OPTS: &[(&str, &AtomicBool)] = &[
997    ("errtrace", &BO_ERRTRACE),
998    ("functrace", &BO_FUNCTRACE),
999    ("history", &BO_HISTORY),
1000    ("keyword", &BO_KEYWORD),
1001    ("nolog", &BO_NOLOG),
1002    ("posix", &BO_POSIX),
1003];
1004
1005static BO_ERRTRACE: AtomicBool = AtomicBool::new(false);
1006static BO_FUNCTRACE: AtomicBool = AtomicBool::new(false);
1007static BO_HISTORY: AtomicBool = AtomicBool::new(false);
1008static BO_KEYWORD: AtomicBool = AtomicBool::new(false);
1009static BO_NOLOG: AtomicBool = AtomicBool::new(false);
1010static BO_POSIX: AtomicBool = AtomicBool::new(false);
1011
1012/// Read one bash `set -o` option's state by its BASH name: the bash-only
1013/// side-table first, else the zsh option it maps to.
1014pub fn bash_set_o_get(bash_name: &str) -> bool {
1015    if let Some((_, cell)) = BASH_ONLY_OPTS.iter().find(|(n, _)| *n == bash_name) {
1016        return cell.load(Ordering::Relaxed);
1017    }
1018    match BASH_SET_O.iter().find(|(b, _)| *b == bash_name) {
1019        Some((_, zname)) => crate::ported::options::opt_state_get(zname).unwrap_or(false),
1020        None => false,
1021    }
1022}
1023
1024/// `set -o NAME` / `set +o NAME` in `--bash` mode.
1025///
1026/// Returns `None` when bash mode does not own the name, so the caller falls
1027/// through to zsh's faithful `optlookup` + `dosetopt` path (`Src/builtin.c:642`);
1028/// `Some(0)` when the assignment was applied.
1029///
1030/// Two of the names — `monitor` and `onecmd` — map to zsh options that
1031/// `dosetopt` refuses to change after startup (`Src/options.c:746`, the
1032/// INTERACTIVE / SHINSTDIN / SINGLECOMMAND gate). bash accepts both at any
1033/// time (`bash -c 'set -o monitor'` → status 0), so they are written straight
1034/// to the option state, which is what `dosetopt(…, force=1)` would do.
1035pub fn bash_set_o(bash_name: &str, on: bool) -> Option<i32> {
1036    if !bash_mode() {
1037        return None;
1038    }
1039    if let Some((_, cell)) = BASH_ONLY_OPTS.iter().find(|(n, _)| *n == bash_name) {
1040        cell.store(on, Ordering::Relaxed);
1041        return Some(0);
1042    }
1043    let (_, zname) = BASH_SET_O.iter().find(|(b, _)| *b == bash_name)?;
1044    // Via the alias resolver, not a raw write: several of these names are zsh
1045    // NEGATION aliases (`braceexpand` → NO_IGNORE_BRACES, `noglob` → NO_GLOB,
1046    // `nounset` → NO_UNSET, c:Src/options.c:269-280), so a raw
1047    // `opt_state_set("braceexpand", false)` would leave the canonical
1048    // `ignorebraces` slot untouched and the change would not take effect.
1049    crate::ported::options::opt_state_set_via_alias(zname, on);
1050    Some(0)
1051}
1052
1053/// The bash `set` option letter -> `set -o` name, for the letters zsh either
1054/// lacks or reads differently (`set -h`, `-B`, `-E`, `-H`, `-P`, `-T`, `-k`, `-t`).
1055const BASH_SET_LETTERS: &[(char, &str)] = &[
1056    ('h', "hashall"),
1057    ('B', "braceexpand"),
1058    ('E', "errtrace"),
1059    ('H', "histexpand"),
1060    ('P', "physical"),
1061    ('T', "functrace"),
1062    ('k', "keyword"),
1063    ('t', "onecmd"),
1064];
1065
1066/// `set -L` / `set +L` for a bash-only letter. Returns false when `letter` is
1067/// not one of them (the caller then takes zsh's letter table).
1068pub fn bash_set_letter(letter: char, on: bool) -> bool {
1069    bash_mode()
1070        && BASH_SET_LETTERS
1071            .iter()
1072            .find(|(l, _)| *l == letter)
1073            .is_some_and(|(_, name)| bash_set_o(name, on).is_some())
1074}
1075
1076/// `$-` in `--bash` mode: bash's flag letters, lowercase alphabetical (with
1077/// `i`), then the uppercase ones, then `c` / `s` for the invocation kind.
1078/// `bash -c 'echo $-'` is `hBc`.
1079pub fn bash_dollar_dash() -> String {
1080    const ORDER: &[(char, &str)] = &[
1081        ('a', "allexport"),
1082        ('b', "notify"),
1083        ('e', "errexit"),
1084        ('f', "noglob"),
1085        ('h', "hashall"),
1086        ('i', ""),
1087        ('k', "keyword"),
1088        ('m', "monitor"),
1089        ('n', "noexec"),
1090        ('p', "privileged"),
1091        ('t', "onecmd"),
1092        ('u', "nounset"),
1093        ('v', "verbose"),
1094        ('x', "xtrace"),
1095        ('B', "braceexpand"),
1096        ('C', "noclobber"),
1097        ('E', "errtrace"),
1098        ('H', "histexpand"),
1099        ('P', "physical"),
1100        ('T', "functrace"),
1101    ];
1102    use crate::ported::zsh_h::{isset, INTERACTIVE, SHINSTDIN};
1103    let mut out: String = ORDER
1104        .iter()
1105        .filter(|(l, name)| if *l == 'i' { isset(INTERACTIVE) } else { bash_set_o_get(name) })
1106        .map(|(l, _)| *l)
1107        .collect();
1108    if crate::ported::params::getsparam("ZSH_EXECUTION_STRING").is_some_and(|c| !c.is_empty()) {
1109        out.push('c');
1110    }
1111    if isset(SHINSTDIN) {
1112        out.push('s');
1113    }
1114    out
1115}
1116
1117/// `$SHELLOPTS` in `--bash` mode: the colon-joined, alphabetically-ordered
1118/// list of `set -o` options currently ON (bash(1), "Shell Variables":
1119/// "SHELLOPTS — A colon-separated list of enabled shell options").
1120/// [`BASH_SET_O`] is already in bash's alphabetical order.
1121pub fn bash_shellopts() -> String {
1122    BASH_SET_O
1123        .iter()
1124        .filter(|(b, _)| bash_set_o_get(b))
1125        .map(|(b, _)| *b)
1126        .collect::<Vec<_>>()
1127        .join(":")
1128}
1129
1130// ===========================================================================
1131// !!! WARNING: RUST-ONLY HELPERS — NO zsh C COUNTERPART !!!
1132//
1133// Output-format and bookkeeping deltas between zsh and the REAL Bourne-family
1134// shells that `zshrs --bash` / `--ksh` / `--mksh` / `--pdksh` / `--sh` /
1135// `--dash` / `--ash` stand in for. zsh's C source is NOT the spec for any of
1136// these — each was measured against the actual reference binary and the
1137// observed output is quoted at the definition.
1138//
1139// Every predicate below hangs off `posix_faithful()`, which the binary raises
1140// only for a BARE drop-in flag, so all of them are false in native zshrs, in
1141// `--zsh`, under a runtime `emulate sh`, and in the zsh-STYLE cross-emulation
1142// legs (`--sh --zsh` / `--ksh --zsh`). zsh's own formats cannot regress.
1143// ===========================================================================
1144
1145/// `umask` with no arguments prints a FIXED four-octal-digit mask in the real
1146/// Bourne-family shells; zsh prints three digits and emits the leading `0`
1147/// only when the owner field is non-zero (`Src/builtin.c:7522-7524` —
1148/// `if (um & 0700) putchar('0'); printf("%03o\n", um);`).
1149///
1150/// Measured (`<shell> -c 'umask 022; umask'`):
1151///
1152/// ```text
1153/// bash 5.3 → 0022    dash → 0022    ksh93 → 0022    ash → 0022
1154/// /bin/sh  → 0022    mksh → 022     zsh   → 022
1155/// ```
1156///
1157/// mksh is the outlier — it uses zsh's conditional-zero form — so the pdksh
1158/// line is excluded. Confirmed across the range: `000`/`007`/`022`/`077` gain
1159/// the zero, `0700`/`0777` already have it, and `umask -S` is byte-identical
1160/// in every shell, so the `-S` arm is untouched.
1161#[inline]
1162pub fn umask_four_digit() -> bool {
1163    posix_faithful() && !pdksh_family()
1164}
1165
1166// ---------------------------------------------------------------------------
1167// getopts: $OPTIND reporting, the end-of-options contract, and reset detection
1168// ---------------------------------------------------------------------------
1169
1170/// Last value zshrs wrote to `$OPTIND`, and the internal `zoptind` it came
1171/// from. `bin_getopts` re-reads `$OPTIND` at entry (scripts reset it to start
1172/// a new parse), so a reported value carrying the emulation bias has to be
1173/// translated back before it is used as an argument index. Both start at -1,
1174/// which no report can produce, so the first call always looks like a script
1175/// assignment.
1176static GETOPTS_REPORTED: AtomicI32 = AtomicI32::new(-1);
1177/// Internal `zoptind` matching [`GETOPTS_REPORTED`].
1178static GETOPTS_INTERNAL: AtomicI32 = AtomicI32::new(-1);
1179
1180/// The value the emulated shell would show in `$OPTIND`, given zsh's internal
1181/// parse state — and the point where the two families disagree.
1182///
1183/// zsh advances `zoptind` LAZILY: it names the argument currently being
1184/// scanned and is bumped only on the NEXT `getopts` call, when the cursor
1185/// `optcind` is found past the end of that argument
1186/// (`Src/builtin.c:5699-5703`). Every real Bourne shell instead reports the
1187/// index of the next argument as soon as the current one is finished. zsh's
1188/// own answer is 1 where all of them say 2 — a genuine zsh-vs-POSIX semantic
1189/// difference, not a port bug, so it is corrected only in the drop-in modes:
1190///
1191/// ```text
1192/// $ for s in bash dash ksh mksh ash zsh; do $s -c \
1193///     'OPTIND=1; getopts "ab" o -b; printf "%s\n" "$OPTIND"'; done
1194/// 2  2  2  2  2  1
1195/// ```
1196///
1197/// The families split mid-CLUSTER (`-ab`). bash and ksh93 keep reporting the
1198/// cluster's own index until it is exhausted; dash, ash and mksh report the
1199/// next index as soon as any character has been consumed:
1200///
1201/// ```text
1202/// $ for s in bash ksh dash ash mksh; do $s -c \
1203///     'OPTIND=1; getopts "ab" o -ab; printf "%s\n" "$OPTIND"'; done
1204/// 1  1  2  2  2
1205/// ```
1206///
1207/// `optcind == 0` means "positioned at an argument boundary" — nothing was
1208/// taken from the current word (a fresh call, an exhausted-and-advanced
1209/// cursor, or the post-`OPTARG` reset at `Src/builtin.c:5771-5772`) — and
1210/// every shell, zsh included, reports `zoptind` unchanged there.
1211///
1212/// `--sh` follows the bash rule: `/bin/sh` is bash on this platform (and on
1213/// the RHEL-family Linuxes), and the two rules differ only mid-cluster.
1214///
1215/// Records the pair so [`getopts_optind_internal`] can undo the bias on the
1216/// next call. Outside the drop-in modes it returns `zoptind` untouched and
1217/// records nothing.
1218pub fn getopts_optind_report(zoptind: i32, optcind: i32, lenstr: i32) -> i32 {
1219    if !posix_faithful() {
1220        return zoptind;
1221    }
1222    if optcind == 0 {
1223        // Argument boundary: the value shown IS the internal index. Record the
1224        // identity pair so a stale mid-cluster pair (`-abval`: a -> 2 from 1)
1225        // cannot alias this value back onto the earlier index on the next call.
1226        GETOPTS_REPORTED.store(zoptind, Ordering::Relaxed);
1227        GETOPTS_INTERNAL.store(zoptind, Ordering::Relaxed);
1228        return zoptind;
1229    }
1230    let eager = dash_strict() || pdksh_family();
1231    let reported = if eager || optcind >= lenstr {
1232        zoptind + 1
1233    } else {
1234        zoptind
1235    };
1236    GETOPTS_REPORTED.store(reported, Ordering::Relaxed);
1237    GETOPTS_INTERNAL.store(zoptind, Ordering::Relaxed);
1238    reported
1239}
1240
1241/// Translate the `$OPTIND` a script can see back into the internal `zoptind`
1242/// `bin_getopts` left off at. Only the exact value zshrs itself last reported
1243/// is translated; anything else is a script assignment (`OPTIND=1`) and is
1244/// passed through so the reset still works. Identity outside the drop-in modes.
1245pub fn getopts_optind_internal(param_value: i64) -> i64 {
1246    if !posix_faithful() {
1247        return param_value;
1248    }
1249    let reported = GETOPTS_REPORTED.load(Ordering::Relaxed);
1250    if reported >= 0 && param_value == reported as i64 {
1251        return GETOPTS_INTERNAL.load(Ordering::Relaxed) as i64;
1252    }
1253    param_value
1254}
1255
1256/// True when a script's own write to `$OPTIND` must ALSO rewind the
1257/// within-argument cursor, so the next `getopts` re-scans that argument from
1258/// its first character.
1259///
1260/// Every POSIX shell measured treats a CHANGED `$OPTIND` as a full reset,
1261/// dash included — its `getoptsreset()` drops the within-argument offset when
1262/// the variable is assigned. zsh instead rewinds only when the new value is 1
1263/// AND its internal index was elsewhere. Measured on this host:
1264///
1265/// ```text
1266/// $ <shell> -c 'OPTIND=1; getopts "ab" o -ab; OPTIND=1; getopts "ab" o -ab;
1267///               printf "%s/%s\n" "$o" "$OPTIND"'
1268/// dash a/2      bash a/1      mksh a/2
1269/// ```
1270///
1271/// What separates the families is an assignment of the SAME value, which bash
1272/// treats as a reset (it hooks the assignment) while dash and mksh do not
1273/// (they compare values):
1274///
1275/// ```text
1276/// $ <shell> -c 'OPTIND=1; getopts "ab" o -ab; OPTIND=$OPTIND;
1277///               getopts "ab" o -ab; printf "%s/%s\n" "$o" "$OPTIND"'
1278/// dash ?/2      bash a/1      mksh ?/2
1279/// ```
1280///
1281/// The value comparison below is therefore right for the dash family too; it
1282/// used to be suppressed by a `dash_strict()` guard on the strength of the
1283/// first table having been recorded as `dash/ash → ?/2`, which no dash tested
1284/// here produces.
1285///
1286/// `raw_param` is `$OPTIND` exactly as the parameter table holds it, BEFORE
1287/// [`getopts_optind_internal`] removes the reporting bias; anything other than
1288/// the value zshrs last wrote is a script assignment. False outside the
1289/// drop-in modes and in the dash family, so zsh's own rule
1290/// (`Src/builtin.c:5681-5685`) stands alone there.
1291///
1292/// KNOWN GAP: bash hooks the ASSIGNMENT rather than comparing values, so it
1293/// also resets when the assigned value equals the one it last reported —
1294/// visible only mid-cluster (`getopts ab o -ab; OPTIND=1; getopts ab o -ab`
1295/// → bash `a`, here `b`). Catching that needs an `$OPTIND` assignment hook in
1296/// the parameter table; pinned by an ignored parity test rather than faked.
1297pub fn getopts_optind_user_reset(raw_param: i64) -> bool {
1298    if !posix_faithful() {
1299        return false;
1300    }
1301    raw_param != GETOPTS_REPORTED.load(Ordering::Relaxed) as i64
1302}
1303
1304/// POSIX end-of-options bookkeeping for `getopts`, which zsh does not do.
1305///
1306/// XCU `getopts`: "When the end of options is encountered, the getopts utility
1307/// shall exit with a return value greater than zero; … and *name* shall be set
1308/// to the question-mark character." zsh leaves *name* holding whatever it held
1309/// before:
1310///
1311/// ```text
1312/// $ <shell> -c 'o=INIT; OPTIND=1; getopts "ab" o --; printf "[%s]\n" "$o"'
1313/// bash/ksh/mksh/dash/ash → [?]        zsh → [INIT]
1314/// ```
1315///
1316/// bash, ksh93 and mksh also CLEAR `$OPTARG` on that return; dash and ash
1317/// leave the last option's argument in place, and so does zsh:
1318///
1319/// ```text
1320/// $ <shell> -c 'OPTIND=1; for i in 1 2 3; do getopts "a:b" o -b -a W; done;
1321///               printf "[%s]\n" "$OPTARG"'
1322/// bash/ksh/mksh → []      dash/ash → [W]      zsh → [W]
1323/// ```
1324///
1325/// Call at every "no more options" (`return 1`) exit of `bin_getopts`. No-op
1326/// outside the drop-in modes.
1327pub fn getopts_end_of_options(var: &str) {
1328    if !posix_faithful() {
1329        return;
1330    }
1331    crate::ported::params::setsparam(var, "?");
1332    if !dash_strict() {
1333        crate::ported::params::setsparam("OPTARG", "");
1334    }
1335}
1336
1337// ---------------------------------------------------------------------------
1338// `trap` with no arguments — the listing format
1339// ---------------------------------------------------------------------------
1340
1341/// One `trap -- <body> <SIG>` listing line in the emulated shell's own format,
1342/// or `None` when zsh's format (`Src/builtin.c:7370-7375`) applies unchanged.
1343///
1344/// Three deltas, each measured rather than derived from zsh's C:
1345///
1346/// 1. **Raw body, not a re-deparse.** zsh compiles the trap body to an Eprog
1347///    and renders THAT back for the listing (`getpermtext`), so the text is
1348///    canonicalised. Every other shell echoes the string it was given:
1349///
1350///    ```text
1351///    $ trap 'printf a; printf b' INT; trap
1352///    zsh                    → trap -- $'printf a\nprintf b' INT
1353///    bash/dash/ash/ksh/mksh → trap -- 'printf a; printf b' <SIG>
1354///    ```
1355///
1356/// 2. **Quoting.** zsh and the Korn pair quote only when the body needs it, so
1357///    `trap ':' HUP` lists as `trap -- : HUP` there and as `trap -- ':' HUP`
1358///    in bash, dash, ash and `/bin/sh`. The escapes differ too — measured with
1359///    the bodies `printf 'a b'`, `'x`, `x'`, `a'b'c` and `a\b'`:
1360///
1361///    ```text
1362///    body        bash / /bin/sh   dash / ash        ksh93          mksh
1363///    printf 'a b' 'printf '\''a b'\''' 'printf '"'"'a b'"'" $'printf \'a b\'' 'printf '\''a b'\'
1364///    'x           ''\''x'         ''"'"'x'          $'\'x'         \''x'
1365///    x'           'x'\'''         'x'"'"            $'x\''         'x'\'
1366///    a\b'         'a\b'\'''       'a\b'"'"          $'a\\b\''      'a\b'\'
1367///    ```
1368///
1369///    bash keeps empty runs at BOTH ends; dash and ash drop a trailing empty
1370///    one but keep a leading one; ksh93 switches to ANSI-C `$'…'` as soon as
1371///    the body holds an apostrophe; mksh is byte-identical to zsh's
1372///    `quotedzputs`, empty-run trimming included.
1373///
1374/// 3. **`SIG`-prefixed names in bash only.** `bash -c "trap ':' HUP; trap"`
1375///    prints `trap -- ':' SIGHUP`, while dash, ash, `/bin/sh`, ksh93 and mksh
1376///    all print the bare `HUP`. bash leaves the pseudo-signals alone —
1377///    `EXIT`, `DEBUG`, `ERR` and `RETURN` print unprefixed — so only names
1378///    resolving to a real signal number are prefixed.
1379///
1380/// An empty body is `''` in every shell including zsh, so it needs no special
1381/// case beyond forcing the quotes.
1382pub fn trap_listing_line(signame: &str, body: &str) -> Option<String> {
1383    if !posix_faithful() {
1384        return None;
1385    }
1386    let quoted = if korn_mode() && !pdksh_family() && body.contains('\'') {
1387        // ksh93's ANSI-C form: backslash doubles, apostrophe is escaped.
1388        format!("$'{}'", body.replace('\\', "\\\\").replace('\'', "\\'"))
1389    } else if korn_mode() {
1390        // ksh93 without an apostrophe, and mksh always: quote only when
1391        // needed, exactly like zsh — but over the RAW body.
1392        crate::ported::utils::quotedzputs(body)
1393    } else if dash_strict() {
1394        // dash / ash close the quote, emit `"'"`, reopen — then drop a
1395        // TRAILING empty reopened run (a leading one is kept).
1396        let mut q = format!("'{}'", body.replace('\'', "'\"'\"'"));
1397        // An IGNORED trap has an empty body and lists as `trap -- '' SIG` in
1398        // every shell, so the trim must not eat the whole quoted string.
1399        if q.len() > 2 && q.ends_with("''") {
1400            q.truncate(q.len() - 2);
1401        }
1402        q
1403    } else {
1404        // bash and /bin/sh: `'` → `'\''`, no empty-run trimming at either end.
1405        format!("'{}'", body.replace('\'', "'\\''"))
1406    };
1407    let name = if bash_mode() && real_signal_name(signame) {
1408        format!("SIG{}", signame)
1409    } else {
1410        signame.to_string()
1411    };
1412    Some(format!("trap -- {} {}", quoted, name))
1413}
1414
1415/// True when `name` is a real signal (1..=SIGCOUNT) rather than one of the
1416/// pseudo-signals zsh keeps in the same table (`EXIT` at index 0, `ZERR` and
1417/// `DEBUG` above `SIGCOUNT` — `Src/signals.h:34-46`). Only real signals take
1418/// bash's `SIG` prefix.
1419fn real_signal_name(name: &str) -> bool {
1420    match crate::ported::jobs::getsigidx(name) {
1421        Some(idx) => idx >= 1 && idx <= crate::ported::signals_h::SIGCOUNT,
1422        None => false,
1423    }
1424}
1425
1426/// ksh93 lists `trap` entries in DESCENDING signal-number order, where zsh,
1427/// bash, dash, ash and mksh all walk the table upwards:
1428///
1429/// ```text
1430/// $ <shell> -c "trap ':' EXIT INT HUP USR1 TERM QUIT; trap"
1431/// ksh93                 zsh / bash / dash / ash / mksh
1432///   trap -- : USR1        trap -- : EXIT
1433///   trap -- : TERM        trap -- : HUP     (bash: SIGHUP, …)
1434///   trap -- : QUIT        trap -- : INT
1435///   trap -- : INT         trap -- : QUIT
1436///   trap -- : HUP         trap -- : TERM
1437///   trap -- : EXIT        trap -- : USR1
1438/// ```
1439///
1440/// True only for a bare `--ksh`; the pdksh line (`--mksh` / `--pdksh`) matches
1441/// zsh's ascending walk (`Src/builtin.c:7358` — `for (sig = 0;
1442/// sig < TRAPCOUNT; sig++)`), and so does every other mode.
1443#[inline]
1444pub fn trap_listing_descending() -> bool {
1445    korn_mode() && !pdksh_family()
1446}
1447
1448// ---------------------------------------------------------------------------
1449// `type` / `command -V` / `whence -v` on a shell function
1450// ---------------------------------------------------------------------------
1451
1452/// The verbose one-line description of a shell function in the emulated
1453/// shell's wording, or `None` to keep zsh's.
1454///
1455/// zsh appends the file the function was loaded from
1456/// (`Src/hashtable.c:927-941` — `" is a shell function"` then `" from "` and
1457/// the filename), which for a `-c` script is the shell's own name, so
1458/// `zsh -fc 'f() { :; }; type f'` prints `f is a shell function from zsh`.
1459/// No other shell says anything of the kind:
1460///
1461/// ```text
1462/// bash 5.3 → f is a function          (then the definition, see below)
1463/// /bin/sh  → f is a function          (then the definition)
1464/// ksh93    → f is a function
1465/// mksh     → f is a function
1466/// dash     → f is a shell function
1467/// ash      → f is a shell function
1468/// ```
1469pub fn function_whence_verbose(name: &str) -> Option<String> {
1470    if !posix_faithful() {
1471        return None;
1472    }
1473    if dash_strict() {
1474        Some(format!("{} is a shell function", name))
1475    } else {
1476        Some(format!("{} is a function", name))
1477    }
1478}
1479
1480/// True when the emulated shell follows its `type` / `command -V` header line
1481/// with the function's DEFINITION. bash does; ksh93, mksh, dash and ash do not:
1482///
1483/// ```text
1484/// $ bash -c 'f() { :; }; type f'      $ ksh -c 'f() { :; }; type f'
1485/// f is a function                     f is a function
1486/// f ()
1487/// {
1488///     :
1489/// }
1490/// ```
1491///
1492/// `/bin/sh` is bash on this platform and behaves the same, so it is included.
1493#[inline]
1494pub fn function_whence_prints_body() -> bool {
1495    posix_faithful() && !korn_mode() && !dash_strict()
1496}
1497
1498/// Private-use stand-in for a backslash-escaped `&` between
1499/// `strip_replacement_backslashes` and [`bash_patsub`].
1500const PATSUB_LITERAL_AMP: char = '\u{F8FE}';
1501
1502/// bash 5.2 `${v/pat/rep}` with `shopt patsub_replacement` (default on): an
1503/// unescaped `&` in the replacement stands for the matched text and `\&` is a
1504/// literal `&`. Identity outside `--bash` or with the shopt off.
1505pub fn bash_patsub(repl: String, matched: &str) -> String {
1506    if !bash_mode() || bash_shopt_get("patsub_replacement") != Some(true) {
1507        return repl;
1508    }
1509    if !repl.contains('&') && !repl.contains(PATSUB_LITERAL_AMP) {
1510        return repl;
1511    }
1512    let mut out = String::with_capacity(repl.len() + matched.len());
1513    for c in repl.chars() {
1514        match c {
1515            '&' => out.push_str(matched),
1516            PATSUB_LITERAL_AMP => out.push('&'),
1517            _ => out.push(c),
1518        }
1519    }
1520    out
1521}
1522
1523/// Render a stored function body as bash lists it (`declare -f` / `type`).
1524pub fn bash_function_listing(name: &str, body_src: Option<&str>) -> String {
1525    // Stored source re-parsed and deparsed, one statement per line (the same
1526    // rendering bin_trap uses for a trap body).
1527    let rendered = body_src
1528        .filter(|b| !b.is_empty())
1529        .and_then(|b| crate::ported::exec::parse_string(b, 1))
1530        .map(|prog| crate::ported::text::getpermtext(Box::new(prog), None, 0))
1531        .unwrap_or_default();
1532    bash_function_body(name, &rendered)
1533}
1534
1535/// Re-lay a deparsed function body in bash's `type` format.
1536///
1537/// bash re-prints the parsed function from its own AST (`make_command_string`):
1538/// the header is `NAME () ` then `{ ` — each with ONE trailing space —
1539/// statements are indented four spaces per level, and every line but the last
1540/// carries a trailing `;`.
1541///
1542/// ```text
1543/// $ bash -c 'f() { echo a; echo b; }; type f' | cat -e
1544/// f is a function$
1545/// f () $
1546/// { $
1547///     echo a;$
1548///     echo b$
1549/// }$
1550/// ```
1551///
1552/// `body_lines` is zsh's own rendering of the body (one statement per line,
1553/// one leading TAB per nesting level, keywords on their own lines).
1554/// `BashDeparse` re-lays `if`/`for`/`while`/`until`/`select`/`case`, `( )`,
1555/// `{ }` and nested function definitions in bash's layout; a body with a
1556/// shape it does not model (here-documents) falls back to the flat
1557/// one-line-per-statement layout.
1558pub fn bash_function_body(name: &str, body_lines: &str) -> String {
1559    let mut out = format!("{} () \n{{ \n", name);
1560    match BashDeparse::parse(body_lines) {
1561        Some(nodes) => out.push_str(&BashDeparse::render(&nodes, 1, true)),
1562        None => {
1563            let lines: Vec<&str> = body_lines.lines().collect();
1564            for (i, line) in lines.iter().enumerate() {
1565                let depth = line.chars().take_while(|c| *c == '\t').count();
1566                let text = line.trim_start_matches('\t');
1567                let last = i + 1 == lines.len();
1568                out.push_str(&" ".repeat(4 * (depth + 1)));
1569                out.push_str(text);
1570                out.push_str(if last { "\n" } else { ";\n" });
1571            }
1572        }
1573    }
1574    out.push_str("}\n");
1575    out
1576}
1577
1578/// bash expands braces BEFORE parameters (`x=; echo $x{,a}` -> `$x $xa`, both
1579/// empty and dropped), zsh after (`a`). For a tokenized command word that
1580/// carries both a brace list and an expansion, run `xpandbraces` on the source
1581/// text with every opaque span (`$name`, `${..}`, `$(..)`, `` `..` ``, quotes)
1582/// masked by a placeholder, then restore the spans in each alternative.
1583/// Returns the alternatives (empty unquoted ones dropped), or `None` when the
1584/// word needs no pre-splitting.
1585pub fn bash_presplit_braces(word: &str) -> Option<Vec<String>> {
1586    use crate::ported::zsh_h::{
1587        Bnull, Comma, Dnull, Inbrace, Inpar, Inparmath, Outbrace, Outpar, Outparmath, Qstring,
1588        Qtick, Snull, Stringg, Tick,
1589    };
1590    if !bash_mode()
1591        || !word.contains(Inbrace)
1592        || !word.contains(Comma)
1593        || !word.chars().any(|c| matches!(c, Stringg | Qstring | Tick | Qtick))
1594    {
1595        return None;
1596    }
1597    let ch: Vec<char> = word.chars().collect();
1598    let mut holes: Vec<String> = Vec::new();
1599    let mut masked = String::new();
1600    let mut hole = |text: String, masked: &mut String| {
1601        masked.push(char::from_u32(0xF0000 + holes.len() as u32).unwrap_or('\u{f0000}'));
1602        holes.push(text);
1603    };
1604    let mut i = 0;
1605    while i < ch.len() {
1606        let c = ch[i];
1607        // End (exclusive) of the span an opener at `i` opens.
1608        let end = match c {
1609            Snull | Dnull | Tick | Qtick => {
1610                (i + 1..ch.len()).find(|&j| ch[j] == c).map_or(ch.len(), |j| j + 1)
1611            }
1612            Bnull => (i + 2).min(ch.len()),
1613            Stringg | Qstring => match ch.get(i + 1) {
1614                Some(&Inbrace) | Some(&Inpar) | Some(&Inparmath) => {
1615                    let mut depth = 0i32;
1616                    let mut j = i + 1;
1617                    while j < ch.len() {
1618                        match ch[j] {
1619                            Inbrace | Inpar => depth += 1,
1620                            Inparmath => depth += 2,
1621                            Outbrace | Outpar => depth -= 1,
1622                            Outparmath => depth -= 2,
1623                            _ => {}
1624                        }
1625                        j += 1;
1626                        if depth <= 0 {
1627                            break;
1628                        }
1629                    }
1630                    j
1631                }
1632                Some(n) if n.is_ascii_alphanumeric() || *n == '_' => {
1633                    let mut j = i + 1;
1634                    while j < ch.len() && (ch[j].is_ascii_alphanumeric() || ch[j] == '_') {
1635                        j += 1;
1636                    }
1637                    j
1638                }
1639                Some(_) => i + 2,
1640                None => i + 1,
1641            },
1642            _ => i,
1643        };
1644        if end > i {
1645            hole(ch[i..end].iter().collect(), &mut masked);
1646            i = end;
1647        } else {
1648            masked.push(c);
1649            i += 1;
1650        }
1651    }
1652    let parts = crate::ported::glob::xpandbraces(&masked, false);
1653    if parts.len() == 1 && parts[0] == masked {
1654        return None;
1655    }
1656    Some(
1657        parts
1658            .into_iter()
1659            .filter(|p| !p.is_empty())
1660            .map(|p| {
1661                p.chars()
1662                    .map(|c| match (c as u32).checked_sub(0xF0000) {
1663                        Some(k) if (k as usize) < holes.len() => holes[k as usize].clone(),
1664                        _ => c.to_string(),
1665                    })
1666                    .collect()
1667            })
1668            .collect(),
1669    )
1670}
1671
1672/// One command of a function body, as bash's `print_cmd.c` lays it out.
1673enum BashNode {
1674    Simple(String),
1675    If { cond: String, then: Vec<BashNode>, els: Option<Vec<BashNode>>, closer: String },
1676    Loop { kw: &'static str, cond: String, body: Vec<BashNode>, closer: String },
1677    For { head: String, body: Vec<BashNode>, closer: String },
1678    Case { subject: String, arms: Vec<(String, Vec<BashNode>, String)>, closer: String },
1679    Subshell { body: Vec<BashNode>, closer: String },
1680    Group { body: Vec<BashNode>, closer: String },
1681    Function { name: String, body: Vec<BashNode> },
1682}
1683
1684impl BashNode {
1685    /// True when this statement, or anything nested in it, carries a here-document.
1686    fn has_heredoc(&self) -> bool {
1687        let any = |v: &[BashNode]| v.iter().any(BashNode::has_heredoc);
1688        match self {
1689            BashNode::Simple(s) => s.contains(HEREDOC_BODY),
1690            BashNode::If { then, els, .. } => any(then) || els.as_deref().is_some_and(any),
1691            BashNode::Loop { body, .. }
1692            | BashNode::For { body, .. }
1693            | BashNode::Subshell { body, .. }
1694            | BashNode::Group { body, .. }
1695            | BashNode::Function { body, .. } => any(body),
1696            BashNode::Case { arms, .. } => arms.iter().any(|(_, b, _)| any(b)),
1697        }
1698    }
1699}
1700
1701/// Re-lays zsh's deparsed function body (one command per line, keywords on
1702/// their own lines) in bash's layout: `if C; then` on one line, `for`/`do`
1703/// split, `case` arms with `;;` on its own line, `( a; b )` subshells.
1704/// Parses the keyword structure of the zsh text; any shape it does not
1705/// recognise yields `None` and the caller falls back to the flat layout.
1706struct BashDeparse {
1707    lines: Vec<String>,
1708    pos: usize,
1709}
1710
1711const ARM_END: char = '\u{1}';
1712/// Separates a here-document command from its body inside one deparse line.
1713const HEREDOC_BODY: char = '\u{2}';
1714
1715impl BashDeparse {
1716    fn parse(body_lines: &str) -> Option<Vec<BashNode>> {
1717        let mut lines: Vec<String> = Vec::new();
1718        let raw_lines: Vec<&str> = body_lines.lines().collect();
1719        let mut ri = 0;
1720        while ri < raw_lines.len() {
1721            let t = raw_lines[ri].trim();
1722            ri += 1;
1723            // A here-document: fold its body and terminator into the command
1724            // line behind HEREDOC_BODY so the layout pass treats it as one
1725            // statement (bash prints the body at column 0 after the command).
1726            let mut heredocs = t
1727                .match_indices("<<")
1728                .filter(|(i, _)| !t[*i..].starts_with("<<<") && !t[..*i].ends_with('<'));
1729            if let Some((at, _)) = heredocs.next() {
1730                if heredocs.next().is_some() {
1731                    return None;
1732                }
1733                let strip_tabs = t[at + 2..].starts_with('-');
1734                let after = t[at + 2..].trim_start_matches('-').trim_start();
1735                let word = after.split_whitespace().next()?;
1736                let delim: String = word.chars().filter(|c| !matches!(c, '"' | '\'' | '\\')).collect();
1737                let mut cmd = t.to_string();
1738                if word.starts_with('"') {
1739                    // bash re-prints a quoted delimiter with single quotes.
1740                    cmd = cmd.replacen(word, &format!("'{delim}'"), 1);
1741                }
1742                let mut body: Vec<String> = Vec::new();
1743                loop {
1744                    let l = *raw_lines.get(ri)?;
1745                    ri += 1;
1746                    let l = if strip_tabs { l.trim_start_matches('\t') } else { l };
1747                    body.push(l.to_string());
1748                    if l.trim_end() == delim {
1749                        break;
1750                    }
1751                }
1752                lines.push(format!("{cmd}{HEREDOC_BODY}{}", body.join("\n")));
1753                continue;
1754            }
1755            if t.ends_with('{') && t != "{" && !t.ends_with(" () {") {
1756                return None;
1757            }
1758            match [" ;;&", " ;&", " ;;"].iter().find(|e| t.ends_with(**e)) {
1759                Some(e) => {
1760                    lines.push(t[..t.len() - e.len()].to_string());
1761                    lines.push(format!("{ARM_END}{}", e.trim()));
1762                }
1763                None => lines.push(t.to_string()),
1764            }
1765        }
1766        let mut p = BashDeparse { lines, pos: 0 };
1767        let nodes = p.block()?;
1768        (p.pos == p.lines.len()).then_some(nodes)
1769    }
1770
1771    fn peek(&self) -> Option<&str> {
1772        self.lines.get(self.pos).map(String::as_str)
1773    }
1774
1775    fn first_word(line: &str) -> &str {
1776        line.split_whitespace().next().unwrap_or("")
1777    }
1778
1779    fn is_stop(line: &str) -> bool {
1780        line.starts_with(ARM_END)
1781            || line.starts_with('}')
1782            || line.starts_with(')')
1783            || matches!(Self::first_word(line), "fi" | "done" | "esac" | "else" | "elif" | "then" | "do")
1784    }
1785
1786    /// Statements up to (not including) the next stop line.
1787    fn block(&mut self) -> Option<Vec<BashNode>> {
1788        let mut out = Vec::new();
1789        while let Some(line) = self.peek() {
1790            if Self::is_stop(line) {
1791                break;
1792            }
1793            out.push(self.node()?);
1794        }
1795        Some(out)
1796    }
1797
1798    fn expect_word(&mut self, w: &str) -> Option<()> {
1799        (self.peek()? == w).then(|| self.pos += 1)
1800    }
1801
1802    /// A closer line (`fi`, `done > f`, `}`, `)`): returns its trailing redirections.
1803    fn closer(&mut self, word: &str) -> Option<String> {
1804        let line = self.peek()?.to_string();
1805        let rest = line.strip_prefix(word)?;
1806        if !rest.is_empty() && !rest.starts_with(' ') {
1807            return None;
1808        }
1809        self.pos += 1;
1810        Some(rest.to_string())
1811    }
1812
1813    fn node(&mut self) -> Option<BashNode> {
1814        let line = self.peek()?.to_string();
1815        self.pos += 1;
1816        if let Some(cond) = line.strip_prefix("if ") {
1817            return self.if_rest(cond.to_string());
1818        }
1819        for kw in ["while", "until"] {
1820            if let Some(cond) = line.strip_prefix(&format!("{kw} ")) {
1821                self.expect_word("do")?;
1822                let body = self.block()?;
1823                let closer = self.closer("done")?;
1824                return Some(BashNode::Loop {
1825                    kw: if kw == "while" { "while" } else { "until" },
1826                    cond: cond.to_string(),
1827                    body,
1828                    closer,
1829                });
1830            }
1831        }
1832        if line.starts_with("for ((") && line.ends_with(" do") {
1833            let body = self.block()?;
1834            let closer = self.closer("done")?;
1835            return Some(BashNode::For { head: line[..line.len() - 3].to_string(), body, closer });
1836        }
1837        if line.starts_with("for ") || line.starts_with("select ") {
1838            self.expect_word("do")?;
1839            let body = self.block()?;
1840            let closer = self.closer("done")?;
1841            return Some(BashNode::For { head: line, body, closer });
1842        }
1843        if let Some(rest) = line.strip_prefix("case ") {
1844            let subject = rest.strip_suffix(" in")?.to_string();
1845            let mut arms = Vec::new();
1846            while self.peek()?.starts_with('(') {
1847                let arm = self.peek()?.to_string();
1848                let close = arm.find(") ").or_else(|| arm.ends_with(')').then(|| arm.len() - 1))?;
1849                let pat = arm[1..close].to_string();
1850                let first = arm[close + 1..].trim().to_string();
1851                if first.is_empty() {
1852                    self.pos += 1;
1853                } else {
1854                    self.lines[self.pos] = first;
1855                }
1856                let body = self.block()?;
1857                let term = self.peek()?.strip_prefix(ARM_END)?.to_string();
1858                self.pos += 1;
1859                arms.push((pat, body, term));
1860            }
1861            let closer = self.closer("esac")?;
1862            return Some(BashNode::Case { subject, arms, closer });
1863        }
1864        if let Some(name) = line.strip_suffix(" () {") {
1865            let body = self.block()?;
1866            self.closer("}")?;
1867            return Some(BashNode::Function { name: name.to_string(), body });
1868        }
1869        if line == "(" || line == "{" {
1870            let body = self.block()?;
1871            let closer = self.closer(if line == "(" { ")" } else { "}" })?;
1872            return Some(if line == "(" {
1873                BashNode::Subshell { body, closer }
1874            } else {
1875                BashNode::Group { body, closer }
1876            });
1877        }
1878        Some(BashNode::Simple(line))
1879    }
1880
1881    fn if_rest(&mut self, cond: String) -> Option<BashNode> {
1882        self.expect_word("then")?;
1883        let then = self.block()?;
1884        let mut els = None;
1885        match self.peek() {
1886            Some(l) if l.starts_with("elif ") => {
1887                // bash prints `elif` as `else` plus a nested `if`.
1888                let nested = l["elif ".len()..].to_string();
1889                self.pos += 1;
1890                els = Some(vec![self.if_rest(nested)?]);
1891                return Some(BashNode::If { cond, then, els, closer: String::new() });
1892            }
1893            Some("else") => {
1894                self.pos += 1;
1895                els = Some(self.block()?);
1896            }
1897            _ => {}
1898        }
1899        let closer = self.closer("fi")?;
1900        Some(BashNode::If { cond, then, els, closer })
1901    }
1902
1903    /// Lay `nodes` out at `indent` levels; `bare_last` drops the `;` after
1904    /// the final command (function, `{ }`, subshell and case-arm bodies).
1905    fn render(nodes: &[BashNode], indent: usize, bare_last: bool) -> String {
1906        let mut out = String::new();
1907        for (i, n) in nodes.iter().enumerate() {
1908            // bash prints no `;` after a statement that holds a here-document:
1909            // the body's own newline ends it.
1910            let term = if (bare_last && i + 1 == nodes.len()) || n.has_heredoc() { "" } else { ";" };
1911            out.push_str(&Self::render_one(n, indent, term));
1912        }
1913        out
1914    }
1915
1916    fn render_one(n: &BashNode, indent: usize, term: &str) -> String {
1917        let ind = "    ".repeat(indent);
1918        match n {
1919            BashNode::Simple(s) if s.contains(HEREDOC_BODY) => {
1920                // bash: command, body and terminator at column 0, then a blank line.
1921                let (cmd, body) = s.split_once(HEREDOC_BODY).unwrap_or((s, ""));
1922                format!("{ind}{cmd}\n{body}\n\n")
1923            }
1924            BashNode::Simple(s) => {
1925                let term = if s.ends_with('&') { "" } else { term };
1926                format!("{ind}{s}{term}\n")
1927            }
1928            BashNode::If { cond, then, els, closer } => {
1929                let mut o = format!("{ind}if {cond}; then\n{}", Self::render(then, indent + 1, false));
1930                if let Some(e) = els {
1931                    o.push_str(&format!("{ind}else\n{}", Self::render(e, indent + 1, false)));
1932                }
1933                o.push_str(&format!("{ind}fi{closer}{term}\n"));
1934                o
1935            }
1936            BashNode::Loop { kw, cond, body, closer } => format!(
1937                "{ind}{kw} {cond}; do\n{}{ind}done{closer}{term}\n",
1938                Self::render(body, indent + 1, false)
1939            ),
1940            BashNode::For { head, body, closer } => {
1941                let semi = if head.starts_with("for ((") { "" } else { ";" };
1942                format!(
1943                    "{ind}{head}{semi}\n{ind}do\n{}{ind}done{closer}{term}\n",
1944                    Self::render(body, indent + 1, false)
1945                )
1946            }
1947            BashNode::Case { subject, arms, closer } => {
1948                let mut o = format!("{ind}case {subject} in \n");
1949                for (pat, body, t) in arms {
1950                    o.push_str(&format!(
1951                        "{ind}    {pat})\n{}{ind}    {t}\n",
1952                        Self::render(body, indent + 2, true)
1953                    ));
1954                }
1955                o.push_str(&format!("{ind}esac{closer}{term}\n"));
1956                o
1957            }
1958            BashNode::Subshell { body, closer } => {
1959                let inner = Self::render(body, indent, true);
1960                let inner = inner.trim_end_matches('\n');
1961                let inner = inner.strip_prefix(ind.as_str()).unwrap_or(inner);
1962                format!("{ind}( {inner} ){closer}{term}\n")
1963            }
1964            BashNode::Function { name, body } => format!(
1965                "{ind}function {name} () \n{ind}{{ \n{}{ind}}}{term}\n",
1966                Self::render(body, indent + 1, true)
1967            ),
1968            BashNode::Group { body, closer } => {
1969                format!("{ind}{{ \n{}{ind}}}{closer}{term}\n", Self::render(body, indent + 1, true))
1970            }
1971        }
1972    }
1973}
1974
1975/// True when replacing the positional parameters (`set -- …`) must also
1976/// rewind `getopts`, and when a write to `$OPTIND` must NOT.
1977///
1978/// dash and ash key `getopts` off `shellparam.optind` / `shellparam.optoff`,
1979/// which `setparam()` resets and an `$OPTIND` assignment does not reach — the
1980/// exact opposite of bash and the Korn shells:
1981///
1982/// ```text
1983/// $ <shell> -c 'getopts "ab" o -a; set -- -b -a; getopts "ab" o;
1984///               printf "%s %s\n" "$o" "$OPTIND"'
1985/// dash/ash → b 2        bash/ksh/mksh → a 3        zsh → a 2
1986/// ```
1987///
1988/// Only the `set --` half is acted on here; the `$OPTIND`-write half stays a
1989/// pinned gap (see the ignored parity test) because zsh routes the parse
1990/// index through the parameter itself.
1991#[inline]
1992pub fn getopts_reset_on_set_positional() -> bool {
1993    posix_faithful() && dash_strict()
1994}
1995
1996/// Rewind the `getopts` cursor recorded by [`getopts_optind_report`], so a
1997/// later `$OPTIND` read is not translated against a stale pair.
1998pub fn getopts_forget_reported() {
1999    GETOPTS_REPORTED.store(-1, Ordering::Relaxed);
2000    GETOPTS_INTERNAL.store(-1, Ordering::Relaxed);
2001}
2002
2003// ===========================================================================
2004// bash `${parameter@operator}` parameter transformations
2005// ===========================================================================
2006//
2007// !!! WARNING: RUST-ONLY HELPER !!!
2008//
2009// Nothing below has a zsh C counterpart, so nothing below carries a `// c:NNN`
2010// citation.  `${var@Q}`, `${var@A}`, `${var@K}`, `${var@k}`, `${var@a}`,
2011// `${var@E}` and the `@U` / `@L` / `@u` case operators are bash extensions;
2012// zsh answers "bad substitution" for every one of them:
2013//
2014// ```text
2015// $ zsh -fc 'v=abc; echo "${v@A}"'
2016// zsh:1: bad substitution
2017// ```
2018//
2019// THE REFERENCE IS BASH ITSELF (GNU bash 5.3.15), not `Src/subst.c`.  Every
2020// rule encoded here was established by running bash and recording its output;
2021// the transcripts are pinned as parity tests in
2022// `tests/parity/bash_param_transform.rs`.  Callers must gate on
2023// [`bash_mode`] so `--zsh` keeps answering "bad substitution".
2024
2025/// bash's `isprint()` test, as `ansic_shouldquote` (lib/sh/strtrans.c) uses
2026/// it: C0 controls and DEL are unprintable, everything else — including
2027/// non-ASCII — is printable.  `${v@Q}` on `héllo` answers `'héllo'`, not a
2028/// `$'…'` byte dump, which is what fixes the boundary here.
2029fn bash_isprint(c: char) -> bool {
2030    let u = c as u32;
2031    !(u < 0x20 || u == 0x7f)
2032}
2033
2034/// bash `ansic_shouldquote` — true when the value holds a character that
2035/// cannot survive `'…'` quoting and so forces the `$'…'` form.
2036pub fn bash_ansic_shouldquote(s: &str) -> bool {
2037    s.chars().any(|c| !bash_isprint(c))
2038}
2039
2040/// bash `ansic_quote` — render `s` as a `$'…'` literal.
2041///
2042/// ```text
2043/// $ bash -c $'v=$\'\\a\\v\\b\\f\\n\\r\\t\\e\\001\\177\'; printf "<%s>" "${v@Q}"'
2044/// <$'\a\v\b\f\n\r\t\E\001\177'>
2045/// ```
2046pub fn bash_ansic_quote(s: &str) -> String {
2047    let mut out = String::with_capacity(s.len() + 4);
2048    out.push_str("$'");
2049    for c in s.chars() {
2050        match c {
2051            '\u{7}' => out.push_str("\\a"),
2052            '\u{8}' => out.push_str("\\b"),
2053            '\u{b}' => out.push_str("\\v"),
2054            '\u{c}' => out.push_str("\\f"),
2055            '\n' => out.push_str("\\n"),
2056            '\r' => out.push_str("\\r"),
2057            '\t' => out.push_str("\\t"),
2058            '\u{1b}' => out.push_str("\\E"),
2059            '\\' => out.push_str("\\\\"),
2060            '\'' => out.push_str("\\'"),
2061            _ if bash_isprint(c) => out.push(c),
2062            // Unprintable and not one of the named escapes: bash emits one
2063            // three-digit octal escape per BYTE (`\001`, `\177`).
2064            _ => {
2065                let mut buf = [0u8; 4];
2066                for b in c.encode_utf8(&mut buf).as_bytes() {
2067                    out.push_str(&format!("\\{:03o}", b));
2068                }
2069            }
2070        }
2071    }
2072    out.push('\'');
2073    out
2074}
2075
2076/// bash `sh_single_quote` — `'…'`, with an embedded `'` written `'\''`.
2077///
2078/// ```text
2079/// $ bash -c $'v="it\'s"; printf "<%s>" "${v@Q}"'
2080/// <'it'\''s'>
2081/// ```
2082pub fn bash_single_quote(s: &str) -> String {
2083    let mut out = String::with_capacity(s.len() + 2);
2084    out.push('\'');
2085    for c in s.chars() {
2086        if c == '\'' {
2087            out.push_str("'\\''");
2088        } else {
2089            out.push(c);
2090        }
2091    }
2092    out.push('\'');
2093    out
2094}
2095
2096/// bash `sh_double_quote` — `"…"`, backslash-escaping only the four
2097/// characters that stay live inside double quotes (`CBSDQUOTE`).
2098pub fn bash_double_quote(s: &str) -> String {
2099    let mut out = String::with_capacity(s.len() + 2);
2100    out.push('"');
2101    for c in s.chars() {
2102        if matches!(c, '"' | '\\' | '$' | '`') {
2103            out.push('\\');
2104        }
2105        out.push(c);
2106    }
2107    out.push('"');
2108    out
2109}
2110
2111/// bash `sh_quote_reusable` — what `${v@Q}` and the SCALAR form of `${v@A}`
2112/// emit: `$'…'` when the value needs it, otherwise `'…'`.
2113pub fn bash_quote_reusable(s: &str) -> String {
2114    if bash_ansic_shouldquote(s) {
2115        bash_ansic_quote(s)
2116    } else {
2117        bash_single_quote(s)
2118    }
2119}
2120
2121/// The form bash uses for one ELEMENT inside a `name=(…)` assignment or a
2122/// `@K` key/value pair: `$'…'` when unprintable, otherwise DOUBLE quotes.
2123///
2124/// ```text
2125/// $ bash -c 'a=(x "y z"); printf "<%s>" "${a[*]@K}"'
2126/// <0 "x" 1 "y z">
2127/// ```
2128pub fn bash_quote_element(s: &str) -> String {
2129    if bash_ansic_shouldquote(s) {
2130        bash_ansic_quote(s)
2131    } else {
2132        bash_double_quote(s)
2133    }
2134}
2135
2136/// bash `sh_contains_shell_metas` — decides whether an associative-array KEY
2137/// has to be quoted inside `@A` / `@K` output.  Measured char-by-char against
2138/// bash 5.3: `- . + , : / @ % = # ~` stay bare mid-word, the rest quote.
2139pub fn bash_contains_shell_metas(s: &str) -> bool {
2140    let b: Vec<char> = s.chars().collect();
2141    for (i, &c) in b.iter().enumerate() {
2142        match c {
2143            ' ' | '\t' | '\n' => return true,
2144            '\'' | '"' | '\\' => return true,
2145            '|' | '&' | ';' => return true,
2146            '(' | ')' | '<' | '>' => return true,
2147            '!' | '{' | '}' => return true,
2148            '*' | '[' | '?' | ']' => return true,
2149            '^' => return true,
2150            '$' | '`' => return true,
2151            // Tilde expansion only fires at the start of a word or right
2152            // after `=` / `:`, so a mid-word `~` needs no quoting.
2153            '~' => {
2154                if i == 0 || b[i - 1] == '=' || b[i - 1] == ':' {
2155                    return true;
2156                }
2157            }
2158            // A comment only starts a word.
2159            '#' => {
2160                if i == 0 {
2161                    return true;
2162                }
2163            }
2164            _ => {}
2165        }
2166    }
2167    false
2168}
2169
2170/// The form bash uses for an associative-array key inside `@A` / `@K`.
2171pub fn bash_quote_assoc_key(s: &str) -> String {
2172    if bash_ansic_shouldquote(s) {
2173        bash_ansic_quote(s)
2174    } else if bash_contains_shell_metas(s) {
2175        bash_double_quote(s)
2176    } else {
2177        s.to_string()
2178    }
2179}
2180
2181/// The shape a `${var@A}` / `@K` / `@k` transformation is looking at.
2182///
2183/// `Indexed` carries the LIVE indices, so a sparse bash array recreates as
2184/// `declare -a a=([0]="x" [5]="z")` rather than renumbering its holes away.
2185pub enum BashParamShape<'a> {
2186    /// The reference resolved to nothing at all.
2187    Unset,
2188    /// A scalar, or an array/assoc reduced to one element by a subscript.
2189    Scalar(&'a str),
2190    /// A whole indexed array: `(index, value)` in index order.
2191    Indexed(&'a [(usize, String)]),
2192    /// A whole associative array: `(key, value)`.
2193    Assoc(&'a [(String, String)]),
2194}
2195
2196/// bash `${var@A}` — the assignment statement that would recreate the
2197/// parameter with its attributes.
2198///
2199/// `attrs` is the attribute-letter string [`bash_attr_letters`] produces; an
2200/// empty one means bash omits the `declare` word entirely.
2201///
2202/// ```text
2203/// $ bash -c 'v=abc;          printf "<%s>" "${v@A}"'    → <v='abc'>
2204/// $ bash -c 'declare -i n=5; printf "<%s>" "${n@A}"'    → <declare -i n='5'>
2205/// $ bash -c 'a=(x "y z");    printf "<%s>" "${a[*]@A}"' → <declare -a a=([0]="x" [1]="y z")>
2206/// $ bash -c 'declare -A h=([q]=1); printf "<%s>" "${h[*]@A}"'
2207///                                                       → <declare -A h=([q]="1" )>
2208/// ```
2209pub fn bash_assignment_string(name: &str, attrs: &str, shape: &BashParamShape) -> String {
2210    let decl = if attrs.is_empty() {
2211        String::new()
2212    } else {
2213        format!("declare -{} ", attrs)
2214    };
2215    match shape {
2216        // An unset reference recreates as a bare declaration with no `=`.
2217        BashParamShape::Unset => format!("{}{}", decl, name),
2218        BashParamShape::Scalar(v) => format!("{}{}={}", decl, name, bash_quote_reusable(v)),
2219        BashParamShape::Indexed(items) => {
2220            let body = items
2221                .iter()
2222                .map(|(i, v)| format!("[{}]={}", i, bash_quote_element(v)))
2223                .collect::<Vec<_>>()
2224                .join(" ");
2225            format!("{}{}=({})", decl, name, body)
2226        }
2227        // bash's assoc writer emits a trailing space after EVERY pair, so the
2228        // body ends `… )` — reproduced verbatim.  Keys are bracketed here,
2229        // unlike in the `@K` pair form.
2230        BashParamShape::Assoc(items) => {
2231            let mut body = String::new();
2232            for (k, v) in items.iter() {
2233                body.push('[');
2234                body.push_str(&bash_quote_assoc_key(k));
2235                body.push_str("]=");
2236                body.push_str(&bash_quote_element(v));
2237                body.push(' ');
2238            }
2239            format!("{}{}=({})", decl, name, body)
2240        }
2241    }
2242}
2243
2244/// bash `${var@K}` — like `@Q` for a scalar; for an array, `key "value"`
2245/// pairs in ONE word.
2246///
2247/// ```text
2248/// $ bash -c 'a=(x "y z");          printf "<%s>" "${a[@]@K}"' → <0 "x" 1 "y z">
2249/// $ bash -c 'declare -A h=([q]=1); printf "<%s>" "${h[*]@K}"' → <q "1" >
2250/// ```
2251pub fn bash_kvpair_string(shape: &BashParamShape) -> String {
2252    match shape {
2253        BashParamShape::Unset => String::new(),
2254        BashParamShape::Scalar(v) => bash_quote_reusable(v),
2255        BashParamShape::Indexed(items) => items
2256            .iter()
2257            .map(|(i, v)| format!("{} {}", i, bash_quote_element(v)))
2258            .collect::<Vec<_>>()
2259            .join(" "),
2260        BashParamShape::Assoc(items) => {
2261            let mut out = String::new();
2262            for (k, v) in items.iter() {
2263                out.push_str(&bash_quote_assoc_key(k));
2264                out.push(' ');
2265                out.push_str(&bash_quote_element(v));
2266                out.push(' ');
2267            }
2268            out
2269        }
2270    }
2271}
2272
2273/// bash `${var@k}` — the `@K` pairs with NO quoting, each key and each value
2274/// its own word.
2275///
2276/// ```text
2277/// $ bash -c 'a=(x "y z"); printf "<%s>" "${a[@]@k}"' → <0><x><1><y z>
2278/// ```
2279pub fn bash_kv_words(shape: &BashParamShape) -> Vec<String> {
2280    match shape {
2281        BashParamShape::Unset => Vec::new(),
2282        BashParamShape::Scalar(v) => vec![bash_quote_reusable(v)],
2283        BashParamShape::Indexed(items) => items
2284            .iter()
2285            .flat_map(|(i, v)| [i.to_string(), v.clone()])
2286            .collect(),
2287        BashParamShape::Assoc(items) => items
2288            .iter()
2289            .flat_map(|(k, v)| [k.clone(), v.clone()])
2290            .collect(),
2291    }
2292}
2293
2294/// Split a `${var[@]@A}` result into words the way bash does.
2295///
2296/// bash hands the assignment statement back through IFS field splitting, but
2297/// with quote regions skipped, so `declare -a a=([0]="x" [1]="y z")` becomes
2298/// three words and the parenthesised body stays whole.  Measured:
2299///
2300/// ```text
2301/// $ bash -c 'a=(x "y z");            printf "<%s>" "${a[@]@A}"'
2302/// <declare><-a><a=([0]="x" [1]="y z")>
2303/// $ bash -c 'a=(x "y z"); IFS=":";   printf "<%s>" "${a[@]@A}"'
2304/// <declare -a a=([0]="x" [1]="y z")>
2305/// $ bash -c 'a=(x "y z"); IFS="a";   printf "<%s>" "${a[@]@A}"'
2306/// <decl><re ->< ><=([0]="x" [1]="y z")>
2307/// ```
2308///
2309/// An IFS that is unset OR empty falls back to `" \t\n"` — the empty case is
2310/// bash's own, and is why `IFS=""` above still splits.
2311///
2312/// A parenthesised region is protected too, which is what keeps the whole
2313/// `a=(…)` body one word even though its interior is full of spaces:
2314///
2315/// ```text
2316/// $ bash -c 'a=(x y); IFS="0"; printf "<%s>" "${a[@]@A}"'
2317/// <declare -a a=([0]="x" [1]="y")>          # the `0`s live inside the parens
2318/// $ bash -c 'a=(x y); IFS="[";  printf "<%s>" "${a[@]@A}"'
2319/// <declare -a a=([0]="x" [1]="y")>
2320/// ```
2321pub fn bash_split_ifs_quote_aware(s: &str, ifs: Option<&str>) -> Vec<String> {
2322    let seps: Vec<char> = match ifs {
2323        Some(v) if !v.is_empty() => v.chars().collect(),
2324        _ => vec![' ', '\t', '\n'],
2325    };
2326    let is_sep = |c: char| seps.contains(&c);
2327    let is_ws_sep = |c: char| is_sep(c) && (c == ' ' || c == '\t' || c == '\n');
2328
2329    let cs: Vec<char> = s.chars().collect();
2330    let mut out: Vec<String> = Vec::new();
2331    let mut cur = String::new();
2332    let mut started = false;
2333    let mut depth = 0usize; // unquoted `(` … `)` nesting — never split inside
2334    let mut i = 0usize;
2335    while i < cs.len() {
2336        let c = cs[i];
2337        if c == '(' {
2338            depth += 1;
2339            cur.push(c);
2340            started = true;
2341            i += 1;
2342            continue;
2343        }
2344        if c == ')' {
2345            depth = depth.saturating_sub(1);
2346            cur.push(c);
2347            started = true;
2348            i += 1;
2349            continue;
2350        }
2351        if c == '\'' {
2352            // Single-quoted region (also the tail of `$'…'`): nothing inside
2353            // it splits and no backslash ends it early.
2354            cur.push(c);
2355            started = true;
2356            i += 1;
2357            while i < cs.len() && cs[i] != '\'' {
2358                cur.push(cs[i]);
2359                i += 1;
2360            }
2361            if i < cs.len() {
2362                cur.push('\'');
2363                i += 1;
2364            }
2365            continue;
2366        }
2367        if c == '"' {
2368            cur.push(c);
2369            started = true;
2370            i += 1;
2371            while i < cs.len() && cs[i] != '"' {
2372                if cs[i] == '\\' && i + 1 < cs.len() {
2373                    cur.push(cs[i]);
2374                    i += 1;
2375                }
2376                cur.push(cs[i]);
2377                i += 1;
2378            }
2379            if i < cs.len() {
2380                cur.push('"');
2381                i += 1;
2382            }
2383            continue;
2384        }
2385        if c == '\\' && i + 1 < cs.len() {
2386            cur.push(c);
2387            cur.push(cs[i + 1]);
2388            started = true;
2389            i += 2;
2390            continue;
2391        }
2392        if is_sep(c) && depth == 0 {
2393            if started {
2394                out.push(std::mem::take(&mut cur));
2395                started = false;
2396            }
2397            if is_ws_sep(c) {
2398                // Runs of IFS whitespace collapse into one separator.
2399                while i + 1 < cs.len() && is_ws_sep(cs[i + 1]) {
2400                    i += 1;
2401                }
2402            }
2403            i += 1;
2404            continue;
2405        }
2406        cur.push(c);
2407        started = true;
2408        i += 1;
2409    }
2410    if started {
2411        out.push(cur);
2412    }
2413    out
2414}
2415
2416/// bash `${var@a}` — the parameter's attribute flags as a letter string, and
2417/// the same letters `${var@A}` puts after `declare -`.
2418///
2419/// The ORDER is bash's, measured:
2420///
2421/// ```text
2422/// $ bash -c 'declare -air z=(1 2); printf "<%s>" "${z@a}"'  → <air>
2423/// $ bash -c 'declare -xi  q=3;     printf "<%s>" "${q@a}"'  → <ix>
2424/// $ bash -c 'declare -A   h; h[x]=1; printf "<%s>" "${h[@]@a}"' → <A>
2425/// $ bash -c 'v=abc;                printf "<%s>" "${v@a}"'  → <>
2426/// ```
2427pub fn bash_attr_letters(flags: u32) -> String {
2428    use crate::ported::zsh_h::{
2429        PM_ARRAY, PM_EXPORTED, PM_HASHED, PM_INTEGER, PM_LOWER, PM_READONLY, PM_UPPER,
2430    };
2431    let mut attrs = String::new();
2432    if flags & PM_HASHED != 0 {
2433        attrs.push('A');
2434    } else if flags & PM_ARRAY != 0 {
2435        attrs.push('a');
2436    }
2437    if flags & PM_INTEGER != 0 {
2438        attrs.push('i');
2439    }
2440    if flags & PM_READONLY != 0 {
2441        attrs.push('r');
2442    }
2443    if flags & PM_EXPORTED != 0 {
2444        attrs.push('x');
2445    }
2446    if flags & PM_UPPER != 0 {
2447        attrs.push('u');
2448    }
2449    if flags & PM_LOWER != 0 {
2450        attrs.push('l');
2451    }
2452    attrs
2453}
2454
2455/// Rebuild the scalar `val` after a bash `${var@OP}` transformation ran
2456/// ELEMENT-WISE over an array reference.
2457///
2458/// A `"${a[*]…}"` reference was already collapsed to one word before the
2459/// transform ran, so its result has to be re-joined with the same separator
2460/// (`$IFS[0]`, or an explicit `(j:X:)`); a `"${a[@]…}"` one still splats from
2461/// `split_parts` and only needs a placeholder join, which mirrors what the
2462/// `casmod` arm in `subst.rs` does.
2463///
2464/// ```text
2465/// $ bash -c 'a=(x "y z"); IFS=":"; printf "<%s>" "${a[*]@Q}"'  → <'x':'y z'>
2466/// ```
2467pub fn bash_rejoin_elems(parts: &[String], dq_collapsed: bool, sep: Option<&str>) -> String {
2468    if dq_collapsed {
2469        crate::ported::utils::sepjoin(parts, sep)
2470    } else {
2471        parts.join(" ")
2472    }
2473}
2474
2475/// The raw `PM_*` flag word of a named parameter, or 0 when it has none.
2476/// Feeds [`bash_attr_letters`] for `${v@a}` and `${v@A}`.
2477pub fn bash_param_flags(name: &str) -> u32 {
2478    crate::ported::params::paramtab()
2479        .read()
2480        .ok()
2481        .and_then(|t| t.get(name).map(|p| p.node.flags as u32))
2482        .unwrap_or(0)
2483}
2484
2485/// ksh93's `printf %q` quoting of one argument.
2486///
2487/// !!! EMULATION-ONLY (no C counterpart) !!! zsh backslash-escapes
2488/// (`a\ b`); ksh93u+m wraps in single quotes (`'a b'`), switches to the
2489/// ANSI-C `$'…'` form when the argument holds a control character or an
2490/// apostrophe, and leaves `a=b`, `a!b`, `a,b`, `a%b`, `a+b`, `a@b`, `a^b`
2491/// and non-ASCII text bare. Measured against ksh93u+m 1.0.10 with every
2492/// ASCII punctuation character between two letters, plus leading `=`.
2493pub fn ksh93_printf_q(arg: &str) -> String {
2494    if arg.is_empty() {
2495        return "''".to_string();
2496    }
2497    if arg.chars().any(|c| c.is_control()) || arg.contains('\'') {
2498        let mut out = String::from("$'");
2499        for c in arg.chars() {
2500            match c {
2501                '\\' => out.push_str("\\\\"),
2502                '\'' => out.push_str("\\'"),
2503                '\t' => out.push_str("\\t"),
2504                '\n' => out.push_str("\\n"),
2505                '\r' => out.push_str("\\r"),
2506                c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
2507                c => out.push(c),
2508            }
2509        }
2510        out.push('\'');
2511        return out;
2512    }
2513    const SPECIAL: &str = " \"#$&()*;<>?[\\]`{|}~";
2514    if arg.starts_with('=') || arg.chars().any(|c| SPECIAL.contains(c)) {
2515        return format!("'{arg}'");
2516    }
2517    arg.to_string()
2518}
2519
2520/// ksh93 `print` / `echo -e` backslash escapes, as measured on
2521/// /opt/homebrew/bin/ksh (93u+m). The set is narrower than zsh's
2522/// GETKEYS_PRINT/ECHO: `\a \b \E \f \n \r \t \v \\`, `\0` plus up to three
2523/// octal digits, and `\u` plus exactly four hex digits. `\c` ends the output
2524/// (the returned flag is true: drop the rest and the newline). Everything
2525/// else — `\e`, `\x41`, `\101`, `\1`, `\U…`, `\"`, `\q`, a trailing `\` —
2526/// keeps its backslash. Bytes >= 0x80 are metafied like getkeystring's.
2527///
2528/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2529pub fn ksh93_print_escapes(s: &str) -> (String, bool) {
2530    let chars: Vec<char> = s.chars().collect();
2531    let mut out = String::with_capacity(s.len());
2532    let mut i = 0;
2533    while i < chars.len() {
2534        let c = chars[i];
2535        if c != '\\' || i + 1 >= chars.len() {
2536            out.push(c);
2537            i += 1;
2538            continue;
2539        }
2540        let simple = match chars[i + 1] {
2541            'a' => Some('\x07'),
2542            'b' => Some('\x08'),
2543            'E' => Some('\x1b'),
2544            'f' => Some('\x0c'),
2545            'n' => Some('\n'),
2546            'r' => Some('\r'),
2547            't' => Some('\t'),
2548            'v' => Some('\x0b'),
2549            '\\' => Some('\\'),
2550            _ => None,
2551        };
2552        if let Some(ch) = simple {
2553            out.push(ch);
2554            i += 2;
2555        } else if chars[i + 1] == 'c' {
2556            return (out, true);
2557        } else if chars[i + 1] == '0' {
2558            let digits: Vec<u32> = chars[i + 2..]
2559                .iter()
2560                .take(3)
2561                .map_while(|d| d.to_digit(8))
2562                .collect();
2563            let byte = (digits.iter().fold(0, |acc, d| acc * 8 + d) & 0xff) as u8;
2564            if byte < 0x80 {
2565                out.push(byte as char);
2566            } else {
2567                out.push('\u{83}'); // Meta
2568                out.push(char::from(byte ^ 32));
2569            }
2570            i += 2 + digits.len();
2571        } else if chars[i + 1] == 'u'
2572            && chars.len() >= i + 6
2573            && chars[i + 2..i + 6].iter().all(|d| d.is_ascii_hexdigit())
2574        {
2575            let hex: String = chars[i + 2..i + 6].iter().collect();
2576            out.push(char::from_u32(u32::from_str_radix(&hex, 16).unwrap_or(0xfffd)).unwrap_or('\u{fffd}'));
2577            i += 6;
2578        } else {
2579            out.push('\\');
2580            i += 1;
2581        }
2582    }
2583    (out, false)
2584}
2585
2586/// Names currently defined with the ksh `function name { … }` keyword (as
2587/// opposed to the POSIX `name() { … }` spelling). Written at definition time
2588/// by [`note_function_spelling`].
2589static KEYWORD_FUNCS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
2590
2591/// Record which spelling defined `name`; a redefinition replaces the old entry.
2592///
2593/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2594pub fn note_function_spelling(name: &str, keyword: bool) {
2595    let mut names = KEYWORD_FUNCS.lock().unwrap_or_else(|e| e.into_inner());
2596    names.retain(|n| n != name);
2597    if keyword {
2598        names.push(name.to_string());
2599    }
2600}
2601
2602/// True while the innermost running shell function is a ksh93 `function name
2603/// { … }` definition. ksh93 gives only that spelling its own EXIT-trap scope
2604/// (the trap fires when the function returns); a `name() { … }` function's
2605/// EXIT trap is the shell's and fires at shell exit — which is zsh's
2606/// POSIX_TRAPS behavior, so the keyword spelling is the one to special-case.
2607///
2608/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2609pub fn in_ksh93_keyword_function() -> bool {
2610    ksh93_mode() && innermost_function_is_keyword_spelled() == Some(true)
2611}
2612
2613/// True while the innermost running shell function is a POSIX-spelled
2614/// `name() { … }` definition under `--ksh`. ksh93 gives that spelling no
2615/// local scope: `f() { typeset x=1; }; f; print $x` prints `1`, so `typeset`
2616/// and its aliases act as `typeset -g` there (the `function name { … }`
2617/// spelling keeps zsh's local scope).
2618///
2619/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2620pub fn in_ksh93_posix_function() -> bool {
2621    ksh93_mode() && innermost_function_is_keyword_spelled() == Some(false)
2622}
2623
2624/// `Some(true)` when the innermost running function was defined with the
2625/// `function` keyword, `Some(false)` for `name()`, `None` outside a function.
2626fn innermost_function_is_keyword_spelled() -> Option<bool> {
2627    let top = crate::ported::modules::parameter::FUNCSTACK
2628        .lock()
2629        .ok()
2630        .and_then(|stk| {
2631            stk.last()
2632                .filter(|f| f.tp == crate::ported::zsh_h::FS_FUNC)
2633                .map(|f| f.name.clone())
2634        });
2635    top.map(|name| {
2636        KEYWORD_FUNCS
2637            .lock()
2638            .unwrap_or_else(|e| e.into_inner())
2639            .contains(&name)
2640    })
2641}
2642
2643/// Scope guard for a bash command substitution body.
2644///
2645/// bash(1) `set -e`: "Subshells spawned to execute command substitutions
2646/// inherit the value of the -e option from the parent shell. When not in
2647/// posix mode, bash clears the -e option in such subshells" — unless
2648/// `shopt -s inherit_errexit`. So `set -e; echo $(false; echo hi)` prints
2649/// `hi`. zsh (and ksh) keep ERREXIT inside the substitution. Restores the
2650/// option on drop.
2651///
2652/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2653pub struct BashCmdsubstErrexit {
2654    armed: bool,
2655}
2656
2657impl BashCmdsubstErrexit {
2658    pub fn enter() -> Self {
2659        use crate::ported::zsh_h::{isset, ERREXIT};
2660        let armed = bash_mode()
2661            && isset(ERREXIT)
2662            && !bash_shopt_get("inherit_errexit").unwrap_or(false);
2663        if armed {
2664            crate::ported::options::dosetopt(ERREXIT, 0, 1);
2665        }
2666        Self { armed }
2667    }
2668}
2669
2670impl Drop for BashCmdsubstErrexit {
2671    fn drop(&mut self) {
2672        if self.armed {
2673            crate::ported::options::dosetopt(crate::ported::zsh_h::ERREXIT, 1, 1);
2674        }
2675    }
2676}
2677
2678/// C `oldpwd` global (Src/params.c:75). `cd -` and `~-` read THIS, not the
2679/// `$OLDPWD` parameter: assigning `OLDPWD=x` leaves it alone (Src/builtin.c:909
2680/// `: oldpwd`, Src/subst.c:755). `None` until startup or the first `cd` sets it.
2681static OLDPWD_GLOBAL: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
2682
2683/// `oldpwd = <val>` (c:Src/builtin.c:1238-1239, c:Src/init.c:1255-1259).
2684pub fn set_oldpwd_global(val: &str) {
2685    *OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) = Some(val.to_string());
2686}
2687
2688/// What `cd -` / `~-` resolve to. zsh reads the `oldpwd` global; the real
2689/// POSIX-family shells (dash, ksh93, bash) read `$OLDPWD` itself, so an
2690/// assignment to it redirects `cd -` there.
2691///
2692/// !!! RUST-ONLY ADAPTER — the posix_faithful branch has no zsh C counterpart !!!
2693pub fn oldpwd_cd_target() -> Option<String> {
2694    let param = || crate::ported::params::getsparam("OLDPWD");
2695    if posix_faithful() {
2696        return param();
2697    }
2698    OLDPWD_GLOBAL
2699        .lock()
2700        .unwrap_or_else(|e| e.into_inner())
2701        .clone()
2702        .or_else(param)
2703}
2704
2705/// True when a blank at bracket depth `brct` must NOT end the token: the word
2706/// so far (`lexbuf`) is `NAME[` in command position (`incmdpos`) and the shell
2707/// is bash or a Korn drop-in, whose assignments take a blank-bearing subscript
2708/// (`h[a b]=1`, `a[1 + 1]=x`). zsh itself splits the word at the blank, so
2709/// every other personality keeps Src/lex.c:958.
2710///
2711/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2712pub fn korn_subscript_blank(brct: i32, incmdpos: bool, lexbuf: &str) -> bool {
2713    if brct <= 0 || !incmdpos || !(bash_mode() || korn_mode()) {
2714        return false;
2715    }
2716    let name_len = lexbuf
2717        .find(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
2718        .unwrap_or(lexbuf.len());
2719    name_len > 0
2720        && !lexbuf.starts_with(|c: char| c.is_ascii_digit())
2721        && lexbuf[name_len..].starts_with(crate::ported::zsh_h::Inbrack)
2722}
2723
2724/// ksh93 `printf %f/%e/%g` rounds an exactly-representable tie away from zero
2725/// (`printf '%.0f' 0.5 2.5` -> `1 3`, `%.1f 0.25` -> `0.3`), where libc and
2726/// Rust round the tie to even. Nudge such a tie one ulp away from zero so the
2727/// ordinary formatter rounds it up; every non-tie and every other mode is
2728/// returned unchanged. `prec` is the directive's precision (`None` = default).
2729pub fn ksh93_round_ties_away(n: f64, conv: char, prec: Option<usize>) -> f64 {
2730    if !ksh93_mode() || n == 0.0 || !n.is_finite() {
2731        return n;
2732    }
2733    let sci = matches!(conv, 'e' | 'E' | 'g' | 'G');
2734    let p = match conv {
2735        'g' | 'G' => prec.unwrap_or(6).max(1) - 1,
2736        _ => prec.unwrap_or(6),
2737    };
2738    // Exact decimal expansion with enough extra digits to expose any non-tie.
2739    let exact = if sci { format!("{:.*e}", p + 40, n) } else { format!("{:.*}", p + 40, n) };
2740    let mantissa = exact.split('e').next().unwrap_or("");
2741    let frac = mantissa.split_once('.').map_or("", |(_, f)| f);
2742    let is_tie = frac.as_bytes().get(p) == Some(&b'5') && frac[p + 1..].bytes().all(|b| b == b'0');
2743    if is_tie { f64::from_bits(n.to_bits() + 1) } else { n }
2744}
2745
2746/// Index of a single-element subscript assignment `a[KEY]=v` for the sparse
2747/// side-table: a plain number, or (Korn/bash arithmetic subscript) a
2748/// side-effect-free expression such as `1+1` or a variable name. ksh93 and
2749/// mksh keep `a[1+1]=x` sparse (`${!a[@]}` -> `2`), same as `a[2]=x`.
2750/// Expressions with `++`/`--`/`=`/`,`/`?` or any other operator are not
2751/// evaluated here (a second evaluation would repeat the side effect), and a
2752/// negative result has no table slot.
2753pub fn sparse_subscript_index(key: &str) -> Option<usize> {
2754    let key = key.trim();
2755    if let Ok(i) = key.parse::<usize>() {
2756        return Some(i);
2757    }
2758    let pure = !key.is_empty()
2759        && key.chars().all(|c| c.is_ascii_alphanumeric() || " _+-*/%()".contains(c))
2760        && !key.contains("++")
2761        && !key.contains("--");
2762    if !pure {
2763        return None;
2764    }
2765    let v = crate::ported::math::mathevali(key).ok()?;
2766    usize::try_from(v).ok()
2767}
2768
2769/// True for the body of a ksh93 stepped CHARACTER range `{a..e..2}`: two
2770/// single-character endpoints and a signed integer step. zsh's `hasbraces`
2771/// (Src/glob.c:2042) only accepts a stepped range when the endpoints are
2772/// numeric, but ksh93 expands `{a..e..2}` -> `a c e`.
2773pub fn ksh93_char_step_range(body: &str) -> bool {
2774    if !ksh93_mode() {
2775        return false;
2776    }
2777    let norm: String = body.chars().map(|c| if c == '\u{e19b}' { '-' } else { c }).collect();
2778    let parts: Vec<&str> = norm.split("..").collect();
2779    parts.len() == 3
2780        && parts[..2].iter().all(|p| p.chars().count() == 1)
2781        && parts[2].parse::<i64>().is_ok()
2782}
2783
2784/// mksh's quoting for `typeset -p` values: bare when every character is
2785/// alphanumeric, non-ASCII or one of `_-./:,+@%^!{}~`; otherwise POSIX
2786/// single quotes (`'\''` for an embedded quote); control characters switch to
2787/// the `$'..'` form. Measured against mksh R59 over the ASCII punctuation set.
2788pub fn mksh_quote(v: &str) -> String {
2789    if v.chars().any(|c| c.is_ascii_control()) {
2790        let mut out = String::from("$'");
2791        for c in v.chars() {
2792            match c {
2793                '\n' => out.push_str("\\n"),
2794                '\t' => out.push_str("\\t"),
2795                '\r' => out.push_str("\\r"),
2796                '\'' => out.push_str("\\'"),
2797                '\\' => out.push_str("\\\\"),
2798                c if c.is_ascii_control() => out.push_str(&format!("\\{:03o}", c as u32)),
2799                c => out.push(c),
2800            }
2801        }
2802        out.push('\'');
2803        return out;
2804    }
2805    let bare = |c: char| !c.is_ascii() || c.is_ascii_alphanumeric() || "_-./:,+@%^!{}~".contains(c);
2806    if !v.is_empty() && v.chars().all(bare) {
2807        v.to_string()
2808    } else {
2809        format!("'{}'", v.replace('\'', "'\\''"))
2810    }
2811}
2812
2813/// Lines of mksh's `typeset -p NAME` for a SET scalar or indexed array.
2814/// Attribute words come in mksh's fixed order `-i -x -r -t -L|-R -Z -l|-u -U`
2815/// (width/base glued to `-L`/`-R`; a base-N integer shows its value as
2816/// `N#digits` under a bare `-i`). Arrays print `set -A NAME` followed by one
2817/// `typeset NAME[i]=v` per live element, holes skipped.
2818///
2819/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2820pub fn pdksh_typeset_p(
2821    name: &str,
2822    flags: u32,
2823    width: i32,
2824    value: &str,
2825    elems: Option<Vec<(usize, String)>>,
2826) -> Vec<String> {
2827    use crate::ported::zsh_h::{
2828        PM_EXPORTED, PM_INTEGER, PM_LEFT, PM_LOWER, PM_READONLY, PM_RIGHT_B, PM_RIGHT_Z, PM_TAGGED,
2829        PM_UNIQUE, PM_UPPER,
2830    };
2831    if let Some(elems) = elems {
2832        let mut lines = vec![format!("set -A {name}")];
2833        lines.extend(elems.into_iter().map(|(i, v)| format!("typeset {name}[{i}]={}", mksh_quote(&v))));
2834        return lines;
2835    }
2836    let mut attrs: Vec<String> = Vec::new();
2837    for (flag, word) in [(PM_INTEGER, "-i"), (PM_EXPORTED, "-x"), (PM_READONLY, "-r"), (PM_TAGGED, "-t")] {
2838        if flags & flag != 0 {
2839            attrs.push(word.to_string());
2840        }
2841    }
2842    if flags & PM_LEFT != 0 {
2843        attrs.push(format!("-L{width}"));
2844    }
2845    if flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
2846        attrs.push(format!("-R{width}"));
2847    }
2848    if flags & PM_RIGHT_Z != 0 {
2849        attrs.push("-Z".to_string());
2850    }
2851    for (flag, word) in [(PM_LOWER, "-l"), (PM_UPPER, "-u"), (PM_UNIQUE, "-U")] {
2852        if flags & flag != 0 {
2853            attrs.push(word.to_string());
2854        }
2855    }
2856    let head = if attrs.is_empty() { "typeset".to_string() } else { format!("typeset {}", attrs.join(" ")) };
2857    let int_text = |s: &str| {
2858        let (base, digits) = s.split_once('#').unwrap_or(("", s));
2859        let digits_ok = |d: &str| !d.is_empty() && d.trim_start_matches('-').bytes().all(|b| b.is_ascii_alphanumeric());
2860        digits_ok(digits) && (base.is_empty() || base.trim_start_matches('-').bytes().all(|b| b.is_ascii_digit()))
2861    };
2862    let shown = if flags & PM_INTEGER != 0 && int_text(value) { value.to_string() } else { mksh_quote(value) };
2863    vec![format!("{head} {name}={shown}")]
2864}
2865
2866/// mksh `${v@Q}`: the `typeset -p` quoting, except a value holding control
2867/// characters is wrapped in `$'..'` with its bytes left RAW (mksh R59 does not
2868/// escape them there: a tab stays a tab, a newline stays a newline).
2869pub fn mksh_at_q(v: &str) -> String {
2870    if v.chars().any(|c| c.is_ascii_control()) {
2871        format!("$'{}'", v.replace('\'', "\\'"))
2872    } else {
2873        mksh_quote(v)
2874    }
2875}
2876
2877/// `$KSH_VERSION` of the emulated Korn shell: ksh93u+m 1.0.10 for `--ksh`,
2878/// mksh R59 for the pdksh line (the reference binaries this mode is measured
2879/// against).
2880pub fn ksh_version_string() -> &'static str {
2881    if pdksh_family() {
2882        "@(#)MIRBSD KSH R59 2020/10/31"
2883    } else {
2884        "Version AJM 93u+m/1.0.10 2024-08-01"
2885    }
2886}
2887
2888/// dash's `ulimit` (miscbltin.c `ulimitcmd`): `[-HSa] [-tfdscmlpnv] [limit]`,
2889/// its own fixed resource table, `%-20s ` listing for `-a`, and "Illegal
2890/// option" (status 2) for every other letter. zsh's table (`-u`, `-T`, ...)
2891/// differs, so the bare drop-in does not go through `bin_ulimit`.
2892///
2893/// Platform-specific extras dash adds on Linux (`-w -i -q -e -r`) are not
2894/// carried: only the ten resources common to dash on every host are.
2895///
2896/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2897#[cfg(unix)]
2898pub fn dash_ulimit(argv: &[String]) -> i32 {
2899    // (name, resource, factor, option letter) — dash's `limits[]` order.
2900    let limits: [(&str, libc::c_int, libc::rlim_t, char); 10] = [
2901        ("time(seconds)", libc::RLIMIT_CPU as _, 1, 't'),
2902        ("file(blocks)", libc::RLIMIT_FSIZE as _, 512, 'f'),
2903        ("data(kbytes)", libc::RLIMIT_DATA as _, 1024, 'd'),
2904        ("stack(kbytes)", libc::RLIMIT_STACK as _, 1024, 's'),
2905        ("coredump(blocks)", libc::RLIMIT_CORE as _, 512, 'c'),
2906        ("memory(kbytes)", libc::RLIMIT_RSS as _, 1024, 'm'),
2907        ("locked memory(kbytes)", libc::RLIMIT_MEMLOCK as _, 1024, 'l'),
2908        ("process", libc::RLIMIT_NPROC as _, 1, 'p'),
2909        ("nofiles", libc::RLIMIT_NOFILE as _, 1, 'n'),
2910        ("vmemory(kbytes)", libc::RLIMIT_AS as _, 1024, 'v'),
2911    ];
2912    let (mut soft, mut hard, mut all) = (true, true, false);
2913    let mut what = 'f';
2914    let mut i = 0;
2915    while let Some(arg) = argv.get(i) {
2916        if arg == "--" {
2917            i += 1;
2918            break;
2919        }
2920        if !arg.starts_with('-') || arg.len() == 1 {
2921            break;
2922        }
2923        for c in arg[1..].chars() {
2924            match c {
2925                'H' => (soft, hard) = (false, true),
2926                'S' => (soft, hard) = (true, false),
2927                'a' => all = true,
2928                c if limits.iter().any(|l| l.3 == c) => what = c,
2929                c => {
2930                    crate::ported::utils::zwarnnam("ulimit", &format!("Illegal option -{c}"));
2931                    return 2;
2932                }
2933            }
2934        }
2935        i += 1;
2936    }
2937    let value = argv.get(i);
2938    let get = |res: libc::c_int| -> libc::rlimit {
2939        let mut r = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
2940        // SAFETY: `r` is a valid out-pointer for the duration of the call.
2941        unsafe { libc::getrlimit(res as _, &mut r) };
2942        r
2943    };
2944    let show = |r: &libc::rlimit, factor: libc::rlim_t| -> String {
2945        let v = if soft { r.rlim_cur } else { r.rlim_max };
2946        if v == libc::RLIM_INFINITY {
2947            "unlimited".to_string()
2948        } else {
2949            (v / factor).to_string()
2950        }
2951    };
2952    if let Some(v) = value {
2953        if all || argv.get(i + 1).is_some() {
2954            crate::ported::utils::zwarnnam("ulimit", "too many arguments");
2955            return 2;
2956        }
2957        let (_, res, factor, _) = limits.iter().find(|l| l.3 == what).copied().unwrap();
2958        let new = if v == "unlimited" {
2959            libc::RLIM_INFINITY
2960        } else if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
2961            match v.parse::<libc::rlim_t>().ok().and_then(|n| n.checked_mul(factor)) {
2962                Some(n) => n,
2963                None => {
2964                    crate::ported::utils::zwarnnam("ulimit", "bad number");
2965                    return 2;
2966                }
2967            }
2968        } else {
2969            crate::ported::utils::zwarnnam("ulimit", "bad number");
2970            return 2;
2971        };
2972        let mut r = get(res);
2973        if soft {
2974            r.rlim_cur = new;
2975        }
2976        if hard {
2977            r.rlim_max = new;
2978        }
2979        // SAFETY: `r` is a valid rlimit for the duration of the call.
2980        if unsafe { libc::setrlimit(res as _, &r) } < 0 {
2981            crate::ported::utils::zwarnnam(
2982                "ulimit",
2983                &format!("error setting limit ({})", std::io::Error::last_os_error()),
2984            );
2985            return 2;
2986        }
2987        return 0;
2988    }
2989    if all {
2990        for (name, res, factor, _) in &limits {
2991            println!("{:<20} {}", name, show(&get(*res), *factor));
2992        }
2993    } else {
2994        let (_, res, factor, _) = limits.iter().find(|l| l.3 == what).copied().unwrap();
2995        println!("{}", show(&get(res), factor));
2996    }
2997    0
2998}
2999
3000/// Double the backslash of every `\c` in a printf format so zsh's
3001/// `getkeystring` (which reads `\c` as "stop output") emits `\c` literally,
3002/// as dash's printf does. An already-escaped `\\` pair is passed through.
3003///
3004/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3005pub fn dash_printf_keep_backslash_c(fmt: &str) -> String {
3006    let mut out = String::with_capacity(fmt.len() + 2);
3007    let mut chars = fmt.chars();
3008    while let Some(c) = chars.next() {
3009        if c != '\\' {
3010            out.push(c);
3011            continue;
3012        }
3013        match chars.next() {
3014            Some('c') => out.push_str("\\\\c"),
3015            Some(n) => {
3016                out.push('\\');
3017                out.push(n);
3018            }
3019            None => out.push('\\'),
3020        }
3021    }
3022    out
3023}
3024
3025/// Skip the separators that may follow a `&&` / `||` / `|` / `|&` operator.
3026///
3027/// dash allows only newlines there: a `;` or the end of input is
3028/// `Syntax error: ";" unexpected` / `end of file unexpected`. Returns false
3029/// after reporting that error; every other mode skips any separator, as zsh
3030/// does.
3031///
3032/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3033pub fn dash_skip_operator_separators() -> bool {
3034    use crate::ported::lex::{set_tok, tok, zshlex, LEX_ISNEWLIN};
3035    use crate::ported::zsh_h::{ENDINPUT, LEXERR, NEWLIN, SEPER};
3036    let faithful = dash_faithful();
3037    while tok() == NEWLIN
3038        || (tok() == SEPER && (!faithful || LEX_ISNEWLIN.with(|c| c.get()) != 0))
3039    {
3040        zshlex();
3041    }
3042    if !faithful {
3043        return true;
3044    }
3045    match tok() {
3046        SEPER => crate::ported::utils::zerr("Syntax error: \";\" unexpected"),
3047        ENDINPUT => crate::ported::utils::zerr("Syntax error: end of file unexpected"),
3048        _ => return true,
3049    }
3050    set_tok(LEXERR);
3051    false
3052}
3053
3054// ---------------------------------------------------------------------------
3055// dash `test` / `[` — a port of dash's src/bltin/test.c (`testcmd`, `oexpr`,
3056// `aexpr`, `nexpr`, `primary`, `binop`, `filstat`, `t_lex`, `isoperand`).
3057// zsh's `bin_test` grammar (Src/builtin.c:7231, Src/parse.c par_cond) is a
3058// different parser with different corner cases (`-a`/`-o`/`( )` with odd
3059// operand counts, `-v`, unsigned-looking numbers, missing-file `-nt`/`-ot`),
3060// so the bare drop-in runs dash's own algorithm.
3061// ---------------------------------------------------------------------------
3062
3063/// dash `test` operator numbers (test.c `enum token`).
3064#[derive(Clone, Copy, PartialEq, Eq)]
3065enum DashTok {
3066    Eoi,
3067    Operand,
3068    FilRd,
3069    FilWr,
3070    FilEx,
3071    FilExist,
3072    FilReg,
3073    FilDir,
3074    FilCdev,
3075    FilBdev,
3076    FilFifo,
3077    FilSuid,
3078    FilSgid,
3079    FilStck,
3080    FilGz,
3081    FilTt,
3082    StrEz,
3083    StrNz,
3084    FilSym,
3085    FilUid,
3086    FilGid,
3087    FilSock,
3088    StrEq,
3089    StrNe,
3090    StrLt,
3091    StrGt,
3092    IntEq,
3093    IntNe,
3094    IntGe,
3095    IntGt,
3096    IntLe,
3097    IntLt,
3098    FilNt,
3099    FilOt,
3100    FilEq,
3101    Not,
3102    And,
3103    Or,
3104    LParen,
3105    RParen,
3106}
3107
3108#[derive(Clone, Copy, PartialEq, Eq)]
3109enum DashOpType {
3110    Unop,
3111    Binop,
3112    BUnop,
3113    BBinop,
3114    Paren,
3115}
3116
3117/// test.c `ops[]`.
3118const DASH_TEST_OPS: &[(&str, DashTok, DashOpType)] = &[
3119    ("-r", DashTok::FilRd, DashOpType::Unop),
3120    ("-w", DashTok::FilWr, DashOpType::Unop),
3121    ("-x", DashTok::FilEx, DashOpType::Unop),
3122    ("-e", DashTok::FilExist, DashOpType::Unop),
3123    ("-f", DashTok::FilReg, DashOpType::Unop),
3124    ("-d", DashTok::FilDir, DashOpType::Unop),
3125    ("-c", DashTok::FilCdev, DashOpType::Unop),
3126    ("-b", DashTok::FilBdev, DashOpType::Unop),
3127    ("-p", DashTok::FilFifo, DashOpType::Unop),
3128    ("-u", DashTok::FilSuid, DashOpType::Unop),
3129    ("-g", DashTok::FilSgid, DashOpType::Unop),
3130    ("-k", DashTok::FilStck, DashOpType::Unop),
3131    ("-s", DashTok::FilGz, DashOpType::Unop),
3132    ("-t", DashTok::FilTt, DashOpType::Unop),
3133    ("-z", DashTok::StrEz, DashOpType::Unop),
3134    ("-n", DashTok::StrNz, DashOpType::Unop),
3135    ("-h", DashTok::FilSym, DashOpType::Unop),
3136    ("-O", DashTok::FilUid, DashOpType::Unop),
3137    ("-G", DashTok::FilGid, DashOpType::Unop),
3138    ("-L", DashTok::FilSym, DashOpType::Unop),
3139    ("-S", DashTok::FilSock, DashOpType::Unop),
3140    ("=", DashTok::StrEq, DashOpType::Binop),
3141    ("!=", DashTok::StrNe, DashOpType::Binop),
3142    ("<", DashTok::StrLt, DashOpType::Binop),
3143    (">", DashTok::StrGt, DashOpType::Binop),
3144    ("-eq", DashTok::IntEq, DashOpType::Binop),
3145    ("-ne", DashTok::IntNe, DashOpType::Binop),
3146    ("-ge", DashTok::IntGe, DashOpType::Binop),
3147    ("-gt", DashTok::IntGt, DashOpType::Binop),
3148    ("-le", DashTok::IntLe, DashOpType::Binop),
3149    ("-lt", DashTok::IntLt, DashOpType::Binop),
3150    ("-nt", DashTok::FilNt, DashOpType::Binop),
3151    ("-ot", DashTok::FilOt, DashOpType::Binop),
3152    ("-ef", DashTok::FilEq, DashOpType::Binop),
3153    ("!", DashTok::Not, DashOpType::BUnop),
3154    ("-a", DashTok::And, DashOpType::BBinop),
3155    ("-o", DashTok::Or, DashOpType::BBinop),
3156    ("(", DashTok::LParen, DashOpType::Paren),
3157    (")", DashTok::RParen, DashOpType::Paren),
3158];
3159
3160type DashOp = (&'static str, DashTok, DashOpType);
3161
3162fn dash_getop(s: &str) -> Option<&'static DashOp> {
3163    DASH_TEST_OPS.iter().find(|o| o.0 == s)
3164}
3165
3166/// dash `atomax10`: optional blanks, sign and decimal digits filling the whole
3167/// string (overflow saturates, as strtoimax does); anything else is
3168/// "Illegal number".
3169fn dash_getn(s: &str) -> Result<i64, String> {
3170    let t = s.trim_start_matches(|c: char| c.is_ascii_whitespace());
3171    let (neg, digits) = match t.as_bytes().first() {
3172        Some(b'-') => (true, &t[1..]),
3173        Some(b'+') => (false, &t[1..]),
3174        _ => (false, t),
3175    };
3176    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
3177        return Err(format!("Illegal number: {s}"));
3178    }
3179    let mag = digits.bytes().try_fold(0i64, |a, b| a.checked_mul(10)?.checked_add((b - b'0') as i64));
3180    Ok(match (mag, neg) {
3181        (Some(m), false) => m,
3182        (Some(m), true) => -m,
3183        (None, false) => i64::MAX,
3184        (None, true) => i64::MIN,
3185    })
3186}
3187
3188struct DashTest {
3189    argv: Vec<String>,
3190    wp: usize,
3191    wp_op: Option<&'static DashOp>,
3192}
3193
3194impl DashTest {
3195    fn at(&self, i: usize) -> Option<&str> {
3196        self.argv.get(i).map(String::as_str)
3197    }
3198
3199    /// test.c `isoperand`.
3200    fn isoperand(&self, tp: usize) -> bool {
3201        let Some(s) = self.at(tp + 1) else { return true };
3202        if self.at(tp + 2).is_none() {
3203            return false;
3204        }
3205        dash_getop(s).is_some_and(|o| o.2 == DashOpType::Binop)
3206    }
3207
3208    /// test.c `t_lex`.
3209    fn t_lex(&mut self, tp: usize) -> DashTok {
3210        let Some(s) = self.at(tp) else {
3211            self.wp_op = None;
3212            return DashTok::Eoi;
3213        };
3214        if let Some(op) = dash_getop(s) {
3215            if !(op.2 == DashOpType::Unop && self.isoperand(tp))
3216                && !(op.1 == DashTok::LParen && self.at(tp + 1).is_none())
3217            {
3218                self.wp_op = Some(op);
3219                return op.1;
3220            }
3221        }
3222        self.wp_op = None;
3223        DashTok::Operand
3224    }
3225
3226    fn oexpr(&mut self, mut n: DashTok) -> Result<bool, String> {
3227        let mut res = false;
3228        loop {
3229            res |= self.aexpr(n)?;
3230            if self.at(self.wp).is_none() {
3231                break;
3232            }
3233            n = self.t_lex(self.wp + 1);
3234            if n != DashTok::Or {
3235                break;
3236            }
3237            self.wp += 2;
3238            n = self.t_lex(self.wp);
3239        }
3240        Ok(res)
3241    }
3242
3243    fn aexpr(&mut self, mut n: DashTok) -> Result<bool, String> {
3244        let mut res = true;
3245        loop {
3246            if !self.nexpr(n)? {
3247                res = false;
3248            }
3249            if self.at(self.wp).is_none() {
3250                break;
3251            }
3252            n = self.t_lex(self.wp + 1);
3253            if n != DashTok::And {
3254                break;
3255            }
3256            self.wp += 2;
3257            n = self.t_lex(self.wp);
3258        }
3259        Ok(res)
3260    }
3261
3262    fn nexpr(&mut self, n: DashTok) -> Result<bool, String> {
3263        if n != DashTok::Not {
3264            return self.primary(n);
3265        }
3266        let n = self.t_lex(self.wp + 1);
3267        if n != DashTok::Eoi {
3268            self.wp += 1;
3269        }
3270        Ok(!self.nexpr(n)?)
3271    }
3272
3273    fn primary(&mut self, n: DashTok) -> Result<bool, String> {
3274        if n == DashTok::Eoi {
3275            return Ok(false);
3276        }
3277        if n == DashTok::LParen {
3278            self.wp += 1;
3279            let nn = self.t_lex(self.wp);
3280            if nn == DashTok::RParen {
3281                return Ok(false);
3282            }
3283            let res = self.oexpr(nn)?;
3284            self.wp += 1;
3285            if self.t_lex(self.wp) != DashTok::RParen {
3286                return Err("closing paren expected".to_string());
3287            }
3288            return Ok(res);
3289        }
3290        if let Some(op) = self.wp_op.filter(|o| o.2 == DashOpType::Unop) {
3291            self.wp += 1;
3292            let Some(arg) = self.at(self.wp).map(str::to_owned) else {
3293                return Err(format!("{}: argument expected", op.0));
3294            };
3295            return Ok(match n {
3296                DashTok::StrEz => arg.is_empty(),
3297                DashTok::StrNz => !arg.is_empty(),
3298                // SAFETY: isatty takes any int; a bad fd just reports 0.
3299                DashTok::FilTt => unsafe { libc::isatty(dash_getn(&arg)? as libc::c_int) != 0 },
3300                DashTok::FilRd => dash_access(&arg, libc::R_OK),
3301                DashTok::FilWr => dash_access(&arg, libc::W_OK),
3302                DashTok::FilEx => dash_access(&arg, libc::X_OK),
3303                _ => dash_filstat(&arg, n),
3304            });
3305        }
3306        self.t_lex(self.wp + 1);
3307        if self.wp_op.is_some_and(|o| o.2 == DashOpType::Binop) {
3308            return self.binop();
3309        }
3310        Ok(self.at(self.wp).is_some_and(|s| !s.is_empty()))
3311    }
3312
3313    fn binop(&mut self) -> Result<bool, String> {
3314        let a = self.at(self.wp).unwrap_or("").to_owned();
3315        self.wp += 1;
3316        self.t_lex(self.wp);
3317        let Some(op) = self.wp_op else { return Ok(false) };
3318        self.wp += 1;
3319        let Some(b) = self.at(self.wp).map(str::to_owned) else {
3320            return Err(format!("{}: argument expected", op.0));
3321        };
3322        Ok(match op.1 {
3323            DashTok::StrEq => a == b,
3324            DashTok::StrNe => a != b,
3325            DashTok::StrLt => dash_strcoll(&a, &b) < 0,
3326            DashTok::StrGt => dash_strcoll(&a, &b) > 0,
3327            DashTok::IntEq => dash_getn(&a)? == dash_getn(&b)?,
3328            DashTok::IntNe => dash_getn(&a)? != dash_getn(&b)?,
3329            DashTok::IntGe => dash_getn(&a)? >= dash_getn(&b)?,
3330            DashTok::IntGt => dash_getn(&a)? > dash_getn(&b)?,
3331            DashTok::IntLe => dash_getn(&a)? <= dash_getn(&b)?,
3332            DashTok::IntLt => dash_getn(&a)? < dash_getn(&b)?,
3333            DashTok::FilNt => dash_newerf(&a, &b),
3334            DashTok::FilOt => dash_olderf(&a, &b),
3335            DashTok::FilEq => dash_equalf(&a, &b),
3336            _ => false,
3337        })
3338    }
3339}
3340
3341fn dash_strcoll(a: &str, b: &str) -> i32 {
3342    let (Ok(a), Ok(b)) = (std::ffi::CString::new(a), std::ffi::CString::new(b)) else {
3343        return 0;
3344    };
3345    // SAFETY: both pointers are valid NUL-terminated strings.
3346    unsafe { libc::strcoll(a.as_ptr(), b.as_ptr()) }
3347}
3348
3349/// `faccessat(AT_FDCWD, path, mode, AT_EACCESS)`.
3350fn dash_access(path: &str, mode: libc::c_int) -> bool {
3351    let Ok(p) = std::ffi::CString::new(path) else { return false };
3352    // SAFETY: `p` is a valid NUL-terminated string.
3353    unsafe { libc::faccessat(libc::AT_FDCWD, p.as_ptr(), mode, libc::AT_EACCESS) == 0 }
3354}
3355
3356/// test.c `filstat`.
3357fn dash_filstat(path: &str, mode: DashTok) -> bool {
3358    use std::os::unix::fs::{FileTypeExt, MetadataExt};
3359    let md = if mode == DashTok::FilSym {
3360        std::fs::symlink_metadata(path)
3361    } else {
3362        std::fs::metadata(path)
3363    };
3364    let Ok(md) = md else { return false };
3365    let ft = md.file_type();
3366    let m = md.mode();
3367    match mode {
3368        DashTok::FilExist => true,
3369        DashTok::FilReg => ft.is_file(),
3370        DashTok::FilDir => ft.is_dir(),
3371        DashTok::FilCdev => ft.is_char_device(),
3372        DashTok::FilBdev => ft.is_block_device(),
3373        DashTok::FilFifo => ft.is_fifo(),
3374        DashTok::FilSock => ft.is_socket(),
3375        DashTok::FilSym => ft.is_symlink(),
3376        DashTok::FilSuid => m & libc::S_ISUID as u32 != 0,
3377        DashTok::FilSgid => m & libc::S_ISGID as u32 != 0,
3378        DashTok::FilStck => m & libc::S_ISVTX as u32 != 0,
3379        DashTok::FilGz => md.size() != 0,
3380        // SAFETY: geteuid/getegid have no preconditions.
3381        DashTok::FilUid => md.uid() == unsafe { libc::geteuid() },
3382        DashTok::FilGid => md.gid() == unsafe { libc::getegid() },
3383        _ => true,
3384    }
3385}
3386
3387fn dash_mtime(path: &str) -> Option<(i64, i64)> {
3388    use std::os::unix::fs::MetadataExt;
3389    std::fs::metadata(path).ok().map(|m| (m.mtime(), m.mtime_nsec()))
3390}
3391
3392/// test.c `newerf`: a missing FIRST file is never newer, a missing second is.
3393fn dash_newerf(f1: &str, f2: &str) -> bool {
3394    let Some(a) = dash_mtime(f1) else { return false };
3395    dash_mtime(f2).map_or(true, |b| a > b)
3396}
3397
3398/// test.c `olderf`: a missing SECOND file is never newer, a missing first is.
3399fn dash_olderf(f1: &str, f2: &str) -> bool {
3400    let Some(b) = dash_mtime(f2) else { return false };
3401    dash_mtime(f1).map_or(true, |a| a < b)
3402}
3403
3404/// test.c `equalf`: same device and inode.
3405fn dash_equalf(f1: &str, f2: &str) -> bool {
3406    use std::os::unix::fs::MetadataExt;
3407    match (std::fs::metadata(f1), std::fs::metadata(f2)) {
3408        (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
3409        _ => false,
3410    }
3411}
3412
3413/// dash `testcmd` (test.c): `name` is `test` or `[`; `args` exclude the name.
3414/// Returns the exit status: 0 true, 1 false, 2 for a diagnosed error.
3415///
3416/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3417pub fn dash_test(name: &str, args: &[String]) -> i32 {
3418    let mut v: Vec<String> = Vec::with_capacity(args.len() + 1);
3419    v.push(name.to_string());
3420    v.extend(args.iter().cloned());
3421    let mut argc = v.len();
3422    if name.starts_with('[') {
3423        argc -= 1;
3424        if v[argc] != "]" {
3425            crate::ported::utils::zwarnnam(name, "missing ]");
3426            return 2;
3427        }
3428        v.truncate(argc);
3429    }
3430    let mut t = DashTest { argv: v, wp: 0, wp_op: None };
3431    let mut res = true; // C: `int res = 1`, flipped by `res ^= oexpr()`.
3432    let mut base = 0usize;
3433    let n;
3434    loop {
3435        base += 1;
3436        argc -= 1;
3437        if argc < 1 {
3438            return i32::from(res);
3439        }
3440        if argc == 3
3441            && t.at(base + 1).and_then(dash_getop).is_some_and(|o| o.2 == DashOpType::Binop)
3442        {
3443            n = DashTok::Operand;
3444            break;
3445        }
3446        if argc == 3 || argc == 4 {
3447            if t.at(base) == Some("(") && t.at(base + argc - 1) == Some(")") {
3448                t.argv.truncate(base + argc - 1);
3449                base += 1;
3450                argc -= 1;
3451            } else if t.at(base) == Some("!") {
3452                res = false;
3453                continue;
3454            }
3455        }
3456        n = t.t_lex(base);
3457        break;
3458    }
3459    t.wp = base;
3460    let r = match t.oexpr(n) {
3461        Ok(r) => r,
3462        Err(msg) => {
3463            crate::ported::utils::zwarnnam(name, &msg);
3464            return 2;
3465        }
3466    };
3467    // C: `res ^= oexpr(n)` with `res` starting at 1 (false) or flipped to 0 by `!`.
3468    let status = res ^ r;
3469    if t.at(t.wp).is_some() && t.at(t.wp + 1).is_some() {
3470        crate::ported::utils::zwarnnam(name, &format!("{}: unexpected operator", t.argv[t.wp]));
3471        return 2;
3472    }
3473    i32::from(status)
3474}