Skip to main content

reef/
translate.rs

1//! Bash-to-fish translator.
2//!
3//! Walks the AST produced by [`crate::parser::Parser`] and emits equivalent
4//! fish shell code. Unsupported constructs produce [`TranslateError::Unsupported`].
5
6use std::borrow::Cow;
7use std::fmt;
8
9use crate::ast::*;
10use crate::lexer::ParseError;
11use crate::parser::Parser;
12
13/// Translation context threaded through all emitters.
14struct Ctx {
15    in_subshell: bool,
16}
17
18impl Ctx {
19    fn new() -> Self {
20        Ctx {
21            in_subshell: false,
22        }
23    }
24}
25
26// ---------------------------------------------------------------------------
27// Error type
28// ---------------------------------------------------------------------------
29
30/// Error produced during bash-to-fish translation.
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum TranslateError {
34    /// The input uses a bash feature that has no fish equivalent.
35    Unsupported(&'static str),
36    /// The input failed to parse as valid bash.
37    Parse(ParseError),
38}
39
40impl fmt::Display for TranslateError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            TranslateError::Unsupported(msg) => write!(f, "unsupported: {msg}"),
44            TranslateError::Parse(e) => write!(f, "{e}"),
45        }
46    }
47}
48
49impl std::error::Error for TranslateError {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            TranslateError::Parse(e) => Some(e),
53            TranslateError::Unsupported(_) => None,
54        }
55    }
56}
57
58impl From<ParseError> for TranslateError {
59    fn from(e: ParseError) -> Self {
60        TranslateError::Parse(e)
61    }
62}
63
64/// Module-local result alias — reduces `Result<(), TranslateError>` noise.
65type Res<T> = Result<T, TranslateError>;
66
67// ---------------------------------------------------------------------------
68// Public entry point
69// ---------------------------------------------------------------------------
70
71/// Translate a bash command string to equivalent fish shell syntax.
72///
73/// Parses the input as bash, walks the AST, and emits fish code. The result
74/// may contain multiple lines (separated by `\n`) when the input has multiple
75/// commands. Returns [`TranslateError::Unsupported`] for bash features that
76/// have no fish equivalent, or [`TranslateError::Parse`] for invalid syntax.
77///
78/// # Errors
79///
80/// Returns [`TranslateError::Parse`] if the input is not valid bash syntax,
81/// or [`TranslateError::Unsupported`] if it uses a bash feature with no fish
82/// equivalent (e.g., `select`, coprocesses, associative arrays).
83///
84/// # Examples
85///
86/// ```
87/// use reef::translate::translate_bash_to_fish;
88///
89/// let fish = translate_bash_to_fish("export FOO=bar").unwrap();
90/// assert_eq!(fish, "set -gx FOO bar");
91///
92/// // Unsupported features return an error
93/// assert!(translate_bash_to_fish("select x in a b; do echo $x; done").is_err());
94/// ```
95#[must_use = "translation produces a result that should be inspected"]
96pub fn translate_bash_to_fish(input: &str) -> Result<String, TranslateError> {
97    let cmds = Parser::new(input).parse()?;
98    let mut ctx = Ctx::new();
99    let mut out = String::with_capacity(input.len());
100    for (i, cmd) in cmds.iter().enumerate() {
101        if i > 0 {
102            out.push('\n');
103        }
104        emit_cmd(&mut ctx, cmd, &mut out)?;
105    }
106    Ok(out)
107}
108
109// ---------------------------------------------------------------------------
110// Command-level emitters
111// ---------------------------------------------------------------------------
112
113fn emit_cmd(ctx: &mut Ctx, cmd: &Cmd<'_>, out: &mut String) -> Res<()> {
114    match cmd {
115        Cmd::List(list) => emit_and_or(ctx, list, out),
116        Cmd::Job(list) => {
117            emit_and_or(ctx, list, out)?;
118            out.push_str(" &");
119            Ok(())
120        }
121    }
122}
123
124fn emit_and_or(ctx: &mut Ctx, list: &AndOrList<'_>, out: &mut String) -> Res<()> {
125    emit_pipeline(ctx, &list.first, out)?;
126    for and_or in &list.rest {
127        match and_or {
128            AndOr::And(p) => {
129                out.push_str("; and ");
130                emit_pipeline(ctx, p, out)?;
131            }
132            AndOr::Or(p) => {
133                out.push_str("; or ");
134                emit_pipeline(ctx, p, out)?;
135            }
136        }
137    }
138    Ok(())
139}
140
141fn emit_pipeline(ctx: &mut Ctx, pipeline: &Pipeline<'_>, out: &mut String) -> Res<()> {
142    match pipeline {
143        Pipeline::Single(exec) => emit_exec(ctx, exec, out),
144        Pipeline::Pipe(negated, cmds) => {
145            if *negated {
146                out.push_str("not ");
147            }
148            for (i, c) in cmds.iter().enumerate() {
149                if i > 0 {
150                    out.push_str(" | ");
151                }
152                emit_exec(ctx, c, out)?;
153            }
154            Ok(())
155        }
156    }
157}
158
159fn emit_exec(ctx: &mut Ctx, exec: &Executable<'_>, out: &mut String) -> Res<()> {
160    match exec {
161        Executable::Simple(simple) => emit_simple(ctx, simple, out),
162        Executable::Compound(compound) => emit_compound(ctx, compound, out),
163        Executable::FuncDef(name, body) => {
164            out.push_str("function ");
165            out.push_str(name);
166            out.push('\n');
167            // Unwrap brace group to avoid nested begin/end inside function
168            match &body.kind {
169                CompoundKind::Brace(cmds) => emit_body(ctx, cmds, out)?,
170                other => emit_compound_kind(ctx, other, out)?,
171            }
172            out.push_str("\nend");
173            Ok(())
174        }
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Simple command
180// ---------------------------------------------------------------------------
181
182fn emit_simple(ctx: &mut Ctx, cmd: &SimpleCmd<'_>, out: &mut String) -> Res<()> {
183    let mut env_vars: Vec<(&str, &Option<Word<'_>>)> = Vec::new();
184    let mut array_ops: Vec<&CmdPrefix<'_>> = Vec::new();
185    let mut cmd_words: Vec<&Word<'_>> = Vec::new();
186    let mut redirects: Vec<&Redir<'_>> = Vec::new();
187    let mut herestring: Option<&Word<'_>> = None;
188    let mut heredoc: Option<&HeredocBody<'_>> = None;
189
190    for item in &cmd.prefix {
191        match item {
192            CmdPrefix::Assign(name, val) => env_vars.push((name, val)),
193            CmdPrefix::ArrayAssign(..) | CmdPrefix::ArrayAppend(..) => array_ops.push(item),
194            CmdPrefix::Redirect(Redir::HereString(w)) => herestring = Some(w),
195            CmdPrefix::Redirect(Redir::Heredoc(body)) => heredoc = Some(body),
196            CmdPrefix::Redirect(r) => redirects.push(r),
197        }
198    }
199    for item in &cmd.suffix {
200        match item {
201            CmdSuffix::Word(w) => cmd_words.push(w),
202            CmdSuffix::Redirect(Redir::HereString(w)) => herestring = Some(w),
203            CmdSuffix::Redirect(Redir::Heredoc(body)) => heredoc = Some(body),
204            CmdSuffix::Redirect(r) => redirects.push(r),
205        }
206    }
207
208    // Standalone assignment (no command words)
209    if cmd_words.is_empty() {
210        if !array_ops.is_empty() {
211            return emit_array_assignments(ctx, &env_vars, &array_ops, out);
212        }
213        if !env_vars.is_empty() {
214            return emit_var_assignments(ctx, &env_vars, out);
215        }
216    }
217
218    let cmd_name = cmd_words.first().and_then(|w| word_as_str(w));
219
220    // mapfile/readarray needs its own redirects before here-string emission
221    if matches!(cmd_name.as_deref(), Some("mapfile" | "readarray")) {
222        return emit_mapfile(ctx, &cmd_words, &redirects, herestring, out);
223    }
224
225    // Pipe input: here-string or heredoc
226    if let Some(hs_word) = herestring {
227        out.push_str("echo ");
228        emit_word(ctx, hs_word, out)?;
229        out.push_str(" | ");
230    }
231    if let Some(body) = heredoc {
232        emit_heredoc_body(ctx, body, out)?;
233        out.push_str(" | ");
234    }
235
236    // Prefix assignments with a command: VAR=val cmd args
237    // Bail — fish list variables (PATH, CDPATH) expand differently under env,
238    // and the scoping semantics are subtle. Let bash handle it.
239    // Must check before builtin dispatch to avoid silently dropping the prefix.
240    if !env_vars.is_empty() && !cmd_words.is_empty() {
241        return Err(TranslateError::Unsupported("prefix assignment with command"));
242    }
243
244    // Builtin dispatch — returns early if handled
245    if let Some(ref name) = cmd_name
246        && let Some(result) = dispatch_builtin(ctx, name, &cmd_words, &redirects, out)
247    {
248        return result;
249    }
250
251    // `exit` inside a subshell can't be emulated with fish's begin/end —
252    // `return` would exit the whole function, not just the begin block.
253    // Bail to T2 bash-exec so it runs correctly in a real subprocess.
254    if ctx.in_subshell && cmd_name.as_deref() == Some("exit") {
255        return Err(TranslateError::Unsupported("exit in subshell"));
256    }
257
258    // Emit command and arguments
259    for (i, word) in cmd_words.iter().enumerate() {
260        if i > 0 {
261            out.push(' ');
262        }
263        emit_word(ctx, word, out)?;
264    }
265
266    // Emit redirects
267    for redir in &redirects {
268        out.push(' ');
269        emit_redir(ctx, redir, out)?;
270    }
271
272    Ok(())
273}
274
275/// Emit standalone array assignments: `arr=(a b c)` → `set arr a b c`
276fn emit_array_assignments(ctx: &mut Ctx, 
277    env_vars: &[(&str, &Option<Word<'_>>)],
278    array_ops: &[&CmdPrefix<'_>],
279    out: &mut String,
280) -> Res<()> {
281    let set_kw = if ctx.in_subshell { "set -l " } else { "set " };
282    let mut first = true;
283    for (i, (name, value)) in env_vars.iter().enumerate() {
284        if !first || i > 0 {
285            out.push('\n');
286        }
287        first = false;
288        out.push_str(set_kw);
289        out.push_str(name);
290        if let Some(val) = value {
291            out.push(' ');
292            emit_word(ctx, val, out)?;
293        }
294    }
295    for op in array_ops {
296        if !first {
297            out.push('\n');
298        }
299        first = false;
300        match op {
301            CmdPrefix::ArrayAssign(name, words) => {
302                out.push_str(set_kw);
303                out.push_str(name);
304                for w in words {
305                    out.push(' ');
306                    emit_word(ctx, w, out)?;
307                }
308            }
309            CmdPrefix::ArrayAppend(name, words) => {
310                out.push_str(if ctx.in_subshell { "set -la " } else { "set -a " });
311                out.push_str(name);
312                for w in words {
313                    out.push(' ');
314                    emit_word(ctx, w, out)?;
315                }
316            }
317            _ => unreachable!(),
318        }
319    }
320    Ok(())
321}
322
323/// Emit standalone variable assignments: `VAR=val` → `set VAR val`
324fn emit_var_assignments(ctx: &mut Ctx, 
325    env_vars: &[(&str, &Option<Word<'_>>)],
326    out: &mut String,
327) -> Res<()> {
328    let set_kw = if ctx.in_subshell { "set -l " } else { "set " };
329    for (i, (name, value)) in env_vars.iter().enumerate() {
330        if i > 0 {
331            out.push('\n');
332        }
333        out.push_str(set_kw);
334        out.push_str(name);
335        if let Some(val) = value {
336            out.push(' ');
337            emit_word(ctx, val, out)?;
338        }
339    }
340    Ok(())
341}
342
343/// Dispatch to builtin emitters. Returns `Some(result)` if handled, `None` to fall through.
344fn dispatch_builtin(ctx: &mut Ctx, 
345    name: &str,
346    cmd_words: &[&Word<'_>],
347    redirects: &[&Redir<'_>],
348    out: &mut String,
349) -> Option<Res<()>> {
350    match name {
351        "export" => Some(emit_export(ctx, &cmd_words[1..], out)),
352        "unset" => Some(emit_unset(ctx, &cmd_words[1..], out)),
353        "local" => Some(emit_local(ctx, &cmd_words[1..], out)),
354        "declare" | "typeset" => Some(emit_declare(ctx, &cmd_words[1..], out)),
355        "readonly" => Some(emit_readonly(ctx, &cmd_words[1..], out)),
356        "[[" => Some(emit_double_bracket(ctx, &cmd_words[1..], redirects, out)),
357        "let" => Some(emit_let(ctx, &cmd_words[1..], out)),
358        "shopt" => Some(Err(TranslateError::Unsupported("shopt"))),
359        "trap" => Some(emit_trap(ctx, &cmd_words[1..], out)),
360        "shift" => Some(emit_shift(ctx, &cmd_words[1..], out)),
361        "alias" => Some(emit_alias(ctx, &cmd_words[1..], out)),
362        "read" => Some(emit_read(ctx, cmd_words, redirects, out)),
363        "set" => Some(emit_bash_set(ctx, &cmd_words[1..], out)),
364        "select" => Some(Err(TranslateError::Unsupported("select loop"))),
365        "getopts" => Some(Err(TranslateError::Unsupported(
366            "getopts (use argparse in fish)",
367        ))),
368        "exec" if cmd_words.len() == 1 && !redirects.is_empty() => {
369            Some(Err(TranslateError::Unsupported("exec fd manipulation")))
370        }
371        "eval" => Some(emit_eval(ctx, &cmd_words[1..], out)),
372        "printf" => dispatch_printf(ctx, cmd_words, out),
373        _ => None,
374    }
375}
376
377/// Handle `printf` special cases. Returns `Some` if the call was handled.
378fn dispatch_printf(ctx: &mut Ctx, 
379    cmd_words: &[&Word<'_>],
380    out: &mut String,
381) -> Option<Res<()>> {
382    // Detect repetition pattern: printf '%0.sCHAR' {1..N} or printf '%.0sCHAR' {1..N}
383    if cmd_words.len() >= 3
384        && let Some(fmt) = word_as_str(cmd_words[1])
385        && let Some(ch) = extract_printf_repeat_char(fmt.as_ref())
386        && let Some(count) = extract_brace_range_count(&cmd_words[2..])
387    {
388        out.push_str("string repeat -n ");
389        itoa(out, count);
390        out.push_str(" -- '");
391        out.push(ch);
392        out.push('\'');
393        return Some(Ok(()));
394    }
395    // Reject unsupported %0.s format if not the repeat pattern
396    for w in &cmd_words[1..] {
397        let text: Cow<'_, str> = if let Some(s) = word_as_str(w) {
398            s
399        } else {
400            let mut buf = String::with_capacity(64);
401            let _ = emit_word(ctx, w, &mut buf);
402            Cow::Owned(buf)
403        };
404        if text.contains("%0.s") || text.contains("%.0s") {
405            return Some(Err(TranslateError::Unsupported(
406                "printf %0.s format (fish printf doesn't support this)",
407            )));
408        }
409    }
410    None
411}
412
413// ---------------------------------------------------------------------------
414// Bash builtin translations
415// ---------------------------------------------------------------------------
416
417/// `export VAR=val` → `set -gx VAR val`
418fn emit_export(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
419    let mut first = true;
420    for arg in args {
421        if let Some(s) = word_as_str(arg)
422            && s.starts_with('-')
423        {
424            continue;
425        }
426        if !first {
427            out.push('\n');
428        }
429        first = false;
430
431        if let Some((var_name, value_parts)) = split_word_at_equals(ctx, arg) {
432            out.push_str("set -gx ");
433            out.push_str(&var_name);
434            if !value_parts.is_empty() {
435                out.push(' ');
436                // PATH-like variables: split colon-separated values into fish list
437                if var_name.ends_with("PATH") && value_parts.contains(':') {
438                    out.push_str(&value_parts.replace(':', " "));
439                } else {
440                    out.push_str(&value_parts);
441                }
442            }
443        } else if let Some(s) = word_as_str(arg) {
444            out.push_str("set -gx ");
445            out.push_str(&s);
446            out.push_str(" $");
447            out.push_str(&s);
448        } else {
449            out.push_str("set -gx ");
450            emit_word(ctx, arg, out)?;
451        }
452    }
453    Ok(())
454}
455
456/// Split a word at the first `=` sign, returning (`var_name`, `value_as_fish`).
457fn split_word_at_equals(ctx: &mut Ctx, word: &Word<'_>) -> Option<(String, String)> {
458    let mut full = String::with_capacity(64);
459    if emit_word(ctx, word, &mut full).is_err() {
460        return None;
461    }
462    let eq_pos = full.find('=')?;
463    let value_part = full.split_off(eq_pos + 1);
464    full.pop(); // remove trailing '='
465    let var_name = full;
466
467    let value = if value_part.len() >= 2
468        && ((value_part.starts_with('"') && value_part.ends_with('"'))
469            || (value_part.starts_with('\'') && value_part.ends_with('\'')))
470    {
471        let mut v = value_part;
472        v.pop();
473        v.remove(0);
474        v
475    } else {
476        value_part
477    };
478
479    Some((var_name, value))
480}
481
482/// `unset VAR` → `set -e VAR`
483fn emit_unset(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
484    let mut first = true;
485    for arg in args {
486        let s = word_as_str(arg);
487        if matches!(s.as_deref(), Some(f) if f.starts_with('-')) {
488            continue;
489        }
490        if !first {
491            out.push('\n');
492        }
493        first = false;
494        // Check for array element pattern: arr[n]
495        if let Some(ref s) = s
496            && let Some((name, idx_str)) = parse_array_index_str(s)
497            && let Ok(idx) = idx_str.parse::<i64>()
498        {
499            out.push_str("set -e ");
500            out.push_str(name);
501            out.push('[');
502            itoa(out, idx + 1);
503            out.push(']');
504            continue;
505        }
506        out.push_str("set -e ");
507        emit_word(ctx, arg, out)?;
508    }
509    Ok(())
510}
511
512/// Parse `name[index]` pattern from a string.
513fn parse_array_index_str(s: &str) -> Option<(&str, &str)> {
514    let bracket = s.find('[')?;
515    if !s.ends_with(']') {
516        return None;
517    }
518    let name = &s[..bracket];
519    let idx = &s[bracket + 1..s.len() - 1];
520    if name.is_empty() || idx.is_empty() {
521        return None;
522    }
523    Some((name, idx))
524}
525
526/// `local VAR=val` → `set -l VAR val`
527fn emit_local(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
528    let mut first = true;
529    for arg in args {
530        let s = word_as_str(arg);
531        if matches!(s.as_deref(), Some(f) if f.starts_with('-')) {
532            continue;
533        }
534        if !first {
535            out.push('\n');
536        }
537        first = false;
538
539        if let Some(s) = s {
540            out.push_str("set -l ");
541            if let Some(eq) = s.find('=') {
542                out.push_str(&s[..eq]);
543                out.push(' ');
544                out.push_str(&s[eq + 1..]);
545            } else {
546                out.push_str(&s);
547            }
548        } else if let Some((name, val)) = split_word_at_equals(ctx, arg) {
549            out.push_str("set -l ");
550            out.push_str(&name);
551            out.push(' ');
552            out.push_str(&val);
553        } else {
554            out.push_str("set -l ");
555            emit_word(ctx, arg, out)?;
556        }
557    }
558    Ok(())
559}
560
561/// `declare [-x] [-g] VAR=val` → `set [-gx] VAR val`
562/// `declare -p VAR` → `set --show VAR`
563fn emit_declare(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
564    let mut scope = "-g";
565    let mut print_mode = false;
566    let mut remaining = Vec::new();
567
568    for arg in args {
569        if let Some(s) = word_as_str(arg) {
570            match &*s {
571                "-n" => {
572                    return Err(TranslateError::Unsupported("declare -n (nameref)"));
573                }
574                "-A" | "-Ag" | "-gA" => {
575                    return Err(TranslateError::Unsupported(
576                        "declare -A (associative array)",
577                    ));
578                }
579                "-p" => print_mode = true,
580                "-x" => scope = "-gx",
581                "-g" => scope = "-g",
582                s if s.starts_with('-') => {}
583                _ => remaining.push(*arg),
584            }
585        } else {
586            remaining.push(*arg);
587        }
588    }
589
590    if print_mode {
591        if remaining.is_empty() {
592            out.push_str("set --show");
593        } else {
594            for (i, arg) in remaining.iter().enumerate() {
595                if i > 0 {
596                    out.push('\n');
597                }
598                out.push_str("set --show ");
599                emit_word(ctx, arg, out)?;
600            }
601        }
602        return Ok(());
603    }
604
605    let mut first = true;
606    for arg in &remaining {
607        if !first {
608            out.push('\n');
609        }
610        first = false;
611
612        if let Some((var_name, value_parts)) = split_word_at_equals(ctx, arg) {
613            out.push_str("set ");
614            out.push_str(scope);
615            out.push(' ');
616            out.push_str(&var_name);
617            if !value_parts.is_empty() {
618                out.push(' ');
619                out.push_str(&value_parts);
620            }
621        } else if let Some(s) = word_as_str(arg) {
622            out.push_str("set ");
623            out.push_str(scope);
624            out.push(' ');
625            out.push_str(&s);
626        } else {
627            out.push_str("set ");
628            out.push_str(scope);
629            out.push(' ');
630            emit_word(ctx, arg, out)?;
631        }
632    }
633    Ok(())
634}
635
636/// `read` — strip bash-specific flags that don't exist in fish.
637/// `trap 'handler' SIG ...` → `function __reef_trap_SIG --on-signal SIG; handler; end`
638/// `trap 'handler' EXIT` → `function __reef_trap_EXIT --on-event fish_exit; handler; end`
639/// `trap - SIG` → `functions -e __reef_trap_SIG`
640fn emit_trap(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
641    if args.is_empty() {
642        return Err(TranslateError::Unsupported("bare trap"));
643    }
644
645    let handler_str = word_as_str(args[0]);
646
647    if handler_str.as_deref() == Some("-") {
648        for sig_word in &args[1..] {
649            let sig = word_as_str(sig_word)
650                .ok_or(TranslateError::Unsupported("trap with dynamic signal"))?;
651            let name = sig.strip_prefix("SIG").unwrap_or(&sig);
652            out.push_str("functions -e __reef_trap_");
653            out.push_str(name);
654        }
655        return Ok(());
656    }
657
658    if args.len() < 2 {
659        return Err(TranslateError::Unsupported("trap with missing signal"));
660    }
661
662    // Get fish body from handler: either translate from string or emit directly
663    let fish_body = match &handler_str {
664        Some(h) if h.is_empty() => String::new(),
665        Some(h) => translate_bash_to_fish(h)?,
666        None => {
667            // Handler contains variables — emit it as fish command directly
668            let mut body = String::with_capacity(128);
669            emit_word_unquoted(ctx, args[0], &mut body)?;
670            translate_bash_to_fish(&body)?
671        }
672    };
673
674    for (i, sig_word) in args[1..].iter().enumerate() {
675        if i > 0 {
676            out.push('\n');
677        }
678        let sig =
679            word_as_str(sig_word).ok_or(TranslateError::Unsupported("trap with dynamic signal"))?;
680        let name = sig.strip_prefix("SIG").unwrap_or(&sig);
681
682        // ERR trap has no fish equivalent
683        if name == "ERR" {
684            return Err(TranslateError::Unsupported("trap ERR (no fish equivalent)"));
685        }
686
687        // EXIT trap inside a subshell: fish's begin/end has no "on-exit" event,
688        // so fish_exit won't fire when the begin block ends. Bail to T3.
689        if (name == "EXIT" || name == "0") && ctx.in_subshell {
690            return Err(TranslateError::Unsupported(
691                "trap EXIT in subshell (no fish equivalent)",
692            ));
693        }
694
695        out.push_str("function __reef_trap_");
696        out.push_str(name);
697        if name == "EXIT" || name == "0" {
698            out.push_str(" --on-event fish_exit");
699        } else {
700            out.push_str(" --on-signal ");
701            out.push_str(name);
702        }
703        if fish_body.is_empty() {
704            out.push_str("; end");
705        } else {
706            out.push('\n');
707            out.push_str(&fish_body);
708            out.push_str("\nend");
709        }
710    }
711    Ok(())
712}
713
714fn emit_read(ctx: &mut Ctx, 
715    cmd_words: &[&Word<'_>],
716    redirects: &[&Redir<'_>],
717    out: &mut String,
718) -> Res<()> {
719    out.push_str("read");
720    let mut skip_next = false;
721    for word in &cmd_words[1..] {
722        if skip_next {
723            skip_next = false;
724            // Emit the prompt argument for -P
725            out.push_str(" -P ");
726            emit_word(ctx, word, out)?;
727            continue;
728        }
729        if let Some(s) = word_as_str(word) {
730            if s == "-r" || s == "-ra" || s == "-ar" {
731                // fish read is raw by default; -a handled below
732                if s.contains('a') {
733                    out.push_str(" --list");
734                }
735                continue;
736            }
737            if s == "-a" {
738                // bash read -a → fish read --list
739                out.push_str(" --list");
740                continue;
741            }
742            if s == "-p" {
743                // bash read -p "prompt" → fish read -P "prompt"
744                skip_next = true;
745                continue;
746            }
747            // Handle combined flags like -rp, -rn, etc.
748            if s.as_bytes()[0] == b'-' && s.len() > 1 && s.as_bytes()[1] != b'-' {
749                let mut wrote_flags = false;
750                let mut needs_prompt = false;
751                for &b in &s.as_bytes()[1..] {
752                    match b {
753                        b'r' => {} // skip -r (fish default)
754                        b'a' => out.push_str(" --list"),
755                        b'p' => needs_prompt = true,
756                        _ => {
757                            if !wrote_flags {
758                                out.push_str(" -");
759                                wrote_flags = true;
760                            }
761                            out.push(b as char);
762                        }
763                    }
764                }
765                if needs_prompt {
766                    skip_next = true;
767                }
768                continue;
769            }
770        }
771        out.push(' ');
772        emit_word(ctx, word, out)?;
773    }
774    emit_redirects(ctx, redirects, out)?;
775    Ok(())
776}
777
778/// `mapfile -t arr <<< "$(cmd)"` → `set arr (cmd)`
779/// `readarray -t arr < <(cmd)` → `set arr (cmd)`
780fn emit_mapfile(ctx: &mut Ctx, 
781    cmd_words: &[&Word<'_>],
782    redirects: &[&Redir<'_>],
783    herestring: Option<&Word<'_>>,
784    out: &mut String,
785) -> Res<()> {
786    // Parse args: skip flags (-t, -d, etc.), find variable name
787    let mut var_name: Cow<'_, str> = Cow::Borrowed("MAPFILE"); // default bash array name
788    let mut skip_next = false;
789    for word in &cmd_words[1..] {
790        if skip_next {
791            skip_next = false;
792            continue;
793        }
794        if let Some(s) = word_as_str(word) {
795            match s.as_bytes().first() {
796                Some(b'-') => {
797                    // -t strips trailing newline (fish does this by default)
798                    // -O, -s, -c, -C, -d, -n, -u take an argument
799                    match &*s {
800                        "-O" | "-s" | "-c" | "-C" | "-d" | "-n" | "-u" => skip_next = true,
801                        _ => {}
802                    }
803                }
804                _ => var_name = s,
805            }
806        }
807    }
808    // Check herestring first
809    if let Some(hs_word) = herestring {
810        // mapfile -t arr <<< "$(cmd)" → set arr (string split \n -- "content")
811        out.push_str("set ");
812        out.push_str(&var_name);
813        out.push_str(" (string split -- \\n ");
814        emit_word(ctx, hs_word, out)?;
815        out.push(')');
816        return Ok(());
817    }
818
819    // Find the input source from redirects
820    let mut has_input_redir = false;
821    for redir in redirects {
822        match redir {
823            Redir::HereString(word) => {
824                out.push_str("set ");
825                out.push_str(&var_name);
826                out.push_str(" (string split -- \\n ");
827                emit_word(ctx, word, out)?;
828                out.push(')');
829                has_input_redir = true;
830                break;
831            }
832            Redir::Read(_, word) => {
833                // mapfile -t arr < <(cmd) — extract commands from ProcSubIn
834                out.push_str("set ");
835                out.push_str(&var_name);
836                out.push_str(" (");
837                if let Some(cmds) = extract_procsub_cmds(word) {
838                    for (i, cmd) in cmds.iter().enumerate() {
839                        if i > 0 {
840                            out.push_str("; ");
841                        }
842                        emit_cmd(ctx, cmd, out)?;
843                    }
844                } else {
845                    out.push_str("cat ");
846                    emit_word(ctx, word, out)?;
847                }
848                out.push(')');
849                has_input_redir = true;
850                break;
851            }
852            _ => {}
853        }
854    }
855    if !has_input_redir {
856        out.push_str("set ");
857        out.push_str(&var_name);
858        out.push_str(" (cat)");
859    }
860    Ok(())
861}
862
863/// `shift` → `set -e argv[1]`; `shift N` → `set argv $argv[(math "N+1")..]`
864/// `eval "$(cmd)"` → `cmd | source`
865/// `eval $var` / other forms → unsupported (fall to T2)
866fn emit_eval(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
867    // Extract the command list from eval "$(cmd)" or eval $(cmd)
868    let cmds = extract_eval_cmds(args).ok_or(TranslateError::Unsupported("eval"))?;
869    for (i, cmd) in cmds.iter().enumerate() {
870        if i > 0 {
871            out.push_str("; ");
872        }
873        emit_cmd(ctx, cmd, out)?;
874    }
875    out.push_str(" | source");
876    Ok(())
877}
878
879fn extract_eval_cmds<'a>(args: &[&'a Word<'a>]) -> Option<&'a [Cmd<'a>]> {
880    let [arg] = args else { return None };
881    let subst = match arg {
882        Word::Simple(WordPart::DQuoted(atoms)) => match atoms.as_slice() {
883            [Atom::Subst(s)] => s,
884            _ => return None,
885        },
886        Word::Simple(WordPart::Bare(Atom::Subst(s))) => s,
887        _ => return None,
888    };
889    match subst.as_ref() {
890        Subst::Cmd(cmds) => Some(cmds),
891        _ => None,
892    }
893}
894
895/// `set -e`, `set -u`, `set -x`, `set -o pipefail` → no-op comments (fish has no equivalents).
896/// `set -- args...` → `set argv args...`
897fn emit_bash_set(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
898    if args.is_empty() {
899        out.push_str("set");
900        return Ok(());
901    }
902    if let Some(first) = args.first().and_then(|w| word_as_str(w)) {
903        let fb = first.as_bytes();
904        if fb == b"--" {
905            // set -- val1 val2 → set argv val1 val2
906            out.push_str("set argv");
907            for arg in &args[1..] {
908                out.push(' ');
909                emit_word(ctx, arg, out)?;
910            }
911            return Ok(());
912        }
913        // set [-+][euxo]... — shell options have no fish equivalent
914        if fb.len() >= 2
915            && (fb[0] == b'-' || fb[0] == b'+')
916            && fb[1..]
917                .iter()
918                .all(|&b| matches!(b, b'e' | b'u' | b'x' | b'o'))
919        {
920            out.push_str("# set");
921            for arg in args {
922                out.push(' ');
923                emit_word(ctx, arg, out)?;
924            }
925            out.push_str(" # no fish equivalent");
926            return Ok(());
927        }
928    }
929    // Unknown set usage — pass through
930    out.push_str("set");
931    for arg in args {
932        out.push(' ');
933        emit_word(ctx, arg, out)?;
934    }
935    Ok(())
936}
937
938fn emit_shift(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
939    let Some(first) = args.first() else {
940        out.push_str("set -e argv[1]");
941        return Ok(());
942    };
943    if let Some(s) = word_as_str(first)
944        && let Ok(n) = s.parse::<u32>()
945    {
946        if n <= 1 {
947            out.push_str("set -e argv[1]");
948        } else {
949            out.push_str("set argv $argv[");
950            itoa(out, i64::from(n + 1));
951            out.push_str("..]");
952        }
953        return Ok(());
954    }
955    // Dynamic shift amount
956    out.push_str("set argv $argv[(math \"");
957    emit_word(ctx, first, out)?;
958    out.push_str(" + 1\")..]");
959    Ok(())
960}
961
962/// `alias name='cmd args'` → `alias name 'cmd args'`
963fn emit_alias(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
964    out.push_str("alias");
965    for arg in args {
966        out.push(' ');
967        if let Some(s) = word_as_str(arg) {
968            // Bash alias format: name='value' or name="value"
969            // Fish alias format: alias name 'value' (space-separated)
970            if let Some(eq_pos) = s.find('=') {
971                let name = &s[..eq_pos];
972                let value = &s[eq_pos + 1..];
973                // Strip surrounding quotes from value if present
974                let unquoted = if (value.starts_with('\'') && value.ends_with('\''))
975                    || (value.starts_with('"') && value.ends_with('"'))
976                {
977                    &value[1..value.len() - 1]
978                } else {
979                    value
980                };
981                out.push_str(name);
982                out.push(' ');
983                out.push('\'');
984                out.push_str(unquoted);
985                out.push('\'');
986                continue;
987            }
988        }
989        emit_word(ctx, arg, out)?;
990    }
991    Ok(())
992}
993
994/// `readonly VAR=val` → `set -g VAR val`
995fn emit_readonly(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
996    let mut first = true;
997    for arg in args {
998        if let Some(s) = word_as_str(arg)
999            && s.starts_with('-')
1000        {
1001            continue;
1002        }
1003        if !first {
1004            out.push('\n');
1005        }
1006        first = false;
1007
1008        if let Some(s) = word_as_str(arg) {
1009            if let Some(eq) = s.find('=') {
1010                out.push_str("set -g ");
1011                out.push_str(&s[..eq]);
1012                out.push(' ');
1013                out.push_str(&s[eq + 1..]);
1014            } else {
1015                out.push_str("set -g ");
1016                out.push_str(&s);
1017                out.push_str(" $");
1018                out.push_str(&s);
1019            }
1020        } else if let Some((name, val)) = split_word_at_equals(ctx, arg) {
1021            out.push_str("set -g ");
1022            out.push_str(&name);
1023            out.push(' ');
1024            out.push_str(&val);
1025        } else {
1026            out.push_str("set -g ");
1027            emit_word(ctx, arg, out)?;
1028        }
1029    }
1030    Ok(())
1031}
1032
1033/// `let expr` → parse each argument as an arithmetic expression and emit.
1034fn emit_let(ctx: &mut Ctx, args: &[&Word<'_>], out: &mut String) -> Res<()> {
1035    for (i, arg) in args.iter().enumerate() {
1036        if i > 0 {
1037            out.push('\n');
1038        }
1039        // Reconstruct the argument as a string and re-parse as arithmetic
1040        let mut arg_str = String::with_capacity(32);
1041        emit_word_unquoted(ctx, arg, &mut arg_str)?;
1042
1043        // Parse the let argument as an arithmetic expression
1044        let mut parser = Parser::new(&arg_str);
1045        match parser.arith(0) {
1046            Ok(arith) => {
1047                emit_standalone_arith(ctx, &arith, out)?;
1048            }
1049            Err(_) => {
1050                return Err(TranslateError::Unsupported("'let' with complex expression"));
1051            }
1052        }
1053    }
1054    Ok(())
1055}
1056
1057/// Emit `string match [-rq|-q] 'pattern' -- subject` for [[ ]] operators.
1058fn emit_string_match(ctx: &mut Ctx, 
1059    lhs: &[&Word<'_>],
1060    rhs: &[&Word<'_>],
1061    regex: bool,
1062    negated: bool,
1063    out: &mut String,
1064) -> Res<()> {
1065    if regex {
1066        // For regex matching, capture into __bash_rematch so ${BASH_REMATCH[n]} works.
1067        // `set __bash_rematch (string match -r ...)` returns 0 on match.
1068        if negated {
1069            out.push_str("not ");
1070        }
1071        out.push_str("set __bash_rematch (string match -r -- ");
1072    } else {
1073        if negated {
1074            out.push_str("not ");
1075        }
1076        out.push_str("string match -q -- ");
1077    }
1078    let mut pat_buf = String::with_capacity(32);
1079    for (i, w) in rhs.iter().enumerate() {
1080        if i > 0 {
1081            pat_buf.push(' ');
1082        }
1083        emit_word_unquoted(ctx, w, &mut pat_buf)?;
1084    }
1085    push_sq_escaped(out, &pat_buf);
1086    out.push(' ');
1087    for w in lhs {
1088        emit_word(ctx, w, out)?;
1089    }
1090    if regex {
1091        out.push(')');
1092    }
1093    Ok(())
1094}
1095
1096/// `[[ cond ]]` → `test cond` or `string match -q pattern subject`
1097fn emit_double_bracket(ctx: &mut Ctx, 
1098    args: &[&Word<'_>],
1099    redirects: &[&Redir<'_>],
1100    out: &mut String,
1101) -> Res<()> {
1102    // Strip trailing ]]
1103    let filtered = if args.last().and_then(|a| word_as_str(a)).as_deref() == Some("]]") {
1104        &args[..args.len() - 1]
1105    } else {
1106        args
1107    };
1108
1109    // Strip leading `!` negation operator
1110    let (filtered, bang_negated) =
1111        if !filtered.is_empty() && word_as_str(filtered[0]).as_deref() == Some("!") {
1112            (&filtered[1..], true)
1113        } else {
1114            (filtered, false)
1115        };
1116
1117    // Find =~ operator position for regex matching
1118    let regex_pos = filtered
1119        .iter()
1120        .position(|a| word_as_str(a).as_deref() == Some("=~"));
1121
1122    // Find == or != operator position for glob pattern matching
1123    let op_pos = filtered.iter().position(|a| {
1124        let s = word_as_str(a);
1125        matches!(s.as_deref(), Some("==" | "!="))
1126    });
1127
1128    // [[ -v var ]] → set -q var
1129    if filtered.len() == 2
1130        && let Some(flag) = word_as_str(filtered[0])
1131        && flag.as_ref() == "-v"
1132    {
1133        if bang_negated {
1134            out.push_str("not ");
1135        }
1136        out.push_str("set -q ");
1137        emit_word(ctx, filtered[1], out)?;
1138        emit_redirects(ctx, redirects, out)?;
1139        return Ok(());
1140    }
1141
1142    if let Some(pos) = regex_pos {
1143        emit_string_match(ctx, &filtered[..pos], &filtered[pos + 1..], true, bang_negated, out)?;
1144    } else if let Some(pos) = op_pos {
1145        let negated = word_as_str(filtered[pos]).as_deref() == Some("!=");
1146        // XOR: `[[ ! x != y ]]` → double negation cancels out
1147        emit_string_match(ctx, &filtered[..pos], &filtered[pos + 1..], false, negated ^ bang_negated, out)?;
1148    } else {
1149        if bang_negated {
1150            out.push_str("not ");
1151        }
1152        out.push_str("test");
1153        for arg in filtered {
1154            out.push(' ');
1155            emit_word(ctx, arg, out)?;
1156        }
1157    }
1158
1159    emit_redirects(ctx, redirects, out)?;
1160    Ok(())
1161}
1162
1163// ---------------------------------------------------------------------------
1164// Compound commands
1165// ---------------------------------------------------------------------------
1166
1167fn emit_compound(ctx: &mut Ctx, cmd: &CompoundCmd<'_>, out: &mut String) -> Res<()> {
1168    let herestring = cmd.redirects.iter().find_map(|r| match r {
1169        Redir::HereString(w) => Some(w),
1170        _ => None,
1171    });
1172    let heredoc = cmd.redirects.iter().find_map(|r| match r {
1173        Redir::Heredoc(body) => Some(body),
1174        _ => None,
1175    });
1176    if let Some(hs_word) = herestring {
1177        out.push_str("echo ");
1178        emit_word(ctx, hs_word, out)?;
1179        out.push_str(" | ");
1180    }
1181    if let Some(body) = heredoc {
1182        emit_heredoc_body(ctx, body, out)?;
1183        out.push_str(" | ");
1184    }
1185    emit_compound_kind(ctx, &cmd.kind, out)?;
1186    for redir in &cmd.redirects {
1187        if matches!(redir, Redir::HereString(..) | Redir::Heredoc(..)) {
1188            continue;
1189        }
1190        out.push(' ');
1191        emit_redir(ctx, redir, out)?;
1192    }
1193    Ok(())
1194}
1195
1196/// If the word is a bare (unquoted) command substitution like $(cmd),
1197/// return a reference to the commands inside.
1198fn get_bare_command_subst<'a>(word: &'a Word<'a>) -> Option<&'a [Cmd<'a>]> {
1199    match word {
1200        Word::Simple(WordPart::Bare(Atom::Subst(subst))) => match subst.as_ref() {
1201            Subst::Cmd(cmds) => Some(cmds),
1202            _ => None,
1203        },
1204        _ => None,
1205    }
1206}
1207
1208/// Check if the word is a bare unquoted `$var` that bash would word-split.
1209fn is_bare_var_ref(word: &Word<'_>) -> bool {
1210    matches!(word, Word::Simple(WordPart::Bare(Atom::Param(Param::Var(_)))))
1211}
1212
1213/// Emit a command substitution with `| string split -n ' '` inside the parens,
1214/// replicating bash's IFS word splitting for for-loop word lists.
1215fn emit_command_subst_with_split(ctx: &mut Ctx, cmds: &[Cmd<'_>], out: &mut String) -> Res<()> {
1216    out.push('(');
1217    for (i, cmd) in cmds.iter().enumerate() {
1218        if i > 0 {
1219            out.push_str("; ");
1220        }
1221        emit_cmd(ctx, cmd, out)?;
1222    }
1223    out.push_str(" | string split -n ' ')");
1224    Ok(())
1225}
1226
1227fn emit_compound_kind(ctx: &mut Ctx, kind: &CompoundKind<'_>, out: &mut String) -> Res<()> {
1228    match kind {
1229        CompoundKind::For { var, words, body } => {
1230            out.push_str("for ");
1231            out.push_str(var);
1232            out.push_str(" in ");
1233            if let Some(words) = words {
1234                for (i, w) in words.iter().enumerate() {
1235                    if i > 0 {
1236                        out.push(' ');
1237                    }
1238                    if let Some(cmds) = get_bare_command_subst(w) {
1239                        emit_command_subst_with_split(ctx, cmds, out)?;
1240                    } else if is_bare_var_ref(w) {
1241                        // Bash word-splits unquoted $var; fish doesn't.
1242                        // Wrap in string split to match bash semantics.
1243                        out.push_str("(string split -n -- ' ' ");
1244                        emit_word(ctx, w, out)?;
1245                        out.push(')');
1246                    } else {
1247                        emit_word(ctx, w, out)?;
1248                    }
1249                }
1250            } else {
1251                out.push_str("$argv");
1252            }
1253            out.push('\n');
1254            emit_body(ctx, body, out)?;
1255            out.push_str("\nend");
1256        }
1257
1258        CompoundKind::While(guard_body) => {
1259            out.push_str("while ");
1260            emit_guard(ctx, &guard_body.guard, out)?;
1261            out.push('\n');
1262            emit_body(ctx, &guard_body.body, out)?;
1263            out.push_str("\nend");
1264        }
1265
1266        CompoundKind::Until(guard_body) => {
1267            out.push_str("while not ");
1268            emit_guard(ctx, &guard_body.guard, out)?;
1269            out.push('\n');
1270            emit_body(ctx, &guard_body.body, out)?;
1271            out.push_str("\nend");
1272        }
1273
1274        CompoundKind::If {
1275            conditionals,
1276            else_branch,
1277        } => {
1278            for (i, guard_body) in conditionals.iter().enumerate() {
1279                if i == 0 {
1280                    out.push_str("if ");
1281                } else {
1282                    out.push_str("\nelse if ");
1283                }
1284                emit_guard(ctx, &guard_body.guard, out)?;
1285                out.push('\n');
1286                emit_body(ctx, &guard_body.body, out)?;
1287            }
1288            if let Some(else_body) = else_branch {
1289                out.push_str("\nelse\n");
1290                emit_body(ctx, else_body, out)?;
1291            }
1292            out.push_str("\nend");
1293        }
1294
1295        CompoundKind::Case { word, arms } => {
1296            out.push_str("switch ");
1297            emit_word(ctx, word, out)?;
1298            out.push('\n');
1299            let mut pat_buf = String::with_capacity(32);
1300            for arm in arms {
1301                out.push_str("case ");
1302                for (i, pattern) in arm.patterns.iter().enumerate() {
1303                    if i > 0 {
1304                        out.push(' ');
1305                    }
1306                    pat_buf.clear();
1307                    emit_word(ctx, pattern, &mut pat_buf)?;
1308
1309                    if let Some(expanded) = expand_bracket_pattern(&pat_buf) {
1310                        out.push_str(&expanded);
1311                    } else if pat_buf.contains('*') || pat_buf.contains('?') {
1312                        push_sq_escaped(out, &pat_buf);
1313                    } else {
1314                        out.push_str(&pat_buf);
1315                    }
1316                }
1317                out.push('\n');
1318                emit_body(ctx, &arm.body, out)?;
1319                out.push('\n');
1320            }
1321            out.push_str("end");
1322        }
1323
1324        CompoundKind::CFor {
1325            init,
1326            cond,
1327            step,
1328            body,
1329        } => {
1330            if let Some(init_expr) = init {
1331                emit_standalone_arith(ctx, init_expr, out)?;
1332                out.push('\n');
1333            }
1334            out.push_str("while ");
1335            if let Some(cond_expr) = cond {
1336                emit_arith_condition(cond_expr, out)?;
1337            } else {
1338                out.push_str("true");
1339            }
1340            out.push('\n');
1341            emit_body(ctx, body, out)?;
1342            if let Some(step_expr) = step {
1343                out.push('\n');
1344                emit_standalone_arith(ctx, step_expr, out)?;
1345            }
1346            out.push_str("\nend");
1347        }
1348
1349        CompoundKind::Brace(cmds) => {
1350            out.push_str("begin\n");
1351            emit_body(ctx, cmds, out)?;
1352            out.push_str("\nend");
1353        }
1354
1355        CompoundKind::Subshell(cmds) => {
1356            if cmds.is_empty() {
1357                return Err(TranslateError::Unsupported("empty subshell"));
1358            }
1359            out.push_str("begin\n");
1360            out.push_str("set -l __reef_pwd (pwd)\n");
1361            let prev = ctx.in_subshell;
1362            ctx.in_subshell = true;
1363            emit_body(ctx, cmds, out)?;
1364            ctx.in_subshell = prev;
1365            out.push_str(
1366                "\nset -l __reef_rc $status; cd $__reef_pwd 2>/dev/null\nend",
1367            );
1368        }
1369
1370        CompoundKind::DoubleBracket(cmds) => {
1371            emit_body(ctx, cmds, out)?;
1372        }
1373
1374        CompoundKind::Arithmetic(arith) => {
1375            emit_standalone_arith(ctx, arith, out)?;
1376        }
1377    }
1378    Ok(())
1379}
1380
1381/// Expand a pure bracket pattern [chars] to space-separated alternatives.
1382fn expand_bracket_pattern(pat: &str) -> Option<String> {
1383    if !pat.starts_with('[') || !pat.ends_with(']') || pat.len() < 3 {
1384        return None;
1385    }
1386    let inner = &pat[1..pat.len() - 1];
1387    if inner.contains('-') {
1388        return None;
1389    }
1390    let mut result = String::with_capacity(inner.len() * 4);
1391    for (i, &b) in inner.as_bytes().iter().enumerate() {
1392        if i > 0 {
1393            result.push(' ');
1394        }
1395        if b == b'\'' {
1396            result.push_str("'\\'''");
1397        } else {
1398            result.push('\'');
1399            result.push(b as char);
1400            result.push('\'');
1401        }
1402    }
1403    Some(result)
1404}
1405
1406fn emit_guard(ctx: &mut Ctx, guard: &[Cmd<'_>], out: &mut String) -> Res<()> {
1407    if guard.len() == 1 {
1408        emit_cmd(ctx, &guard[0], out)?;
1409    } else {
1410        out.push_str("begin; ");
1411        for (i, cmd) in guard.iter().enumerate() {
1412            if i > 0 {
1413                out.push_str("; ");
1414            }
1415            emit_cmd(ctx, cmd, out)?;
1416        }
1417        out.push_str("; end");
1418    }
1419    Ok(())
1420}
1421
1422fn emit_body(ctx: &mut Ctx, cmds: &[Cmd<'_>], out: &mut String) -> Res<()> {
1423    for (i, cmd) in cmds.iter().enumerate() {
1424        if i > 0 {
1425            out.push('\n');
1426        }
1427        emit_cmd(ctx, cmd, out)?;
1428    }
1429    Ok(())
1430}
1431
1432// ---------------------------------------------------------------------------
1433// Word-level emitters
1434// ---------------------------------------------------------------------------
1435
1436fn emit_word(ctx: &mut Ctx, word: &Word<'_>, out: &mut String) -> Res<()> {
1437    // Check for nested brace expansion that fish handles in different order
1438    if word_has_nested_braces(word) {
1439        return Err(TranslateError::Unsupported(
1440            "nested brace expansion (fish expands in different order)",
1441        ));
1442    }
1443    // Brace range combined with non-literal parts (e.g. {a..c}$(cmd)) —
1444    // bash expands brace range first, creating separate words each getting
1445    // the suffix. Fish doesn't distribute the suffix across brace-expanded words.
1446    if word_has_brace_range_concat(word) {
1447        return Err(TranslateError::Unsupported(
1448            "brace range with concatenated expansion",
1449        ));
1450    }
1451    match word {
1452        Word::Simple(p) => emit_word_part(ctx, p, out),
1453        Word::Concat(parts) => {
1454            for p in parts {
1455                emit_word_part(ctx, p, out)?;
1456            }
1457            Ok(())
1458        }
1459    }
1460}
1461
1462/// Emit a word with its outer quoting layer stripped.
1463fn emit_word_unquoted(ctx: &mut Ctx, word: &Word<'_>, out: &mut String) -> Res<()> {
1464    match word {
1465        Word::Simple(WordPart::DQuoted(parts)) => {
1466            for part in parts {
1467                emit_atom(ctx, part, out)?;
1468            }
1469            Ok(())
1470        }
1471        Word::Simple(WordPart::SQuoted(s)) => {
1472            out.push_str(s);
1473            Ok(())
1474        }
1475        _ => emit_word(ctx, word, out),
1476    }
1477}
1478
1479fn emit_word_part(ctx: &mut Ctx, part: &WordPart<'_>, out: &mut String) -> Res<()> {
1480    match part {
1481        WordPart::Bare(atom) => emit_atom(ctx, atom, out),
1482        WordPart::DQuoted(parts) => {
1483            let mut in_quotes = true;
1484            out.push('"');
1485            for atom in parts {
1486                if let Atom::Subst(_) = atom {
1487                    if in_quotes {
1488                        out.push('"');
1489                        in_quotes = false;
1490                    }
1491                } else if !in_quotes {
1492                    out.push('"');
1493                    in_quotes = true;
1494                }
1495                emit_atom(ctx, atom, out)?;
1496            }
1497            if in_quotes {
1498                out.push('"');
1499            }
1500            Ok(())
1501        }
1502        WordPart::SQuoted(s) => {
1503            out.push('\'');
1504            out.push_str(s);
1505            out.push('\'');
1506            Ok(())
1507        }
1508    }
1509}
1510
1511fn emit_atom(ctx: &mut Ctx, atom: &Atom<'_>, out: &mut String) -> Res<()> {
1512    match atom {
1513        Atom::Lit(s) => {
1514            out.push_str(s);
1515            Ok(())
1516        }
1517        Atom::Escaped(s) => {
1518            out.push('\\');
1519            out.push_str(s);
1520            Ok(())
1521        }
1522        Atom::Param(param) => {
1523            check_untranslatable_var(param)?;
1524            emit_param(param, out);
1525            Ok(())
1526        }
1527        Atom::Subst(subst) => emit_subst(ctx, subst, out),
1528        Atom::Star => {
1529            out.push('*');
1530            Ok(())
1531        }
1532        Atom::Question => {
1533            out.push('?');
1534            Ok(())
1535        }
1536        Atom::SquareOpen => {
1537            out.push('[');
1538            Ok(())
1539        }
1540        Atom::SquareClose => {
1541            out.push(']');
1542            Ok(())
1543        }
1544        Atom::Tilde => {
1545            out.push('~');
1546            Ok(())
1547        }
1548        Atom::ProcSubIn(cmds) => {
1549            out.push('(');
1550            for (i, cmd) in cmds.iter().enumerate() {
1551                if i > 0 {
1552                    out.push_str("; ");
1553                }
1554                emit_cmd(ctx, cmd, out)?;
1555            }
1556            out.push_str(" | psub)");
1557            Ok(())
1558        }
1559        Atom::AnsiCQuoted(s) => {
1560            emit_ansi_c_quoted(s, out);
1561            Ok(())
1562        }
1563        Atom::BraceRange { start, end, step } => {
1564            emit_brace_range(start, end, *step, out);
1565            Ok(())
1566        }
1567    }
1568}
1569
1570/// Check if a `Concat` word contains a `BraceRange` alongside dynamic parts
1571/// (command substitution, parameter expansion, etc.). Bash distributes the
1572/// suffix across each brace-expanded element; fish doesn't.
1573fn word_has_brace_range_concat(word: &Word<'_>) -> bool {
1574    let Word::Concat(parts) = word else { return false };
1575    let has_brace_range = parts.iter().any(|p| {
1576        matches!(p, WordPart::Bare(Atom::BraceRange { .. }))
1577    });
1578    if !has_brace_range {
1579        return false;
1580    }
1581    // Check if any other part contains an expansion (param, subst, etc.).
1582    // Pure literals like `{a..c}"hello"` are fine — fish handles those correctly.
1583    parts.iter().any(|p| match p {
1584        WordPart::Bare(Atom::Param(_) | Atom::Subst(_) | Atom::ProcSubIn(_)) => true,
1585        WordPart::DQuoted(atoms) => atoms.iter().any(|a| !matches!(a, Atom::Lit(_))),
1586        _ => false,
1587    })
1588}
1589
1590/// Detect adjacent brace comma expansions like `{a,b}{1,2}` which fish expands
1591/// in a different order than bash. Walks AST literal slices directly — zero
1592/// allocation.
1593fn word_has_nested_braces(word: &Word<'_>) -> bool {
1594    let mut state = BraceState::default();
1595    let parts: &[WordPart<'_>] = match word {
1596        Word::Simple(p) => std::slice::from_ref(p),
1597        Word::Concat(parts) => parts,
1598    };
1599    for p in parts {
1600        if scan_part_braces(p, &mut state) {
1601            return true;
1602        }
1603    }
1604    state.count >= 2
1605}
1606
1607#[derive(Default)]
1608struct BraceState {
1609    /// Number of consecutive `{...,..}` groups seen.
1610    count: u32,
1611    /// True while scanning inside a `{` and before seeing `}`.
1612    in_brace: bool,
1613    /// True if we've seen a `,` inside the current brace group.
1614    has_comma: bool,
1615}
1616
1617/// Feed one word-part's literal bytes into the brace state machine.
1618/// Returns `true` early if two adjacent brace groups are detected.
1619fn scan_part_braces(part: &WordPart<'_>, st: &mut BraceState) -> bool {
1620    let slice: &str = match part {
1621        WordPart::Bare(Atom::Lit(s)) | WordPart::SQuoted(s) => s,
1622        _ => return false, // non-literal parts break adjacency
1623    };
1624    for &b in slice.as_bytes() {
1625        if st.in_brace {
1626            match b {
1627                b',' => st.has_comma = true,
1628                b'}' => {
1629                    st.in_brace = false;
1630                    if st.has_comma {
1631                        st.count += 1;
1632                        if st.count >= 2 {
1633                            return true;
1634                        }
1635                    } else {
1636                        st.count = 0;
1637                    }
1638                }
1639                _ => {}
1640            }
1641        } else if b == b'{' {
1642            st.in_brace = true;
1643            st.has_comma = false;
1644        } else {
1645            st.count = 0;
1646        }
1647    }
1648    false
1649}
1650
1651/// Translate bash `$'...'` ANSI-C quoting to fish.
1652/// Fish only interprets escape sequences like `\n`, `\t` outside of quotes,
1653/// so we use double quotes for literal text and break out for escapes.
1654/// Ensure we're outside double quotes (bare mode for fish escape sequences).
1655#[inline]
1656fn ensure_bare(in_dq: &mut bool, out: &mut String) {
1657    if *in_dq {
1658        out.push('"');
1659        *in_dq = false;
1660    }
1661}
1662
1663/// Ensure we're inside double quotes (for literal text).
1664#[inline]
1665fn ensure_dquoted(in_dq: &mut bool, out: &mut String) {
1666    if !*in_dq {
1667        out.push('"');
1668        *in_dq = true;
1669    }
1670}
1671
1672fn emit_ansi_c_quoted(s: &str, out: &mut String) {
1673    let bytes = s.as_bytes();
1674    let mut i = 0;
1675    let mut in_dq = false;
1676
1677    while i < bytes.len() {
1678        if bytes[i] == b'\\' && i + 1 < bytes.len() {
1679            match bytes[i + 1] {
1680                // Escapes that fish interprets only bare (outside quotes)
1681                b'n' | b't' | b'r' | b'a' | b'b' | b'e' | b'f' | b'v' => {
1682                    ensure_bare(&mut in_dq, out);
1683                    out.push('\\');
1684                    out.push(bytes[i + 1] as char);
1685                    i += 2;
1686                }
1687                b'E' => {
1688                    ensure_bare(&mut in_dq, out);
1689                    out.push_str("\\e");
1690                    i += 2;
1691                }
1692                b'x' | b'0' => {
1693                    ensure_bare(&mut in_dq, out);
1694                    out.push('\\');
1695                    i += 1;
1696                    while i < bytes.len()
1697                        && (bytes[i].is_ascii_hexdigit() || bytes[i] == b'x' || bytes[i] == b'0')
1698                    {
1699                        out.push(bytes[i] as char);
1700                        i += 1;
1701                    }
1702                }
1703                b'\'' => {
1704                    ensure_dquoted(&mut in_dq, out);
1705                    out.push('\'');
1706                    i += 2;
1707                }
1708                b'\\' => {
1709                    ensure_dquoted(&mut in_dq, out);
1710                    out.push_str("\\\\");
1711                    i += 2;
1712                }
1713                b'?' => {
1714                    ensure_dquoted(&mut in_dq, out);
1715                    out.push('?');
1716                    i += 2;
1717                }
1718                _ => {
1719                    ensure_dquoted(&mut in_dq, out);
1720                    out.push(bytes[i + 1] as char);
1721                    i += 2;
1722                }
1723            }
1724        } else {
1725            ensure_dquoted(&mut in_dq, out);
1726            match bytes[i] {
1727                b'$' => out.push_str("\\$"),
1728                b'"' => out.push_str("\\\""),
1729                _ => out.push(bytes[i] as char),
1730            }
1731            i += 1;
1732        }
1733    }
1734    if in_dq {
1735        out.push('"');
1736    }
1737}
1738
1739fn emit_brace_range(start: &str, end: &str, step: Option<&str>, out: &mut String) {
1740    // Alpha range: {a..z} → expand inline
1741    let sc = start.as_bytes().first().copied().unwrap_or(0);
1742    let ec = end.as_bytes().first().copied().unwrap_or(0);
1743    if start.len() == 1 && end.len() == 1 && sc.is_ascii_alphabetic() && ec.is_ascii_alphabetic() {
1744        out.push_str(&expand_alpha_range(sc as char, ec as char));
1745        return;
1746    }
1747
1748    // Numeric range
1749    if let Some(step) = step {
1750        out.push_str("(seq ");
1751        out.push_str(start);
1752        out.push(' ');
1753        out.push_str(step);
1754        out.push(' ');
1755        out.push_str(end);
1756        out.push(')');
1757    } else if let (Ok(s), Ok(e)) = (start.parse::<i64>(), end.parse::<i64>()) {
1758        out.push_str("(seq ");
1759        out.push_str(start);
1760        if s > e {
1761            out.push_str(" -1 ");
1762        } else {
1763            out.push(' ');
1764        }
1765        out.push_str(end);
1766        out.push(')');
1767    } else {
1768        out.push_str("(seq ");
1769        out.push_str(start);
1770        out.push(' ');
1771        out.push_str(end);
1772        out.push(')');
1773    }
1774}
1775
1776fn expand_alpha_range(start: char, end: char) -> String {
1777    let (lo, hi) = if start <= end {
1778        (start as u8, end as u8)
1779    } else {
1780        (end as u8, start as u8)
1781    };
1782    let count = (hi - lo + 1) as usize;
1783    let mut result = String::with_capacity(count * 2);
1784    if start <= end {
1785        for c in lo..=hi {
1786            if !result.is_empty() {
1787                result.push(' ');
1788            }
1789            result.push(c as char);
1790        }
1791    } else {
1792        for c in (lo..=hi).rev() {
1793            if !result.is_empty() {
1794                result.push(' ');
1795            }
1796            result.push(c as char);
1797        }
1798    }
1799    result
1800}
1801
1802// ---------------------------------------------------------------------------
1803// Parameter and substitution emitters
1804// ---------------------------------------------------------------------------
1805
1806/// Reject bash-specific variables that have no fish equivalent.
1807fn check_untranslatable_var(param: &Param<'_>) -> Res<()> {
1808    if let Param::Var(name) = param {
1809        match *name {
1810            "LINENO" => return Err(TranslateError::Unsupported("$LINENO")),
1811            "FUNCNAME" => return Err(TranslateError::Unsupported("$FUNCNAME")),
1812            "SECONDS" => return Err(TranslateError::Unsupported("$SECONDS")),
1813            "COMP_WORDS" | "COMP_CWORD" | "COMP_LINE" | "COMP_POINT" => {
1814                return Err(TranslateError::Unsupported("bash completion variable"));
1815            }
1816            _ => {}
1817        }
1818    }
1819    Ok(())
1820}
1821
1822fn emit_param(param: &Param<'_>, out: &mut String) {
1823    match param {
1824        Param::Var("RANDOM") => out.push_str("(random)"),
1825        Param::Var("HOSTNAME") => out.push_str("$hostname"),
1826        Param::Var("BASH_SOURCE" | "BASH_SOURCE[@]") => {
1827            out.push_str("(status filename)");
1828        }
1829        Param::Var("PIPESTATUS") => out.push_str("$pipestatus"),
1830        Param::Var(name) => {
1831            out.push('$');
1832            out.push_str(name);
1833        }
1834        Param::Positional(n) => {
1835            if *n == 0 {
1836                out.push_str("(status filename)");
1837            } else {
1838                out.push_str("$argv[");
1839                itoa(out, i64::from(*n));
1840                out.push(']');
1841            }
1842        }
1843        Param::At | Param::Star => out.push_str("$argv"),
1844        Param::Pound => out.push_str("(count $argv)"),
1845        Param::Status => out.push_str("$status"),
1846        Param::Pid => out.push_str("$fish_pid"),
1847        Param::Bang => out.push_str("$last_pid"),
1848        Param::Dash => out.push_str("\"\""),
1849    }
1850}
1851
1852fn emit_subst(ctx: &mut Ctx, subst: &Subst<'_>, out: &mut String) -> Res<()> {
1853    match subst {
1854        Subst::Cmd(cmds) => {
1855            out.push('(');
1856            for (i, cmd) in cmds.iter().enumerate() {
1857                if i > 0 {
1858                    out.push_str("; ");
1859                }
1860                emit_cmd(ctx, cmd, out)?;
1861            }
1862            out.push(')');
1863            Ok(())
1864        }
1865
1866        Subst::Arith(Some(arith)) => {
1867            if arith_has_unsupported(arith) {
1868                return Err(TranslateError::Unsupported(
1869                    "unsupported arithmetic (bitwise, increment, or assignment)",
1870                ));
1871            }
1872            if arith_needs_test(arith) {
1873                emit_arith_as_command(arith, out)
1874            } else {
1875                out.push_str("(math \"");
1876                emit_arith(arith, out);
1877                out.push_str("\")");
1878                Ok(())
1879            }
1880        }
1881        Subst::Arith(None) => {
1882            out.push_str("(math 0)");
1883            Ok(())
1884        }
1885
1886        Subst::Indirect(name) => {
1887            // ${!ref} → $$ref in fish
1888            out.push_str("$$");
1889            out.push_str(name);
1890            Ok(())
1891        }
1892
1893        Subst::PrefixList(prefix) => {
1894            // ${!prefix*} → (set -n | string match 'prefix*')
1895            out.push_str("(set -n | string match '");
1896            out.push_str(prefix);
1897            out.push_str("*')");
1898            Ok(())
1899        }
1900
1901        Subst::Transform(name, op) => {
1902            match op {
1903                b'Q' => {
1904                    // ${var@Q} → (string escape -- $var)
1905                    out.push_str("(string escape -- $");
1906                    out.push_str(name);
1907                    out.push(')');
1908                    Ok(())
1909                }
1910                b'U' => {
1911                    // ${var@U} → (string upper -- $var)
1912                    out.push_str("(string upper -- $");
1913                    out.push_str(name);
1914                    out.push(')');
1915                    Ok(())
1916                }
1917                b'u' => {
1918                    // ${var@u} → capitalize first char
1919                    out.push_str("(string sub -l 1 -- $");
1920                    out.push_str(name);
1921                    out.push_str(" | string upper)(string sub -s 2 -- $");
1922                    out.push_str(name);
1923                    out.push(')');
1924                    Ok(())
1925                }
1926                b'L' => {
1927                    // ${var@L} → (string lower -- $var)
1928                    out.push_str("(string lower -- $");
1929                    out.push_str(name);
1930                    out.push(')');
1931                    Ok(())
1932                }
1933                b'E' => Err(TranslateError::Unsupported("${var@E} escape expansion")),
1934                b'P' => Err(TranslateError::Unsupported("${var@P} prompt expansion")),
1935                b'A' => Err(TranslateError::Unsupported("${var@A} assignment form")),
1936                b'K' => Err(TranslateError::Unsupported("${var@K} quoted key-value")),
1937                b'a' => Err(TranslateError::Unsupported("${var@a} attribute flags")),
1938                _ => Err(TranslateError::Unsupported(
1939                    "unsupported parameter transformation",
1940                )),
1941            }
1942        }
1943
1944        Subst::Len(param) => {
1945            out.push_str("(string length -- \"");
1946            emit_param(param, out);
1947            out.push_str("\")");
1948            Ok(())
1949        }
1950
1951        Subst::Default(param, word) => {
1952            out.push_str("(set -q ");
1953            emit_param_name(param, out);
1954            out.push_str("; and echo $");
1955            emit_param_name(param, out);
1956            out.push_str("; or echo ");
1957            if let Some(w) = word {
1958                emit_word(ctx, w, out)?;
1959            }
1960            out.push(')');
1961            Ok(())
1962        }
1963
1964        Subst::Assign(param, word) => {
1965            out.push_str("(set -q ");
1966            emit_param_name(param, out);
1967            out.push_str("; or set ");
1968            emit_param_name(param, out);
1969            out.push(' ');
1970            if let Some(w) = word {
1971                emit_word(ctx, w, out)?;
1972            }
1973            out.push_str("; echo $");
1974            emit_param_name(param, out);
1975            out.push(')');
1976            Ok(())
1977        }
1978
1979        Subst::Error(param, word) => {
1980            out.push_str("(set -q ");
1981            emit_param_name(param, out);
1982            out.push_str("; and echo $");
1983            emit_param_name(param, out);
1984            out.push_str("; or begin; echo ");
1985            if let Some(w) = word {
1986                emit_word(ctx, w, out)?;
1987            } else {
1988                out.push_str("'parameter ");
1989                emit_param_name(param, out);
1990                out.push_str(" not set'");
1991            }
1992            out.push_str(" >&2; return 1; end)");
1993            Ok(())
1994        }
1995
1996        Subst::Alt(param, word) => {
1997            out.push_str("(set -q ");
1998            emit_param_name(param, out);
1999            out.push_str("; and echo ");
2000            if let Some(w) = word {
2001                emit_word(ctx, w, out)?;
2002            }
2003            out.push(')');
2004            Ok(())
2005        }
2006
2007        Subst::TrimSuffixSmall(param, pattern) => {
2008            emit_string_op(ctx, param, pattern.as_ref(), "suffix", false, out)
2009        }
2010        Subst::TrimSuffixLarge(param, pattern) => {
2011            emit_string_op(ctx, param, pattern.as_ref(), "suffix", true, out)
2012        }
2013        Subst::TrimPrefixSmall(param, pattern) => {
2014            emit_string_op(ctx, param, pattern.as_ref(), "prefix", false, out)
2015        }
2016        Subst::TrimPrefixLarge(param, pattern) => {
2017            emit_string_op(ctx, param, pattern.as_ref(), "prefix", true, out)
2018        }
2019
2020        Subst::Upper(all, param) => {
2021            if !all {
2022                // ${var^} capitalize first char: upper first char + rest
2023                out.push_str("(string sub -l 1 -- $");
2024                emit_param_name(param, out);
2025                out.push_str(" | string upper)(string sub -s 2 -- $");
2026                emit_param_name(param, out);
2027                out.push(')');
2028                return Ok(());
2029            }
2030            out.push_str("(string upper -- \"");
2031            emit_param(param, out);
2032            out.push_str("\")");
2033            Ok(())
2034        }
2035        Subst::Lower(all, param) => {
2036            if !all {
2037                out.push_str("(string sub -l 1 -- $");
2038                emit_param_name(param, out);
2039                out.push_str(" | string lower)(string sub -s 2 -- $");
2040                emit_param_name(param, out);
2041                out.push(')');
2042                return Ok(());
2043            }
2044            out.push_str("(string lower -- \"");
2045            emit_param(param, out);
2046            out.push_str("\")");
2047            Ok(())
2048        }
2049
2050        Subst::Replace(param, pattern, replacement) => {
2051            emit_string_replace(ctx, param, pattern.as_ref(), replacement.as_ref(), false, false, false, out)
2052        }
2053        Subst::ReplaceAll(param, pattern, replacement) => {
2054            emit_string_replace(ctx, param, pattern.as_ref(), replacement.as_ref(), true, false, false, out)
2055        }
2056        Subst::ReplacePrefix(param, pattern, replacement) => {
2057            emit_string_replace(ctx, param, pattern.as_ref(), replacement.as_ref(), false, true, false, out)
2058        }
2059        Subst::ReplaceSuffix(param, pattern, replacement) => {
2060            emit_string_replace(ctx, param, pattern.as_ref(), replacement.as_ref(), false, false, true, out)
2061        }
2062
2063        Subst::Substring(param, offset, length) => {
2064            out.push_str("(string sub -s (math \"");
2065            out.push_str(offset);
2066            out.push_str(" + 1\")");
2067            if let Some(len) = length {
2068                out.push_str(" -l (math \"");
2069                out.push_str(len);
2070                out.push_str("\")");
2071            }
2072            out.push_str(" -- \"");
2073            emit_param(param, out);
2074            out.push_str("\")");
2075            Ok(())
2076        }
2077
2078        // --- Array operations ---
2079        Subst::ArrayElement(name, idx) => {
2080            if *name == "BASH_REMATCH" {
2081                out.push_str("$__bash_rematch[");
2082                emit_array_index(ctx, idx, out)?;
2083                out.push(']');
2084            } else if *name == "PIPESTATUS" {
2085                out.push_str("$pipestatus[");
2086                emit_array_index(ctx, idx, out)?;
2087                out.push(']');
2088            } else {
2089                // ${arr[n]} → $arr[n+1]  (bash 0-indexed → fish 1-indexed)
2090                out.push('$');
2091                out.push_str(name);
2092                out.push('[');
2093                emit_array_index(ctx, idx, out)?;
2094                out.push(']');
2095            }
2096            Ok(())
2097        }
2098        Subst::ArrayAll(name) => {
2099            // ${arr[@]} → $arr
2100            if *name == "PIPESTATUS" {
2101                out.push_str("$pipestatus");
2102            } else {
2103                out.push('$');
2104                out.push_str(name);
2105            }
2106            Ok(())
2107        }
2108        Subst::ArrayLen(name) => {
2109            // ${#arr[@]} → (count $arr)
2110            out.push_str("(count $");
2111            out.push_str(name);
2112            out.push(')');
2113            Ok(())
2114        }
2115        Subst::ArraySlice(name, offset, length) => {
2116            // ${arr[@]:offset:length} → $arr[(math "offset + 1")..(math "offset + length")]
2117            out.push('$');
2118            out.push_str(name);
2119            out.push_str("[(math \"");
2120            out.push_str(offset);
2121            out.push_str(" + 1\")..(math \"");
2122            if let Some(len) = length {
2123                out.push_str(offset);
2124                out.push_str(" + ");
2125                out.push_str(len);
2126            } else {
2127                // No length — to end of array
2128                out.push_str("(count $");
2129                out.push_str(name);
2130                out.push(')');
2131            }
2132            out.push_str("\")]");
2133            Ok(())
2134        }
2135    }
2136}
2137
2138/// Emit a bash array index as a fish 1-based index.
2139/// Handles: literal numbers (compile-time +1), $var (math "$var + 1"),
2140/// and $((expr)) (inlines the arithmetic + 1).
2141fn emit_array_index(ctx: &mut Ctx, idx: &Word<'_>, out: &mut String) -> Res<()> {
2142    // Case 1: simple literal number — add 1 at compile time
2143    if let Some(s) = word_as_str(idx)
2144        && let Ok(n) = s.parse::<i64>()
2145    {
2146        itoa(out, n + 1);
2147        return Ok(());
2148    }
2149
2150    // Case 2: $((arith_expr)) — inline the arithmetic expression
2151    if let Word::Simple(WordPart::Bare(Atom::Subst(subst))) = idx
2152        && let Subst::Arith(Some(arith)) = subst.as_ref()
2153    {
2154        out.push_str("(math \"");
2155        emit_arith(arith, out);
2156        out.push_str(" + 1\")");
2157        return Ok(());
2158    }
2159
2160    // Case 3: other expressions ($var, etc.) — wrap in math
2161    out.push_str("(math \"");
2162    emit_word(ctx, idx, out)?;
2163    out.push_str(" + 1\")");
2164    Ok(())
2165}
2166
2167/// Emit ${var%pattern} / ${var#pattern} style operations using fish string replace.
2168fn emit_string_op(ctx: &mut Ctx, 
2169    param: &Param<'_>,
2170    pattern: Option<&Word<'_>>,
2171    kind: &str,
2172    greedy: bool,
2173    out: &mut String,
2174) -> Res<()> {
2175    // For non-greedy suffix removal (%), use ^(.*)PATTERN$ → '$1'.
2176    // The greedy (.*) captures max prefix, leaving the shortest suffix.
2177    let suffix_small = kind == "suffix" && !greedy;
2178
2179    out.push_str("(string replace -r -- '");
2180
2181    if suffix_small {
2182        out.push_str("^(.*)");
2183    } else if kind == "prefix" {
2184        out.push('^');
2185    }
2186
2187    if let Some(p) = pattern {
2188        // For suffix_small, pattern uses greedy * because the prefix
2189        // capture group handles shortest-suffix semantics.
2190        let pat_greedy = if suffix_small { true } else { greedy };
2191        emit_word_as_pattern(ctx, p, out, pat_greedy)?;
2192    }
2193
2194    if kind == "suffix" {
2195        out.push('$');
2196    }
2197
2198    if suffix_small {
2199        out.push_str("' '$1' $");
2200    } else {
2201        out.push_str("' '' $");
2202    }
2203    emit_param_name(param, out);
2204    out.push(')');
2205    Ok(())
2206}
2207
2208/// Emit `${var/pat/rep}` family using fish `string replace`.
2209// Each parameter corresponds to a distinct semantic role (mode flags,
2210// param, pattern, replacement, output, context, quoting) — collapsing
2211// them would obscure the intent more than the long signature does.
2212#[allow(clippy::too_many_arguments)]
2213fn emit_string_replace(ctx: &mut Ctx,
2214    param: &Param<'_>,
2215    pattern: Option<&Word<'_>>,
2216    replacement: Option<&Word<'_>>,
2217    all: bool,
2218    prefix: bool,
2219    suffix: bool,
2220    out: &mut String,
2221) -> Res<()> {
2222    let needs_regex = prefix || suffix || pattern.is_some_and(word_has_glob);
2223
2224    out.push_str("(string replace ");
2225    if needs_regex {
2226        out.push_str("-r ");
2227    }
2228    if all {
2229        out.push_str("-a ");
2230    }
2231    out.push_str("-- '");
2232
2233    if prefix {
2234        out.push('^');
2235    }
2236    if let Some(p) = pattern {
2237        if needs_regex {
2238            emit_word_as_pattern(ctx, p, out, true)?;
2239        } else {
2240            emit_word_unquoted(ctx, p, out)?;
2241        }
2242    }
2243    if suffix {
2244        out.push('$');
2245    }
2246    out.push_str("' '");
2247    if let Some(r) = replacement {
2248        emit_word_unquoted(ctx, r, out)?;
2249    }
2250    out.push_str("' \"$");
2251    emit_param_name(param, out);
2252    out.push_str("\")");
2253    Ok(())
2254}
2255
2256/// Emit a word as a regex pattern (basic glob→regex conversion).
2257/// Uses lookahead to correctly convert non-greedy `*` by examining
2258/// the character that follows the glob star.
2259fn emit_word_as_pattern(ctx: &mut Ctx, 
2260    word: &Word<'_>,
2261    out: &mut String,
2262    greedy: bool,
2263) -> Res<()> {
2264    // Flatten pattern to a list of "pattern pieces" for lookahead
2265    let mut pieces: Vec<PatPiece<'_>> = Vec::new();
2266    match word {
2267        Word::Simple(p) => collect_pattern_pieces(p, &mut pieces),
2268        Word::Concat(parts) => {
2269            for p in parts {
2270                collect_pattern_pieces(p, &mut pieces);
2271            }
2272        }
2273    }
2274
2275    // Emit with lookahead
2276    for piece in &pieces {
2277        match piece {
2278            PatPiece::Lit(s) => {
2279                for &b in s.as_bytes() {
2280                    match b {
2281                        b'.' | b'+' | b'(' | b')' | b'{' | b'}' | b'|' | b'\\' | b'^' | b'$' => {
2282                            out.push('\\');
2283                            out.push(b as char);
2284                        }
2285                        _ => out.push(b as char),
2286                    }
2287                }
2288            }
2289            PatPiece::Star => {
2290                if greedy {
2291                    out.push_str(".*");
2292                } else {
2293                    out.push_str(".*?");
2294                }
2295            }
2296            PatPiece::Question => out.push('.'),
2297            PatPiece::Other(atom) => emit_atom(ctx, atom, out)?,
2298        }
2299    }
2300    Ok(())
2301}
2302
2303enum PatPiece<'a> {
2304    Lit(&'a str),
2305    Star,
2306    Question,
2307    Other(&'a Atom<'a>),
2308}
2309
2310fn collect_pattern_pieces<'a>(part: &'a WordPart<'a>, pieces: &mut Vec<PatPiece<'a>>) {
2311    match part {
2312        WordPart::Bare(atom) => match atom {
2313            Atom::Lit(s) => pieces.push(PatPiece::Lit(s)),
2314            Atom::Star => pieces.push(PatPiece::Star),
2315            Atom::Question => pieces.push(PatPiece::Question),
2316            other => pieces.push(PatPiece::Other(other)),
2317        },
2318        WordPart::SQuoted(s) => pieces.push(PatPiece::Lit(s)),
2319        WordPart::DQuoted(atoms) => {
2320            for atom in atoms {
2321                match atom {
2322                    Atom::Lit(s) => pieces.push(PatPiece::Lit(s)),
2323                    other => pieces.push(PatPiece::Other(other)),
2324                }
2325            }
2326        }
2327    }
2328}
2329
2330// ---------------------------------------------------------------------------
2331// Arithmetic
2332// ---------------------------------------------------------------------------
2333
2334/// Emit standalone `(( expr ))` as a fish assignment.
2335fn emit_standalone_arith(ctx: &mut Ctx, arith: &Arith<'_>, out: &mut String) -> Res<()> {
2336    let set_kw = if ctx.in_subshell { "set -l " } else { "set " };
2337    match arith {
2338        Arith::PostInc(var) | Arith::PreInc(var) => {
2339            out.push_str(set_kw);
2340            out.push_str(var);
2341            out.push_str(" (math \"$");
2342            out.push_str(var);
2343            out.push_str(" + 1\")");
2344            Ok(())
2345        }
2346        Arith::PostDec(var) | Arith::PreDec(var) => {
2347            out.push_str(set_kw);
2348            out.push_str(var);
2349            out.push_str(" (math \"$");
2350            out.push_str(var);
2351            out.push_str(" - 1\")");
2352            Ok(())
2353        }
2354        Arith::Assign(var, expr) => {
2355            out.push_str(set_kw);
2356            out.push_str(var);
2357            out.push_str(" (math \"");
2358            emit_arith(expr, out);
2359            out.push_str("\")");
2360            Ok(())
2361        }
2362        Arith::Lt(..)
2363        | Arith::Le(..)
2364        | Arith::Gt(..)
2365        | Arith::Ge(..)
2366        | Arith::Eq(..)
2367        | Arith::Ne(..)
2368        | Arith::LogAnd(..)
2369        | Arith::LogOr(..)
2370        | Arith::LogNot(..) => emit_arith_condition(arith, out),
2371
2372        _ => Err(TranslateError::Unsupported(
2373            "unsupported standalone arithmetic expression",
2374        )),
2375    }
2376}
2377
2378fn emit_arith(arith: &Arith<'_>, out: &mut String) {
2379    match arith {
2380        Arith::Var(name) => {
2381            // Positional parameters: $1 → $argv[1], etc.
2382            if name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
2383                out.push_str("$argv[");
2384                out.push_str(name);
2385                out.push(']');
2386            } else {
2387                out.push('$');
2388                out.push_str(name);
2389            }
2390        }
2391        Arith::Lit(n) => {
2392            itoa(out, *n);
2393        }
2394
2395        Arith::Add(l, r) => emit_arith_binop(l, " + ", r, out),
2396        Arith::Sub(l, r) => emit_arith_binop(l, " - ", r, out),
2397        Arith::Mul(l, r) => emit_arith_binop(l, " * ", r, out),
2398        Arith::Div(l, r) => {
2399            // Bash integer division truncates toward zero; fish math returns float.
2400            // floor() is correct for positive quotients (the common case).
2401            // Negative quotients differ: floor(-7/2)=-4 vs bash's -3.  Fish has
2402            // no trunc(), and negative integer division in interactive shells is rare.
2403            out.push_str("floor(");
2404            emit_arith(l, out);
2405            out.push_str(" / ");
2406            emit_arith(r, out);
2407            out.push(')');
2408        }
2409        Arith::Rem(l, r) => emit_arith_binop(l, " % ", r, out),
2410        Arith::Pow(l, r) => emit_arith_binop(l, " ^ ", r, out),
2411        Arith::Lt(l, r) => emit_arith_binop(l, " < ", r, out),
2412        Arith::Le(l, r) => emit_arith_binop(l, " <= ", r, out),
2413        Arith::Gt(l, r) => emit_arith_binop(l, " > ", r, out),
2414        Arith::Ge(l, r) => emit_arith_binop(l, " >= ", r, out),
2415        Arith::Eq(l, r) => emit_arith_binop(l, " == ", r, out),
2416        Arith::Ne(l, r) => emit_arith_binop(l, " != ", r, out),
2417        Arith::BitAnd(l, r) => {
2418            out.push_str("bitand(");
2419            emit_arith(l, out);
2420            out.push_str(", ");
2421            emit_arith(r, out);
2422            out.push(')');
2423        }
2424        Arith::BitOr(l, r) => {
2425            out.push_str("bitor(");
2426            emit_arith(l, out);
2427            out.push_str(", ");
2428            emit_arith(r, out);
2429            out.push(')');
2430        }
2431        Arith::BitXor(l, r) => {
2432            out.push_str("bitxor(");
2433            emit_arith(l, out);
2434            out.push_str(", ");
2435            emit_arith(r, out);
2436            out.push(')');
2437        }
2438        Arith::LogAnd(l, r) => emit_arith_binop(l, " && ", r, out),
2439        Arith::LogOr(l, r) => emit_arith_binop(l, " || ", r, out),
2440        Arith::Shl(l, r) => {
2441            // fish math doesn't have <<, use: a * 2^n
2442            out.push('(');
2443            emit_arith(l, out);
2444            out.push_str(" * 2 ^ ");
2445            emit_arith(r, out);
2446            out.push(')');
2447        }
2448        Arith::Shr(l, r) => {
2449            // fish math doesn't have >>, use: floor(a / 2^n)
2450            out.push_str("floor(");
2451            emit_arith(l, out);
2452            out.push_str(" / 2 ^ ");
2453            emit_arith(r, out);
2454            out.push(')');
2455        }
2456
2457        Arith::Pos(e) => {
2458            out.push('+');
2459            emit_arith(e, out);
2460        }
2461        Arith::Neg(e) => {
2462            out.push('-');
2463            emit_arith(e, out);
2464        }
2465        Arith::LogNot(e) => {
2466            out.push('!');
2467            emit_arith(e, out);
2468        }
2469        Arith::BitNot(e) => {
2470            // fish math doesn't have ~, use: bitxor(x, -1) which flips all bits
2471            out.push_str("bitxor(");
2472            emit_arith(e, out);
2473            out.push_str(", -1)");
2474        }
2475
2476        Arith::PostInc(var) | Arith::PreInc(var) => {
2477            out.push_str("($");
2478            out.push_str(var);
2479            out.push_str(" + 1)");
2480        }
2481        Arith::PostDec(var) | Arith::PreDec(var) => {
2482            out.push_str("($");
2483            out.push_str(var);
2484            out.push_str(" - 1)");
2485        }
2486
2487        Arith::Ternary(cond, then_val, else_val) => {
2488            out.push('(');
2489            emit_arith(cond, out);
2490            out.push_str(" ? ");
2491            emit_arith(then_val, out);
2492            out.push_str(" : ");
2493            emit_arith(else_val, out);
2494            out.push(')');
2495        }
2496
2497        Arith::Assign(var, expr) => {
2498            out.push_str(var);
2499            out.push_str(" = ");
2500            emit_arith(expr, out);
2501        }
2502    }
2503}
2504
2505fn emit_arith_binop(l: &Arith<'_>, op: &str, r: &Arith<'_>, out: &mut String) {
2506    let l_needs_parens = is_arith_binop(l);
2507    let r_needs_parens = is_arith_binop(r);
2508
2509    if l_needs_parens {
2510        out.push('(');
2511    }
2512    emit_arith(l, out);
2513    if l_needs_parens {
2514        out.push(')');
2515    }
2516
2517    out.push_str(op);
2518
2519    if r_needs_parens {
2520        out.push('(');
2521    }
2522    emit_arith(r, out);
2523    if r_needs_parens {
2524        out.push(')');
2525    }
2526}
2527
2528fn is_arith_binop(arith: &Arith<'_>) -> bool {
2529    matches!(
2530        arith,
2531        Arith::Add(..)
2532            | Arith::Sub(..)
2533            | Arith::Mul(..)
2534            | Arith::Div(..)
2535            | Arith::Rem(..)
2536            | Arith::Pow(..)
2537            | Arith::Lt(..)
2538            | Arith::Le(..)
2539            | Arith::Gt(..)
2540            | Arith::Ge(..)
2541            | Arith::Eq(..)
2542            | Arith::Ne(..)
2543            | Arith::BitAnd(..)
2544            | Arith::BitOr(..)
2545            | Arith::BitXor(..)
2546            | Arith::LogAnd(..)
2547            | Arith::LogOr(..)
2548            | Arith::Shl(..)
2549            | Arith::Shr(..)
2550    )
2551}
2552
2553/// Check if an arithmetic expression contains operations that fish math can't handle.
2554fn arith_has_unsupported(arith: &Arith<'_>) -> bool {
2555    match arith {
2556        Arith::PostInc(..)
2557        | Arith::PreInc(..)
2558        | Arith::PostDec(..)
2559        | Arith::PreDec(..)
2560        | Arith::Assign(..) => true,
2561
2562        Arith::Add(l, r)
2563        | Arith::Sub(l, r)
2564        | Arith::Mul(l, r)
2565        | Arith::Div(l, r)
2566        | Arith::Rem(l, r)
2567        | Arith::Pow(l, r)
2568        | Arith::Lt(l, r)
2569        | Arith::Le(l, r)
2570        | Arith::Gt(l, r)
2571        | Arith::Ge(l, r)
2572        | Arith::Eq(l, r)
2573        | Arith::Ne(l, r)
2574        | Arith::LogAnd(l, r)
2575        | Arith::LogOr(l, r)
2576        | Arith::BitAnd(l, r)
2577        | Arith::BitOr(l, r)
2578        | Arith::BitXor(l, r)
2579        | Arith::Shl(l, r)
2580        | Arith::Shr(l, r) => arith_has_unsupported(l) || arith_has_unsupported(r),
2581
2582        Arith::Pos(e) | Arith::Neg(e) | Arith::LogNot(e) | Arith::BitNot(e) => {
2583            arith_has_unsupported(e)
2584        }
2585
2586        Arith::Ternary(c, t, f) => {
2587            arith_has_unsupported(c) || arith_has_unsupported(t) || arith_has_unsupported(f)
2588        }
2589
2590        Arith::Var(_) | Arith::Lit(_) => false,
2591    }
2592}
2593
2594/// Check if an arithmetic expression requires test-based evaluation.
2595fn arith_needs_test(arith: &Arith<'_>) -> bool {
2596    matches!(
2597        arith,
2598        Arith::Lt(..)
2599            | Arith::Le(..)
2600            | Arith::Gt(..)
2601            | Arith::Ge(..)
2602            | Arith::Eq(..)
2603            | Arith::Ne(..)
2604            | Arith::LogAnd(..)
2605            | Arith::LogOr(..)
2606            | Arith::LogNot(..)
2607            | Arith::Ternary(..)
2608    )
2609}
2610
2611fn emit_arith_as_command(arith: &Arith<'_>, out: &mut String) -> Res<()> {
2612    if let Arith::Ternary(cond, then_val, else_val) = arith {
2613        out.push_str("(if ");
2614        emit_arith_condition(cond, out)?;
2615        out.push_str("; echo ");
2616        emit_arith_value(then_val, out)?;
2617        out.push_str("; else; echo ");
2618        emit_arith_value(else_val, out)?;
2619        out.push_str("; end)");
2620    } else {
2621        out.push('(');
2622        emit_arith_condition(arith, out)?;
2623        out.push_str("; and echo 1; or echo 0)");
2624    }
2625    Ok(())
2626}
2627
2628fn emit_arith_condition(arith: &Arith<'_>, out: &mut String) -> Res<()> {
2629    match arith {
2630        Arith::Lt(l, r) => emit_test_cmp(l, "-lt", r, out),
2631        Arith::Le(l, r) => emit_test_cmp(l, "-le", r, out),
2632        Arith::Gt(l, r) => emit_test_cmp(l, "-gt", r, out),
2633        Arith::Ge(l, r) => emit_test_cmp(l, "-ge", r, out),
2634        Arith::Eq(l, r) => emit_test_cmp(l, "-eq", r, out),
2635        Arith::Ne(l, r) => emit_test_cmp(l, "-ne", r, out),
2636        Arith::LogAnd(l, r) => {
2637            emit_arith_condition(l, out)?;
2638            out.push_str("; and ");
2639            emit_arith_condition(r, out)
2640        }
2641        Arith::LogOr(l, r) => {
2642            emit_arith_condition(l, out)?;
2643            out.push_str("; or ");
2644            emit_arith_condition(r, out)
2645        }
2646        Arith::LogNot(e) => {
2647            out.push_str("not ");
2648            emit_arith_condition(e, out)
2649        }
2650        _ => {
2651            out.push_str("test ");
2652            emit_arith_value(arith, out)?;
2653            out.push_str(" -ne 0");
2654            Ok(())
2655        }
2656    }
2657}
2658
2659fn emit_test_cmp(
2660    l: &Arith<'_>,
2661    op: &str,
2662    r: &Arith<'_>,
2663    out: &mut String,
2664) -> Res<()> {
2665    out.push_str("test ");
2666    emit_arith_value(l, out)?;
2667    out.push(' ');
2668    out.push_str(op);
2669    out.push(' ');
2670    emit_arith_value(r, out)
2671}
2672
2673fn emit_arith_value(arith: &Arith<'_>, out: &mut String) -> Res<()> {
2674    match arith {
2675        Arith::Var(name) => {
2676            out.push('$');
2677            out.push_str(name);
2678            Ok(())
2679        }
2680        Arith::Lit(n) => {
2681            itoa(out, *n);
2682            Ok(())
2683        }
2684        _ if arith_needs_test(arith) => emit_arith_as_command(arith, out),
2685        _ => {
2686            out.push_str("(math \"");
2687            emit_arith(arith, out);
2688            out.push_str("\")");
2689            Ok(())
2690        }
2691    }
2692}
2693
2694// ---------------------------------------------------------------------------
2695// Redirects
2696// ---------------------------------------------------------------------------
2697
2698fn emit_redirects(ctx: &mut Ctx, redirects: &[&Redir<'_>], out: &mut String) -> Res<()> {
2699    for redir in redirects {
2700        out.push(' ');
2701        emit_redir(ctx, redir, out)?;
2702    }
2703    Ok(())
2704}
2705
2706fn emit_redir(ctx: &mut Ctx, redir: &Redir<'_>, out: &mut String) -> Res<()> {
2707    fn write_fd(fd: Option<u16>, out: &mut String) {
2708        if let Some(n) = fd {
2709            itoa(out, i64::from(n));
2710        }
2711    }
2712
2713    match redir {
2714        Redir::Read(fd, word) => {
2715            write_fd(*fd, out);
2716            out.push('<');
2717            emit_word(ctx, word, out)?;
2718        }
2719        Redir::Write(fd, word) => {
2720            write_fd(*fd, out);
2721            out.push('>');
2722            emit_word(ctx, word, out)?;
2723        }
2724        Redir::Append(fd, word) => {
2725            write_fd(*fd, out);
2726            out.push_str(">>");
2727            emit_word(ctx, word, out)?;
2728        }
2729        Redir::ReadWrite(fd, word) => {
2730            write_fd(*fd, out);
2731            out.push_str("<>");
2732            emit_word(ctx, word, out)?;
2733        }
2734        Redir::Clobber(fd, word) => {
2735            write_fd(*fd, out);
2736            out.push_str(">|");
2737            emit_word(ctx, word, out)?;
2738        }
2739        Redir::DupRead(fd, word) => {
2740            write_fd(*fd, out);
2741            out.push_str("<&");
2742            emit_word(ctx, word, out)?;
2743        }
2744        Redir::DupWrite(fd, word) => {
2745            write_fd(*fd, out);
2746            out.push_str(">&");
2747            emit_word(ctx, word, out)?;
2748        }
2749        Redir::HereString(_) | Redir::Heredoc(_) => {
2750            // Handled at a higher level (emit_simple / emit_compound)
2751        }
2752        Redir::WriteAll(word) => {
2753            out.push('>');
2754            emit_word(ctx, word, out)?;
2755            out.push_str(" 2>&1");
2756        }
2757        Redir::AppendAll(word) => {
2758            out.push_str(">>");
2759            emit_word(ctx, word, out)?;
2760            out.push_str(" 2>&1");
2761        }
2762    }
2763    Ok(())
2764}
2765
2766// ---------------------------------------------------------------------------
2767// Helpers
2768// ---------------------------------------------------------------------------
2769
2770/// Extract the repeated character from a printf format like `%0.s-` or `%.0s-`.
2771/// Returns Some(char) if the format is a repetition pattern.
2772fn extract_printf_repeat_char(fmt: &str) -> Option<char> {
2773    // Match patterns: "%0.sCHAR", "%.0sCHAR", "%0.0sCHAR" etc.
2774    let stripped = fmt.strip_prefix('%')?;
2775    // Find the 's' after digits and dots
2776    let s_pos = stripped.find('s')?;
2777    let before_s = &stripped[..s_pos];
2778    // Validate it's a zero-width format: "0.", ".0", "0.0", etc.
2779    if before_s.contains('0') && (before_s.contains('.') || before_s == "0") {
2780        let after_s = &stripped[s_pos + 1..];
2781        after_s.as_bytes().first().map(|&b| b as char)
2782    } else {
2783        None
2784    }
2785}
2786
2787/// Extract a count from brace range arguments like {1..N}.
2788/// Checks if the remaining args form a brace range and returns the count.
2789fn extract_brace_range_count(args: &[&Word<'_>]) -> Option<i64> {
2790    // Look for BraceRange atom in the word
2791    for arg in args {
2792        if let Word::Simple(WordPart::Bare(Atom::BraceRange { start, end, step })) = arg {
2793            let s: i64 = start.parse().ok()?;
2794            let e: i64 = end.parse().ok()?;
2795            let st: i64 = step.and_then(|s| s.parse().ok()).unwrap_or(1);
2796            if st == 0 {
2797                return None;
2798            }
2799            let count = ((e - s).abs() / st.abs()) + 1;
2800            return Some(count);
2801        }
2802    }
2803    None
2804}
2805
2806/// Extract commands from a `ProcSubIn` atom inside a word.
2807fn extract_procsub_cmds<'a>(word: &'a Word<'a>) -> Option<&'a Vec<Cmd<'a>>> {
2808    match word {
2809        Word::Simple(WordPart::Bare(Atom::ProcSubIn(cmds))) => Some(cmds),
2810        _ => None,
2811    }
2812}
2813
2814fn word_as_str<'a>(word: &'a Word<'a>) -> Option<Cow<'a, str>> {
2815    if let Word::Simple(WordPart::Bare(Atom::Lit(s)) | WordPart::SQuoted(s)) = word {
2816        return Some(Cow::Borrowed(s));
2817    }
2818    let mut buf = String::with_capacity(64);
2819    if word_to_simple_string(word, &mut buf) {
2820        Some(Cow::Owned(buf))
2821    } else {
2822        None
2823    }
2824}
2825
2826#[inline]
2827fn word_has_glob(word: &Word<'_>) -> bool {
2828    match word {
2829        Word::Simple(p) => part_has_glob(p),
2830        Word::Concat(parts) => parts.iter().any(part_has_glob),
2831    }
2832}
2833
2834#[inline]
2835fn part_has_glob(part: &WordPart<'_>) -> bool {
2836    match part {
2837        WordPart::Bare(atom) => matches!(atom, Atom::Star | Atom::Question),
2838        WordPart::DQuoted(atoms) => atoms
2839            .iter()
2840            .any(|a| matches!(a, Atom::Star | Atom::Question)),
2841        WordPart::SQuoted(_) => false,
2842    }
2843}
2844
2845fn word_to_simple_string(word: &Word<'_>, out: &mut String) -> bool {
2846    match word {
2847        Word::Simple(p) => part_to_string(p, out),
2848        Word::Concat(parts) => {
2849            for p in parts {
2850                if !part_to_string(p, out) {
2851                    return false;
2852                }
2853            }
2854            true
2855        }
2856    }
2857}
2858
2859fn part_to_string(part: &WordPart<'_>, out: &mut String) -> bool {
2860    match part {
2861        WordPart::Bare(atom) => atom_to_string(atom, out),
2862        WordPart::SQuoted(s) => {
2863            out.push_str(s);
2864            true
2865        }
2866        WordPart::DQuoted(atoms) => {
2867            for atom in atoms {
2868                if !atom_to_string(atom, out) {
2869                    return false;
2870                }
2871            }
2872            true
2873        }
2874    }
2875}
2876
2877fn atom_to_string(atom: &Atom<'_>, out: &mut String) -> bool {
2878    match atom {
2879        Atom::Lit(s) => {
2880            out.push_str(s);
2881            true
2882        }
2883        Atom::Escaped(s) => {
2884            out.push_str(s);
2885            true
2886        }
2887        Atom::SquareOpen => {
2888            out.push('[');
2889            true
2890        }
2891        Atom::SquareClose => {
2892            out.push(']');
2893            true
2894        }
2895        Atom::Tilde => {
2896            out.push('~');
2897            true
2898        }
2899        Atom::Star => {
2900            out.push('*');
2901            true
2902        }
2903        Atom::Question => {
2904            out.push('?');
2905            true
2906        }
2907        _ => false,
2908    }
2909}
2910
2911/// Append an integer to a string without going through `fmt::Display`.
2912///
2913/// Uses a small stack buffer and manual digit extraction — avoids the
2914/// formatting machinery overhead for a hot path.
2915#[inline]
2916fn itoa(out: &mut String, n: i64) {
2917    let mut buf = [0u8; 20]; // i64::MIN has 20 chars
2918    let mut pos = buf.len();
2919    let negative = n < 0;
2920    let mut val = n.unsigned_abs();
2921    loop {
2922        pos -= 1;
2923        buf[pos] = b'0' + (val % 10) as u8;
2924        val /= 10;
2925        if val == 0 {
2926            break;
2927        }
2928    }
2929    if negative {
2930        pos -= 1;
2931        buf[pos] = b'-';
2932    }
2933    // SAFETY: buf[pos..] contains only ASCII digits and optionally '-'
2934    out.push_str(std::str::from_utf8(&buf[pos..]).expect("ASCII digits"));
2935}
2936
2937/// Push `s` into `out` wrapped in single quotes, escaping internal `'` chars.
2938/// Writes directly — no intermediate String allocation.
2939fn push_sq_escaped(out: &mut String, s: &str) {
2940    out.push('\'');
2941    for b in s.bytes() {
2942        if b == b'\'' {
2943            out.push_str("'\\''");
2944        } else {
2945            out.push(b as char);
2946        }
2947    }
2948    out.push('\'');
2949}
2950
2951/// Emit a heredoc body. Literal bodies use single quotes, interpolated bodies
2952/// use double quotes with variable/command expansion.
2953fn emit_heredoc_body(ctx: &mut Ctx, body: &HeredocBody<'_>, out: &mut String) -> Res<()> {
2954    match body {
2955        HeredocBody::Literal(text) => {
2956            out.push_str("printf '%s\\n' ");
2957            push_sq_escaped(out, text.strip_suffix('\n').unwrap_or(text));
2958            Ok(())
2959        }
2960        HeredocBody::Interpolated(atoms) => {
2961            // Build the body content with literal newlines (fish double quotes
2962            // support embedded newlines), then strip the trailing newline.
2963            let mut body_str = String::with_capacity(256);
2964            for atom in atoms {
2965                match atom {
2966                    Atom::Lit(s) => {
2967                        for &b in s.as_bytes() {
2968                            match b {
2969                                b'"' => body_str.push_str("\\\""),
2970                                b'\\' => body_str.push_str("\\\\"),
2971                                b'$' => body_str.push_str("\\$"),
2972                                _ => body_str.push(b as char),
2973                            }
2974                        }
2975                    }
2976                    Atom::Escaped(s) => {
2977                        // \$ → literal $, \\ → literal \, \` → literal `
2978                        match s.as_ref() {
2979                            "$" => body_str.push('$'),
2980                            "\\" => body_str.push_str("\\\\"),
2981                            "`" => body_str.push('`'),
2982                            _ => body_str.push_str(s),
2983                        }
2984                    }
2985                    Atom::Param(param) => emit_param(param, &mut body_str),
2986                    Atom::Subst(subst) => {
2987                        body_str.push('"');
2988                        emit_subst(ctx, subst, &mut body_str)?;
2989                        body_str.push('"');
2990                    }
2991                    _ => emit_atom(ctx, atom, &mut body_str)?,
2992                }
2993            }
2994            // Strip trailing newline (the one before the delimiter line)
2995            let trimmed = body_str.strip_suffix('\n').unwrap_or(&body_str);
2996            out.push_str("printf '%s\\n' \"");
2997            out.push_str(trimmed);
2998            out.push('"');
2999            Ok(())
3000        }
3001    }
3002}
3003
3004fn emit_param_name(param: &Param<'_>, out: &mut String) {
3005    match param {
3006        Param::Var("HOSTNAME") => out.push_str("hostname"),
3007        Param::Var("PIPESTATUS") => out.push_str("pipestatus"),
3008        Param::Var(name) => out.push_str(name),
3009        Param::Positional(n) => {
3010            out.push_str("argv[");
3011            itoa(out, i64::from(*n));
3012            out.push(']');
3013        }
3014        Param::At | Param::Star => out.push_str("argv"),
3015        Param::Pound => out.push_str("ARGC"),
3016        Param::Status => out.push_str("status"),
3017        Param::Pid => out.push_str("fish_pid"),
3018        Param::Bang => out.push_str("last_pid"),
3019        Param::Dash => out.push_str("FISH_FLAGS"),
3020    }
3021}
3022
3023// ---------------------------------------------------------------------------
3024// Tests
3025// ---------------------------------------------------------------------------
3026
3027#[cfg(test)]
3028mod tests {
3029    use super::*;
3030
3031    fn t(bash: &str) -> String {
3032        translate_bash_to_fish(bash).unwrap()
3033    }
3034
3035    fn t_unsupported(bash: &str) {
3036        assert!(matches!(translate_bash_to_fish(bash), Err(TranslateError::Unsupported(_))));
3037    }
3038
3039    // --- Simple commands ---
3040
3041    #[test]
3042    fn simple_echo() {
3043        assert_eq!(t("echo hello world"), "echo hello world");
3044    }
3045
3046    #[test]
3047    fn simple_pipeline() {
3048        assert_eq!(
3049            t("cat file | grep foo | wc -l"),
3050            "cat file | grep foo | wc -l"
3051        );
3052    }
3053
3054    #[test]
3055    fn and_or_chain() {
3056        assert_eq!(
3057            t("mkdir -p foo && cd foo || echo fail"),
3058            "mkdir -p foo; and cd foo; or echo fail"
3059        );
3060    }
3061
3062    // --- Variable assignment ---
3063
3064    #[test]
3065    fn standalone_assignment() {
3066        assert_eq!(t("FOO=bar"), "set FOO bar");
3067    }
3068
3069    #[test]
3070    fn env_prefix_command() {
3071        // Prefix assignments with a command bail to bash passthrough
3072        t_unsupported("FOO=bar command");
3073    }
3074
3075    // --- Export ---
3076
3077    #[test]
3078    fn export_simple() {
3079        assert_eq!(t("export EDITOR=vim"), "set -gx EDITOR vim");
3080    }
3081
3082    #[test]
3083    fn export_path_splits_colons() {
3084        assert_eq!(
3085            t("export PATH=/usr/bin:$PATH"),
3086            "set -gx PATH /usr/bin $PATH"
3087        );
3088        assert_eq!(
3089            t("export PATH=$HOME/bin:/usr/local/bin:$PATH"),
3090            "set -gx PATH $HOME/bin /usr/local/bin $PATH"
3091        );
3092        // Non-PATH variable should NOT split on colons
3093        assert_eq!(
3094            t("export FOO=a:b:c"),
3095            "set -gx FOO a:b:c"
3096        );
3097    }
3098
3099    // --- For loop ---
3100
3101    #[test]
3102    fn for_loop_with_seq() {
3103        let result = t("for i in $(seq 5); do echo $i; done");
3104        assert!(result.contains("for i in (seq 5 | string split -n ' ')"));
3105        assert!(result.contains("echo $i"));
3106        assert!(result.contains("end"));
3107    }
3108
3109    #[test]
3110    fn for_loop_word_split_echo() {
3111        // Bare $(echo a b c) in for-loop should get string split
3112        let result = t("for f in $(echo a b c); do echo $f; done");
3113        assert!(result.contains("for f in (echo a b c | string split -n ' ')"));
3114    }
3115
3116    #[test]
3117    fn for_loop_literal_words_no_split() {
3118        // Literal words in for-loop should NOT get string split
3119        let result = t("for f in a b c; do echo $f; done");
3120        assert!(result.contains("for f in a b c"));
3121        assert!(!result.contains("string split"));
3122    }
3123
3124    #[test]
3125    fn for_loop_quoted_subst_no_split() {
3126        // Quoted "$(cmd)" should NOT get string split (quotes suppress it in bash)
3127        let result = t("for f in \"$(echo a b c)\"; do echo $f; done");
3128        assert!(!result.contains("string split"));
3129    }
3130
3131    #[test]
3132    fn for_loop_with_glob() {
3133        let result = t("for f in *.txt; do echo $f; done");
3134        assert!(result.contains("for f in *.txt"));
3135        assert!(result.contains("echo $f"));
3136    }
3137
3138    #[test]
3139    fn for_loop_bare_var_gets_split() {
3140        let result = t(r#"files="a b c"; for f in $files; do echo $f; done"#);
3141        assert!(result.contains("(string split -n -- ' ' $files)"));
3142    }
3143
3144    // --- If ---
3145
3146    #[test]
3147    fn if_then_fi() {
3148        let result = t("if test -f foo; then echo exists; fi");
3149        assert!(result.contains("if test -f foo"));
3150        assert!(result.contains("echo exists"));
3151        assert!(result.contains("end"));
3152    }
3153
3154    #[test]
3155    fn if_else() {
3156        let result = t("if test -f foo; then echo yes; else echo no; fi");
3157        assert!(result.contains("if test -f foo"));
3158        assert!(result.contains("echo yes"));
3159        assert!(result.contains("else"));
3160        assert!(result.contains("echo no"));
3161        assert!(result.contains("end"));
3162    }
3163
3164    // --- While ---
3165
3166    #[test]
3167    fn while_loop() {
3168        let result = t("while true; do echo loop; done");
3169        assert!(result.contains("while true"));
3170        assert!(result.contains("echo loop"));
3171        assert!(result.contains("end"));
3172    }
3173
3174    // --- Command substitution ---
3175
3176    #[test]
3177    fn command_substitution() {
3178        assert_eq!(t("echo $(whoami)"), "echo (whoami)");
3179    }
3180
3181    // --- Arithmetic ---
3182
3183    #[test]
3184    fn arithmetic_substitution() {
3185        let result = t("echo $((2 + 2))");
3186        assert!(result.contains("math"));
3187        assert!(result.contains("2 + 2"));
3188    }
3189
3190    // --- Parameters ---
3191
3192    #[test]
3193    fn special_params() {
3194        assert_eq!(t("echo $?"), "echo $status");
3195    }
3196
3197    #[test]
3198    fn positional_params() {
3199        assert_eq!(t("echo $1"), "echo $argv[1]");
3200    }
3201
3202    #[test]
3203    fn all_args() {
3204        assert_eq!(t("echo $@"), "echo $argv");
3205    }
3206
3207    // --- Unset ---
3208
3209    #[test]
3210    fn unset_var() {
3211        assert_eq!(t("unset FOO"), "set -e FOO");
3212    }
3213
3214    // --- Local ---
3215
3216    #[test]
3217    fn local_var() {
3218        assert_eq!(t("local FOO=bar"), "set -l FOO bar");
3219    }
3220
3221    // --- Background job ---
3222
3223    #[test]
3224    fn background_job() {
3225        assert_eq!(t("sleep 10 &"), "sleep 10 &");
3226    }
3227
3228    // --- Negated pipeline ---
3229
3230    #[test]
3231    fn negated_pipeline() {
3232        assert_eq!(t("! grep -q pattern file"), "not grep -q pattern file");
3233    }
3234
3235    // --- Case ---
3236
3237    #[test]
3238    fn case_statement() {
3239        let result = t("case $1 in foo) echo foo;; bar) echo bar;; esac");
3240        assert!(result.contains("switch"));
3241        assert!(result.contains("case foo"));
3242        assert!(result.contains("echo foo"));
3243        assert!(result.contains("case bar"));
3244        assert!(result.contains("echo bar"));
3245        assert!(result.contains("end"));
3246    }
3247
3248    // --- Redirects ---
3249
3250    #[test]
3251    fn stderr_redirect() {
3252        assert_eq!(t("cmd 2>/dev/null"), "cmd 2>/dev/null");
3253    }
3254
3255    #[test]
3256    fn stderr_to_stdout() {
3257        assert_eq!(t("cmd 2>&1"), "cmd 2>&1");
3258    }
3259
3260    // --- Brace group ---
3261
3262    #[test]
3263    fn brace_group() {
3264        let result = t("{ echo a; echo b; }");
3265        assert!(result.contains("begin"));
3266        assert!(result.contains("echo a"));
3267        assert!(result.contains("echo b"));
3268        assert!(result.contains("end"));
3269    }
3270
3271    // =======================================================================
3272    // EXOTIC / OBSCURE / HARD PATTERNS
3273    // =======================================================================
3274
3275    // --- Nested command substitution ---
3276
3277    #[test]
3278    fn nested_command_substitution() {
3279        let result = t("echo $(basename $(pwd))");
3280        assert!(result.contains("(basename (pwd))"));
3281    }
3282
3283    #[test]
3284    fn command_subst_in_args() {
3285        // Common Stack Overflow pattern
3286        let result = t("$(which python3) --version");
3287        assert!(result.contains("(which python3)"));
3288    }
3289
3290    // --- Multiple statements ---
3291
3292    #[test]
3293    fn semicolon_separated() {
3294        let result = t("echo a; echo b; echo c");
3295        assert!(result.contains("echo a"));
3296        assert!(result.contains("echo b"));
3297        assert!(result.contains("echo c"));
3298    }
3299
3300    // --- Parameter expansion varieties ---
3301
3302    #[test]
3303    fn param_default_value() {
3304        // ${var:-default} — the bread and butter of bash scripts
3305        let result = t("echo ${HOME:-/tmp}");
3306        assert!(result.contains("set -q HOME"));
3307        assert!(result.contains("echo $HOME"));
3308        assert!(result.contains("/tmp"));
3309    }
3310
3311    #[test]
3312    fn param_assign_default() {
3313        // ${var:=default} — assign if unset
3314        let result = t("echo ${FOO:=hello}");
3315        assert!(result.contains("set -q FOO"));
3316        assert!(result.contains("set FOO"));
3317        assert!(result.contains("hello"));
3318    }
3319
3320    #[test]
3321    fn param_error_if_unset() {
3322        // ${var:?message} — error if unset
3323        let result = t("echo ${REQUIRED:?must be set}");
3324        assert!(result.contains("set -q REQUIRED"));
3325        assert!(result.contains("return 1"));
3326    }
3327
3328    #[test]
3329    fn param_alternative_value() {
3330        // ${var:+word} — use word if var IS set
3331        let result = t("echo ${DEBUG:+--verbose}");
3332        assert!(result.contains("set -q DEBUG"));
3333        assert!(result.contains("--verbose"));
3334    }
3335
3336    #[test]
3337    fn param_length() {
3338        // ${#var} — string length
3339        let result = t("echo ${#PATH}");
3340        assert!(result.contains("string length"));
3341        assert!(result.contains("$PATH"));
3342    }
3343
3344    #[test]
3345    fn param_strip_suffix() {
3346        // ${file%.txt} — remove suffix
3347        let result = t("echo ${file%.*}");
3348        assert!(result.contains("string replace"));
3349        assert!(result.contains("$file"));
3350    }
3351
3352    #[test]
3353    fn param_strip_prefix() {
3354        // ${path#*/} — remove prefix
3355        let result = t("echo ${path#*/}");
3356        assert!(result.contains("string replace"));
3357        assert!(result.contains("$path"));
3358    }
3359
3360    // --- Complex arithmetic ---
3361
3362    #[test]
3363    fn arithmetic_multiplication() {
3364        let result = t("echo $((3 * 4 + 1))");
3365        assert!(result.contains("math"));
3366    }
3367
3368    #[test]
3369    fn arithmetic_modulo() {
3370        let result = t("echo $((x % 2))");
3371        assert!(result.contains("math"));
3372        assert!(result.contains('%'));
3373    }
3374
3375    #[test]
3376    fn arithmetic_comparison() {
3377        // $((a > b)) returns 0 or 1 in bash — translated to test-based evaluation
3378        let result = t("echo $((a > b))");
3379        assert!(result.contains("test"), "got: {}", result);
3380        assert!(result.contains("-gt"), "got: {}", result);
3381    }
3382
3383    #[test]
3384    fn arithmetic_in_double_quotes() {
3385        // $((x * 2)) inside a double-quoted string: the math subst is pulled outside
3386        // the double quotes to avoid inner " conflicts.
3387        // "result is $((x * 2))" → "result is "(math "$x * 2")
3388        let result = t(r#"echo "result is $((x * 2))""#);
3389        assert!(result.contains("math"), "got: {}", result);
3390        // The outer string should close before math
3391        assert!(
3392            result.contains(r#""result is ""#),
3393            "outer quotes should close before math, got: {}",
3394            result
3395        );
3396    }
3397
3398    // --- Nested control structures ---
3399
3400    #[test]
3401    fn nested_for_if() {
3402        let result = t("for f in $(ls); do if test -f $f; then echo $f is a file; fi; done");
3403        assert!(result.contains("for f in (ls | string split -n ' ')"));
3404        assert!(result.contains("if test -f $f"));
3405        assert!(result.contains("end\nend"));
3406    }
3407
3408    #[test]
3409    fn if_elif_else() {
3410        let result = t(
3411            "if test $x -eq 1; then echo one; elif test $x -eq 2; then echo two; else echo other; fi",
3412        );
3413        assert!(result.contains("if test $x -eq 1"));
3414        assert!(result.contains("else if test $x -eq 2"));
3415        assert!(result.contains("echo one"));
3416        assert!(result.contains("echo two"));
3417        assert!(result.contains("else\necho other"));
3418        assert!(result.contains("end"));
3419    }
3420
3421    // --- Until loop (not in fish) ---
3422
3423    #[test]
3424    fn until_loop() {
3425        let result = t("until test -f /tmp/ready; do sleep 1; done");
3426        assert!(result.contains("while not"));
3427        assert!(result.contains("test -f /tmp/ready"));
3428        assert!(result.contains("sleep 1"));
3429        assert!(result.contains("end"));
3430    }
3431
3432    // --- Multiple env var prefix ---
3433
3434    #[test]
3435    fn multi_env_prefix() {
3436        // Prefix assignments with a command bail to bash passthrough
3437        t_unsupported("CC=gcc CXX=g++ make");
3438    }
3439
3440    // --- Multiple assignments ---
3441
3442    #[test]
3443    fn multi_assignment() {
3444        let result = t("A=1; B=2; C=3");
3445        assert!(result.contains("set A 1"));
3446        assert!(result.contains("set B 2"));
3447        assert!(result.contains("set C 3"));
3448    }
3449
3450    // --- Quoting ---
3451
3452    #[test]
3453    fn single_quoted_string() {
3454        assert_eq!(t("echo 'hello world'"), "echo 'hello world'");
3455    }
3456
3457    #[test]
3458    fn ansi_c_quoting_simple() {
3459        assert_eq!(t("echo $'hello'"), "echo \"hello\"");
3460    }
3461
3462    #[test]
3463    fn ansi_c_quoting_newline() {
3464        // \n must be emitted bare (outside quotes) for fish to interpret it
3465        assert_eq!(t("echo $'line1\\nline2'"), "echo \"line1\"\\n\"line2\"");
3466    }
3467
3468    #[test]
3469    fn ansi_c_quoting_tab() {
3470        assert_eq!(t("echo $'a\\tb'"), "echo \"a\"\\t\"b\"");
3471    }
3472
3473    #[test]
3474    fn ansi_c_quoting_escaped_squote() {
3475        assert_eq!(t("echo $'it\\'s'"), "echo \"it's\"");
3476    }
3477
3478    #[test]
3479    fn ansi_c_quoting_escape_e() {
3480        assert_eq!(t("echo $'\\E[31m'"), "echo \\e\"[31m\"");
3481    }
3482
3483    #[test]
3484    fn ansi_c_quoting_dollar() {
3485        assert_eq!(t("echo $'costs $5'"), "echo \"costs \\$5\"");
3486    }
3487
3488    #[test]
3489    fn double_quoted_with_var() {
3490        let result = t("echo \"hello $USER\"");
3491        assert!(result.contains("\"hello $USER\""));
3492    }
3493
3494    #[test]
3495    fn double_quoted_with_subst() {
3496        // Command substitutions inside double quotes get split out to avoid
3497        // inner quote conflicts: "today is $(date)" → "today is "(date)
3498        let result = t("echo \"today is $(date)\"");
3499        assert!(result.contains("\"today is \""), "got: {}", result);
3500        assert!(result.contains("(date)"), "got: {}", result);
3501    }
3502
3503    // --- Complex real-world one-liners from Stack Overflow ---
3504
3505    #[test]
3506    fn find_and_exec() {
3507        // Common pattern people paste
3508        let result = t("find . -name '*.py' -exec grep -l TODO {} +");
3509        assert!(result.contains("find"));
3510        assert!(result.contains("'*.py'"));
3511    }
3512
3513    #[test]
3514    fn while_read_loop() {
3515        // Very common: while read line; do ... done < file
3516        let result = t("while read line; do echo $line; done");
3517        assert!(result.contains("while read line"));
3518        assert!(result.contains("echo $line"));
3519        assert!(result.contains("end"));
3520    }
3521
3522    #[test]
3523    fn chained_and_or_complex() {
3524        let result = t("test -d /opt && echo exists || mkdir -p /opt && echo created");
3525        assert!(result.contains("test -d /opt"));
3526        assert!(result.contains("; and echo exists"));
3527        assert!(result.contains("; or mkdir -p /opt"));
3528        assert!(result.contains("; and echo created"));
3529    }
3530
3531    // --- Subshell ---
3532
3533    #[test]
3534    fn subshell() {
3535        let result = t("(cd /tmp && ls)");
3536        assert!(result.contains("begin\n"));
3537        assert!(result.contains("cd /tmp; and ls"));
3538        assert!(result.contains("set -l __reef_pwd (pwd)"));
3539        assert!(result.contains("cd $__reef_pwd 2>/dev/null"));
3540        assert!(result.contains("\nend"));
3541    }
3542
3543    #[test]
3544    fn subshell_pipeline() {
3545        let result = t("(echo hello; echo world)");
3546        assert!(result.contains("begin\n"));
3547        assert!(result.contains("echo hello\necho world"));
3548        assert!(result.contains("\nend"));
3549    }
3550
3551    // --- Function definition ---
3552
3553    #[test]
3554    fn function_def() {
3555        let result = t("greet() { echo hello $1; }");
3556        assert!(result.contains("function greet"));
3557        assert!(result.contains("echo hello $argv[1]"));
3558        assert!(result.contains("end"));
3559    }
3560
3561    // --- Complex pipeline with redirects ---
3562
3563    #[test]
3564    fn pipeline_with_redirect() {
3565        let result = t("cat file 2>/dev/null | sort | uniq -c | sort -rn");
3566        assert!(result.contains("cat file 2>/dev/null"));
3567        assert!(result.contains("| sort |"));
3568        assert!(result.contains("| uniq -c |"));
3569        assert!(result.contains("sort -rn"));
3570    }
3571
3572    // --- Backtick command substitution ---
3573
3574    #[test]
3575    fn backtick_substitution() {
3576        // `cmd` is an older form of $(cmd) — parser handles both
3577        let result = t("echo `whoami`");
3578        assert!(result.contains("(whoami)"));
3579    }
3580
3581    // --- Export multiple vars ---
3582
3583    #[test]
3584    fn export_multiple() {
3585        let result = t("export A=1; export B=2");
3586        assert!(result.contains("set -gx A 1"));
3587        assert!(result.contains("set -gx B 2"));
3588    }
3589
3590    // --- Declare with flags ---
3591
3592    #[test]
3593    fn declare_export() {
3594        assert_eq!(t("declare -x FOO=bar"), "set -gx FOO bar");
3595    }
3596
3597    // --- Case with wildcards ---
3598
3599    #[test]
3600    fn case_with_wildcards() {
3601        let result = t("case $1 in *.txt) echo text;; *.py) echo python;; *) echo unknown;; esac");
3602        assert!(result.contains("switch"));
3603        // Patterns with * are quoted to prevent file globbing (fish case still treats quoted * as wildcard)
3604        assert!(result.contains("case '*.txt'"));
3605        assert!(result.contains("case '*.py'"));
3606        assert!(result.contains("case '*'"));
3607    }
3608
3609    // --- Multi-line for with complex body ---
3610
3611    #[test]
3612    fn for_with_pipeline_body() {
3613        let result = t("for f in $(find . -name '*.log'); do cat $f | wc -l; done");
3614        assert!(result.contains("for f in"));
3615        assert!(result.contains("cat $f | wc -l"));
3616        assert!(result.contains("end"));
3617    }
3618
3619    // --- Redirect append ---
3620
3621    #[test]
3622    fn append_redirect() {
3623        assert_eq!(t("echo hello >>log.txt"), "echo hello >>log.txt");
3624    }
3625
3626    // --- Input redirect ---
3627
3628    #[test]
3629    fn input_redirect() {
3630        assert_eq!(t("sort <input.txt"), "sort <input.txt");
3631    }
3632
3633    // --- Variable in double-quoted redirect target ---
3634
3635    #[test]
3636    fn redirect_with_var() {
3637        let result = t("echo hello >$LOGFILE");
3638        assert!(result.contains("echo hello >$LOGFILE"));
3639    }
3640
3641    // --- Empty command (just comments or blank) ---
3642
3643    #[test]
3644    fn comment_only() {
3645        // Comments are stripped by the parser — empty output
3646        let result = t("# this is a comment");
3647        assert_eq!(result, "");
3648    }
3649
3650    // --- Special variables ---
3651
3652    #[test]
3653    fn dollar_dollar() {
3654        assert_eq!(t("echo $$"), "echo $fish_pid");
3655    }
3656
3657    #[test]
3658    fn dollar_bang() {
3659        let result = t("echo $!");
3660        assert!(result.contains("$last_pid"));
3661    }
3662
3663    #[test]
3664    fn dollar_random() {
3665        assert_eq!(t("echo $RANDOM"), "echo (random)");
3666    }
3667
3668    #[test]
3669    fn dollar_pound() {
3670        let result = t("echo $#");
3671        assert!(result.contains("count $argv"));
3672    }
3673
3674    // --- Tilde expansion ---
3675
3676    #[test]
3677    fn tilde_expansion() {
3678        let result = t("cd ~/projects");
3679        assert!(result.contains('~'));
3680        assert!(result.contains("projects"));
3681    }
3682
3683    // --- Escaped characters ---
3684
3685    #[test]
3686    fn escaped_dollar() {
3687        let result = t("echo \\$HOME");
3688        assert!(result.contains("\\$"));
3689    }
3690
3691    // --- Multiple pipelines joined with && ---
3692
3693    #[test]
3694    fn complex_and_or_pipeline() {
3695        let result = t("cat file | grep foo && echo found || echo not found");
3696        assert!(result.contains("cat file | grep foo"));
3697        assert!(result.contains("; and echo found"));
3698        assert!(result.contains("; or echo not found"));
3699    }
3700
3701    // --- For loop without explicit word list ---
3702
3703    #[test]
3704    fn for_without_in() {
3705        // for var; do ... done iterates over positional params
3706        let result = t("for arg; do echo $arg; done");
3707        assert!(result.contains("for arg in $argv"));
3708        assert!(result.contains("echo $arg"));
3709        assert!(result.contains("end"));
3710    }
3711
3712    // --- Nested arithmetic ---
3713
3714    #[test]
3715    fn nested_arithmetic() {
3716        let result = t("echo $((2 * (3 + 4)))");
3717        assert!(result.contains("math"));
3718    }
3719
3720    // --- Parse error falls through ---
3721
3722    #[test]
3723    fn double_bracket_test() {
3724        // [[ ]] is bash-specific — translate to test and strip ]]
3725        let result = t("[[ -n $HOME ]]");
3726        assert!(result.contains("test -n $HOME"), "got: {}", result);
3727        assert!(!result.contains("[["));
3728        assert!(!result.contains("]]"));
3729    }
3730
3731    #[test]
3732    fn double_bracket_equality() {
3733        // [[ $a == $b ]] → string match for pattern matching
3734        let result = t("[[ $a == $b ]]");
3735        assert!(result.contains("string match -q"), "got: {}", result);
3736    }
3737
3738    #[test]
3739    fn double_bracket_wildcard_pattern() {
3740        // [[ "world" == w* ]] → string match -q 'w*' "world"
3741        let result = t(r#"if [[ "world" == w* ]]; then echo yes; fi"#);
3742        assert!(result.contains("string match -q -- 'w*'"), "got: {}", result);
3743        assert!(result.contains("echo yes"), "got: {}", result);
3744    }
3745
3746    #[test]
3747    fn double_bracket_negated_pattern() {
3748        // [[ $x != *.txt ]] → not string match -q '*.txt' $x
3749        let result = t("[[ $x != *.txt ]]");
3750        assert!(result.contains("not string match -q"), "got: {}", result);
3751    }
3752
3753    #[test]
3754    fn double_bracket_and() {
3755        // [[ -f x && -r x ]] → test -f x; and test -r x
3756        let result = t("if [[ -f /etc/hostname && -r /etc/hostname ]]; then echo ok; fi");
3757        assert!(result.contains("test -f /etc/hostname"), "got: {}", result);
3758        assert!(
3759            result.contains("; and test -r /etc/hostname"),
3760            "got: {}",
3761            result
3762        );
3763    }
3764
3765    #[test]
3766    fn double_bracket_or() {
3767        let result = t("[[ -z \"$x\" || -z \"$y\" ]]");
3768        assert!(result.contains("test -z \"$x\""), "got: {}", result);
3769        assert!(result.contains("; or test -z \"$y\""), "got: {}", result);
3770    }
3771
3772    #[test]
3773    fn double_bracket_regex() {
3774        let result = t(r#"[[ "$str" =~ ^[a-z]+$ ]]"#);
3775        assert!(result.contains("string match -r"), "got: {}", result);
3776        assert!(result.contains("__bash_rematch"), "got: {}", result);
3777        assert!(result.contains("'^[a-z]+$' \"$str\""), "got: {}", result);
3778    }
3779
3780    #[test]
3781    fn brace_range_simple() {
3782        let result = t("echo {1..5}");
3783        assert!(result.contains("echo (seq 1 5)"), "got: {}", result);
3784    }
3785
3786    #[test]
3787    fn brace_range_with_step() {
3788        let result = t("for i in {1..10..2}; do echo $i; done");
3789        assert!(result.contains("seq 1 2 10"), "got: {}", result);
3790    }
3791
3792    #[test]
3793    fn ternary_arithmetic() {
3794        let result = t("echo $((x > 5 ? 1 : 0))");
3795        assert!(result.contains("if test $x -gt 5"), "got: {}", result);
3796        assert!(result.contains("echo 1"), "got: {}", result);
3797        assert!(result.contains("echo 0"), "got: {}", result);
3798    }
3799
3800    #[test]
3801    fn herestring_with_preceding_statement() {
3802        let result = t(r#"name="world"; grep -o "world" <<< "hello $name""#);
3803        assert!(result.contains("set name \"world\""), "got: {}", result);
3804        assert!(
3805            result.contains("echo \"hello $name\" | grep"),
3806            "got: {}",
3807            result
3808        );
3809    }
3810
3811    // --- Real-world: install script patterns ---
3812
3813    #[test]
3814    fn curl_pipe_bash() {
3815        // People paste this ALL the time
3816        let result = t("curl -fsSL https://example.com/install.sh | bash");
3817        assert!(result.contains("curl"));
3818        assert!(result.contains("| bash"));
3819    }
3820
3821    #[test]
3822    fn git_clone_and_cd() {
3823        let result = t("git clone https://github.com/user/repo.git && cd repo");
3824        assert!(result.contains("git clone"));
3825        assert!(result.contains("; and cd repo"));
3826    }
3827
3828    // --- Deeply nested ---
3829
3830    #[test]
3831    fn deeply_nested_loops() {
3832        let result = t("for i in 1 2 3; do for j in a b c; do echo $i$j; done; done");
3833        assert!(result.contains("for i in 1 2 3"));
3834        assert!(result.contains("for j in a b c"));
3835        assert!(result.contains("echo $i$j"));
3836        // Two end keywords for the two loops
3837        let end_count = result.matches("end").count();
3838        assert!(
3839            end_count >= 2,
3840            "Expected at least 2 'end' keywords, got {}",
3841            end_count
3842        );
3843    }
3844
3845    // --- If with && in condition ---
3846
3847    #[test]
3848    fn if_with_and_condition() {
3849        let result = t("if test -f foo && test -r foo; then cat foo; fi");
3850        assert!(result.contains("if"));
3851        assert!(result.contains("test -f foo"));
3852        assert!(result.contains("test -r foo"));
3853        assert!(result.contains("cat foo"));
3854        assert!(result.contains("end"));
3855    }
3856
3857    // --- Command with single-quoted args containing special chars ---
3858
3859    #[test]
3860    fn single_quoted_special_chars() {
3861        let result = t("grep -E '^[0-9]+$' file.txt");
3862        assert!(result.contains("grep"));
3863        assert!(result.contains("'^[0-9]+$'"));
3864    }
3865
3866    // --- Multiple exports on one line ---
3867
3868    #[test]
3869    fn export_no_value() {
3870        // export VAR (no =) — just marks as exported
3871        let result = t("export HOME");
3872        assert!(result.contains("set -gx HOME $HOME"));
3873    }
3874
3875    // --- Here-string (<<<) ---
3876
3877    #[test]
3878    fn herestring_quoted() {
3879        let result = t(r#"while read line; do echo ">> $line"; done <<< "hello world""#);
3880        assert!(result.contains("echo \"hello world\" |"), "got: {}", result);
3881        assert!(result.contains("while read line"));
3882    }
3883
3884    #[test]
3885    fn herestring_bare() {
3886        let result = t("cat <<< hello");
3887        assert!(result.contains("echo hello | cat"), "got: {}", result);
3888    }
3889
3890    #[test]
3891    fn herestring_variable() {
3892        let result = t("grep foo <<< $input");
3893        assert!(result.contains("echo $input | grep foo"), "got: {}", result);
3894    }
3895
3896    // --- Standalone (( )) arithmetic ---
3897
3898    #[test]
3899    fn standalone_arith_post_increment() {
3900        let result = t("(( i++ ))");
3901        assert!(result.contains("set i"), "got: {}", result);
3902        assert!(result.contains("math"), "got: {}", result);
3903        assert!(result.contains("+ 1"), "got: {}", result);
3904    }
3905
3906    #[test]
3907    fn standalone_arith_pre_increment() {
3908        let result = t("(( ++i ))");
3909        assert!(result.contains("set i"), "got: {}", result);
3910        assert!(result.contains("+ 1"), "got: {}", result);
3911    }
3912
3913    #[test]
3914    fn standalone_arith_post_decrement() {
3915        let result = t("(( i-- ))");
3916        assert!(result.contains("set i"), "got: {}", result);
3917        assert!(result.contains("- 1"), "got: {}", result);
3918    }
3919
3920    #[test]
3921    fn standalone_arith_pre_decrement() {
3922        let result = t("(( --i ))");
3923        assert!(result.contains("set i"), "got: {}", result);
3924        assert!(result.contains("- 1"), "got: {}", result);
3925    }
3926
3927    #[test]
3928    fn standalone_arith_plus_equals() {
3929        let result = t("(( count += 5 ))");
3930        assert!(result.contains("set count"), "got: {}", result);
3931        assert!(result.contains("math"), "got: {}", result);
3932        assert!(result.contains("+ 5"), "got: {}", result);
3933    }
3934
3935    #[test]
3936    fn standalone_arith_minus_equals() {
3937        let result = t("(( x -= 3 ))");
3938        assert!(result.contains("set x"), "got: {}", result);
3939        assert!(result.contains("- 3"), "got: {}", result);
3940    }
3941
3942    #[test]
3943    fn standalone_arith_times_equals() {
3944        let result = t("(( x *= 2 ))");
3945        assert!(result.contains("set x"), "got: {}", result);
3946        assert!(result.contains("* 2"), "got: {}", result);
3947    }
3948
3949    #[test]
3950    fn standalone_arith_div_equals() {
3951        let result = t("(( x /= 4 ))");
3952        assert!(result.contains("set x"), "got: {}", result);
3953        assert!(result.contains("/ 4"), "got: {}", result);
3954    }
3955
3956    #[test]
3957    fn standalone_arith_mod_equals() {
3958        let result = t("(( x %= 3 ))");
3959        assert!(result.contains("set x"), "got: {}", result);
3960        assert!(result.contains("% 3"), "got: {}", result);
3961    }
3962
3963    #[test]
3964    fn standalone_arith_simple_assign() {
3965        let result = t("(( x = 42 ))");
3966        assert!(result.contains("set x"), "got: {}", result);
3967        assert!(result.contains("42"), "got: {}", result);
3968    }
3969
3970    #[test]
3971    fn standalone_arith_assign_expr() {
3972        let result = t("(( x = y + 1 ))");
3973        assert!(result.contains("set x"), "got: {}", result);
3974        assert!(result.contains("math"), "got: {}", result);
3975    }
3976
3977    #[test]
3978    fn standalone_arith_in_loop() {
3979        // Common pattern: while loop with counter
3980        let result = t("while test $i -lt 10; do echo $i; (( i++ )); done");
3981        assert!(result.contains("while test $i -lt 10"), "got: {}", result);
3982        assert!(result.contains("set i"), "got: {}", result);
3983        assert!(result.contains("+ 1"), "got: {}", result);
3984        assert!(result.contains("end"), "got: {}", result);
3985    }
3986
3987    #[test]
3988    fn standalone_arith_comparison() {
3989        assert_eq!(t("(( x > 5 ))"), "test $x -gt 5");
3990    }
3991
3992    #[test]
3993    fn standalone_arith_comparison_eq() {
3994        assert_eq!(t("(( x == 0 ))"), "test $x -eq 0");
3995    }
3996
3997    #[test]
3998    fn standalone_arith_logical_and() {
3999        assert_eq!(
4000            t("(( x > 0 && y < 10 ))"),
4001            "test $x -gt 0; and test $y -lt 10"
4002        );
4003    }
4004
4005    #[test]
4006    fn cstyle_for_loop() {
4007        let result = t("for (( i=0; i<10; i++ )); do echo $i; done");
4008        assert!(result.contains("set i (math \"0\")"), "got: {}", result);
4009        assert!(result.contains("while test $i -lt 10"), "got: {}", result);
4010        assert!(result.contains("echo $i"), "got: {}", result);
4011        assert!(
4012            result.contains("set i (math \"$i + 1\")"),
4013            "got: {}",
4014            result
4015        );
4016        assert!(result.contains("end"), "got: {}", result);
4017    }
4018
4019    #[test]
4020    fn standalone_arith_in_quotes_untouched() {
4021        // (( )) inside quotes should not be rewritten
4022        let result = t("echo '(( i++ ))'");
4023        assert!(result.contains("(( i++ ))"), "got: {}", result);
4024    }
4025
4026    // --- Comprehensive arithmetic $((…)) ---
4027
4028    #[test]
4029    fn arith_subtraction() {
4030        let result = t("echo $((10 - 3))");
4031        assert_eq!(result, r#"echo (math "10 - 3")"#);
4032    }
4033
4034    #[test]
4035    fn arith_division() {
4036        let result = t("echo $((20 / 4))");
4037        assert_eq!(result, r#"echo (math "floor(20 / 4)")"#);
4038    }
4039
4040    #[test]
4041    fn arith_power() {
4042        let result = t("echo $((2 ** 10))");
4043        assert_eq!(result, r#"echo (math "2 ^ 10")"#);
4044    }
4045
4046    #[test]
4047    fn arith_nested_parens() {
4048        let result = t("echo $(( (2 + 3) * (4 - 1) ))");
4049        assert!(result.contains("math"), "got: {}", result);
4050        assert!(result.contains("(2 + 3) * (4 - 1)"), "got: {}", result);
4051    }
4052
4053    #[test]
4054    fn arith_unary_neg() {
4055        let result = t("echo $((-x + 5))");
4056        assert!(result.contains("math"), "got: {}", result);
4057        assert!(result.contains("-$x"), "got: {}", result);
4058    }
4059
4060    #[test]
4061    fn arith_variables_only() {
4062        let result = t("echo $((a + b * c))");
4063        assert!(result.contains("math"), "got: {}", result);
4064        assert!(result.contains("$a + ($b * $c)"), "got: {}", result);
4065    }
4066
4067    #[test]
4068    fn arith_comparison_eq() {
4069        let result = t("echo $((x == y))");
4070        assert!(result.contains("test $x -eq $y"), "got: {}", result);
4071    }
4072
4073    #[test]
4074    fn arith_comparison_ne() {
4075        let result = t("echo $((x != y))");
4076        assert!(result.contains("test $x -ne $y"), "got: {}", result);
4077    }
4078
4079    #[test]
4080    fn arith_comparison_le() {
4081        let result = t("echo $((a <= b))");
4082        assert!(result.contains("test $a -le $b"), "got: {}", result);
4083    }
4084
4085    #[test]
4086    fn arith_comparison_ge() {
4087        let result = t("echo $((a >= b))");
4088        assert!(result.contains("test $a -ge $b"), "got: {}", result);
4089    }
4090
4091    #[test]
4092    fn arith_comparison_lt() {
4093        let result = t("echo $((a < b))");
4094        assert!(result.contains("test $a -lt $b"), "got: {}", result);
4095    }
4096
4097    #[test]
4098    fn arith_logic_and() {
4099        let result = t("echo $((a > 0 && b > 0))");
4100        assert!(result.contains("test $a -gt 0"), "got: {}", result);
4101        assert!(result.contains("; and "), "got: {}", result);
4102        assert!(result.contains("test $b -gt 0"), "got: {}", result);
4103    }
4104
4105    #[test]
4106    fn arith_logic_or() {
4107        let result = t("echo $((a == 0 || b == 0))");
4108        assert!(result.contains("test $a -eq 0"), "got: {}", result);
4109        assert!(result.contains("; or "), "got: {}", result);
4110    }
4111
4112    #[test]
4113    fn arith_logic_not() {
4114        let result = t("echo $((!x))");
4115        assert!(result.contains("not "), "got: {}", result);
4116    }
4117
4118    #[test]
4119    fn arith_ternary_with_math() {
4120        let result = t("echo $((x > 0 ? x * 2 : 0))");
4121        assert!(result.contains("if test $x -gt 0"), "got: {}", result);
4122        assert!(result.contains("math"), "got: {}", result);
4123    }
4124
4125    #[test]
4126    fn arith_in_assignment() {
4127        let result = t("z=$((x + y))");
4128        assert!(result.contains("set z"), "got: {}", result);
4129        assert!(result.contains("math"), "got: {}", result);
4130    }
4131
4132    #[test]
4133    fn arith_in_condition() {
4134        let result = t("if [ $((x % 2)) -eq 0 ]; then echo even; fi");
4135        assert!(result.contains("math"), "got: {}", result);
4136        assert!(result.contains("echo even"), "got: {}", result);
4137    }
4138
4139    #[test]
4140    fn arith_multiple_in_line() {
4141        let result = t("echo $((a + 1)) $((b + 2))");
4142        assert!(result.contains(r#"(math "$a + 1")"#), "got: {}", result);
4143        assert!(result.contains(r#"(math "$b + 2")"#), "got: {}", result);
4144    }
4145
4146    #[test]
4147    fn arith_deeply_nested() {
4148        let result = t("echo $(( ((2 + 3)) * ((4 + 5)) ))");
4149        assert!(result.contains("math"), "got: {}", result);
4150    }
4151
4152    #[test]
4153    fn arith_empty() {
4154        // $(()) is valid bash, evaluates to 0
4155        let result = t("echo $(())");
4156        assert!(result.contains("echo"), "got: {}", result);
4157    }
4158
4159    #[test]
4160    fn arith_complex_expression() {
4161        let result = t("echo $(( (x + y) / 2 - z * 3 ))");
4162        assert!(result.contains("math"), "got: {}", result);
4163        assert!(result.contains("/ 2"), "got: {}", result);
4164    }
4165
4166    #[test]
4167    fn arith_in_export() {
4168        let result = t("export N=$((x + 1))");
4169        assert!(result.contains("set -gx N"), "got: {}", result);
4170        assert!(result.contains("math"), "got: {}", result);
4171    }
4172
4173    #[test]
4174    fn arith_in_local() {
4175        let result = t("local result=$((a * b))");
4176        assert!(result.contains("set -l result"), "got: {}", result);
4177        assert!(result.contains("math"), "got: {}", result);
4178    }
4179
4180    // --- Standalone (( )) with compound expressions ---
4181
4182    #[test]
4183    fn standalone_arith_assign_compound() {
4184        let result = t("(( total = x + y * 2 ))");
4185        assert!(result.contains("set total"), "got: {}", result);
4186        assert!(result.contains("math"), "got: {}", result);
4187        assert!(result.contains("$x + ($y * 2)"), "got: {}", result);
4188    }
4189
4190    #[test]
4191    fn standalone_arith_nested_assign() {
4192        let result = t("(( x = (a + b) * c ))");
4193        assert!(result.contains("set x"), "got: {}", result);
4194        assert!(result.contains("math"), "got: {}", result);
4195    }
4196
4197    #[test]
4198    fn standalone_arith_multiple_in_sequence() {
4199        let result = t("(( x++ )); (( y-- ))");
4200        assert!(result.contains("set x"), "got: {}", result);
4201        assert!(result.contains("set y"), "got: {}", result);
4202        assert!(result.contains("+ 1"), "got: {}", result);
4203        assert!(result.contains("- 1"), "got: {}", result);
4204    }
4205
4206    // --- Case modification ---
4207
4208    #[test]
4209    fn upper_all() {
4210        let result = t("echo ${var^^}");
4211        assert_eq!(result, "echo (string upper -- \"$var\")");
4212    }
4213
4214    #[test]
4215    fn lower_all() {
4216        let result = t("echo ${var,,}");
4217        assert_eq!(result, "echo (string lower -- \"$var\")");
4218    }
4219
4220    #[test]
4221    fn upper_first() {
4222        let result = t("echo ${var^}");
4223        assert!(result.contains("string sub -l 1"));
4224        assert!(result.contains("string upper"));
4225        assert!(result.contains("string sub -s 2"));
4226    }
4227
4228    #[test]
4229    fn lower_first() {
4230        let result = t("echo ${var,}");
4231        assert!(result.contains("string sub -l 1"));
4232        assert!(result.contains("string lower"));
4233        assert!(result.contains("string sub -s 2"));
4234    }
4235
4236    // --- Pattern replacement ---
4237
4238    #[test]
4239    fn replace_first() {
4240        let result = t("echo ${var/foo/bar}");
4241        assert!(result.contains("string replace"), "got: {}", result);
4242        assert!(result.contains("'foo'"), "got: {}", result);
4243        assert!(result.contains("'bar'"), "got: {}", result);
4244        assert!(result.contains("$var"), "got: {}", result);
4245    }
4246
4247    #[test]
4248    fn replace_all() {
4249        let result = t("echo ${var//foo/bar}");
4250        assert!(result.contains("string replace"), "got: {}", result);
4251        assert!(result.contains("-a"), "got: {}", result);
4252    }
4253
4254    #[test]
4255    fn replace_prefix() {
4256        let result = t("echo ${var/#foo/bar}");
4257        assert!(result.contains("string replace"), "got: {}", result);
4258        assert!(result.contains("-r"), "got: {}", result);
4259        assert!(result.contains("'^foo'"), "got: {}", result);
4260    }
4261
4262    #[test]
4263    fn replace_suffix() {
4264        let result = t("echo ${var/%foo/bar}");
4265        assert!(result.contains("string replace"), "got: {}", result);
4266        assert!(result.contains("-r"), "got: {}", result);
4267        assert!(result.contains("'foo$'"), "got: {}", result);
4268    }
4269
4270    #[test]
4271    fn replace_delete() {
4272        let result = t("echo ${var/foo}");
4273        assert!(result.contains("string replace"), "got: {}", result);
4274        assert!(result.contains("-- 'foo' '' \"$var\""), "got: {}", result);
4275    }
4276
4277    // --- Substring ---
4278
4279    #[test]
4280    fn substring_offset_only() {
4281        let result = t("echo ${var:2}");
4282        assert!(result.contains("string sub"), "got: {}", result);
4283        assert!(result.contains("-s (math \"2 + 1\")"), "got: {}", result);
4284        assert!(result.contains("$var"), "got: {}", result);
4285    }
4286
4287    #[test]
4288    fn substring_offset_and_length() {
4289        let result = t("echo ${var:2:5}");
4290        assert!(result.contains("string sub"), "got: {}", result);
4291        assert!(result.contains("-s (math \"2 + 1\")"), "got: {}", result);
4292        assert!(result.contains("-l (math \"5\")"), "got: {}", result);
4293    }
4294
4295    // --- Process substitution ---
4296
4297    #[test]
4298    fn process_substitution_in() {
4299        let result = t("diff <(sort a) <(sort b)");
4300        assert!(result.contains("(sort a | psub)"), "got: {}", result);
4301        assert!(result.contains("(sort b | psub)"), "got: {}", result);
4302    }
4303
4304    #[test]
4305    fn process_substitution_out_unsupported() {
4306        let result = translate_bash_to_fish("tee >(grep foo)");
4307        assert!(result.is_err());
4308    }
4309
4310    // --- C-style for ---
4311
4312    #[test]
4313    fn cstyle_for_no_init() {
4314        let result = t("for (( ; i<5; i++ )); do echo $i; done");
4315        assert!(result.contains("while test $i -lt 5"), "got: {}", result);
4316        assert!(
4317            result.contains("set i (math \"$i + 1\")"),
4318            "got: {}",
4319            result
4320        );
4321    }
4322
4323    #[test]
4324    fn cstyle_for_no_step() {
4325        let result = t("for (( i=0; i<5; )); do echo $i; done");
4326        assert!(result.contains("set i (math \"0\")"), "got: {}", result);
4327        assert!(result.contains("while test $i -lt 5"), "got: {}", result);
4328    }
4329
4330    // --- Heredoc ---
4331
4332    #[test]
4333    fn heredoc_quoted() {
4334        let result = t("cat <<'EOF'\nhello world\nEOF");
4335        assert!(result.contains("printf"), "got: {}", result);
4336        assert!(result.contains("hello world"), "got: {}", result);
4337        assert!(result.contains("| cat"), "got: {}", result);
4338    }
4339
4340    #[test]
4341    fn heredoc_double_quoted() {
4342        let result = t("cat <<\"EOF\"\nhello world\nEOF");
4343        assert!(result.contains("printf"), "got: {}", result);
4344        assert!(result.contains("| cat"), "got: {}", result);
4345    }
4346
4347    #[test]
4348    fn heredoc_unquoted() {
4349        let result = t("cat <<EOF\nhello $NAME\nEOF");
4350        assert!(result.contains("printf"), "got: {}", result);
4351        assert!(result.contains("$NAME"), "got: {}", result);
4352        assert!(result.contains("| cat"), "got: {}", result);
4353    }
4354
4355    // --- Case fallthrough errors ---
4356
4357    #[test]
4358    fn case_fallthrough_error() {
4359        let result = translate_bash_to_fish("case $x in a) echo a;& b) echo b;; esac");
4360        assert!(result.is_err());
4361    }
4362
4363    #[test]
4364    fn case_continue_error() {
4365        let result = translate_bash_to_fish("case $x in a) echo a;;& b) echo b;; esac");
4366        assert!(result.is_err());
4367    }
4368
4369    // --- Arrays ---
4370
4371    #[test]
4372    fn array_assign() {
4373        let result = t("arr=(one two three)");
4374        assert_eq!(result, "set arr one two three");
4375    }
4376
4377    #[test]
4378    fn array_element_access() {
4379        let result = t("echo ${arr[1]}");
4380        assert!(result.contains("$arr[2]"), "got: {}", result);
4381    }
4382
4383    #[test]
4384    fn array_all() {
4385        let result = t("echo ${arr[@]}");
4386        assert!(result.contains("$arr"), "got: {}", result);
4387    }
4388
4389    #[test]
4390    fn array_length() {
4391        let result = t("echo ${#arr[@]}");
4392        assert!(result.contains("(count $arr)"), "got: {}", result);
4393    }
4394
4395    #[test]
4396    fn array_append() {
4397        let result = t("arr+=(three)");
4398        assert_eq!(result, "set -a arr three");
4399    }
4400
4401    #[test]
4402    fn array_slice() {
4403        let result = t("echo ${arr[@]:1:3}");
4404        assert!(result.contains("$arr["), "got: {}", result);
4405    }
4406
4407    // --- Trap ---
4408
4409    #[test]
4410    fn trap_exit() {
4411        assert_eq!(
4412            t("trap 'echo bye' EXIT"),
4413            "function __reef_trap_EXIT --on-event fish_exit\necho bye\nend"
4414        );
4415    }
4416
4417    #[test]
4418    fn trap_signal() {
4419        assert_eq!(
4420            t("trap 'cleanup' INT"),
4421            "function __reef_trap_INT --on-signal INT\ncleanup\nend"
4422        );
4423    }
4424
4425    #[test]
4426    fn trap_sigprefix() {
4427        assert_eq!(
4428            t("trap 'cleanup' SIGTERM"),
4429            "function __reef_trap_TERM --on-signal TERM\ncleanup\nend"
4430        );
4431    }
4432
4433    #[test]
4434    fn trap_reset() {
4435        assert_eq!(t("trap - INT"), "functions -e __reef_trap_INT");
4436    }
4437
4438    #[test]
4439    fn trap_ignore() {
4440        assert_eq!(
4441            t("trap '' INT"),
4442            "function __reef_trap_INT --on-signal INT; end"
4443        );
4444    }
4445
4446    #[test]
4447    fn trap_multiple_signals() {
4448        let result = t("trap 'cleanup' INT TERM");
4449        assert!(result.contains("__reef_trap_INT --on-signal INT"));
4450        assert!(result.contains("__reef_trap_TERM --on-signal TERM"));
4451    }
4452
4453    // --- declare -p ---
4454
4455    #[test]
4456    fn declare_print() {
4457        assert_eq!(t("declare -p FOO"), "set --show FOO");
4458    }
4459
4460    #[test]
4461    fn declare_print_all() {
4462        assert_eq!(t("declare -p"), "set --show");
4463    }
4464
4465    #[test]
4466    fn declare_print_multiple() {
4467        let result = t("declare -p FOO BAR");
4468        assert!(result.contains("set --show FOO"), "got: {}", result);
4469        assert!(result.contains("set --show BAR"), "got: {}", result);
4470    }
4471
4472    // --- ${!prefix*} ---
4473
4474    #[test]
4475    fn prefix_list() {
4476        assert_eq!(
4477            t("echo ${!BASH_*}"),
4478            "echo (set -n | string match 'BASH_*')"
4479        );
4480    }
4481
4482    #[test]
4483    fn prefix_list_at() {
4484        assert_eq!(t("echo ${!MY@}"), "echo (set -n | string match 'MY*')");
4485    }
4486
4487    // --- set -e/u/x ---
4488
4489    #[test]
4490    fn bash_set_errexit() {
4491        let result = t("set -e");
4492        assert!(result.contains("# set -e"), "got: {}", result);
4493        assert!(result.contains("no fish equivalent"), "got: {}", result);
4494    }
4495
4496    #[test]
4497    fn bash_set_eux() {
4498        let result = t("set -eux");
4499        assert!(result.contains("# set -eux"), "got: {}", result);
4500    }
4501
4502    #[test]
4503    fn bash_set_positional() {
4504        assert_eq!(t("set -- a b c"), "set argv a b c");
4505    }
4506
4507    // --- select / getopts / exec fd / eval ---
4508
4509    #[test]
4510    fn select_unsupported() {
4511        assert!(translate_bash_to_fish("select opt in a b c; do echo $opt; done").is_err());
4512    }
4513
4514    #[test]
4515    fn getopts_unsupported() {
4516        assert!(translate_bash_to_fish("getopts 'abc' opt").is_err());
4517    }
4518
4519    #[test]
4520    fn exec_fd_unsupported() {
4521        assert!(translate_bash_to_fish("exec 3>&1").is_err());
4522    }
4523
4524    #[test]
4525    fn eval_cmd_subst() {
4526        assert_eq!(
4527            t("eval \"$(pyenv init --path)\""),
4528            "pyenv init --path | source"
4529        );
4530    }
4531
4532    #[test]
4533    fn eval_dynamic_unsupported() {
4534        assert!(translate_bash_to_fish("eval $cmd").is_err());
4535    }
4536
4537    // --- Untranslatable variables ---
4538
4539    #[test]
4540    fn lineno_unsupported() {
4541        assert!(translate_bash_to_fish("echo $LINENO").is_err());
4542    }
4543
4544    #[test]
4545    fn funcname_unsupported() {
4546        assert!(translate_bash_to_fish("echo $FUNCNAME").is_err());
4547    }
4548
4549    #[test]
4550    fn seconds_unsupported() {
4551        assert!(translate_bash_to_fish("echo $SECONDS").is_err());
4552    }
4553
4554    // --- @E/@A transformations unsupported ---
4555
4556    #[test]
4557    fn transform_e_unsupported() {
4558        assert!(translate_bash_to_fish("echo ${var@E}").is_err());
4559    }
4560
4561    #[test]
4562    fn transform_a_unsupported() {
4563        assert!(translate_bash_to_fish("echo ${var@A}").is_err());
4564    }
4565
4566    // --- Regression: previously fixed bugs ---
4567
4568    #[test]
4569    fn negation_double_bracket_glob() {
4570        let result = t(r#"[[ ! "hello" == w* ]]"#);
4571        assert!(result.contains("not "), "should negate: got: {}", result);
4572        assert!(!result.contains(r"\!"), "should not escape !: got: {}", result);
4573    }
4574
4575    #[test]
4576    fn negation_double_bracket_string() {
4577        let result = t(r#"[[ ! "$x" == "yes" ]]"#);
4578        assert!(
4579            result.contains("not ") || result.contains("!="),
4580            "should negate: got: {}",
4581            result
4582        );
4583    }
4584
4585    #[test]
4586    fn negation_double_bracket_test_flag() {
4587        let result = t(r#"[[ ! -z "$var" ]]"#);
4588        assert!(result.contains("not test"), "should negate: got: {}", result);
4589    }
4590
4591    #[test]
4592    fn integer_division_truncates() {
4593        let result = t("echo $((10 / 3))");
4594        assert!(result.contains("floor(10 / 3)"), "got: {}", result);
4595    }
4596
4597    #[test]
4598    fn integer_division_exact() {
4599        let result = t("echo $((20 / 4))");
4600        assert!(result.contains("floor(20 / 4)"), "got: {}", result);
4601    }
4602
4603    #[test]
4604    fn path_colon_splitting() {
4605        let result = t("export PATH=/usr/local/bin:/usr/bin:$PATH");
4606        assert!(
4607            !result.contains(':'),
4608            "colons should be split: got: {}",
4609            result
4610        );
4611        assert!(result.contains("/usr/local/bin /usr/bin"), "got: {}", result);
4612    }
4613
4614    #[test]
4615    fn manpath_colon_splitting() {
4616        let result = t("export MANPATH=/usr/share/man:/usr/local/man");
4617        assert!(
4618            result.contains("/usr/share/man /usr/local/man"),
4619            "got: {}",
4620            result
4621        );
4622    }
4623
4624    #[test]
4625    fn non_path_var_keeps_colons() {
4626        assert_eq!(t("export FOO=a:b:c"), "set -gx FOO a:b:c");
4627    }
4628
4629    #[test]
4630    fn prefix_assignment_bails_to_t2() {
4631        assert!(translate_bash_to_fish("IFS=: read -ra parts").is_err());
4632    }
4633
4634    #[test]
4635    fn subshell_exit_bails_to_t2() {
4636        assert!(translate_bash_to_fish("(exit 1)").is_err());
4637    }
4638
4639    #[test]
4640    fn trap_exit_in_subshell_bails() {
4641        assert!(translate_bash_to_fish("( trap 'echo bye' EXIT; echo hi )").is_err());
4642        // But trap EXIT at top level should still work
4643        assert!(translate_bash_to_fish("trap 'echo bye' EXIT").is_ok());
4644    }
4645
4646    #[test]
4647    fn brace_range_with_subst_bails() {
4648        // {a..c}$(cmd) — bash distributes suffix, fish doesn't
4649        assert!(translate_bash_to_fish("echo {a..c}$(echo X)").is_err());
4650        // {a..c}$var — same distribution issue
4651        assert!(translate_bash_to_fish("echo {a..c}$suffix").is_err());
4652        // Plain brace range without dynamic parts should still work
4653        assert!(translate_bash_to_fish("echo {a..c}").is_ok());
4654        assert!(translate_bash_to_fish("echo {1..5}").is_ok());
4655        // Brace range with static string — fish handles correctly
4656        assert!(translate_bash_to_fish(r#"echo {a..c}"hello""#).is_ok());
4657    }
4658
4659    // --- Complex real-world translations ---
4660
4661    #[test]
4662    fn translate_if_dir_exists() {
4663        let result = t("if [ -d /tmp ]; then echo exists; else echo nope; fi");
4664        assert!(result.contains("[ -d /tmp ]"), "got: {}", result);
4665        assert!(result.contains("else"), "got: {}", result);
4666        assert!(result.contains("end"), "got: {}", result);
4667    }
4668
4669    #[test]
4670    fn translate_for_glob() {
4671        let result = t("for f in *.txt; do echo $f; done");
4672        assert!(result.contains("for f in *.txt"), "got: {}", result);
4673        assert!(result.contains("end"), "got: {}", result);
4674    }
4675
4676    #[test]
4677    fn translate_while_read() {
4678        let result = t("while read -r line; do echo $line; done < /tmp/input");
4679        assert!(result.contains("while read"), "got: {}", result);
4680        assert!(result.contains("end"), "got: {}", result);
4681    }
4682
4683    #[test]
4684    fn translate_command_in_string() {
4685        let result = t(r#"echo "Hello $USER, you are in $(pwd)""#);
4686        assert!(result.contains("$USER"), "got: {}", result);
4687        assert!(result.contains("(pwd)"), "got: {}", result);
4688    }
4689
4690    #[test]
4691    fn translate_test_and_or() {
4692        let result = t("test -f /etc/passwd && echo found || echo missing");
4693        assert!(result.contains("test -f /etc/passwd"), "got: {}", result);
4694        assert!(result.contains("; and echo found"), "got: {}", result);
4695        assert!(result.contains("; or echo missing"), "got: {}", result);
4696    }
4697
4698    #[test]
4699    fn translate_chained_commands() {
4700        let result = t("mkdir -p /tmp/test && cd /tmp/test && touch file.txt");
4701        assert!(result.contains("mkdir -p /tmp/test"), "got: {}", result);
4702        assert!(result.contains("cd /tmp/test"), "got: {}", result);
4703    }
4704
4705    #[test]
4706    fn translate_pipeline() {
4707        let result = t("cat file.txt | grep pattern | sort | uniq -c");
4708        assert!(result.contains("cat file.txt | grep pattern | sort | uniq -c"), "got: {}", result);
4709    }
4710
4711    #[test]
4712    fn translate_home_expansion() {
4713        let result = t("echo ${HOME}/documents");
4714        assert!(result.contains("$HOME"), "got: {}", result);
4715        assert!(result.contains("/documents"), "got: {}", result);
4716    }
4717
4718    #[test]
4719    fn translate_command_v() {
4720        let result = t("command -v git > /dev/null 2>&1 && echo installed");
4721        assert!(result.contains("command -v git"), "got: {}", result);
4722    }
4723
4724    #[test]
4725    fn translate_regex_match() {
4726        let result = t(r#"[[ "$x" =~ ^[0-9]+$ ]]"#);
4727        assert!(result.contains("string match -r"), "got: {}", result);
4728        assert!(result.contains("^[0-9]+$"), "got: {}", result);
4729    }
4730
4731    // --- C-style for edge cases ---
4732
4733    #[test]
4734    fn cstyle_for_decrementing() {
4735        let result = t("for ((i=10; i>0; i--)); do echo $i; done");
4736        assert!(result.contains("set i"), "got: {}", result);
4737        assert!(result.contains("while test"), "got: {}", result);
4738        assert!(result.contains("end"), "got: {}", result);
4739    }
4740
4741    #[test]
4742    fn cstyle_for_step_by_two() {
4743        let result = t("for ((i=0; i<10; i+=2)); do echo $i; done");
4744        assert!(result.contains("set i"), "got: {}", result);
4745        assert!(result.contains("$i + 2"), "got: {}", result);
4746    }
4747
4748    #[test]
4749    fn cstyle_for_infinite() {
4750        let result = t("for ((;;)); do echo loop; break; done");
4751        assert!(result.contains("while true"), "got: {}", result);
4752        assert!(result.contains("break"), "got: {}", result);
4753    }
4754
4755    // --- Case statement edge cases ---
4756
4757    #[test]
4758    fn case_char_classes() {
4759        let result = t(r#"case "$x" in [0-9]*) echo num;; [a-z]*) echo alpha;; esac"#);
4760        assert!(result.contains("switch"), "got: {}", result);
4761        assert!(result.contains("'[0-9]*'"), "got: {}", result);
4762    }
4763
4764    #[test]
4765    fn case_multiple_patterns() {
4766        let result = t(
4767            r#"case "$1" in -h|--help) echo help;; -v|--verbose) echo verbose;; esac"#,
4768        );
4769        assert!(result.contains("switch"), "got: {}", result);
4770        assert!(result.contains("--help"), "got: {}", result);
4771        assert!(result.contains("-h"), "got: {}", result);
4772    }
4773
4774    // --- String operation edge cases ---
4775
4776    #[test]
4777    fn replace_with_empty_replacement() {
4778        let result = t("echo ${var/foo}");
4779        assert!(result.contains("string replace"), "got: {}", result);
4780        assert!(result.contains("foo"), "got: {}", result);
4781    }
4782
4783    #[test]
4784    fn substring_negative_not_supported() {
4785        // Ensure negative offset doesn't panic — T2 bail is acceptable
4786        let _ = translate_bash_to_fish("echo ${var: -3}");
4787    }
4788
4789    // --- Heredoc edge cases ---
4790
4791    #[test]
4792    fn heredoc_multiline_body() {
4793        let result = t("cat <<'EOF'\nline1\nline2\nline3\nEOF");
4794        assert!(result.contains("printf"), "got: {}", result);
4795        assert!(result.contains("line1"), "got: {}", result);
4796        assert!(result.contains("line3"), "got: {}", result);
4797        assert!(result.contains("| cat"), "got: {}", result);
4798    }
4799
4800    #[test]
4801    fn heredoc_with_grep() {
4802        let result = t("grep pattern <<'END'\nfoo\nbar\nbaz\nEND");
4803        assert!(result.contains("printf"), "got: {}", result);
4804        assert!(result.contains("| grep pattern"), "got: {}", result);
4805    }
4806
4807    // --- Process substitution ---
4808
4809    #[test]
4810    fn process_sub_diff() {
4811        let result = t("diff <(sort file1) <(sort file2)");
4812        assert!(result.contains("psub"), "got: {}", result);
4813        assert!(result.contains("sort file1"), "got: {}", result);
4814        assert!(result.contains("sort file2"), "got: {}", result);
4815    }
4816
4817    // --- Arithmetic edge cases ---
4818
4819    #[test]
4820    fn arith_modulo_integer() {
4821        let result = t("echo $((10 % 3))");
4822        assert!(result.contains("10 % 3"), "got: {}", result);
4823    }
4824
4825    #[test]
4826    fn arith_nested_operations() {
4827        let result = t("echo $(( (a + b) * (c - d) ))");
4828        assert!(result.contains("$a + $b"), "got: {}", result);
4829        assert!(result.contains("$c - $d"), "got: {}", result);
4830    }
4831
4832    #[test]
4833    fn arith_postincrement_standalone() {
4834        let result = t("(( i++ ))");
4835        assert!(result.contains("set i (math"), "got: {}", result);
4836    }
4837
4838    #[test]
4839    fn arith_compound_assign_standalone() {
4840        let result = t("(( x += 5 ))");
4841        assert!(result.contains("set x (math"), "got: {}", result);
4842    }
4843
4844    // --- Double bracket operators ---
4845
4846    #[test]
4847    fn double_bracket_not_equal() {
4848        let result = t(r#"[[ "$x" != "hello" ]]"#);
4849        assert!(result.contains("string match") || result.contains("!="), "got: {}", result);
4850    }
4851
4852    #[test]
4853    fn double_bracket_less_than() {
4854        // `<` inside [[ ]] is tricky — parser may not handle (redirect ambiguity).
4855        // T2 bail is acceptable; just verify no panic.
4856        let _ = translate_bash_to_fish(r#"[[ "$a" < "$b" ]]"#);
4857    }
4858
4859    #[test]
4860    fn double_bracket_n_flag() {
4861        let result = t(r#"[[ -n "$var" ]]"#);
4862        assert!(result.contains("test -n"), "got: {}", result);
4863    }
4864
4865    #[test]
4866    fn double_bracket_z_flag() {
4867        let result = t(r#"[[ -z "$var" ]]"#);
4868        assert!(result.contains("test -z"), "got: {}", result);
4869    }
4870
4871    // --- Redirect edge cases ---
4872
4873    #[test]
4874    fn redirect_dev_null() {
4875        let result = t("command > /dev/null 2>&1");
4876        assert!(result.contains(">/dev/null") || result.contains("> /dev/null"), "got: {}", result);
4877    }
4878
4879    #[test]
4880    fn redirect_stderr_to_file() {
4881        let result = t("command 2> errors.log");
4882        assert!(result.contains("errors.log"), "got: {}", result);
4883    }
4884
4885    // --- Mixed complex scenarios ---
4886
4887    #[test]
4888    fn nested_if_with_arithmetic() {
4889        let result = t("if [ $((x + 1)) -gt 5 ]; then echo big; fi");
4890        assert!(result.contains("if "), "got: {}", result);
4891        assert!(result.contains("-gt 5"), "got: {}", result);
4892        assert!(result.contains("end"), "got: {}", result);
4893    }
4894
4895    #[test]
4896    fn function_with_local_vars() {
4897        let result = t("myfunc() { local x=1; echo $x; }");
4898        assert!(result.contains("function myfunc"), "got: {}", result);
4899        assert!(result.contains("set -l x 1"), "got: {}", result);
4900    }
4901
4902    #[test]
4903    fn for_loop_with_command_substitution() {
4904        let result = t("for f in $(ls *.txt); do echo $f; done");
4905        assert!(result.contains("for f in"), "got: {}", result);
4906        assert!(result.contains("ls *.txt"), "got: {}", result);
4907        assert!(result.contains("end"), "got: {}", result);
4908    }
4909
4910    #[test]
4911    fn shopt_bails_to_t2() {
4912        assert!(translate_bash_to_fish("shopt -s nullglob").is_err());
4913    }
4914
4915    #[test]
4916    fn declare_export_flag() {
4917        assert!(t("declare -x FOO=bar").contains("set -gx FOO bar"));
4918    }
4919
4920    // --- Eval special patterns ---
4921
4922    #[test]
4923    fn eval_pyenv_init() {
4924        let result = t(r#"eval "$(pyenv init -)""#);
4925        assert!(result.contains("pyenv init -"), "got: {}", result);
4926        assert!(result.contains("source"), "got: {}", result);
4927    }
4928
4929    #[test]
4930    fn eval_ssh_agent() {
4931        let result = t(r#"eval "$(ssh-agent -s)""#);
4932        assert!(result.contains("ssh-agent -s"), "got: {}", result);
4933        assert!(result.contains("source"), "got: {}", result);
4934    }
4935
4936    // --- Herestring edge cases ---
4937
4938    #[test]
4939    fn herestring_with_variable() {
4940        let result = t("read x <<< $HOME");
4941        assert!(result.contains("echo $HOME"), "got: {}", result);
4942        assert!(result.contains("| read x"), "got: {}", result);
4943    }
4944
4945    #[test]
4946    fn herestring_with_double_quoted() {
4947        let result = t(r#"read x <<< "hello world""#);
4948        assert!(result.contains("hello world"), "got: {}", result);
4949        assert!(result.contains("| read x"), "got: {}", result);
4950    }
4951
4952    // --- Empty/trivial inputs ---
4953
4954    #[test]
4955    fn translate_empty_command() {
4956        assert!(translate_bash_to_fish("").is_ok());
4957    }
4958
4959    #[test]
4960    fn translate_comment_stripped() {
4961        // Parser strips comments — empty output
4962        assert!(t("# this is a comment").is_empty());
4963    }
4964
4965    #[test]
4966    fn translate_multiple_semicolons() {
4967        let result = t("echo a; echo b; echo c");
4968        assert_eq!(result, "echo a\necho b\necho c");
4969    }
4970
4971    // --- Bitwise arithmetic ---
4972
4973    #[test]
4974    fn arith_bitand() {
4975        let result = t("echo $((x & 0xFF))");
4976        assert!(result.contains("bitand("), "got: {}", result);
4977    }
4978
4979    #[test]
4980    fn arith_bitor() {
4981        let result = t("echo $((a | b))");
4982        assert!(result.contains("bitor("), "got: {}", result);
4983    }
4984
4985    #[test]
4986    fn arith_bitxor() {
4987        let result = t("echo $((a ^ b))");
4988        assert!(result.contains("bitxor("), "got: {}", result);
4989    }
4990
4991    #[test]
4992    fn arith_bitnot() {
4993        let result = t("echo $((~x))");
4994        assert!(result.contains("bitxor("), "got: {}", result);
4995        assert!(result.contains("-1"), "got: {}", result);
4996    }
4997
4998    #[test]
4999    fn arith_shift_left() {
5000        let result = t("echo $((1 << 4))");
5001        assert!(result.contains("* 2 ^"), "got: {}", result);
5002    }
5003
5004    #[test]
5005    fn arith_shift_right() {
5006        let result = t("echo $((x >> 2))");
5007        assert!(result.contains("floor("), "got: {}", result);
5008        assert!(result.contains("/ 2 ^"), "got: {}", result);
5009    }
5010
5011    // --- Indirect expansion ---
5012
5013    #[test]
5014    fn indirect_expansion() {
5015        let result = t(r#"echo "${!ref}""#);
5016        assert!(result.contains("$$ref"), "got: {}", result);
5017    }
5018
5019    // --- Parameter transform ---
5020
5021    #[test]
5022    fn transform_quote() {
5023        let result = t(r#"echo "${var@Q}""#);
5024        assert!(result.contains("string escape -- $var"), "got: {}", result);
5025    }
5026
5027    #[test]
5028    fn transform_upper() {
5029        let result = t(r#"echo "${var@U}""#);
5030        assert!(result.contains("string upper -- $var"), "got: {}", result);
5031    }
5032
5033    #[test]
5034    fn transform_lower() {
5035        let result = t(r#"echo "${var@L}""#);
5036        assert!(result.contains("string lower -- $var"), "got: {}", result);
5037    }
5038
5039    #[test]
5040    fn transform_capitalize() {
5041        let result = t(r#"echo "${var@u}""#);
5042        assert!(result.contains("string sub -l 1"), "got: {}", result);
5043        assert!(result.contains("string upper"), "got: {}", result);
5044    }
5045
5046    #[test]
5047    fn transform_p_unsupported() {
5048        assert!(translate_bash_to_fish(r#"echo "${var@P}""#).is_err());
5049    }
5050
5051    #[test]
5052    fn transform_k_unsupported() {
5053        assert!(translate_bash_to_fish(r#"echo "${var@K}""#).is_err());
5054    }
5055
5056    // --- Real-world one-liners ---
5057
5058    #[test]
5059    fn pip_install() {
5060        let result = t("pip install -r requirements.txt");
5061        assert_eq!(result, "pip install -r requirements.txt");
5062    }
5063
5064    #[test]
5065    fn docker_run() {
5066        let result = t("docker run -it --rm -v /tmp:/data ubuntu bash");
5067        assert!(result.contains("docker run"), "got: {}", result);
5068    }
5069
5070    #[test]
5071    fn npm_run_dev() {
5072        let result = t("npm run dev");
5073        assert_eq!(result, "npm run dev");
5074    }
5075
5076    #[test]
5077    fn make_j() {
5078        let result = t("make -j4");
5079        assert_eq!(result, "make -j4");
5080    }
5081
5082    #[test]
5083    fn cargo_test_filter() {
5084        let result = t("cargo test -- --test-threads=1");
5085        assert_eq!(result, "cargo test -- --test-threads=1");
5086    }
5087
5088    #[test]
5089    fn git_log_oneline() {
5090        let result = t("git log --oneline -10");
5091        assert_eq!(result, "git log --oneline -10");
5092    }
5093
5094    #[test]
5095    fn tar_extract() {
5096        let result = t("tar xzf archive.tar.gz -C /tmp");
5097        assert_eq!(result, "tar xzf archive.tar.gz -C /tmp");
5098    }
5099
5100    #[test]
5101    fn chmod_recursive() {
5102        let result = t("chmod -R 755 /var/www");
5103        assert_eq!(result, "chmod -R 755 /var/www");
5104    }
5105
5106    #[test]
5107    fn grep_recursive() {
5108        let result = t("grep -rn TODO src/");
5109        assert_eq!(result, "grep -rn TODO src/");
5110    }
5111
5112    #[test]
5113    fn xargs_rm() {
5114        let result = t("find . -name '*.bak' -print0 | xargs -0 rm -f");
5115        assert!(result.contains("find ."), "got: {}", result);
5116        assert!(result.contains("| xargs"), "got: {}", result);
5117    }
5118
5119    #[test]
5120    fn ssh_command() {
5121        let result = t("ssh user@host 'uptime'");
5122        assert!(result.contains("ssh user@host"), "got: {}", result);
5123    }
5124
5125    #[test]
5126    fn rsync_command() {
5127        let result = t("rsync -avz --delete src/ dest/");
5128        assert_eq!(result, "rsync -avz --delete src/ dest/");
5129    }
5130
5131    #[test]
5132    fn curl_json() {
5133        let result = t("curl -s -H 'Content-Type: application/json' https://api.example.com/data");
5134        assert!(result.contains("curl -s"), "got: {}", result);
5135    }
5136
5137    #[test]
5138    fn systemctl_status() {
5139        let result = t("systemctl status nginx");
5140        assert_eq!(result, "systemctl status nginx");
5141    }
5142
5143    #[test]
5144    fn kill_process() {
5145        let result = t("kill -9 1234");
5146        assert_eq!(result, "kill -9 1234");
5147    }
5148
5149    #[test]
5150    fn ps_grep_pipeline() {
5151        let result = t("ps aux | grep nginx | grep -v grep");
5152        assert_eq!(result, "ps aux | grep nginx | grep -v grep");
5153    }
5154
5155    #[test]
5156    fn du_sort() {
5157        let result = t("du -sh * | sort -hr | head -10");
5158        assert!(result.contains("du -sh"), "got: {}", result);
5159        assert!(result.contains("| sort -hr"), "got: {}", result);
5160    }
5161
5162    #[test]
5163    fn source_env_file() {
5164        // source passes through (fish also has `source`)
5165        let result = t("source ~/.bashrc");
5166        assert!(result.contains("source"), "got: {}", result);
5167    }
5168
5169    #[test]
5170    fn dot_source_profile() {
5171        // . (dot source) passes through
5172        let result = t(". ~/.profile");
5173        assert!(result.contains('.'), "got: {}", result);
5174    }
5175
5176    // --- Nested substitution ---
5177
5178    #[test]
5179    fn nested_param_in_cmd_subst() {
5180        let result = t(r#"echo "$(basename "${file}")""#);
5181        assert!(result.contains("basename"), "got: {}", result);
5182    }
5183
5184    #[test]
5185    fn cmd_subst_in_assignment() {
5186        let result = t("result=$(grep -c error log.txt)");
5187        assert!(result.contains("set result"), "got: {}", result);
5188        assert!(result.contains("grep -c error"), "got: {}", result);
5189    }
5190
5191    #[test]
5192    fn arith_in_array_index() {
5193        let result = t("echo ${arr[$((i+1))]}");
5194        assert!(result.contains("$arr"), "got: {}", result);
5195    }
5196
5197    #[test]
5198    fn nested_cmd_subst_three_deep() {
5199        let result = t("echo $(dirname $(dirname $(which python)))");
5200        assert!(result.contains("dirname"), "got: {}", result);
5201        assert!(result.contains("which python"), "got: {}", result);
5202    }
5203
5204    // --- Complex quoting ---
5205
5206    #[test]
5207    fn mixed_quotes_in_command() {
5208        let result = t(r#"echo "It's a test""#);
5209        assert!(result.contains("It"), "got: {}", result);
5210    }
5211
5212    #[test]
5213    fn double_quotes_preserve_variable() {
5214        let result = t(r#"echo "Hello $USER, you are in $PWD""#);
5215        assert!(result.contains("$USER"), "got: {}", result);
5216        assert!(result.contains("$PWD"), "got: {}", result);
5217    }
5218
5219    #[test]
5220    fn empty_string_arg() {
5221        let result = t(r#"echo "" foo"#);
5222        assert!(result.contains(r#""""#), "got: {}", result);
5223    }
5224
5225    // --- For loop edge cases ---
5226
5227    #[test]
5228    fn for_in_brace_range() {
5229        let result = t("for i in {1..5}; do echo $i; done");
5230        assert!(result.contains("for i in (seq 1 5)"), "got: {}", result);
5231    }
5232
5233    #[test]
5234    fn for_in_brace_range_with_step() {
5235        let result = t("for i in {0..10..2}; do echo $i; done");
5236        assert!(result.contains("seq 0 2 10"), "got: {}", result);
5237    }
5238
5239    #[test]
5240    fn for_loop_multiple_commands() {
5241        let result = t("for f in *.txt; do echo $f; wc -l $f; done");
5242        assert!(result.contains("for f in *.txt"), "got: {}", result);
5243        assert!(result.contains("echo $f"), "got: {}", result);
5244        assert!(result.contains("wc -l $f"), "got: {}", result);
5245    }
5246
5247    // --- While loop edge cases ---
5248
5249    #[test]
5250    fn while_true_loop() {
5251        let result = t("while true; do echo loop; sleep 1; done");
5252        assert!(result.contains("while true"), "got: {}", result);
5253        assert!(result.contains("sleep 1"), "got: {}", result);
5254    }
5255
5256    #[test]
5257    fn while_command_condition() {
5258        let result = t("while pgrep -x nginx > /dev/null; do sleep 5; done");
5259        assert!(result.contains("while pgrep"), "got: {}", result);
5260    }
5261
5262    // --- If edge cases ---
5263
5264    #[test]
5265    fn if_command_condition() {
5266        let result = t("if grep -q error /var/log/syslog; then echo found; fi");
5267        assert!(result.contains("if grep -q error"), "got: {}", result);
5268        assert!(result.contains("echo found"), "got: {}", result);
5269    }
5270
5271    #[test]
5272    fn if_negated_condition() {
5273        let result = t("if ! command -v git > /dev/null; then echo missing; fi");
5274        assert!(result.contains("if not"), "got: {}", result);
5275        assert!(result.contains("command -v git"), "got: {}", result);
5276    }
5277
5278    #[test]
5279    fn if_test_file_ops() {
5280        let result = t("if [ -f /etc/passwd ] && [ -r /etc/passwd ]; then echo ok; fi");
5281        assert!(result.contains("-f /etc/passwd"), "got: {}", result);
5282        assert!(result.contains("-r /etc/passwd"), "got: {}", result);
5283    }
5284
5285    #[test]
5286    fn if_elif_chain() {
5287        let result = t("if [ $x -eq 1 ]; then echo one; elif [ $x -eq 2 ]; then echo two; elif [ $x -eq 3 ]; then echo three; else echo other; fi");
5288        assert!(result.contains("else if"), "got: {}", result);
5289        assert!(result.contains("echo three"), "got: {}", result);
5290        assert!(result.contains("echo other"), "got: {}", result);
5291    }
5292
5293    // --- Case edge cases ---
5294
5295    #[test]
5296    fn case_with_default_only() {
5297        let result = t(r#"case "$x" in *) echo default ;; esac"#);
5298        assert!(result.contains("switch"), "got: {}", result);
5299        assert!(result.contains("case '*'"), "got: {}", result);
5300    }
5301
5302    #[test]
5303    fn case_empty_body() {
5304        // Empty case arm: a) ;; — was causing parser infinite loop
5305        let result = t(r#"case "$x" in a) ;; b) echo b ;; esac"#);
5306        assert!(result.contains("switch"), "got: {}", result);
5307        assert!(result.contains("echo b"), "got: {}", result);
5308    }
5309
5310    // --- Function edge cases ---
5311
5312    #[test]
5313    fn function_with_return() {
5314        let result = t("myfunc() { echo hello; return 0; }");
5315        assert!(result.contains("function myfunc"), "got: {}", result);
5316        assert!(result.contains("return 0"), "got: {}", result);
5317    }
5318
5319    #[test]
5320    fn function_keyword_syntax() {
5321        let result = t("function myfunc { echo hello; }");
5322        assert!(result.contains("function myfunc"), "got: {}", result);
5323    }
5324
5325    // --- Export edge cases ---
5326
5327    #[test]
5328    fn export_with_special_chars_value() {
5329        let result = t(r#"export GREETING="Hello World""#);
5330        assert!(result.contains("set -gx GREETING"), "got: {}", result);
5331        assert!(result.contains("Hello World"), "got: {}", result);
5332    }
5333
5334    #[test]
5335    fn export_append_path() {
5336        let result = t(r#"export PATH="$HOME/bin:$PATH""#);
5337        assert!(result.contains("set -gx PATH"), "got: {}", result);
5338    }
5339
5340    // --- Declare edge cases ---
5341
5342    #[test]
5343    fn declare_local() {
5344        let result = t("declare foo=bar");
5345        assert!(result.contains("set") && result.contains("foo") && result.contains("bar"), "got: {}", result);
5346    }
5347
5348    #[test]
5349    fn declare_integer() {
5350        let result = translate_bash_to_fish("declare -i num=42");
5351        // -i might bail or be handled
5352        let _ = result;
5353    }
5354
5355    // --- Read command ---
5356
5357    #[test]
5358    fn read_single_var() {
5359        let result = t("read name");
5360        assert!(result.contains("read name"), "got: {}", result);
5361    }
5362
5363    #[test]
5364    fn read_prompt() {
5365        let result = t(r#"read -p "Enter name: " name"#);
5366        assert!(result.contains("read"), "got: {}", result);
5367    }
5368
5369    // --- Test/bracket edge cases ---
5370
5371    #[test]
5372    fn test_string_equality() {
5373        let result = t(r#"[ "$a" = "hello" ]"#);
5374        assert!(result.contains("test") || result.contains('['), "got: {}", result);
5375    }
5376
5377    #[test]
5378    fn test_numeric_comparison() {
5379        let result = t("[ $count -gt 10 ]");
5380        assert!(result.contains("10"), "got: {}", result);
5381    }
5382
5383    #[test]
5384    fn double_bracket_regex_with_capture() {
5385        let result = t(r#"[[ "$line" =~ ^([0-9]+) ]]"#);
5386        assert!(result.contains("string match -r"), "got: {}", result);
5387    }
5388
5389    #[test]
5390    fn double_bracket_compound() {
5391        let result = t(r#"[[ -n "$a" && -z "$b" ]]"#);
5392        assert!(result.contains("-n"), "got: {}", result);
5393        assert!(result.contains("-z"), "got: {}", result);
5394    }
5395
5396    // --- Redirect edge cases ---
5397
5398    #[test]
5399    fn redirect_both_to_file() {
5400        let result = t("command > out.txt 2>&1");
5401        assert!(result.contains("out.txt"), "got: {}", result);
5402    }
5403
5404    #[test]
5405    fn redirect_input_and_output() {
5406        let result = t("sort < input.txt > output.txt");
5407        assert!(result.contains("sort"), "got: {}", result);
5408        assert!(result.contains("input.txt"), "got: {}", result);
5409    }
5410
5411    #[test]
5412    fn redirect_append_stderr() {
5413        let result = t("command >> log.txt 2>&1");
5414        assert!(result.contains("log.txt"), "got: {}", result);
5415    }
5416
5417    // --- Trap edge cases ---
5418
5419    #[test]
5420    fn trap_on_err() {
5421        let result = translate_bash_to_fish("trap 'echo error' ERR");
5422        // ERR trap may or may not be supported
5423        let _ = result;
5424    }
5425
5426    #[test]
5427    fn trap_cleanup_function() {
5428        let result = t("trap cleanup EXIT");
5429        assert!(result.contains("cleanup"), "got: {}", result);
5430        assert!(result.contains("fish_exit"), "got: {}", result);
5431    }
5432
5433    // --- Arithmetic edge cases ---
5434
5435    #[test]
5436    fn arith_comma_operator() {
5437        // Comma in arithmetic: ((a=1, b=2))
5438        let result = translate_bash_to_fish("((a=1, b=2))");
5439        // May or may not be supported
5440        let _ = result;
5441    }
5442
5443    #[test]
5444    fn arith_pre_decrement_in_subst() {
5445        // Pre-decrement inside $(()) is unsupported — bails to T2
5446        assert!(translate_bash_to_fish("echo $((--x))").is_err());
5447    }
5448
5449    #[test]
5450    fn arith_hex_literal() {
5451        let result = t("echo $((0xFF))");
5452        assert!(result.contains("math"), "got: {}", result);
5453    }
5454
5455    // --- Compound commands ---
5456
5457    #[test]
5458    fn brace_group_with_redirect() {
5459        let result = t("{ echo a; echo b; } > output.txt");
5460        assert!(result.contains("echo a"), "got: {}", result);
5461        assert!(result.contains("echo b"), "got: {}", result);
5462    }
5463
5464    #[test]
5465    fn subshell_with_env() {
5466        // Subshell that modifies env — fish begin/end isn't a true subshell
5467        let result = translate_bash_to_fish("(cd /tmp && ls)");
5468        // Should work as begin/end or bail
5469        let _ = result;
5470    }
5471
5472    // --- Complex real-world patterns ---
5473
5474    #[test]
5475    fn nvm_init_pattern() {
5476        let result = translate_bash_to_fish(r#"export NVM_DIR="$HOME/.nvm""#);
5477        assert!(result.is_ok());
5478    }
5479
5480    #[test]
5481    fn conditional_mkdir() {
5482        let result = t("[ -d /tmp/mydir ] || mkdir -p /tmp/mydir");
5483        assert!(result.contains("/tmp/mydir"), "got: {}", result);
5484        assert!(result.contains("mkdir"), "got: {}", result);
5485    }
5486
5487    #[test]
5488    fn count_files() {
5489        let result = t("ls -1 | wc -l");
5490        assert_eq!(result, "ls -1 | wc -l");
5491    }
5492
5493    #[test]
5494    fn check_exit_code() {
5495        let result = t("if [ $? -ne 0 ]; then echo failed; fi");
5496        assert!(result.contains("$status"), "got: {}", result);
5497    }
5498
5499    #[test]
5500    fn string_contains_check() {
5501        let result = t(r#"[[ "$string" == *"substring"* ]]"#);
5502        assert!(result.contains("string match"), "got: {}", result);
5503    }
5504
5505    #[test]
5506    fn default_value_in_assignment() {
5507        let result = t(r#"name="${1:-World}""#);
5508        assert!(result.contains("World"), "got: {}", result);
5509    }
5510
5511    #[test]
5512    fn multiline_if() {
5513        let result = t("if [ -f ~/.bashrc ]; then\n  echo found\nfi");
5514        assert!(result.contains("if"), "got: {}", result);
5515        assert!(result.contains("echo found"), "got: {}", result);
5516    }
5517
5518    #[test]
5519    fn variable_in_path() {
5520        let result = t(r#"ls "$HOME/Documents""#);
5521        assert!(result.contains("$HOME"), "got: {}", result);
5522    }
5523
5524    #[test]
5525    fn command_chaining() {
5526        let result = t("mkdir -p build && cd build && cmake ..");
5527        assert!(result.contains("mkdir -p build"), "got: {}", result);
5528        assert!(result.contains("cd build"), "got: {}", result);
5529    }
5530
5531    #[test]
5532    fn process_sub_with_while() {
5533        let result = t("while read line; do echo $line; done < <(ls -1)");
5534        assert!(result.contains("psub"), "got: {}", result);
5535    }
5536
5537    #[test]
5538    fn heredoc_cat_pattern() {
5539        let result = t("cat <<'EOF'\nhello world\nEOF");
5540        assert!(result.contains("hello world"), "got: {}", result);
5541    }
5542
5543    #[test]
5544    fn heredoc_to_file() {
5545        let result = t("cat <<'EOF' > /tmp/file\ncontent\nEOF");
5546        assert!(result.contains("content"), "got: {}", result);
5547    }
5548
5549    // --- Param expansion edge cases ---
5550
5551    #[test]
5552    fn param_strip_extension() {
5553        let result = t(r#"echo "${filename%.*}""#);
5554        assert!(result.contains("string replace -r"), "got: {}", result);
5555    }
5556
5557    #[test]
5558    fn param_strip_path() {
5559        let result = t(r#"echo "${filepath##*/}""#);
5560        assert!(result.contains("string replace -r"), "got: {}", result);
5561    }
5562
5563    #[test]
5564    fn param_get_extension() {
5565        let result = t(r#"echo "${filename##*.}""#);
5566        assert!(result.contains("string replace -r"), "got: {}", result);
5567    }
5568
5569    #[test]
5570    fn param_get_directory() {
5571        let result = t(r#"echo "${filepath%/*}""#);
5572        assert!(result.contains("string replace -r"), "got: {}", result);
5573    }
5574
5575    #[test]
5576    fn param_default_empty_var() {
5577        let result = t(r#"echo "${unset_var:-default_value}""#);
5578        assert!(result.contains("default_value"), "got: {}", result);
5579    }
5580
5581    #[test]
5582    fn param_error_with_message() {
5583        let result = t(r#"echo "${required:?must be set}""#);
5584        assert!(result.contains("must be set"), "got: {}", result);
5585    }
5586
5587    #[test]
5588    fn substring_from_end() {
5589        let result = t(r#"echo "${str:0:3}""#);
5590        assert!(result.contains("string sub"), "got: {}", result);
5591    }
5592
5593    // --- Array edge cases ---
5594
5595    #[test]
5596    fn array_iteration() {
5597        let result = t(r#"for item in "${arr[@]}"; do echo "$item"; done"#);
5598        assert!(result.contains("for item in"), "got: {}", result);
5599        assert!(result.contains("$arr"), "got: {}", result);
5600    }
5601
5602    #[test]
5603    fn array_length_check() {
5604        let result = t(r#"echo "${#arr[@]}""#);
5605        assert!(result.contains("count $arr"), "got: {}", result);
5606    }
5607
5608    #[test]
5609    fn array_with_spaces() {
5610        let result = t(r#"arr=("hello world" "foo bar")"#);
5611        assert!(result.contains("set arr"), "got: {}", result);
5612    }
5613
5614    // --- Background and job control ---
5615
5616    #[test]
5617    fn background_with_redirect() {
5618        let result = t("long_running_task > /dev/null 2>&1 &");
5619        assert!(result.contains('&'), "got: {}", result);
5620    }
5621
5622    #[test]
5623    fn sequential_background() {
5624        let result = t("cmd1 & cmd2 &");
5625        assert!(result.contains('&'), "got: {}", result);
5626    }
5627
5628    // --- Unset edge cases ---
5629
5630    #[test]
5631    fn unset_multiple() {
5632        let result = t("unset FOO BAR BAZ");
5633        assert!(result.contains("set -e FOO"), "got: {}", result);
5634        assert!(result.contains("set -e BAR"), "got: {}", result);
5635        assert!(result.contains("set -e BAZ"), "got: {}", result);
5636    }
5637
5638    #[test]
5639    fn unset_function() {
5640        // unset -f should bail
5641        let result = translate_bash_to_fish("unset -f myfunc");
5642        let _ = result;
5643    }
5644
5645    // --- Misc edge cases ---
5646
5647    #[test]
5648    fn true_false_commands() {
5649        assert_eq!(t("true"), "true");
5650        assert_eq!(t("false"), "false");
5651    }
5652
5653    #[test]
5654    fn colon_noop() {
5655        let result = t(":");
5656        assert!(result.contains(':') || result.contains("true") || result.is_empty(), "got: {}", result);
5657    }
5658
5659    #[test]
5660    fn echo_with_flags() {
5661        let result = t("echo -n hello");
5662        assert!(result.contains("echo -n hello"), "got: {}", result);
5663    }
5664
5665    #[test]
5666    fn echo_with_escape() {
5667        let result = t("echo -e 'hello\\nworld'");
5668        assert!(result.contains("echo"), "got: {}", result);
5669    }
5670
5671    #[test]
5672    fn printf_format() {
5673        let result = t(r#"printf "%s\n" hello"#);
5674        assert!(result.contains("printf"), "got: {}", result);
5675    }
5676
5677    #[test]
5678    fn test_with_not() {
5679        let result = t("[ ! -f /tmp/lock ]");
5680        assert!(result.contains('!') || result.contains("not"), "got: {}", result);
5681    }
5682
5683    #[test]
5684    fn pipeline_three_stages() {
5685        let result = t("cat file | sort | uniq -c");
5686        assert!(result.contains("| sort |"), "got: {}", result);
5687    }
5688
5689    #[test]
5690    fn subshell_captures_output() {
5691        let result = t("result=$(cd /tmp && pwd)");
5692        assert!(result.contains("set result"), "got: {}", result);
5693    }
5694
5695    #[test]
5696    fn multiple_var_assignment() {
5697        let result = t("a=1; b=2; c=3");
5698        assert!(result.contains("set a 1"), "got: {}", result);
5699        assert!(result.contains("set b 2"), "got: {}", result);
5700        assert!(result.contains("set c 3"), "got: {}", result);
5701    }
5702
5703    #[test]
5704    fn replace_all_slashes() {
5705        let result = t(r#"echo "${path//\//\\.}""#);
5706        assert!(result.contains("string replace"), "got: {}", result);
5707    }
5708}