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        // `set -o posix` is what turns bash's POSIX special-builtin rules on
1042        // (persistent prefix assignments, fatal special-builtin errors); they
1043        // are off in a plain `bash`.
1044        if bash_name == "posix" {
1045            crate::ported::options::opt_state_set("posixbuiltins", on);
1046        }
1047        return Some(0);
1048    }
1049    let (_, zname) = BASH_SET_O.iter().find(|(b, _)| *b == bash_name)?;
1050    // Via the alias resolver, not a raw write: several of these names are zsh
1051    // NEGATION aliases (`braceexpand` → NO_IGNORE_BRACES, `noglob` → NO_GLOB,
1052    // `nounset` → NO_UNSET, c:Src/options.c:269-280), so a raw
1053    // `opt_state_set("braceexpand", false)` would leave the canonical
1054    // `ignorebraces` slot untouched and the change would not take effect.
1055    crate::ported::options::opt_state_set_via_alias(zname, on);
1056    Some(0)
1057}
1058
1059/// The bash `set` option letter -> `set -o` name, for the letters zsh either
1060/// lacks or reads differently (`set -h`, `-B`, `-E`, `-H`, `-P`, `-T`, `-k`, `-t`).
1061const BASH_SET_LETTERS: &[(char, &str)] = &[
1062    ('h', "hashall"),
1063    ('B', "braceexpand"),
1064    ('E', "errtrace"),
1065    ('H', "histexpand"),
1066    ('P', "physical"),
1067    ('T', "functrace"),
1068    ('k', "keyword"),
1069    ('t', "onecmd"),
1070];
1071
1072/// `set -L` / `set +L` for a bash-only letter. Returns false when `letter` is
1073/// not one of them (the caller then takes zsh's letter table).
1074pub fn bash_set_letter(letter: char, on: bool) -> bool {
1075    bash_mode()
1076        && BASH_SET_LETTERS
1077            .iter()
1078            .find(|(l, _)| *l == letter)
1079            .is_some_and(|(_, name)| bash_set_o(name, on).is_some())
1080}
1081
1082/// `$-` in `--bash` mode: bash's flag letters, lowercase alphabetical (with
1083/// `i`), then the uppercase ones, then `c` / `s` for the invocation kind.
1084/// `bash -c 'echo $-'` is `hBc`.
1085pub fn bash_dollar_dash() -> String {
1086    const ORDER: &[(char, &str)] = &[
1087        ('a', "allexport"),
1088        ('b', "notify"),
1089        ('e', "errexit"),
1090        ('f', "noglob"),
1091        ('h', "hashall"),
1092        ('i', ""),
1093        ('k', "keyword"),
1094        ('m', "monitor"),
1095        ('n', "noexec"),
1096        ('p', "privileged"),
1097        ('t', "onecmd"),
1098        ('u', "nounset"),
1099        ('v', "verbose"),
1100        ('x', "xtrace"),
1101        ('B', "braceexpand"),
1102        ('C', "noclobber"),
1103        ('E', "errtrace"),
1104        ('H', "histexpand"),
1105        ('P', "physical"),
1106        ('T', "functrace"),
1107    ];
1108    use crate::ported::zsh_h::{isset, INTERACTIVE, SHINSTDIN};
1109    let mut out: String = ORDER
1110        .iter()
1111        .filter(|(l, name)| if *l == 'i' { isset(INTERACTIVE) } else { bash_set_o_get(name) })
1112        .map(|(l, _)| *l)
1113        .collect();
1114    if crate::ported::params::getsparam("ZSH_EXECUTION_STRING").is_some_and(|c| !c.is_empty()) {
1115        out.push('c');
1116    }
1117    if isset(SHINSTDIN) {
1118        out.push('s');
1119    }
1120    out
1121}
1122
1123/// `$SHELLOPTS` in `--bash` mode: the colon-joined, alphabetically-ordered
1124/// list of `set -o` options currently ON (bash(1), "Shell Variables":
1125/// "SHELLOPTS — A colon-separated list of enabled shell options").
1126/// [`BASH_SET_O`] is already in bash's alphabetical order.
1127pub fn bash_shellopts() -> String {
1128    BASH_SET_O
1129        .iter()
1130        .filter(|(b, _)| bash_set_o_get(b))
1131        .map(|(b, _)| *b)
1132        .collect::<Vec<_>>()
1133        .join(":")
1134}
1135
1136// ===========================================================================
1137// !!! WARNING: RUST-ONLY HELPERS — NO zsh C COUNTERPART !!!
1138//
1139// Output-format and bookkeeping deltas between zsh and the REAL Bourne-family
1140// shells that `zshrs --bash` / `--ksh` / `--mksh` / `--pdksh` / `--sh` /
1141// `--dash` / `--ash` stand in for. zsh's C source is NOT the spec for any of
1142// these — each was measured against the actual reference binary and the
1143// observed output is quoted at the definition.
1144//
1145// Every predicate below hangs off `posix_faithful()`, which the binary raises
1146// only for a BARE drop-in flag, so all of them are false in native zshrs, in
1147// `--zsh`, under a runtime `emulate sh`, and in the zsh-STYLE cross-emulation
1148// legs (`--sh --zsh` / `--ksh --zsh`). zsh's own formats cannot regress.
1149// ===========================================================================
1150
1151/// `umask` with no arguments prints a FIXED four-octal-digit mask in the real
1152/// Bourne-family shells; zsh prints three digits and emits the leading `0`
1153/// only when the owner field is non-zero (`Src/builtin.c:7522-7524` —
1154/// `if (um & 0700) putchar('0'); printf("%03o\n", um);`).
1155///
1156/// Measured (`<shell> -c 'umask 022; umask'`):
1157///
1158/// ```text
1159/// bash 5.3 → 0022    dash → 0022    ksh93 → 0022    ash → 0022
1160/// /bin/sh  → 0022    mksh → 022     zsh   → 022
1161/// ```
1162///
1163/// mksh is the outlier — it uses zsh's conditional-zero form — so the pdksh
1164/// line is excluded. Confirmed across the range: `000`/`007`/`022`/`077` gain
1165/// the zero, `0700`/`0777` already have it, and `umask -S` is byte-identical
1166/// in every shell, so the `-S` arm is untouched.
1167#[inline]
1168pub fn umask_four_digit() -> bool {
1169    posix_faithful() && !pdksh_family()
1170}
1171
1172// ---------------------------------------------------------------------------
1173// getopts: $OPTIND reporting, the end-of-options contract, and reset detection
1174// ---------------------------------------------------------------------------
1175
1176/// Last value zshrs wrote to `$OPTIND`, and the internal `zoptind` it came
1177/// from. `bin_getopts` re-reads `$OPTIND` at entry (scripts reset it to start
1178/// a new parse), so a reported value carrying the emulation bias has to be
1179/// translated back before it is used as an argument index. Both start at -1,
1180/// which no report can produce, so the first call always looks like a script
1181/// assignment.
1182static GETOPTS_REPORTED: AtomicI32 = AtomicI32::new(-1);
1183/// Internal `zoptind` matching [`GETOPTS_REPORTED`].
1184static GETOPTS_INTERNAL: AtomicI32 = AtomicI32::new(-1);
1185
1186/// The value the emulated shell would show in `$OPTIND`, given zsh's internal
1187/// parse state — and the point where the two families disagree.
1188///
1189/// zsh advances `zoptind` LAZILY: it names the argument currently being
1190/// scanned and is bumped only on the NEXT `getopts` call, when the cursor
1191/// `optcind` is found past the end of that argument
1192/// (`Src/builtin.c:5699-5703`). Every real Bourne shell instead reports the
1193/// index of the next argument as soon as the current one is finished. zsh's
1194/// own answer is 1 where all of them say 2 — a genuine zsh-vs-POSIX semantic
1195/// difference, not a port bug, so it is corrected only in the drop-in modes:
1196///
1197/// ```text
1198/// $ for s in bash dash ksh mksh ash zsh; do $s -c \
1199///     'OPTIND=1; getopts "ab" o -b; printf "%s\n" "$OPTIND"'; done
1200/// 2  2  2  2  2  1
1201/// ```
1202///
1203/// The families split mid-CLUSTER (`-ab`). bash and ksh93 keep reporting the
1204/// cluster's own index until it is exhausted; dash, ash and mksh report the
1205/// next index as soon as any character has been consumed:
1206///
1207/// ```text
1208/// $ for s in bash ksh dash ash mksh; do $s -c \
1209///     'OPTIND=1; getopts "ab" o -ab; printf "%s\n" "$OPTIND"'; done
1210/// 1  1  2  2  2
1211/// ```
1212///
1213/// `optcind == 0` means "positioned at an argument boundary" — nothing was
1214/// taken from the current word (a fresh call, an exhausted-and-advanced
1215/// cursor, or the post-`OPTARG` reset at `Src/builtin.c:5771-5772`) — and
1216/// every shell, zsh included, reports `zoptind` unchanged there.
1217///
1218/// `--sh` follows the bash rule: `/bin/sh` is bash on this platform (and on
1219/// the RHEL-family Linuxes), and the two rules differ only mid-cluster.
1220///
1221/// Records the pair so [`getopts_optind_internal`] can undo the bias on the
1222/// next call. Outside the drop-in modes it returns `zoptind` untouched and
1223/// records nothing.
1224pub fn getopts_optind_report(zoptind: i32, optcind: i32, lenstr: i32) -> i32 {
1225    if !posix_faithful() {
1226        return zoptind;
1227    }
1228    if optcind == 0 {
1229        // Argument boundary: the value shown IS the internal index. Record the
1230        // identity pair so a stale mid-cluster pair (`-abval`: a -> 2 from 1)
1231        // cannot alias this value back onto the earlier index on the next call.
1232        GETOPTS_REPORTED.store(zoptind, Ordering::Relaxed);
1233        GETOPTS_INTERNAL.store(zoptind, Ordering::Relaxed);
1234        return zoptind;
1235    }
1236    let eager = dash_strict() || pdksh_family();
1237    let reported = if eager || optcind >= lenstr {
1238        zoptind + 1
1239    } else {
1240        zoptind
1241    };
1242    GETOPTS_REPORTED.store(reported, Ordering::Relaxed);
1243    GETOPTS_INTERNAL.store(zoptind, Ordering::Relaxed);
1244    reported
1245}
1246
1247/// Translate the `$OPTIND` a script can see back into the internal `zoptind`
1248/// `bin_getopts` left off at. Only the exact value zshrs itself last reported
1249/// is translated; anything else is a script assignment (`OPTIND=1`) and is
1250/// passed through so the reset still works. Identity outside the drop-in modes.
1251pub fn getopts_optind_internal(param_value: i64) -> i64 {
1252    if !posix_faithful() {
1253        return param_value;
1254    }
1255    let reported = GETOPTS_REPORTED.load(Ordering::Relaxed);
1256    if reported >= 0 && param_value == reported as i64 {
1257        return GETOPTS_INTERNAL.load(Ordering::Relaxed) as i64;
1258    }
1259    param_value
1260}
1261
1262/// True when a script's own write to `$OPTIND` must ALSO rewind the
1263/// within-argument cursor, so the next `getopts` re-scans that argument from
1264/// its first character.
1265///
1266/// Every POSIX shell measured treats a CHANGED `$OPTIND` as a full reset,
1267/// dash included — its `getoptsreset()` drops the within-argument offset when
1268/// the variable is assigned. zsh instead rewinds only when the new value is 1
1269/// AND its internal index was elsewhere. Measured on this host:
1270///
1271/// ```text
1272/// $ <shell> -c 'OPTIND=1; getopts "ab" o -ab; OPTIND=1; getopts "ab" o -ab;
1273///               printf "%s/%s\n" "$o" "$OPTIND"'
1274/// dash a/2      bash a/1      mksh a/2
1275/// ```
1276///
1277/// What separates the families is an assignment of the SAME value, which bash
1278/// treats as a reset (it hooks the assignment) while dash and mksh do not
1279/// (they compare values):
1280///
1281/// ```text
1282/// $ <shell> -c 'OPTIND=1; getopts "ab" o -ab; OPTIND=$OPTIND;
1283///               getopts "ab" o -ab; printf "%s/%s\n" "$o" "$OPTIND"'
1284/// dash ?/2      bash a/1      mksh ?/2
1285/// ```
1286///
1287/// The value comparison below is therefore right for the dash family too; it
1288/// used to be suppressed by a `dash_strict()` guard on the strength of the
1289/// first table having been recorded as `dash/ash → ?/2`, which no dash tested
1290/// here produces.
1291///
1292/// `raw_param` is `$OPTIND` exactly as the parameter table holds it, BEFORE
1293/// [`getopts_optind_internal`] removes the reporting bias; anything other than
1294/// the value zshrs last wrote is a script assignment. False outside the
1295/// drop-in modes and in the dash family, so zsh's own rule
1296/// (`Src/builtin.c:5681-5685`) stands alone there.
1297///
1298/// KNOWN GAP: bash hooks the ASSIGNMENT rather than comparing values, so it
1299/// also resets when the assigned value equals the one it last reported —
1300/// visible only mid-cluster (`getopts ab o -ab; OPTIND=1; getopts ab o -ab`
1301/// → bash `a`, here `b`). Catching that needs an `$OPTIND` assignment hook in
1302/// the parameter table; pinned by an ignored parity test rather than faked.
1303pub fn getopts_optind_user_reset(raw_param: i64) -> bool {
1304    if !posix_faithful() {
1305        return false;
1306    }
1307    raw_param != GETOPTS_REPORTED.load(Ordering::Relaxed) as i64
1308}
1309
1310/// POSIX end-of-options bookkeeping for `getopts`, which zsh does not do.
1311///
1312/// XCU `getopts`: "When the end of options is encountered, the getopts utility
1313/// shall exit with a return value greater than zero; … and *name* shall be set
1314/// to the question-mark character." zsh leaves *name* holding whatever it held
1315/// before:
1316///
1317/// ```text
1318/// $ <shell> -c 'o=INIT; OPTIND=1; getopts "ab" o --; printf "[%s]\n" "$o"'
1319/// bash/ksh/mksh/dash/ash → [?]        zsh → [INIT]
1320/// ```
1321///
1322/// bash, ksh93 and mksh also CLEAR `$OPTARG` on that return; dash and ash
1323/// leave the last option's argument in place, and so does zsh:
1324///
1325/// ```text
1326/// $ <shell> -c 'OPTIND=1; for i in 1 2 3; do getopts "a:b" o -b -a W; done;
1327///               printf "[%s]\n" "$OPTARG"'
1328/// bash/ksh/mksh → []      dash/ash → [W]      zsh → [W]
1329/// ```
1330///
1331/// Call at every "no more options" (`return 1`) exit of `bin_getopts`. No-op
1332/// outside the drop-in modes.
1333pub fn getopts_end_of_options(var: &str) {
1334    if !posix_faithful() {
1335        return;
1336    }
1337    crate::ported::params::setsparam(var, "?");
1338    if !dash_strict() {
1339        crate::ported::params::setsparam("OPTARG", "");
1340    }
1341}
1342
1343// ---------------------------------------------------------------------------
1344// `trap` with no arguments — the listing format
1345// ---------------------------------------------------------------------------
1346
1347/// One `trap -- <body> <SIG>` listing line in the emulated shell's own format,
1348/// or `None` when zsh's format (`Src/builtin.c:7370-7375`) applies unchanged.
1349///
1350/// Three deltas, each measured rather than derived from zsh's C:
1351///
1352/// 1. **Raw body, not a re-deparse.** zsh compiles the trap body to an Eprog
1353///    and renders THAT back for the listing (`getpermtext`), so the text is
1354///    canonicalised. Every other shell echoes the string it was given:
1355///
1356///    ```text
1357///    $ trap 'printf a; printf b' INT; trap
1358///    zsh                    → trap -- $'printf a\nprintf b' INT
1359///    bash/dash/ash/ksh/mksh → trap -- 'printf a; printf b' <SIG>
1360///    ```
1361///
1362/// 2. **Quoting.** zsh and the Korn pair quote only when the body needs it, so
1363///    `trap ':' HUP` lists as `trap -- : HUP` there and as `trap -- ':' HUP`
1364///    in bash, dash, ash and `/bin/sh`. The escapes differ too — measured with
1365///    the bodies `printf 'a b'`, `'x`, `x'`, `a'b'c` and `a\b'`:
1366///
1367///    ```text
1368///    body        bash / /bin/sh   dash / ash        ksh93          mksh
1369///    printf 'a b' 'printf '\''a b'\''' 'printf '"'"'a b'"'" $'printf \'a b\'' 'printf '\''a b'\'
1370///    'x           ''\''x'         ''"'"'x'          $'\'x'         \''x'
1371///    x'           'x'\'''         'x'"'"            $'x\''         'x'\'
1372///    a\b'         'a\b'\'''       'a\b'"'"          $'a\\b\''      'a\b'\'
1373///    ```
1374///
1375///    bash keeps empty runs at BOTH ends; dash and ash drop a trailing empty
1376///    one but keep a leading one; ksh93 switches to ANSI-C `$'…'` as soon as
1377///    the body holds an apostrophe; mksh is byte-identical to zsh's
1378///    `quotedzputs`, empty-run trimming included.
1379///
1380/// 3. **`SIG`-prefixed names in bash only.** `bash -c "trap ':' HUP; trap"`
1381///    prints `trap -- ':' SIGHUP`, while dash, ash, `/bin/sh`, ksh93 and mksh
1382///    all print the bare `HUP`. bash leaves the pseudo-signals alone —
1383///    `EXIT`, `DEBUG`, `ERR` and `RETURN` print unprefixed — so only names
1384///    resolving to a real signal number are prefixed.
1385///
1386/// An empty body is `''` in every shell including zsh, so it needs no special
1387/// case beyond forcing the quotes.
1388pub fn trap_listing_line(signame: &str, body: &str) -> Option<String> {
1389    if !posix_faithful() {
1390        return None;
1391    }
1392    let quoted = if korn_mode() && !pdksh_family() && body.contains('\'') {
1393        // ksh93's ANSI-C form: backslash doubles, apostrophe is escaped.
1394        format!("$'{}'", body.replace('\\', "\\\\").replace('\'', "\\'"))
1395    } else if korn_mode() {
1396        // ksh93 without an apostrophe, and mksh always: quote only when
1397        // needed, exactly like zsh — but over the RAW body.
1398        crate::ported::utils::quotedzputs(body)
1399    } else if dash_strict() {
1400        // dash / ash close the quote, emit `"'"`, reopen — then drop a
1401        // TRAILING empty reopened run (a leading one is kept).
1402        let mut q = format!("'{}'", body.replace('\'', "'\"'\"'"));
1403        // An IGNORED trap has an empty body and lists as `trap -- '' SIG` in
1404        // every shell, so the trim must not eat the whole quoted string.
1405        if q.len() > 2 && q.ends_with("''") {
1406            q.truncate(q.len() - 2);
1407        }
1408        q
1409    } else {
1410        // bash and /bin/sh: `'` → `'\''`, no empty-run trimming at either end.
1411        format!("'{}'", body.replace('\'', "'\\''"))
1412    };
1413    let name = if bash_mode() && real_signal_name(signame) {
1414        format!("SIG{}", signame)
1415    } else {
1416        signame.to_string()
1417    };
1418    Some(format!("trap -- {} {}", quoted, name))
1419}
1420
1421/// True when `name` is a real signal (1..=SIGCOUNT) rather than one of the
1422/// pseudo-signals zsh keeps in the same table (`EXIT` at index 0, `ZERR` and
1423/// `DEBUG` above `SIGCOUNT` — `Src/signals.h:34-46`). Only real signals take
1424/// bash's `SIG` prefix.
1425fn real_signal_name(name: &str) -> bool {
1426    match crate::ported::jobs::getsigidx(name) {
1427        Some(idx) => idx >= 1 && idx <= crate::ported::signals_h::SIGCOUNT,
1428        None => false,
1429    }
1430}
1431
1432/// ksh93 lists `trap` entries in DESCENDING signal-number order, where zsh,
1433/// bash, dash, ash and mksh all walk the table upwards:
1434///
1435/// ```text
1436/// $ <shell> -c "trap ':' EXIT INT HUP USR1 TERM QUIT; trap"
1437/// ksh93                 zsh / bash / dash / ash / mksh
1438///   trap -- : USR1        trap -- : EXIT
1439///   trap -- : TERM        trap -- : HUP     (bash: SIGHUP, …)
1440///   trap -- : QUIT        trap -- : INT
1441///   trap -- : INT         trap -- : QUIT
1442///   trap -- : HUP         trap -- : TERM
1443///   trap -- : EXIT        trap -- : USR1
1444/// ```
1445///
1446/// True only for a bare `--ksh`; the pdksh line (`--mksh` / `--pdksh`) matches
1447/// zsh's ascending walk (`Src/builtin.c:7358` — `for (sig = 0;
1448/// sig < TRAPCOUNT; sig++)`), and so does every other mode.
1449#[inline]
1450pub fn trap_listing_descending() -> bool {
1451    korn_mode() && !pdksh_family()
1452}
1453
1454// ---------------------------------------------------------------------------
1455// `type` / `command -V` / `whence -v` on a shell function
1456// ---------------------------------------------------------------------------
1457
1458/// The verbose one-line description of a shell function in the emulated
1459/// shell's wording, or `None` to keep zsh's.
1460///
1461/// zsh appends the file the function was loaded from
1462/// (`Src/hashtable.c:927-941` — `" is a shell function"` then `" from "` and
1463/// the filename), which for a `-c` script is the shell's own name, so
1464/// `zsh -fc 'f() { :; }; type f'` prints `f is a shell function from zsh`.
1465/// No other shell says anything of the kind:
1466///
1467/// ```text
1468/// bash 5.3 → f is a function          (then the definition, see below)
1469/// /bin/sh  → f is a function          (then the definition)
1470/// ksh93    → f is a function
1471/// mksh     → f is a function
1472/// dash     → f is a shell function
1473/// ash      → f is a shell function
1474/// ```
1475pub fn function_whence_verbose(name: &str) -> Option<String> {
1476    if !posix_faithful() {
1477        return None;
1478    }
1479    if dash_strict() {
1480        Some(format!("{} is a shell function", name))
1481    } else {
1482        Some(format!("{} is a function", name))
1483    }
1484}
1485
1486/// True when the emulated shell follows its `type` / `command -V` header line
1487/// with the function's DEFINITION. bash does; ksh93, mksh, dash and ash do not:
1488///
1489/// ```text
1490/// $ bash -c 'f() { :; }; type f'      $ ksh -c 'f() { :; }; type f'
1491/// f is a function                     f is a function
1492/// f ()
1493/// {
1494///     :
1495/// }
1496/// ```
1497///
1498/// `/bin/sh` is bash on this platform and behaves the same, so it is included.
1499#[inline]
1500pub fn function_whence_prints_body() -> bool {
1501    posix_faithful() && !korn_mode() && !dash_strict()
1502}
1503
1504/// Private-use stand-in for a backslash-escaped `&` between
1505/// `strip_replacement_backslashes` and [`bash_patsub`].
1506const PATSUB_LITERAL_AMP: char = '\u{F8FE}';
1507
1508/// bash 5.2 `${v/pat/rep}` with `shopt patsub_replacement` (default on): an
1509/// unescaped `&` in the replacement stands for the matched text and `\&` is a
1510/// literal `&`. Identity outside `--bash` or with the shopt off.
1511pub fn bash_patsub(repl: String, matched: &str) -> String {
1512    if !bash_mode() || bash_shopt_get("patsub_replacement") != Some(true) {
1513        return repl;
1514    }
1515    if !repl.contains('&') && !repl.contains(PATSUB_LITERAL_AMP) {
1516        return repl;
1517    }
1518    let mut out = String::with_capacity(repl.len() + matched.len());
1519    for c in repl.chars() {
1520        match c {
1521            '&' => out.push_str(matched),
1522            PATSUB_LITERAL_AMP => out.push('&'),
1523            _ => out.push(c),
1524        }
1525    }
1526    out
1527}
1528
1529/// Render a stored function body as bash lists it (`declare -f` / `type`).
1530pub fn bash_function_listing(name: &str, body_src: Option<&str>) -> String {
1531    // Stored source re-parsed and deparsed, one statement per line (the same
1532    // rendering bin_trap uses for a trap body).
1533    let rendered = body_src
1534        .filter(|b| !b.is_empty())
1535        .and_then(|b| crate::ported::exec::parse_string(b, 1))
1536        .map(|prog| crate::ported::text::getpermtext(Box::new(prog), None, 0))
1537        .unwrap_or_default();
1538    bash_function_body(name, &rendered)
1539}
1540
1541/// Re-lay a deparsed function body in bash's `type` format.
1542///
1543/// bash re-prints the parsed function from its own AST (`make_command_string`):
1544/// the header is `NAME () ` then `{ ` — each with ONE trailing space —
1545/// statements are indented four spaces per level, and every line but the last
1546/// carries a trailing `;`.
1547///
1548/// ```text
1549/// $ bash -c 'f() { echo a; echo b; }; type f' | cat -e
1550/// f is a function$
1551/// f () $
1552/// { $
1553///     echo a;$
1554///     echo b$
1555/// }$
1556/// ```
1557///
1558/// `body_lines` is zsh's own rendering of the body (one statement per line,
1559/// one leading TAB per nesting level, keywords on their own lines).
1560/// `BashDeparse` re-lays `if`/`for`/`while`/`until`/`select`/`case`, `( )`,
1561/// `{ }` and nested function definitions in bash's layout; a body with a
1562/// shape it does not model (here-documents) falls back to the flat
1563/// one-line-per-statement layout.
1564pub fn bash_function_body(name: &str, body_lines: &str) -> String {
1565    let mut out = format!("{} () \n{{ \n", name);
1566    match BashDeparse::parse(body_lines) {
1567        Some(nodes) => out.push_str(&BashDeparse::render(&nodes, 1, true)),
1568        None => {
1569            let lines: Vec<&str> = body_lines.lines().collect();
1570            for (i, line) in lines.iter().enumerate() {
1571                let depth = line.chars().take_while(|c| *c == '\t').count();
1572                let text = line.trim_start_matches('\t');
1573                let last = i + 1 == lines.len();
1574                out.push_str(&" ".repeat(4 * (depth + 1)));
1575                out.push_str(text);
1576                out.push_str(if last { "\n" } else { ";\n" });
1577            }
1578        }
1579    }
1580    out.push_str("}\n");
1581    out
1582}
1583
1584/// bash expands braces BEFORE parameters (`x=; echo $x{,a}` -> `$x $xa`, both
1585/// empty and dropped), zsh after (`a`). For a tokenized command word that
1586/// carries both a brace list and an expansion, run `xpandbraces` on the source
1587/// text with every opaque span (`$name`, `${..}`, `$(..)`, `` `..` ``, quotes)
1588/// masked by a placeholder, then restore the spans in each alternative.
1589/// Returns the alternatives (empty unquoted ones dropped), or `None` when the
1590/// word needs no pre-splitting.
1591pub fn bash_presplit_braces(word: &str) -> Option<Vec<String>> {
1592    use crate::ported::zsh_h::{
1593        Bnull, Comma, Dnull, Inbrace, Inpar, Inparmath, Outbrace, Outpar, Outparmath, Qstring,
1594        Qtick, Snull, Stringg, Tick,
1595    };
1596    if !bash_mode()
1597        || !word.contains(Inbrace)
1598        || !word.contains(Comma)
1599        || !word.chars().any(|c| matches!(c, Stringg | Qstring | Tick | Qtick))
1600    {
1601        return None;
1602    }
1603    let ch: Vec<char> = word.chars().collect();
1604    let mut holes: Vec<String> = Vec::new();
1605    let mut masked = String::new();
1606    let mut hole = |text: String, masked: &mut String| {
1607        masked.push(char::from_u32(0xF0000 + holes.len() as u32).unwrap_or('\u{f0000}'));
1608        holes.push(text);
1609    };
1610    let mut i = 0;
1611    while i < ch.len() {
1612        let c = ch[i];
1613        // End (exclusive) of the span an opener at `i` opens.
1614        let end = match c {
1615            Snull | Dnull | Tick | Qtick => {
1616                (i + 1..ch.len()).find(|&j| ch[j] == c).map_or(ch.len(), |j| j + 1)
1617            }
1618            Bnull => (i + 2).min(ch.len()),
1619            Stringg | Qstring => match ch.get(i + 1) {
1620                Some(&Inbrace) | Some(&Inpar) | Some(&Inparmath) => {
1621                    let mut depth = 0i32;
1622                    let mut j = i + 1;
1623                    while j < ch.len() {
1624                        match ch[j] {
1625                            Inbrace | Inpar => depth += 1,
1626                            Inparmath => depth += 2,
1627                            Outbrace | Outpar => depth -= 1,
1628                            Outparmath => depth -= 2,
1629                            _ => {}
1630                        }
1631                        j += 1;
1632                        if depth <= 0 {
1633                            break;
1634                        }
1635                    }
1636                    j
1637                }
1638                Some(n) if n.is_ascii_alphanumeric() || *n == '_' => {
1639                    let mut j = i + 1;
1640                    while j < ch.len() && (ch[j].is_ascii_alphanumeric() || ch[j] == '_') {
1641                        j += 1;
1642                    }
1643                    j
1644                }
1645                Some(_) => i + 2,
1646                None => i + 1,
1647            },
1648            _ => i,
1649        };
1650        if end > i {
1651            hole(ch[i..end].iter().collect(), &mut masked);
1652            i = end;
1653        } else {
1654            masked.push(c);
1655            i += 1;
1656        }
1657    }
1658    let parts = crate::ported::glob::xpandbraces(&masked, false);
1659    if parts.len() == 1 && parts[0] == masked {
1660        return None;
1661    }
1662    Some(
1663        parts
1664            .into_iter()
1665            .filter(|p| !p.is_empty())
1666            .map(|p| {
1667                p.chars()
1668                    .map(|c| match (c as u32).checked_sub(0xF0000) {
1669                        Some(k) if (k as usize) < holes.len() => holes[k as usize].clone(),
1670                        _ => c.to_string(),
1671                    })
1672                    .collect()
1673            })
1674            .collect(),
1675    )
1676}
1677
1678/// One command of a function body, as bash's `print_cmd.c` lays it out.
1679enum BashNode {
1680    Simple(String),
1681    If { cond: String, then: Vec<BashNode>, els: Option<Vec<BashNode>>, closer: String },
1682    Loop { kw: &'static str, cond: String, body: Vec<BashNode>, closer: String },
1683    For { head: String, body: Vec<BashNode>, closer: String },
1684    Case { subject: String, arms: Vec<(String, Vec<BashNode>, String)>, closer: String },
1685    Subshell { body: Vec<BashNode>, closer: String },
1686    Group { body: Vec<BashNode>, closer: String },
1687    Function { name: String, body: Vec<BashNode> },
1688}
1689
1690impl BashNode {
1691    /// True when this statement, or anything nested in it, carries a here-document.
1692    fn has_heredoc(&self) -> bool {
1693        let any = |v: &[BashNode]| v.iter().any(BashNode::has_heredoc);
1694        match self {
1695            BashNode::Simple(s) => s.contains(HEREDOC_BODY),
1696            BashNode::If { then, els, .. } => any(then) || els.as_deref().is_some_and(any),
1697            BashNode::Loop { body, .. }
1698            | BashNode::For { body, .. }
1699            | BashNode::Subshell { body, .. }
1700            | BashNode::Group { body, .. }
1701            | BashNode::Function { body, .. } => any(body),
1702            BashNode::Case { arms, .. } => arms.iter().any(|(_, b, _)| any(b)),
1703        }
1704    }
1705}
1706
1707/// Re-lays zsh's deparsed function body (one command per line, keywords on
1708/// their own lines) in bash's layout: `if C; then` on one line, `for`/`do`
1709/// split, `case` arms with `;;` on its own line, `( a; b )` subshells.
1710/// Parses the keyword structure of the zsh text; any shape it does not
1711/// recognise yields `None` and the caller falls back to the flat layout.
1712struct BashDeparse {
1713    lines: Vec<String>,
1714    pos: usize,
1715}
1716
1717const ARM_END: char = '\u{1}';
1718/// Separates a here-document command from its body inside one deparse line.
1719const HEREDOC_BODY: char = '\u{2}';
1720
1721impl BashDeparse {
1722    fn parse(body_lines: &str) -> Option<Vec<BashNode>> {
1723        let mut lines: Vec<String> = Vec::new();
1724        let raw_lines: Vec<&str> = body_lines.lines().collect();
1725        let mut ri = 0;
1726        while ri < raw_lines.len() {
1727            let t = raw_lines[ri].trim();
1728            ri += 1;
1729            // A here-document: fold its body and terminator into the command
1730            // line behind HEREDOC_BODY so the layout pass treats it as one
1731            // statement (bash prints the body at column 0 after the command).
1732            let mut heredocs = t
1733                .match_indices("<<")
1734                .filter(|(i, _)| !t[*i..].starts_with("<<<") && !t[..*i].ends_with('<'));
1735            if let Some((at, _)) = heredocs.next() {
1736                if heredocs.next().is_some() {
1737                    return None;
1738                }
1739                let strip_tabs = t[at + 2..].starts_with('-');
1740                let after = t[at + 2..].trim_start_matches('-').trim_start();
1741                let word = after.split_whitespace().next()?;
1742                let delim: String = word.chars().filter(|c| !matches!(c, '"' | '\'' | '\\')).collect();
1743                let mut cmd = t.to_string();
1744                if word.starts_with('"') {
1745                    // bash re-prints a quoted delimiter with single quotes.
1746                    cmd = cmd.replacen(word, &format!("'{delim}'"), 1);
1747                }
1748                let mut body: Vec<String> = Vec::new();
1749                loop {
1750                    let l = *raw_lines.get(ri)?;
1751                    ri += 1;
1752                    let l = if strip_tabs { l.trim_start_matches('\t') } else { l };
1753                    body.push(l.to_string());
1754                    if l.trim_end() == delim {
1755                        break;
1756                    }
1757                }
1758                lines.push(format!("{cmd}{HEREDOC_BODY}{}", body.join("\n")));
1759                continue;
1760            }
1761            if t.ends_with('{') && t != "{" && !t.ends_with(" () {") {
1762                return None;
1763            }
1764            match [" ;;&", " ;&", " ;;"].iter().find(|e| t.ends_with(**e)) {
1765                Some(e) => {
1766                    lines.push(t[..t.len() - e.len()].to_string());
1767                    lines.push(format!("{ARM_END}{}", e.trim()));
1768                }
1769                None => lines.push(t.to_string()),
1770            }
1771        }
1772        let mut p = BashDeparse { lines, pos: 0 };
1773        let nodes = p.block()?;
1774        (p.pos == p.lines.len()).then_some(nodes)
1775    }
1776
1777    fn peek(&self) -> Option<&str> {
1778        self.lines.get(self.pos).map(String::as_str)
1779    }
1780
1781    fn first_word(line: &str) -> &str {
1782        line.split_whitespace().next().unwrap_or("")
1783    }
1784
1785    fn is_stop(line: &str) -> bool {
1786        line.starts_with(ARM_END)
1787            || line.starts_with('}')
1788            || line.starts_with(')')
1789            || matches!(Self::first_word(line), "fi" | "done" | "esac" | "else" | "elif" | "then" | "do")
1790    }
1791
1792    /// Statements up to (not including) the next stop line.
1793    fn block(&mut self) -> Option<Vec<BashNode>> {
1794        let mut out = Vec::new();
1795        while let Some(line) = self.peek() {
1796            if Self::is_stop(line) {
1797                break;
1798            }
1799            out.push(self.node()?);
1800        }
1801        Some(out)
1802    }
1803
1804    fn expect_word(&mut self, w: &str) -> Option<()> {
1805        (self.peek()? == w).then(|| self.pos += 1)
1806    }
1807
1808    /// A closer line (`fi`, `done > f`, `}`, `)`): returns its trailing redirections.
1809    fn closer(&mut self, word: &str) -> Option<String> {
1810        let line = self.peek()?.to_string();
1811        let rest = line.strip_prefix(word)?;
1812        if !rest.is_empty() && !rest.starts_with(' ') {
1813            return None;
1814        }
1815        self.pos += 1;
1816        Some(rest.to_string())
1817    }
1818
1819    fn node(&mut self) -> Option<BashNode> {
1820        let line = self.peek()?.to_string();
1821        self.pos += 1;
1822        if let Some(cond) = line.strip_prefix("if ") {
1823            return self.if_rest(cond.to_string());
1824        }
1825        for kw in ["while", "until"] {
1826            if let Some(cond) = line.strip_prefix(&format!("{kw} ")) {
1827                self.expect_word("do")?;
1828                let body = self.block()?;
1829                let closer = self.closer("done")?;
1830                return Some(BashNode::Loop {
1831                    kw: if kw == "while" { "while" } else { "until" },
1832                    cond: cond.to_string(),
1833                    body,
1834                    closer,
1835                });
1836            }
1837        }
1838        if line.starts_with("for ((") && line.ends_with(" do") {
1839            let body = self.block()?;
1840            let closer = self.closer("done")?;
1841            return Some(BashNode::For { head: line[..line.len() - 3].to_string(), body, closer });
1842        }
1843        if line.starts_with("for ") || line.starts_with("select ") {
1844            self.expect_word("do")?;
1845            let body = self.block()?;
1846            let closer = self.closer("done")?;
1847            return Some(BashNode::For { head: line, body, closer });
1848        }
1849        if let Some(rest) = line.strip_prefix("case ") {
1850            let subject = rest.strip_suffix(" in")?.to_string();
1851            let mut arms = Vec::new();
1852            while self.peek()?.starts_with('(') {
1853                let arm = self.peek()?.to_string();
1854                let close = arm.find(") ").or_else(|| arm.ends_with(')').then(|| arm.len() - 1))?;
1855                let pat = arm[1..close].to_string();
1856                let first = arm[close + 1..].trim().to_string();
1857                if first.is_empty() {
1858                    self.pos += 1;
1859                } else {
1860                    self.lines[self.pos] = first;
1861                }
1862                let body = self.block()?;
1863                let term = self.peek()?.strip_prefix(ARM_END)?.to_string();
1864                self.pos += 1;
1865                arms.push((pat, body, term));
1866            }
1867            let closer = self.closer("esac")?;
1868            return Some(BashNode::Case { subject, arms, closer });
1869        }
1870        if let Some(name) = line.strip_suffix(" () {") {
1871            let body = self.block()?;
1872            self.closer("}")?;
1873            return Some(BashNode::Function { name: name.to_string(), body });
1874        }
1875        if line == "(" || line == "{" {
1876            let body = self.block()?;
1877            let closer = self.closer(if line == "(" { ")" } else { "}" })?;
1878            return Some(if line == "(" {
1879                BashNode::Subshell { body, closer }
1880            } else {
1881                BashNode::Group { body, closer }
1882            });
1883        }
1884        Some(BashNode::Simple(line))
1885    }
1886
1887    fn if_rest(&mut self, cond: String) -> Option<BashNode> {
1888        self.expect_word("then")?;
1889        let then = self.block()?;
1890        let mut els = None;
1891        match self.peek() {
1892            Some(l) if l.starts_with("elif ") => {
1893                // bash prints `elif` as `else` plus a nested `if`.
1894                let nested = l["elif ".len()..].to_string();
1895                self.pos += 1;
1896                els = Some(vec![self.if_rest(nested)?]);
1897                return Some(BashNode::If { cond, then, els, closer: String::new() });
1898            }
1899            Some("else") => {
1900                self.pos += 1;
1901                els = Some(self.block()?);
1902            }
1903            _ => {}
1904        }
1905        let closer = self.closer("fi")?;
1906        Some(BashNode::If { cond, then, els, closer })
1907    }
1908
1909    /// Lay `nodes` out at `indent` levels; `bare_last` drops the `;` after
1910    /// the final command (function, `{ }`, subshell and case-arm bodies).
1911    fn render(nodes: &[BashNode], indent: usize, bare_last: bool) -> String {
1912        let mut out = String::new();
1913        for (i, n) in nodes.iter().enumerate() {
1914            // bash prints no `;` after a statement that holds a here-document:
1915            // the body's own newline ends it.
1916            let term = if (bare_last && i + 1 == nodes.len()) || n.has_heredoc() { "" } else { ";" };
1917            out.push_str(&Self::render_one(n, indent, term));
1918        }
1919        out
1920    }
1921
1922    fn render_one(n: &BashNode, indent: usize, term: &str) -> String {
1923        let ind = "    ".repeat(indent);
1924        match n {
1925            BashNode::Simple(s) if s.contains(HEREDOC_BODY) => {
1926                // bash: command, body and terminator at column 0, then a blank line.
1927                let (cmd, body) = s.split_once(HEREDOC_BODY).unwrap_or((s, ""));
1928                format!("{ind}{cmd}\n{body}\n\n")
1929            }
1930            BashNode::Simple(s) => {
1931                let term = if s.ends_with('&') { "" } else { term };
1932                format!("{ind}{s}{term}\n")
1933            }
1934            BashNode::If { cond, then, els, closer } => {
1935                let mut o = format!("{ind}if {cond}; then\n{}", Self::render(then, indent + 1, false));
1936                if let Some(e) = els {
1937                    o.push_str(&format!("{ind}else\n{}", Self::render(e, indent + 1, false)));
1938                }
1939                o.push_str(&format!("{ind}fi{closer}{term}\n"));
1940                o
1941            }
1942            BashNode::Loop { kw, cond, body, closer } => format!(
1943                "{ind}{kw} {cond}; do\n{}{ind}done{closer}{term}\n",
1944                Self::render(body, indent + 1, false)
1945            ),
1946            BashNode::For { head, body, closer } => {
1947                let semi = if head.starts_with("for ((") { "" } else { ";" };
1948                format!(
1949                    "{ind}{head}{semi}\n{ind}do\n{}{ind}done{closer}{term}\n",
1950                    Self::render(body, indent + 1, false)
1951                )
1952            }
1953            BashNode::Case { subject, arms, closer } => {
1954                let mut o = format!("{ind}case {subject} in \n");
1955                for (pat, body, t) in arms {
1956                    o.push_str(&format!(
1957                        "{ind}    {pat})\n{}{ind}    {t}\n",
1958                        Self::render(body, indent + 2, true)
1959                    ));
1960                }
1961                o.push_str(&format!("{ind}esac{closer}{term}\n"));
1962                o
1963            }
1964            BashNode::Subshell { body, closer } => {
1965                let inner = Self::render(body, indent, true);
1966                let inner = inner.trim_end_matches('\n');
1967                let inner = inner.strip_prefix(ind.as_str()).unwrap_or(inner);
1968                format!("{ind}( {inner} ){closer}{term}\n")
1969            }
1970            BashNode::Function { name, body } => format!(
1971                "{ind}function {name} () \n{ind}{{ \n{}{ind}}}{term}\n",
1972                Self::render(body, indent + 1, true)
1973            ),
1974            BashNode::Group { body, closer } => {
1975                format!("{ind}{{ \n{}{ind}}}{closer}{term}\n", Self::render(body, indent + 1, true))
1976            }
1977        }
1978    }
1979}
1980
1981/// True when replacing the positional parameters (`set -- …`) must also
1982/// rewind `getopts`, and when a write to `$OPTIND` must NOT.
1983///
1984/// dash and ash key `getopts` off `shellparam.optind` / `shellparam.optoff`,
1985/// which `setparam()` resets and an `$OPTIND` assignment does not reach — the
1986/// exact opposite of bash and the Korn shells:
1987///
1988/// ```text
1989/// $ <shell> -c 'getopts "ab" o -a; set -- -b -a; getopts "ab" o;
1990///               printf "%s %s\n" "$o" "$OPTIND"'
1991/// dash/ash → b 2        bash/ksh/mksh → a 3        zsh → a 2
1992/// ```
1993///
1994/// Only the `set --` half is acted on here; the `$OPTIND`-write half stays a
1995/// pinned gap (see the ignored parity test) because zsh routes the parse
1996/// index through the parameter itself.
1997#[inline]
1998pub fn getopts_reset_on_set_positional() -> bool {
1999    posix_faithful() && dash_strict()
2000}
2001
2002/// Rewind the `getopts` cursor recorded by [`getopts_optind_report`], so a
2003/// later `$OPTIND` read is not translated against a stale pair.
2004pub fn getopts_forget_reported() {
2005    GETOPTS_REPORTED.store(-1, Ordering::Relaxed);
2006    GETOPTS_INTERNAL.store(-1, Ordering::Relaxed);
2007}
2008
2009// ===========================================================================
2010// bash `${parameter@operator}` parameter transformations
2011// ===========================================================================
2012//
2013// !!! WARNING: RUST-ONLY HELPER !!!
2014//
2015// Nothing below has a zsh C counterpart, so nothing below carries a `// c:NNN`
2016// citation.  `${var@Q}`, `${var@A}`, `${var@K}`, `${var@k}`, `${var@a}`,
2017// `${var@E}` and the `@U` / `@L` / `@u` case operators are bash extensions;
2018// zsh answers "bad substitution" for every one of them:
2019//
2020// ```text
2021// $ zsh -fc 'v=abc; echo "${v@A}"'
2022// zsh:1: bad substitution
2023// ```
2024//
2025// THE REFERENCE IS BASH ITSELF (GNU bash 5.3.15), not `Src/subst.c`.  Every
2026// rule encoded here was established by running bash and recording its output;
2027// the transcripts are pinned as parity tests in
2028// `tests/parity/bash_param_transform.rs`.  Callers must gate on
2029// [`bash_mode`] so `--zsh` keeps answering "bad substitution".
2030
2031/// bash's `isprint()` test, as `ansic_shouldquote` (lib/sh/strtrans.c) uses
2032/// it: C0 controls and DEL are unprintable, everything else — including
2033/// non-ASCII — is printable.  `${v@Q}` on `héllo` answers `'héllo'`, not a
2034/// `$'…'` byte dump, which is what fixes the boundary here.
2035fn bash_isprint(c: char) -> bool {
2036    let u = c as u32;
2037    !(u < 0x20 || u == 0x7f)
2038}
2039
2040/// bash `ansic_shouldquote` — true when the value holds a character that
2041/// cannot survive `'…'` quoting and so forces the `$'…'` form.
2042pub fn bash_ansic_shouldquote(s: &str) -> bool {
2043    s.chars().any(|c| !bash_isprint(c))
2044}
2045
2046/// bash `ansic_quote` — render `s` as a `$'…'` literal.
2047///
2048/// ```text
2049/// $ bash -c $'v=$\'\\a\\v\\b\\f\\n\\r\\t\\e\\001\\177\'; printf "<%s>" "${v@Q}"'
2050/// <$'\a\v\b\f\n\r\t\E\001\177'>
2051/// ```
2052pub fn bash_ansic_quote(s: &str) -> String {
2053    let mut out = String::with_capacity(s.len() + 4);
2054    out.push_str("$'");
2055    for c in s.chars() {
2056        match c {
2057            '\u{7}' => out.push_str("\\a"),
2058            '\u{8}' => out.push_str("\\b"),
2059            '\u{b}' => out.push_str("\\v"),
2060            '\u{c}' => out.push_str("\\f"),
2061            '\n' => out.push_str("\\n"),
2062            '\r' => out.push_str("\\r"),
2063            '\t' => out.push_str("\\t"),
2064            '\u{1b}' => out.push_str("\\E"),
2065            '\\' => out.push_str("\\\\"),
2066            '\'' => out.push_str("\\'"),
2067            _ if bash_isprint(c) => out.push(c),
2068            // Unprintable and not one of the named escapes: bash emits one
2069            // three-digit octal escape per BYTE (`\001`, `\177`).
2070            _ => {
2071                let mut buf = [0u8; 4];
2072                for b in c.encode_utf8(&mut buf).as_bytes() {
2073                    out.push_str(&format!("\\{:03o}", b));
2074                }
2075            }
2076        }
2077    }
2078    out.push('\'');
2079    out
2080}
2081
2082/// bash `sh_single_quote` — `'…'`, with an embedded `'` written `'\''`.
2083///
2084/// ```text
2085/// $ bash -c $'v="it\'s"; printf "<%s>" "${v@Q}"'
2086/// <'it'\''s'>
2087/// ```
2088pub fn bash_single_quote(s: &str) -> String {
2089    let mut out = String::with_capacity(s.len() + 2);
2090    out.push('\'');
2091    for c in s.chars() {
2092        if c == '\'' {
2093            out.push_str("'\\''");
2094        } else {
2095            out.push(c);
2096        }
2097    }
2098    out.push('\'');
2099    out
2100}
2101
2102/// bash `sh_double_quote` — `"…"`, backslash-escaping only the four
2103/// characters that stay live inside double quotes (`CBSDQUOTE`).
2104pub fn bash_double_quote(s: &str) -> String {
2105    let mut out = String::with_capacity(s.len() + 2);
2106    out.push('"');
2107    for c in s.chars() {
2108        if matches!(c, '"' | '\\' | '$' | '`') {
2109            out.push('\\');
2110        }
2111        out.push(c);
2112    }
2113    out.push('"');
2114    out
2115}
2116
2117/// bash `sh_quote_reusable` — what `${v@Q}` and the SCALAR form of `${v@A}`
2118/// emit: `$'…'` when the value needs it, otherwise `'…'`.
2119pub fn bash_quote_reusable(s: &str) -> String {
2120    if bash_ansic_shouldquote(s) {
2121        bash_ansic_quote(s)
2122    } else {
2123        bash_single_quote(s)
2124    }
2125}
2126
2127/// The form bash uses for one ELEMENT inside a `name=(…)` assignment or a
2128/// `@K` key/value pair: `$'…'` when unprintable, otherwise DOUBLE quotes.
2129///
2130/// ```text
2131/// $ bash -c 'a=(x "y z"); printf "<%s>" "${a[*]@K}"'
2132/// <0 "x" 1 "y z">
2133/// ```
2134pub fn bash_quote_element(s: &str) -> String {
2135    if bash_ansic_shouldquote(s) {
2136        bash_ansic_quote(s)
2137    } else {
2138        bash_double_quote(s)
2139    }
2140}
2141
2142/// bash `sh_contains_shell_metas` — decides whether an associative-array KEY
2143/// has to be quoted inside `@A` / `@K` output.  Measured char-by-char against
2144/// bash 5.3: `- . + , : / @ % = # ~` stay bare mid-word, the rest quote.
2145pub fn bash_contains_shell_metas(s: &str) -> bool {
2146    let b: Vec<char> = s.chars().collect();
2147    for (i, &c) in b.iter().enumerate() {
2148        match c {
2149            ' ' | '\t' | '\n' => return true,
2150            '\'' | '"' | '\\' => return true,
2151            '|' | '&' | ';' => return true,
2152            '(' | ')' | '<' | '>' => return true,
2153            '!' | '{' | '}' => return true,
2154            '*' | '[' | '?' | ']' => return true,
2155            '^' => return true,
2156            '$' | '`' => return true,
2157            // Tilde expansion only fires at the start of a word or right
2158            // after `=` / `:`, so a mid-word `~` needs no quoting.
2159            '~' => {
2160                if i == 0 || b[i - 1] == '=' || b[i - 1] == ':' {
2161                    return true;
2162                }
2163            }
2164            // A comment only starts a word.
2165            '#' => {
2166                if i == 0 {
2167                    return true;
2168                }
2169            }
2170            _ => {}
2171        }
2172    }
2173    false
2174}
2175
2176/// The form bash uses for an associative-array key inside `@A` / `@K`.
2177pub fn bash_quote_assoc_key(s: &str) -> String {
2178    if bash_ansic_shouldquote(s) {
2179        bash_ansic_quote(s)
2180    } else if bash_contains_shell_metas(s) {
2181        bash_double_quote(s)
2182    } else {
2183        s.to_string()
2184    }
2185}
2186
2187/// The shape a `${var@A}` / `@K` / `@k` transformation is looking at.
2188///
2189/// `Indexed` carries the LIVE indices, so a sparse bash array recreates as
2190/// `declare -a a=([0]="x" [5]="z")` rather than renumbering its holes away.
2191pub enum BashParamShape<'a> {
2192    /// The reference resolved to nothing at all.
2193    Unset,
2194    /// A scalar, or an array/assoc reduced to one element by a subscript.
2195    Scalar(&'a str),
2196    /// A whole indexed array: `(index, value)` in index order.
2197    Indexed(&'a [(usize, String)]),
2198    /// A whole associative array: `(key, value)`.
2199    Assoc(&'a [(String, String)]),
2200}
2201
2202/// bash `${var@A}` — the assignment statement that would recreate the
2203/// parameter with its attributes.
2204///
2205/// `attrs` is the attribute-letter string [`bash_attr_letters`] produces; an
2206/// empty one means bash omits the `declare` word entirely.
2207///
2208/// ```text
2209/// $ bash -c 'v=abc;          printf "<%s>" "${v@A}"'    → <v='abc'>
2210/// $ bash -c 'declare -i n=5; printf "<%s>" "${n@A}"'    → <declare -i n='5'>
2211/// $ bash -c 'a=(x "y z");    printf "<%s>" "${a[*]@A}"' → <declare -a a=([0]="x" [1]="y z")>
2212/// $ bash -c 'declare -A h=([q]=1); printf "<%s>" "${h[*]@A}"'
2213///                                                       → <declare -A h=([q]="1" )>
2214/// ```
2215pub fn bash_assignment_string(name: &str, attrs: &str, shape: &BashParamShape) -> String {
2216    let decl = if attrs.is_empty() {
2217        String::new()
2218    } else {
2219        format!("declare -{} ", attrs)
2220    };
2221    match shape {
2222        // An unset reference recreates as a bare declaration with no `=`.
2223        BashParamShape::Unset => format!("{}{}", decl, name),
2224        BashParamShape::Scalar(v) => format!("{}{}={}", decl, name, bash_quote_reusable(v)),
2225        BashParamShape::Indexed(items) => {
2226            let body = items
2227                .iter()
2228                .map(|(i, v)| format!("[{}]={}", i, bash_quote_element(v)))
2229                .collect::<Vec<_>>()
2230                .join(" ");
2231            format!("{}{}=({})", decl, name, body)
2232        }
2233        // bash's assoc writer emits a trailing space after EVERY pair, so the
2234        // body ends `… )` — reproduced verbatim.  Keys are bracketed here,
2235        // unlike in the `@K` pair form.
2236        BashParamShape::Assoc(items) => {
2237            let mut body = String::new();
2238            for (k, v) in items.iter() {
2239                body.push('[');
2240                body.push_str(&bash_quote_assoc_key(k));
2241                body.push_str("]=");
2242                body.push_str(&bash_quote_element(v));
2243                body.push(' ');
2244            }
2245            format!("{}{}=({})", decl, name, body)
2246        }
2247    }
2248}
2249
2250/// bash `${var@K}` — like `@Q` for a scalar; for an array, `key "value"`
2251/// pairs in ONE word.
2252///
2253/// ```text
2254/// $ bash -c 'a=(x "y z");          printf "<%s>" "${a[@]@K}"' → <0 "x" 1 "y z">
2255/// $ bash -c 'declare -A h=([q]=1); printf "<%s>" "${h[*]@K}"' → <q "1" >
2256/// ```
2257pub fn bash_kvpair_string(shape: &BashParamShape) -> String {
2258    match shape {
2259        BashParamShape::Unset => String::new(),
2260        BashParamShape::Scalar(v) => bash_quote_reusable(v),
2261        BashParamShape::Indexed(items) => items
2262            .iter()
2263            .map(|(i, v)| format!("{} {}", i, bash_quote_element(v)))
2264            .collect::<Vec<_>>()
2265            .join(" "),
2266        BashParamShape::Assoc(items) => {
2267            let mut out = String::new();
2268            for (k, v) in items.iter() {
2269                out.push_str(&bash_quote_assoc_key(k));
2270                out.push(' ');
2271                out.push_str(&bash_quote_element(v));
2272                out.push(' ');
2273            }
2274            out
2275        }
2276    }
2277}
2278
2279/// bash `${var@k}` — the `@K` pairs with NO quoting, each key and each value
2280/// its own word.
2281///
2282/// ```text
2283/// $ bash -c 'a=(x "y z"); printf "<%s>" "${a[@]@k}"' → <0><x><1><y z>
2284/// ```
2285pub fn bash_kv_words(shape: &BashParamShape) -> Vec<String> {
2286    match shape {
2287        BashParamShape::Unset => Vec::new(),
2288        BashParamShape::Scalar(v) => vec![bash_quote_reusable(v)],
2289        BashParamShape::Indexed(items) => items
2290            .iter()
2291            .flat_map(|(i, v)| [i.to_string(), v.clone()])
2292            .collect(),
2293        BashParamShape::Assoc(items) => items
2294            .iter()
2295            .flat_map(|(k, v)| [k.clone(), v.clone()])
2296            .collect(),
2297    }
2298}
2299
2300/// Split a `${var[@]@A}` result into words the way bash does.
2301///
2302/// bash hands the assignment statement back through IFS field splitting, but
2303/// with quote regions skipped, so `declare -a a=([0]="x" [1]="y z")` becomes
2304/// three words and the parenthesised body stays whole.  Measured:
2305///
2306/// ```text
2307/// $ bash -c 'a=(x "y z");            printf "<%s>" "${a[@]@A}"'
2308/// <declare><-a><a=([0]="x" [1]="y z")>
2309/// $ bash -c 'a=(x "y z"); IFS=":";   printf "<%s>" "${a[@]@A}"'
2310/// <declare -a a=([0]="x" [1]="y z")>
2311/// $ bash -c 'a=(x "y z"); IFS="a";   printf "<%s>" "${a[@]@A}"'
2312/// <decl><re ->< ><=([0]="x" [1]="y z")>
2313/// ```
2314///
2315/// An IFS that is unset OR empty falls back to `" \t\n"` — the empty case is
2316/// bash's own, and is why `IFS=""` above still splits.
2317///
2318/// A parenthesised region is protected too, which is what keeps the whole
2319/// `a=(…)` body one word even though its interior is full of spaces:
2320///
2321/// ```text
2322/// $ bash -c 'a=(x y); IFS="0"; printf "<%s>" "${a[@]@A}"'
2323/// <declare -a a=([0]="x" [1]="y")>          # the `0`s live inside the parens
2324/// $ bash -c 'a=(x y); IFS="[";  printf "<%s>" "${a[@]@A}"'
2325/// <declare -a a=([0]="x" [1]="y")>
2326/// ```
2327pub fn bash_split_ifs_quote_aware(s: &str, ifs: Option<&str>) -> Vec<String> {
2328    let seps: Vec<char> = match ifs {
2329        Some(v) if !v.is_empty() => v.chars().collect(),
2330        _ => vec![' ', '\t', '\n'],
2331    };
2332    let is_sep = |c: char| seps.contains(&c);
2333    let is_ws_sep = |c: char| is_sep(c) && (c == ' ' || c == '\t' || c == '\n');
2334
2335    let cs: Vec<char> = s.chars().collect();
2336    let mut out: Vec<String> = Vec::new();
2337    let mut cur = String::new();
2338    let mut started = false;
2339    let mut depth = 0usize; // unquoted `(` … `)` nesting — never split inside
2340    let mut i = 0usize;
2341    while i < cs.len() {
2342        let c = cs[i];
2343        if c == '(' {
2344            depth += 1;
2345            cur.push(c);
2346            started = true;
2347            i += 1;
2348            continue;
2349        }
2350        if c == ')' {
2351            depth = depth.saturating_sub(1);
2352            cur.push(c);
2353            started = true;
2354            i += 1;
2355            continue;
2356        }
2357        if c == '\'' {
2358            // Single-quoted region (also the tail of `$'…'`): nothing inside
2359            // it splits and no backslash ends it early.
2360            cur.push(c);
2361            started = true;
2362            i += 1;
2363            while i < cs.len() && cs[i] != '\'' {
2364                cur.push(cs[i]);
2365                i += 1;
2366            }
2367            if i < cs.len() {
2368                cur.push('\'');
2369                i += 1;
2370            }
2371            continue;
2372        }
2373        if c == '"' {
2374            cur.push(c);
2375            started = true;
2376            i += 1;
2377            while i < cs.len() && cs[i] != '"' {
2378                if cs[i] == '\\' && i + 1 < cs.len() {
2379                    cur.push(cs[i]);
2380                    i += 1;
2381                }
2382                cur.push(cs[i]);
2383                i += 1;
2384            }
2385            if i < cs.len() {
2386                cur.push('"');
2387                i += 1;
2388            }
2389            continue;
2390        }
2391        if c == '\\' && i + 1 < cs.len() {
2392            cur.push(c);
2393            cur.push(cs[i + 1]);
2394            started = true;
2395            i += 2;
2396            continue;
2397        }
2398        if is_sep(c) && depth == 0 {
2399            if started {
2400                out.push(std::mem::take(&mut cur));
2401                started = false;
2402            }
2403            if is_ws_sep(c) {
2404                // Runs of IFS whitespace collapse into one separator.
2405                while i + 1 < cs.len() && is_ws_sep(cs[i + 1]) {
2406                    i += 1;
2407                }
2408            }
2409            i += 1;
2410            continue;
2411        }
2412        cur.push(c);
2413        started = true;
2414        i += 1;
2415    }
2416    if started {
2417        out.push(cur);
2418    }
2419    out
2420}
2421
2422/// bash `${var@a}` — the parameter's attribute flags as a letter string, and
2423/// the same letters `${var@A}` puts after `declare -`.
2424///
2425/// The ORDER is bash's, measured:
2426///
2427/// ```text
2428/// $ bash -c 'declare -air z=(1 2); printf "<%s>" "${z@a}"'  → <air>
2429/// $ bash -c 'declare -xi  q=3;     printf "<%s>" "${q@a}"'  → <ix>
2430/// $ bash -c 'declare -A   h; h[x]=1; printf "<%s>" "${h[@]@a}"' → <A>
2431/// $ bash -c 'v=abc;                printf "<%s>" "${v@a}"'  → <>
2432/// ```
2433pub fn bash_attr_letters(flags: u32) -> String {
2434    use crate::ported::zsh_h::{
2435        PM_ARRAY, PM_EXPORTED, PM_HASHED, PM_INTEGER, PM_LOWER, PM_READONLY, PM_UPPER,
2436    };
2437    let mut attrs = String::new();
2438    if flags & PM_HASHED != 0 {
2439        attrs.push('A');
2440    } else if flags & PM_ARRAY != 0 {
2441        attrs.push('a');
2442    }
2443    if flags & PM_INTEGER != 0 {
2444        attrs.push('i');
2445    }
2446    if flags & PM_READONLY != 0 {
2447        attrs.push('r');
2448    }
2449    if flags & PM_EXPORTED != 0 {
2450        attrs.push('x');
2451    }
2452    if flags & PM_UPPER != 0 {
2453        attrs.push('u');
2454    }
2455    if flags & PM_LOWER != 0 {
2456        attrs.push('l');
2457    }
2458    attrs
2459}
2460
2461/// Rebuild the scalar `val` after a bash `${var@OP}` transformation ran
2462/// ELEMENT-WISE over an array reference.
2463///
2464/// A `"${a[*]…}"` reference was already collapsed to one word before the
2465/// transform ran, so its result has to be re-joined with the same separator
2466/// (`$IFS[0]`, or an explicit `(j:X:)`); a `"${a[@]…}"` one still splats from
2467/// `split_parts` and only needs a placeholder join, which mirrors what the
2468/// `casmod` arm in `subst.rs` does.
2469///
2470/// ```text
2471/// $ bash -c 'a=(x "y z"); IFS=":"; printf "<%s>" "${a[*]@Q}"'  → <'x':'y z'>
2472/// ```
2473pub fn bash_rejoin_elems(parts: &[String], dq_collapsed: bool, sep: Option<&str>) -> String {
2474    if dq_collapsed {
2475        crate::ported::utils::sepjoin(parts, sep)
2476    } else {
2477        parts.join(" ")
2478    }
2479}
2480
2481/// The raw `PM_*` flag word of a named parameter, or 0 when it has none.
2482/// Feeds [`bash_attr_letters`] for `${v@a}` and `${v@A}`.
2483pub fn bash_param_flags(name: &str) -> u32 {
2484    crate::ported::params::paramtab()
2485        .read()
2486        .ok()
2487        .and_then(|t| t.get(name).map(|p| p.node.flags as u32))
2488        .unwrap_or(0)
2489}
2490
2491/// ksh93's `printf %q` quoting of one argument.
2492///
2493/// !!! EMULATION-ONLY (no C counterpart) !!! zsh backslash-escapes
2494/// (`a\ b`); ksh93u+m wraps in single quotes (`'a b'`), switches to the
2495/// ANSI-C `$'…'` form when the argument holds a control character or an
2496/// apostrophe, and leaves `a=b`, `a!b`, `a,b`, `a%b`, `a+b`, `a@b`, `a^b`
2497/// and non-ASCII text bare. Measured against ksh93u+m 1.0.10 with every
2498/// ASCII punctuation character between two letters, plus leading `=`.
2499pub fn ksh93_printf_q(arg: &str) -> String {
2500    if arg.is_empty() {
2501        return "''".to_string();
2502    }
2503    if arg.chars().any(|c| c.is_control()) || arg.contains('\'') {
2504        let mut out = String::from("$'");
2505        for c in arg.chars() {
2506            match c {
2507                '\\' => out.push_str("\\\\"),
2508                '\'' => out.push_str("\\'"),
2509                '\t' => out.push_str("\\t"),
2510                '\n' => out.push_str("\\n"),
2511                '\r' => out.push_str("\\r"),
2512                c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
2513                c => out.push(c),
2514            }
2515        }
2516        out.push('\'');
2517        return out;
2518    }
2519    const SPECIAL: &str = " \"#$&()*;<>?[\\]`{|}~";
2520    if arg.starts_with('=') || arg.chars().any(|c| SPECIAL.contains(c)) {
2521        return format!("'{arg}'");
2522    }
2523    arg.to_string()
2524}
2525
2526/// ksh93 `print` / `echo -e` backslash escapes, as measured on
2527/// /opt/homebrew/bin/ksh (93u+m). The set is narrower than zsh's
2528/// GETKEYS_PRINT/ECHO: `\a \b \E \f \n \r \t \v \\`, `\0` plus up to three
2529/// octal digits, and `\u` plus exactly four hex digits. `\c` ends the output
2530/// (the returned flag is true: drop the rest and the newline). Everything
2531/// else — `\e`, `\x41`, `\101`, `\1`, `\U…`, `\"`, `\q`, a trailing `\` —
2532/// keeps its backslash. Bytes >= 0x80 are metafied like getkeystring's.
2533///
2534/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2535pub fn ksh93_print_escapes(s: &str) -> (String, bool) {
2536    let chars: Vec<char> = s.chars().collect();
2537    let mut out = String::with_capacity(s.len());
2538    let mut i = 0;
2539    while i < chars.len() {
2540        let c = chars[i];
2541        if c != '\\' || i + 1 >= chars.len() {
2542            out.push(c);
2543            i += 1;
2544            continue;
2545        }
2546        let simple = match chars[i + 1] {
2547            'a' => Some('\x07'),
2548            'b' => Some('\x08'),
2549            'E' => Some('\x1b'),
2550            'f' => Some('\x0c'),
2551            'n' => Some('\n'),
2552            'r' => Some('\r'),
2553            't' => Some('\t'),
2554            'v' => Some('\x0b'),
2555            '\\' => Some('\\'),
2556            _ => None,
2557        };
2558        if let Some(ch) = simple {
2559            out.push(ch);
2560            i += 2;
2561        } else if chars[i + 1] == 'c' {
2562            return (out, true);
2563        } else if chars[i + 1] == '0' {
2564            let digits: Vec<u32> = chars[i + 2..]
2565                .iter()
2566                .take(3)
2567                .map_while(|d| d.to_digit(8))
2568                .collect();
2569            let byte = (digits.iter().fold(0, |acc, d| acc * 8 + d) & 0xff) as u8;
2570            if byte < 0x80 {
2571                out.push(byte as char);
2572            } else {
2573                out.push('\u{83}'); // Meta
2574                out.push(char::from(byte ^ 32));
2575            }
2576            i += 2 + digits.len();
2577        } else if chars[i + 1] == 'u'
2578            && chars.len() >= i + 6
2579            && chars[i + 2..i + 6].iter().all(|d| d.is_ascii_hexdigit())
2580        {
2581            let hex: String = chars[i + 2..i + 6].iter().collect();
2582            out.push(char::from_u32(u32::from_str_radix(&hex, 16).unwrap_or(0xfffd)).unwrap_or('\u{fffd}'));
2583            i += 6;
2584        } else {
2585            out.push('\\');
2586            i += 1;
2587        }
2588    }
2589    (out, false)
2590}
2591
2592/// Names currently defined with the ksh `function name { … }` keyword (as
2593/// opposed to the POSIX `name() { … }` spelling). Written at definition time
2594/// by [`note_function_spelling`].
2595static KEYWORD_FUNCS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
2596
2597/// Record which spelling defined `name`; a redefinition replaces the old entry.
2598///
2599/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2600pub fn note_function_spelling(name: &str, keyword: bool) {
2601    let mut names = KEYWORD_FUNCS.lock().unwrap_or_else(|e| e.into_inner());
2602    names.retain(|n| n != name);
2603    if keyword {
2604        names.push(name.to_string());
2605    }
2606}
2607
2608/// True while the innermost running shell function is a ksh93 `function name
2609/// { … }` definition. ksh93 gives only that spelling its own EXIT-trap scope
2610/// (the trap fires when the function returns); a `name() { … }` function's
2611/// EXIT trap is the shell's and fires at shell exit — which is zsh's
2612/// POSIX_TRAPS behavior, so the keyword spelling is the one to special-case.
2613///
2614/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2615pub fn in_ksh93_keyword_function() -> bool {
2616    ksh93_mode() && innermost_function_is_keyword_spelled() == Some(true)
2617}
2618
2619/// True while the innermost running shell function is a POSIX-spelled
2620/// `name() { … }` definition under `--ksh`. ksh93 gives that spelling no
2621/// local scope: `f() { typeset x=1; }; f; print $x` prints `1`, so `typeset`
2622/// and its aliases act as `typeset -g` there (the `function name { … }`
2623/// spelling keeps zsh's local scope).
2624///
2625/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2626pub fn in_ksh93_posix_function() -> bool {
2627    ksh93_mode() && innermost_function_is_keyword_spelled() == Some(false)
2628}
2629
2630/// `Some(true)` when the innermost running function was defined with the
2631/// `function` keyword, `Some(false)` for `name()`, `None` outside a function.
2632fn innermost_function_is_keyword_spelled() -> Option<bool> {
2633    let top = crate::ported::modules::parameter::FUNCSTACK
2634        .lock()
2635        .ok()
2636        .and_then(|stk| {
2637            stk.last()
2638                .filter(|f| f.tp == crate::ported::zsh_h::FS_FUNC)
2639                .map(|f| f.name.clone())
2640        });
2641    top.map(|name| {
2642        KEYWORD_FUNCS
2643            .lock()
2644            .unwrap_or_else(|e| e.into_inner())
2645            .contains(&name)
2646    })
2647}
2648
2649/// Scope guard for a bash command substitution body.
2650///
2651/// bash(1) `set -e`: "Subshells spawned to execute command substitutions
2652/// inherit the value of the -e option from the parent shell. When not in
2653/// posix mode, bash clears the -e option in such subshells" — unless
2654/// `shopt -s inherit_errexit`. So `set -e; echo $(false; echo hi)` prints
2655/// `hi`. zsh (and ksh) keep ERREXIT inside the substitution. Restores the
2656/// option on drop.
2657///
2658/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2659pub struct BashCmdsubstErrexit {
2660    armed: bool,
2661}
2662
2663impl BashCmdsubstErrexit {
2664    pub fn enter() -> Self {
2665        use crate::ported::zsh_h::{isset, ERREXIT};
2666        let armed = bash_mode()
2667            && isset(ERREXIT)
2668            && !bash_shopt_get("inherit_errexit").unwrap_or(false);
2669        if armed {
2670            crate::ported::options::dosetopt(ERREXIT, 0, 1);
2671        }
2672        Self { armed }
2673    }
2674}
2675
2676impl Drop for BashCmdsubstErrexit {
2677    fn drop(&mut self) {
2678        if self.armed {
2679            crate::ported::options::dosetopt(crate::ported::zsh_h::ERREXIT, 1, 1);
2680        }
2681    }
2682}
2683
2684/// C `oldpwd` global (Src/params.c:75). `cd -` and `~-` read THIS, not the
2685/// `$OLDPWD` parameter: assigning `OLDPWD=x` leaves it alone (Src/builtin.c:909
2686/// `: oldpwd`, Src/subst.c:755). `None` until startup or the first `cd` sets it.
2687static OLDPWD_GLOBAL: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
2688
2689/// `oldpwd = <val>` (c:Src/builtin.c:1238-1239, c:Src/init.c:1255-1259).
2690pub fn set_oldpwd_global(val: &str) {
2691    *OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) = Some(val.to_string());
2692}
2693
2694/// The `oldpwd` global as it stands, for a subshell snapshot.
2695pub fn oldpwd_global() -> Option<String> {
2696    OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()).clone()
2697}
2698
2699/// Puts the `oldpwd` global back to what [`oldpwd_global`] returned. C forks
2700/// for `(...)` / `$(...)`, so a `cd` in there never reaches the parent's
2701/// `oldpwd`; zshrs runs them in-process and restores it by hand.
2702pub fn restore_oldpwd_global(val: Option<String>) {
2703    *OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) = val;
2704}
2705
2706/// The `oldpwd` global as a value, for a subshell to put back on exit
2707/// (C's forked child owns its own copy; the in-process stand-in must restore it).
2708pub fn oldpwd_global_get() -> Option<String> {
2709    OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()).clone()
2710}
2711
2712/// Put the `oldpwd` global back to a value read by [`oldpwd_global_get`].
2713pub fn oldpwd_global_put(val: Option<String>) {
2714    *OLDPWD_GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) = val;
2715}
2716
2717/// What `cd -` / `~-` resolve to. zsh reads the `oldpwd` global; the real
2718/// POSIX-family shells (dash, ksh93, bash) read `$OLDPWD` itself, so an
2719/// assignment to it redirects `cd -` there.
2720///
2721/// !!! RUST-ONLY ADAPTER — the posix_faithful branch has no zsh C counterpart !!!
2722pub fn oldpwd_cd_target() -> Option<String> {
2723    let param = || crate::ported::params::getsparam("OLDPWD");
2724    if posix_faithful() {
2725        return param();
2726    }
2727    OLDPWD_GLOBAL
2728        .lock()
2729        .unwrap_or_else(|e| e.into_inner())
2730        .clone()
2731        .or_else(param)
2732}
2733
2734/// True when a blank at bracket depth `brct` must NOT end the token: the word
2735/// so far (`lexbuf`) is `NAME[` in command position (`incmdpos`) and the shell
2736/// is bash or a Korn drop-in, whose assignments take a blank-bearing subscript
2737/// (`h[a b]=1`, `a[1 + 1]=x`). zsh itself splits the word at the blank, so
2738/// every other personality keeps Src/lex.c:958.
2739///
2740/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2741pub fn korn_subscript_blank(brct: i32, incmdpos: bool, lexbuf: &str) -> bool {
2742    if brct <= 0 || !incmdpos || !(bash_mode() || korn_mode()) {
2743        return false;
2744    }
2745    let name_len = lexbuf
2746        .find(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
2747        .unwrap_or(lexbuf.len());
2748    name_len > 0
2749        && !lexbuf.starts_with(|c: char| c.is_ascii_digit())
2750        && lexbuf[name_len..].starts_with(crate::ported::zsh_h::Inbrack)
2751}
2752
2753/// ksh93 `printf %f/%e/%g` rounds an exactly-representable tie away from zero
2754/// (`printf '%.0f' 0.5 2.5` -> `1 3`, `%.1f 0.25` -> `0.3`), where libc and
2755/// Rust round the tie to even. Nudge such a tie one ulp away from zero so the
2756/// ordinary formatter rounds it up; every non-tie and every other mode is
2757/// returned unchanged. `prec` is the directive's precision (`None` = default).
2758pub fn ksh93_round_ties_away(n: f64, conv: char, prec: Option<usize>) -> f64 {
2759    if !ksh93_mode() || n == 0.0 || !n.is_finite() {
2760        return n;
2761    }
2762    let sci = matches!(conv, 'e' | 'E' | 'g' | 'G');
2763    let p = match conv {
2764        'g' | 'G' => prec.unwrap_or(6).max(1) - 1,
2765        _ => prec.unwrap_or(6),
2766    };
2767    // Exact decimal expansion with enough extra digits to expose any non-tie.
2768    let exact = if sci { format!("{:.*e}", p + 40, n) } else { format!("{:.*}", p + 40, n) };
2769    let mantissa = exact.split('e').next().unwrap_or("");
2770    let frac = mantissa.split_once('.').map_or("", |(_, f)| f);
2771    let is_tie = frac.as_bytes().get(p) == Some(&b'5') && frac[p + 1..].bytes().all(|b| b == b'0');
2772    if is_tie { f64::from_bits(n.to_bits() + 1) } else { n }
2773}
2774
2775/// Index of a single-element subscript assignment `a[KEY]=v` for the sparse
2776/// side-table: a plain number, or (Korn/bash arithmetic subscript) a
2777/// side-effect-free expression such as `1+1` or a variable name. ksh93 and
2778/// mksh keep `a[1+1]=x` sparse (`${!a[@]}` -> `2`), same as `a[2]=x`.
2779/// Expressions with `++`/`--`/`=`/`,`/`?` or any other operator are not
2780/// evaluated here (a second evaluation would repeat the side effect), and a
2781/// negative result has no table slot.
2782pub fn sparse_subscript_index(key: &str) -> Option<usize> {
2783    let key = key.trim();
2784    if let Ok(i) = key.parse::<usize>() {
2785        return Some(i);
2786    }
2787    let pure = !key.is_empty()
2788        && key.chars().all(|c| c.is_ascii_alphanumeric() || " _+-*/%()".contains(c))
2789        && !key.contains("++")
2790        && !key.contains("--");
2791    if !pure {
2792        return None;
2793    }
2794    let v = crate::ported::math::mathevali(key).ok()?;
2795    usize::try_from(v).ok()
2796}
2797
2798/// True for the body of a ksh93 stepped CHARACTER range `{a..e..2}`: two
2799/// single-character endpoints and a signed integer step. zsh's `hasbraces`
2800/// (Src/glob.c:2042) only accepts a stepped range when the endpoints are
2801/// numeric, but ksh93 expands `{a..e..2}` -> `a c e`.
2802pub fn ksh93_char_step_range(body: &str) -> bool {
2803    if !ksh93_mode() {
2804        return false;
2805    }
2806    let norm: String = body.chars().map(|c| if c == '\u{e19b}' { '-' } else { c }).collect();
2807    let parts: Vec<&str> = norm.split("..").collect();
2808    parts.len() == 3
2809        && parts[..2].iter().all(|p| p.chars().count() == 1)
2810        && parts[2].parse::<i64>().is_ok()
2811}
2812
2813/// mksh's quoting for `typeset -p` values: bare when every character is
2814/// alphanumeric, non-ASCII or one of `_-./:,+@%^!{}~`; otherwise POSIX
2815/// single quotes (`'\''` for an embedded quote); control characters switch to
2816/// the `$'..'` form. Measured against mksh R59 over the ASCII punctuation set.
2817pub fn mksh_quote(v: &str) -> String {
2818    if v.chars().any(|c| c.is_ascii_control()) {
2819        let mut out = String::from("$'");
2820        for c in v.chars() {
2821            match c {
2822                '\n' => out.push_str("\\n"),
2823                '\t' => out.push_str("\\t"),
2824                '\r' => out.push_str("\\r"),
2825                '\'' => out.push_str("\\'"),
2826                '\\' => out.push_str("\\\\"),
2827                c if c.is_ascii_control() => out.push_str(&format!("\\{:03o}", c as u32)),
2828                c => out.push(c),
2829            }
2830        }
2831        out.push('\'');
2832        return out;
2833    }
2834    let bare = |c: char| !c.is_ascii() || c.is_ascii_alphanumeric() || "_-./:,+@%^!{}~".contains(c);
2835    if !v.is_empty() && v.chars().all(bare) {
2836        v.to_string()
2837    } else {
2838        format!("'{}'", v.replace('\'', "'\\''"))
2839    }
2840}
2841
2842/// Lines of mksh's `typeset -p NAME` for a SET scalar or indexed array.
2843/// Attribute words come in mksh's fixed order `-i -x -r -t -L|-R -Z -l|-u -U`
2844/// (width/base glued to `-L`/`-R`; a base-N integer shows its value as
2845/// `N#digits` under a bare `-i`). Arrays print `set -A NAME` followed by one
2846/// `typeset NAME[i]=v` per live element, holes skipped.
2847///
2848/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2849pub fn pdksh_typeset_p(
2850    name: &str,
2851    flags: u32,
2852    width: i32,
2853    value: &str,
2854    elems: Option<Vec<(usize, String)>>,
2855) -> Vec<String> {
2856    use crate::ported::zsh_h::{
2857        PM_EXPORTED, PM_INTEGER, PM_LEFT, PM_LOWER, PM_READONLY, PM_RIGHT_B, PM_RIGHT_Z, PM_TAGGED,
2858        PM_UNIQUE, PM_UPPER,
2859    };
2860    if let Some(elems) = elems {
2861        let mut lines = vec![format!("set -A {name}")];
2862        lines.extend(elems.into_iter().map(|(i, v)| format!("typeset {name}[{i}]={}", mksh_quote(&v))));
2863        return lines;
2864    }
2865    let mut attrs: Vec<String> = Vec::new();
2866    for (flag, word) in [(PM_INTEGER, "-i"), (PM_EXPORTED, "-x"), (PM_READONLY, "-r"), (PM_TAGGED, "-t")] {
2867        if flags & flag != 0 {
2868            attrs.push(word.to_string());
2869        }
2870    }
2871    if flags & PM_LEFT != 0 {
2872        attrs.push(format!("-L{width}"));
2873    }
2874    if flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
2875        attrs.push(format!("-R{width}"));
2876    }
2877    if flags & PM_RIGHT_Z != 0 {
2878        attrs.push("-Z".to_string());
2879    }
2880    for (flag, word) in [(PM_LOWER, "-l"), (PM_UPPER, "-u"), (PM_UNIQUE, "-U")] {
2881        if flags & flag != 0 {
2882            attrs.push(word.to_string());
2883        }
2884    }
2885    let head = if attrs.is_empty() { "typeset".to_string() } else { format!("typeset {}", attrs.join(" ")) };
2886    let int_text = |s: &str| {
2887        let (base, digits) = s.split_once('#').unwrap_or(("", s));
2888        let digits_ok = |d: &str| !d.is_empty() && d.trim_start_matches('-').bytes().all(|b| b.is_ascii_alphanumeric());
2889        digits_ok(digits) && (base.is_empty() || base.trim_start_matches('-').bytes().all(|b| b.is_ascii_digit()))
2890    };
2891    let shown = if flags & PM_INTEGER != 0 && int_text(value) { value.to_string() } else { mksh_quote(value) };
2892    vec![format!("{head} {name}={shown}")]
2893}
2894
2895/// mksh `${v@Q}`: the `typeset -p` quoting, except a value holding control
2896/// characters is wrapped in `$'..'` with its bytes left RAW (mksh R59 does not
2897/// escape them there: a tab stays a tab, a newline stays a newline).
2898pub fn mksh_at_q(v: &str) -> String {
2899    if v.chars().any(|c| c.is_ascii_control()) {
2900        format!("$'{}'", v.replace('\'', "\\'"))
2901    } else {
2902        mksh_quote(v)
2903    }
2904}
2905
2906/// `$KSH_VERSION` of the emulated Korn shell: ksh93u+m 1.0.10 for `--ksh`,
2907/// mksh R59 for the pdksh line (the reference binaries this mode is measured
2908/// against).
2909pub fn ksh_version_string() -> &'static str {
2910    if pdksh_family() {
2911        "@(#)MIRBSD KSH R59 2020/10/31"
2912    } else {
2913        "Version AJM 93u+m/1.0.10 2024-08-01"
2914    }
2915}
2916
2917/// dash's `ulimit` (miscbltin.c `ulimitcmd`): `[-HSa] [-tfdscmlpnv] [limit]`,
2918/// its own fixed resource table, `%-20s ` listing for `-a`, and "Illegal
2919/// option" (status 2) for every other letter. zsh's table (`-u`, `-T`, ...)
2920/// differs, so the bare drop-in does not go through `bin_ulimit`.
2921///
2922/// Platform-specific extras dash adds on Linux (`-w -i -q -e -r`) are not
2923/// carried: only the ten resources common to dash on every host are.
2924///
2925/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
2926#[cfg(unix)]
2927pub fn dash_ulimit(argv: &[String]) -> i32 {
2928    // (name, resource, factor, option letter) — dash's `limits[]` order.
2929    let limits: [(&str, libc::c_int, libc::rlim_t, char); 10] = [
2930        ("time(seconds)", libc::RLIMIT_CPU as _, 1, 't'),
2931        ("file(blocks)", libc::RLIMIT_FSIZE as _, 512, 'f'),
2932        ("data(kbytes)", libc::RLIMIT_DATA as _, 1024, 'd'),
2933        ("stack(kbytes)", libc::RLIMIT_STACK as _, 1024, 's'),
2934        ("coredump(blocks)", libc::RLIMIT_CORE as _, 512, 'c'),
2935        ("memory(kbytes)", libc::RLIMIT_RSS as _, 1024, 'm'),
2936        ("locked memory(kbytes)", libc::RLIMIT_MEMLOCK as _, 1024, 'l'),
2937        ("process", libc::RLIMIT_NPROC as _, 1, 'p'),
2938        ("nofiles", libc::RLIMIT_NOFILE as _, 1, 'n'),
2939        ("vmemory(kbytes)", libc::RLIMIT_AS as _, 1024, 'v'),
2940    ];
2941    let (mut soft, mut hard, mut all) = (true, true, false);
2942    let mut what = 'f';
2943    let mut i = 0;
2944    while let Some(arg) = argv.get(i) {
2945        if arg == "--" {
2946            i += 1;
2947            break;
2948        }
2949        if !arg.starts_with('-') || arg.len() == 1 {
2950            break;
2951        }
2952        for c in arg[1..].chars() {
2953            match c {
2954                'H' => (soft, hard) = (false, true),
2955                'S' => (soft, hard) = (true, false),
2956                'a' => all = true,
2957                c if limits.iter().any(|l| l.3 == c) => what = c,
2958                c => {
2959                    crate::ported::utils::zwarnnam("ulimit", &format!("Illegal option -{c}"));
2960                    return 2;
2961                }
2962            }
2963        }
2964        i += 1;
2965    }
2966    let value = argv.get(i);
2967    let get = |res: libc::c_int| -> libc::rlimit {
2968        let mut r = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
2969        // SAFETY: `r` is a valid out-pointer for the duration of the call.
2970        unsafe { libc::getrlimit(res as _, &mut r) };
2971        r
2972    };
2973    let show = |r: &libc::rlimit, factor: libc::rlim_t| -> String {
2974        let v = if soft { r.rlim_cur } else { r.rlim_max };
2975        if v == libc::RLIM_INFINITY {
2976            "unlimited".to_string()
2977        } else {
2978            (v / factor).to_string()
2979        }
2980    };
2981    if let Some(v) = value {
2982        if all || argv.get(i + 1).is_some() {
2983            crate::ported::utils::zwarnnam("ulimit", "too many arguments");
2984            return 2;
2985        }
2986        let (_, res, factor, _) = limits.iter().find(|l| l.3 == what).copied().unwrap();
2987        let new = if v == "unlimited" {
2988            libc::RLIM_INFINITY
2989        } else if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
2990            match v.parse::<libc::rlim_t>().ok().and_then(|n| n.checked_mul(factor)) {
2991                Some(n) => n,
2992                None => {
2993                    crate::ported::utils::zwarnnam("ulimit", "bad number");
2994                    return 2;
2995                }
2996            }
2997        } else {
2998            crate::ported::utils::zwarnnam("ulimit", "bad number");
2999            return 2;
3000        };
3001        let mut r = get(res);
3002        if soft {
3003            r.rlim_cur = new;
3004        }
3005        if hard {
3006            r.rlim_max = new;
3007        }
3008        // SAFETY: `r` is a valid rlimit for the duration of the call.
3009        if unsafe { libc::setrlimit(res as _, &r) } < 0 {
3010            crate::ported::utils::zwarnnam(
3011                "ulimit",
3012                &format!("error setting limit ({})", std::io::Error::last_os_error()),
3013            );
3014            return 2;
3015        }
3016        return 0;
3017    }
3018    if all {
3019        for (name, res, factor, _) in &limits {
3020            println!("{:<20} {}", name, show(&get(*res), *factor));
3021        }
3022    } else {
3023        let (_, res, factor, _) = limits.iter().find(|l| l.3 == what).copied().unwrap();
3024        println!("{}", show(&get(res), factor));
3025    }
3026    0
3027}
3028
3029/// Double the backslash of every `\c` in a printf format so zsh's
3030/// `getkeystring` (which reads `\c` as "stop output") emits `\c` literally,
3031/// as dash's printf does. An already-escaped `\\` pair is passed through.
3032///
3033/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3034pub fn dash_printf_keep_backslash_c(fmt: &str) -> String {
3035    let mut out = String::with_capacity(fmt.len() + 2);
3036    let mut chars = fmt.chars();
3037    while let Some(c) = chars.next() {
3038        if c != '\\' {
3039            out.push(c);
3040            continue;
3041        }
3042        match chars.next() {
3043            Some('c') => out.push_str("\\\\c"),
3044            Some(n) => {
3045                out.push('\\');
3046                out.push(n);
3047            }
3048            None => out.push('\\'),
3049        }
3050    }
3051    out
3052}
3053
3054/// Skip the separators that may follow a `&&` / `||` / `|` / `|&` operator.
3055///
3056/// dash allows only newlines there: a `;` or the end of input is
3057/// `Syntax error: ";" unexpected` / `end of file unexpected`. Returns false
3058/// after reporting that error; every other mode skips any separator, as zsh
3059/// does.
3060///
3061/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3062pub fn dash_skip_operator_separators() -> bool {
3063    use crate::ported::lex::{set_tok, tok, zshlex, LEX_ISNEWLIN};
3064    use crate::ported::zsh_h::{ENDINPUT, LEXERR, NEWLIN, SEPER};
3065    let faithful = dash_faithful();
3066    while tok() == NEWLIN
3067        || (tok() == SEPER && (!faithful || LEX_ISNEWLIN.with(|c| c.get()) != 0))
3068    {
3069        zshlex();
3070    }
3071    if !faithful {
3072        return true;
3073    }
3074    match tok() {
3075        SEPER => crate::ported::utils::zerr("Syntax error: \";\" unexpected"),
3076        ENDINPUT => crate::ported::utils::zerr("Syntax error: end of file unexpected"),
3077        _ => return true,
3078    }
3079    set_tok(LEXERR);
3080    false
3081}
3082
3083// ---------------------------------------------------------------------------
3084// dash `test` / `[` — a port of dash's src/bltin/test.c (`testcmd`, `oexpr`,
3085// `aexpr`, `nexpr`, `primary`, `binop`, `filstat`, `t_lex`, `isoperand`).
3086// zsh's `bin_test` grammar (Src/builtin.c:7231, Src/parse.c par_cond) is a
3087// different parser with different corner cases (`-a`/`-o`/`( )` with odd
3088// operand counts, `-v`, unsigned-looking numbers, missing-file `-nt`/`-ot`),
3089// so the bare drop-in runs dash's own algorithm.
3090// ---------------------------------------------------------------------------
3091
3092/// dash `test` operator numbers (test.c `enum token`).
3093#[derive(Clone, Copy, PartialEq, Eq)]
3094enum DashTok {
3095    Eoi,
3096    Operand,
3097    FilRd,
3098    FilWr,
3099    FilEx,
3100    FilExist,
3101    FilReg,
3102    FilDir,
3103    FilCdev,
3104    FilBdev,
3105    FilFifo,
3106    FilSuid,
3107    FilSgid,
3108    FilStck,
3109    FilGz,
3110    FilTt,
3111    StrEz,
3112    StrNz,
3113    FilSym,
3114    FilUid,
3115    FilGid,
3116    FilSock,
3117    StrEq,
3118    StrNe,
3119    StrLt,
3120    StrGt,
3121    IntEq,
3122    IntNe,
3123    IntGe,
3124    IntGt,
3125    IntLe,
3126    IntLt,
3127    FilNt,
3128    FilOt,
3129    FilEq,
3130    Not,
3131    And,
3132    Or,
3133    LParen,
3134    RParen,
3135}
3136
3137#[derive(Clone, Copy, PartialEq, Eq)]
3138enum DashOpType {
3139    Unop,
3140    Binop,
3141    BUnop,
3142    BBinop,
3143    Paren,
3144}
3145
3146/// test.c `ops[]`.
3147const DASH_TEST_OPS: &[(&str, DashTok, DashOpType)] = &[
3148    ("-r", DashTok::FilRd, DashOpType::Unop),
3149    ("-w", DashTok::FilWr, DashOpType::Unop),
3150    ("-x", DashTok::FilEx, DashOpType::Unop),
3151    ("-e", DashTok::FilExist, DashOpType::Unop),
3152    ("-f", DashTok::FilReg, DashOpType::Unop),
3153    ("-d", DashTok::FilDir, DashOpType::Unop),
3154    ("-c", DashTok::FilCdev, DashOpType::Unop),
3155    ("-b", DashTok::FilBdev, DashOpType::Unop),
3156    ("-p", DashTok::FilFifo, DashOpType::Unop),
3157    ("-u", DashTok::FilSuid, DashOpType::Unop),
3158    ("-g", DashTok::FilSgid, DashOpType::Unop),
3159    ("-k", DashTok::FilStck, DashOpType::Unop),
3160    ("-s", DashTok::FilGz, DashOpType::Unop),
3161    ("-t", DashTok::FilTt, DashOpType::Unop),
3162    ("-z", DashTok::StrEz, DashOpType::Unop),
3163    ("-n", DashTok::StrNz, DashOpType::Unop),
3164    ("-h", DashTok::FilSym, DashOpType::Unop),
3165    ("-O", DashTok::FilUid, DashOpType::Unop),
3166    ("-G", DashTok::FilGid, DashOpType::Unop),
3167    ("-L", DashTok::FilSym, DashOpType::Unop),
3168    ("-S", DashTok::FilSock, DashOpType::Unop),
3169    ("=", DashTok::StrEq, DashOpType::Binop),
3170    ("!=", DashTok::StrNe, DashOpType::Binop),
3171    ("<", DashTok::StrLt, DashOpType::Binop),
3172    (">", DashTok::StrGt, DashOpType::Binop),
3173    ("-eq", DashTok::IntEq, DashOpType::Binop),
3174    ("-ne", DashTok::IntNe, DashOpType::Binop),
3175    ("-ge", DashTok::IntGe, DashOpType::Binop),
3176    ("-gt", DashTok::IntGt, DashOpType::Binop),
3177    ("-le", DashTok::IntLe, DashOpType::Binop),
3178    ("-lt", DashTok::IntLt, DashOpType::Binop),
3179    ("-nt", DashTok::FilNt, DashOpType::Binop),
3180    ("-ot", DashTok::FilOt, DashOpType::Binop),
3181    ("-ef", DashTok::FilEq, DashOpType::Binop),
3182    ("!", DashTok::Not, DashOpType::BUnop),
3183    ("-a", DashTok::And, DashOpType::BBinop),
3184    ("-o", DashTok::Or, DashOpType::BBinop),
3185    ("(", DashTok::LParen, DashOpType::Paren),
3186    (")", DashTok::RParen, DashOpType::Paren),
3187];
3188
3189type DashOp = (&'static str, DashTok, DashOpType);
3190
3191fn dash_getop(s: &str) -> Option<&'static DashOp> {
3192    DASH_TEST_OPS.iter().find(|o| o.0 == s)
3193}
3194
3195/// dash `atomax10`: optional blanks, sign and decimal digits filling the whole
3196/// string (overflow saturates, as strtoimax does); anything else is
3197/// "Illegal number".
3198fn dash_getn(s: &str) -> Result<i64, String> {
3199    let t = s.trim_start_matches(|c: char| c.is_ascii_whitespace());
3200    let (neg, digits) = match t.as_bytes().first() {
3201        Some(b'-') => (true, &t[1..]),
3202        Some(b'+') => (false, &t[1..]),
3203        _ => (false, t),
3204    };
3205    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
3206        return Err(format!("Illegal number: {s}"));
3207    }
3208    let mag = digits.bytes().try_fold(0i64, |a, b| a.checked_mul(10)?.checked_add((b - b'0') as i64));
3209    Ok(match (mag, neg) {
3210        (Some(m), false) => m,
3211        (Some(m), true) => -m,
3212        (None, false) => i64::MAX,
3213        (None, true) => i64::MIN,
3214    })
3215}
3216
3217struct DashTest {
3218    argv: Vec<String>,
3219    wp: usize,
3220    wp_op: Option<&'static DashOp>,
3221}
3222
3223impl DashTest {
3224    fn at(&self, i: usize) -> Option<&str> {
3225        self.argv.get(i).map(String::as_str)
3226    }
3227
3228    /// test.c `isoperand`.
3229    fn isoperand(&self, tp: usize) -> bool {
3230        let Some(s) = self.at(tp + 1) else { return true };
3231        if self.at(tp + 2).is_none() {
3232            return false;
3233        }
3234        dash_getop(s).is_some_and(|o| o.2 == DashOpType::Binop)
3235    }
3236
3237    /// test.c `t_lex`.
3238    fn t_lex(&mut self, tp: usize) -> DashTok {
3239        let Some(s) = self.at(tp) else {
3240            self.wp_op = None;
3241            return DashTok::Eoi;
3242        };
3243        if let Some(op) = dash_getop(s) {
3244            if !(op.2 == DashOpType::Unop && self.isoperand(tp))
3245                && !(op.1 == DashTok::LParen && self.at(tp + 1).is_none())
3246            {
3247                self.wp_op = Some(op);
3248                return op.1;
3249            }
3250        }
3251        self.wp_op = None;
3252        DashTok::Operand
3253    }
3254
3255    fn oexpr(&mut self, mut n: DashTok) -> Result<bool, String> {
3256        let mut res = false;
3257        loop {
3258            res |= self.aexpr(n)?;
3259            if self.at(self.wp).is_none() {
3260                break;
3261            }
3262            n = self.t_lex(self.wp + 1);
3263            if n != DashTok::Or {
3264                break;
3265            }
3266            self.wp += 2;
3267            n = self.t_lex(self.wp);
3268        }
3269        Ok(res)
3270    }
3271
3272    fn aexpr(&mut self, mut n: DashTok) -> Result<bool, String> {
3273        let mut res = true;
3274        loop {
3275            if !self.nexpr(n)? {
3276                res = false;
3277            }
3278            if self.at(self.wp).is_none() {
3279                break;
3280            }
3281            n = self.t_lex(self.wp + 1);
3282            if n != DashTok::And {
3283                break;
3284            }
3285            self.wp += 2;
3286            n = self.t_lex(self.wp);
3287        }
3288        Ok(res)
3289    }
3290
3291    fn nexpr(&mut self, n: DashTok) -> Result<bool, String> {
3292        if n != DashTok::Not {
3293            return self.primary(n);
3294        }
3295        let n = self.t_lex(self.wp + 1);
3296        if n != DashTok::Eoi {
3297            self.wp += 1;
3298        }
3299        Ok(!self.nexpr(n)?)
3300    }
3301
3302    fn primary(&mut self, n: DashTok) -> Result<bool, String> {
3303        if n == DashTok::Eoi {
3304            return Ok(false);
3305        }
3306        if n == DashTok::LParen {
3307            self.wp += 1;
3308            let nn = self.t_lex(self.wp);
3309            if nn == DashTok::RParen {
3310                return Ok(false);
3311            }
3312            let res = self.oexpr(nn)?;
3313            self.wp += 1;
3314            if self.t_lex(self.wp) != DashTok::RParen {
3315                return Err("closing paren expected".to_string());
3316            }
3317            return Ok(res);
3318        }
3319        if let Some(op) = self.wp_op.filter(|o| o.2 == DashOpType::Unop) {
3320            self.wp += 1;
3321            let Some(arg) = self.at(self.wp).map(str::to_owned) else {
3322                return Err(format!("{}: argument expected", op.0));
3323            };
3324            return Ok(match n {
3325                DashTok::StrEz => arg.is_empty(),
3326                DashTok::StrNz => !arg.is_empty(),
3327                // SAFETY: isatty takes any int; a bad fd just reports 0.
3328                DashTok::FilTt => unsafe { libc::isatty(dash_getn(&arg)? as libc::c_int) != 0 },
3329                DashTok::FilRd => dash_access(&arg, libc::R_OK),
3330                DashTok::FilWr => dash_access(&arg, libc::W_OK),
3331                DashTok::FilEx => dash_access(&arg, libc::X_OK),
3332                _ => dash_filstat(&arg, n),
3333            });
3334        }
3335        self.t_lex(self.wp + 1);
3336        if self.wp_op.is_some_and(|o| o.2 == DashOpType::Binop) {
3337            return self.binop();
3338        }
3339        Ok(self.at(self.wp).is_some_and(|s| !s.is_empty()))
3340    }
3341
3342    fn binop(&mut self) -> Result<bool, String> {
3343        let a = self.at(self.wp).unwrap_or("").to_owned();
3344        self.wp += 1;
3345        self.t_lex(self.wp);
3346        let Some(op) = self.wp_op else { return Ok(false) };
3347        self.wp += 1;
3348        let Some(b) = self.at(self.wp).map(str::to_owned) else {
3349            return Err(format!("{}: argument expected", op.0));
3350        };
3351        Ok(match op.1 {
3352            DashTok::StrEq => a == b,
3353            DashTok::StrNe => a != b,
3354            DashTok::StrLt => dash_strcoll(&a, &b) < 0,
3355            DashTok::StrGt => dash_strcoll(&a, &b) > 0,
3356            DashTok::IntEq => dash_getn(&a)? == dash_getn(&b)?,
3357            DashTok::IntNe => dash_getn(&a)? != dash_getn(&b)?,
3358            DashTok::IntGe => dash_getn(&a)? >= dash_getn(&b)?,
3359            DashTok::IntGt => dash_getn(&a)? > dash_getn(&b)?,
3360            DashTok::IntLe => dash_getn(&a)? <= dash_getn(&b)?,
3361            DashTok::IntLt => dash_getn(&a)? < dash_getn(&b)?,
3362            DashTok::FilNt => dash_newerf(&a, &b),
3363            DashTok::FilOt => dash_olderf(&a, &b),
3364            DashTok::FilEq => dash_equalf(&a, &b),
3365            _ => false,
3366        })
3367    }
3368}
3369
3370fn dash_strcoll(a: &str, b: &str) -> i32 {
3371    let (Ok(a), Ok(b)) = (std::ffi::CString::new(a), std::ffi::CString::new(b)) else {
3372        return 0;
3373    };
3374    // SAFETY: both pointers are valid NUL-terminated strings.
3375    unsafe { libc::strcoll(a.as_ptr(), b.as_ptr()) }
3376}
3377
3378/// `faccessat(AT_FDCWD, path, mode, AT_EACCESS)`.
3379fn dash_access(path: &str, mode: libc::c_int) -> bool {
3380    let Ok(p) = std::ffi::CString::new(path) else { return false };
3381    // SAFETY: `p` is a valid NUL-terminated string.
3382    unsafe { libc::faccessat(libc::AT_FDCWD, p.as_ptr(), mode, libc::AT_EACCESS) == 0 }
3383}
3384
3385/// test.c `filstat`.
3386fn dash_filstat(path: &str, mode: DashTok) -> bool {
3387    use std::os::unix::fs::{FileTypeExt, MetadataExt};
3388    let md = if mode == DashTok::FilSym {
3389        std::fs::symlink_metadata(path)
3390    } else {
3391        std::fs::metadata(path)
3392    };
3393    let Ok(md) = md else { return false };
3394    let ft = md.file_type();
3395    let m = md.mode();
3396    match mode {
3397        DashTok::FilExist => true,
3398        DashTok::FilReg => ft.is_file(),
3399        DashTok::FilDir => ft.is_dir(),
3400        DashTok::FilCdev => ft.is_char_device(),
3401        DashTok::FilBdev => ft.is_block_device(),
3402        DashTok::FilFifo => ft.is_fifo(),
3403        DashTok::FilSock => ft.is_socket(),
3404        DashTok::FilSym => ft.is_symlink(),
3405        DashTok::FilSuid => m & libc::S_ISUID as u32 != 0,
3406        DashTok::FilSgid => m & libc::S_ISGID as u32 != 0,
3407        DashTok::FilStck => m & libc::S_ISVTX as u32 != 0,
3408        DashTok::FilGz => md.size() != 0,
3409        // SAFETY: geteuid/getegid have no preconditions.
3410        DashTok::FilUid => md.uid() == unsafe { libc::geteuid() },
3411        DashTok::FilGid => md.gid() == unsafe { libc::getegid() },
3412        _ => true,
3413    }
3414}
3415
3416fn dash_mtime(path: &str) -> Option<(i64, i64)> {
3417    use std::os::unix::fs::MetadataExt;
3418    std::fs::metadata(path).ok().map(|m| (m.mtime(), m.mtime_nsec()))
3419}
3420
3421/// test.c `newerf`: a missing FIRST file is never newer, a missing second is.
3422fn dash_newerf(f1: &str, f2: &str) -> bool {
3423    let Some(a) = dash_mtime(f1) else { return false };
3424    dash_mtime(f2).map_or(true, |b| a > b)
3425}
3426
3427/// test.c `olderf`: a missing SECOND file is never newer, a missing first is.
3428fn dash_olderf(f1: &str, f2: &str) -> bool {
3429    let Some(b) = dash_mtime(f2) else { return false };
3430    dash_mtime(f1).map_or(true, |a| a < b)
3431}
3432
3433/// test.c `equalf`: same device and inode.
3434fn dash_equalf(f1: &str, f2: &str) -> bool {
3435    use std::os::unix::fs::MetadataExt;
3436    match (std::fs::metadata(f1), std::fs::metadata(f2)) {
3437        (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
3438        _ => false,
3439    }
3440}
3441
3442/// dash `testcmd` (test.c): `name` is `test` or `[`; `args` exclude the name.
3443/// Returns the exit status: 0 true, 1 false, 2 for a diagnosed error.
3444///
3445/// !!! RUST-ONLY EXTENSION — no zsh C counterpart !!!
3446pub fn dash_test(name: &str, args: &[String]) -> i32 {
3447    let mut v: Vec<String> = Vec::with_capacity(args.len() + 1);
3448    v.push(name.to_string());
3449    v.extend(args.iter().cloned());
3450    let mut argc = v.len();
3451    if name.starts_with('[') {
3452        argc -= 1;
3453        if v[argc] != "]" {
3454            crate::ported::utils::zwarnnam(name, "missing ]");
3455            return 2;
3456        }
3457        v.truncate(argc);
3458    }
3459    let mut t = DashTest { argv: v, wp: 0, wp_op: None };
3460    let mut res = true; // C: `int res = 1`, flipped by `res ^= oexpr()`.
3461    let mut base = 0usize;
3462    let n;
3463    loop {
3464        base += 1;
3465        argc -= 1;
3466        if argc < 1 {
3467            return i32::from(res);
3468        }
3469        if argc == 3
3470            && t.at(base + 1).and_then(dash_getop).is_some_and(|o| o.2 == DashOpType::Binop)
3471        {
3472            n = DashTok::Operand;
3473            break;
3474        }
3475        if argc == 3 || argc == 4 {
3476            if t.at(base) == Some("(") && t.at(base + argc - 1) == Some(")") {
3477                t.argv.truncate(base + argc - 1);
3478                base += 1;
3479                argc -= 1;
3480            } else if t.at(base) == Some("!") {
3481                res = false;
3482                continue;
3483            }
3484        }
3485        n = t.t_lex(base);
3486        break;
3487    }
3488    t.wp = base;
3489    let r = match t.oexpr(n) {
3490        Ok(r) => r,
3491        Err(msg) => {
3492            crate::ported::utils::zwarnnam(name, &msg);
3493            return 2;
3494        }
3495    };
3496    // C: `res ^= oexpr(n)` with `res` starting at 1 (false) or flipped to 0 by `!`.
3497    let status = res ^ r;
3498    if t.at(t.wp).is_some() && t.at(t.wp + 1).is_some() {
3499        crate::ported::utils::zwarnnam(name, &format!("{}: unexpected operator", t.argv[t.wp]));
3500        return 2;
3501    }
3502    i32::from(status)
3503}