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