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