Skip to main content

rust_bash/interpreter/
walker.rs

1//! AST walking: execution of programs, compound lists, pipelines, and simple commands.
2
3use crate::commands::{CommandContext, CommandResult};
4use crate::error::RustBashError;
5use crate::interpreter::builtins::{self, resolve_path};
6use crate::interpreter::expansion::{expand_word_mut, expand_word_to_string_mut};
7use crate::interpreter::{
8    CallFrame, ExecResult, ExecutionCounters, FunctionDef, InterpreterState, PersistentFd,
9    Variable, VariableAttrs, VariableValue, execute_trap, parse, set_array_element, set_variable,
10};
11
12use brush_parser::ast;
13use brush_parser::ast::SourceLocation;
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::Arc;
17
18// ── xtrace helpers ──────────────────────────────────────────────────
19
20/// Expand PS4 through the normal parameter expansion engine so that
21/// variables like `$x` or `$?` inside PS4 are evaluated.
22fn expand_ps4(state: &mut InterpreterState) -> String {
23    let raw = state
24        .env
25        .get("PS4")
26        .map(|v| v.value.as_scalar().to_string());
27    match raw {
28        Some(s) if !s.is_empty() => {
29            let word = brush_parser::ast::Word {
30                value: s,
31                loc: Default::default(),
32            };
33            expand_word_to_string_mut(&word, state).unwrap_or_else(|_| "+ ".to_string())
34        }
35        Some(_) => "+ ".to_string(), // PS4 is set but empty → default
36        None => String::new(),       // PS4 is unset → no prefix
37    }
38}
39
40/// Quote a single word for xtrace output.  Bash quotes words that contain
41/// whitespace, single quotes, double quotes, backslashes, or non-printable
42/// characters.  The quoting style uses single quotes with the `'\''` escape
43/// for embedded single quotes, except when $'...' is needed for control chars.
44fn xtrace_quote(word: &str) -> String {
45    if word.is_empty() {
46        return "''".to_string();
47    }
48
49    // Check what quoting is needed
50    let has_single_quote = word.contains('\'');
51    let needs_quoting = word
52        .chars()
53        .any(|c| c.is_whitespace() || c == '\'' || c == '"' || c == '\\' || (c as u32) < 0x20);
54
55    if !needs_quoting {
56        return word.to_string();
57    }
58
59    // Bash xtrace uses single quotes for most quoting, but represents
60    // single quotes as \' (breaking out of quoting).
61    // E.g., "it's" → 'it'\''s'
62    // But a bare single quote → \'
63    if has_single_quote {
64        let mut out = String::new();
65        let mut in_squote = false;
66        for c in word.chars() {
67            if c == '\'' {
68                if in_squote {
69                    out.push('\''); // close single quote
70                    in_squote = false;
71                }
72                out.push_str("\\'");
73            } else {
74                if !in_squote {
75                    out.push('\''); // open single quote
76                    in_squote = true;
77                }
78                out.push(c);
79            }
80        }
81        if in_squote {
82            out.push('\'');
83        }
84        out
85    } else {
86        // Simple single-quote wrapping (no single quotes in content)
87        // Bash puts literal tabs and newlines inside single quotes
88        format!("'{word}'")
89    }
90}
91
92/// Format an xtrace line for a simple command invocation.
93fn format_xtrace_command(ps4: &str, cmd: &str, args: &[String]) -> String {
94    let mut parts = Vec::with_capacity(1 + args.len());
95    parts.push(xtrace_quote(cmd));
96    for a in args {
97        parts.push(xtrace_quote(a));
98    }
99    format!("{ps4}{}\n", parts.join(" "))
100}
101
102/// Check the errexit (`set -e`) condition after a command completes.
103/// If errexit is enabled, the last exit code was non-zero, and we're not
104/// in a suppressed context (if/while/until condition, `&&`/`||` left side,
105/// `!` pipeline), set `should_exit = true`.
106fn check_errexit(state: &mut InterpreterState) {
107    if state.shell_opts.errexit
108        && state.last_exit_code != 0
109        && state.errexit_suppressed == 0
110        && !state.in_trap
111    {
112        state.should_exit = true;
113    }
114}
115
116/// Check execution limits and return an error if any are exceeded.
117fn check_limits(state: &InterpreterState) -> Result<(), RustBashError> {
118    if state.counters.command_count > state.limits.max_command_count {
119        return Err(RustBashError::LimitExceeded {
120            limit_name: "max_command_count",
121            limit_value: state.limits.max_command_count,
122            actual_value: state.counters.command_count,
123        });
124    }
125    if state.counters.output_size > state.limits.max_output_size {
126        return Err(RustBashError::LimitExceeded {
127            limit_name: "max_output_size",
128            limit_value: state.limits.max_output_size,
129            actual_value: state.counters.output_size,
130        });
131    }
132    if state.counters.start_time.elapsed() > state.limits.max_execution_time {
133        return Err(RustBashError::Timeout);
134    }
135    Ok(())
136}
137
138/// Execute a parsed program.
139pub fn execute_program(
140    program: &ast::Program,
141    state: &mut InterpreterState,
142) -> Result<ExecResult, RustBashError> {
143    let mut result = ExecResult::default();
144
145    for complete_command in &program.complete_commands {
146        if state.should_exit {
147            break;
148        }
149        let r = execute_compound_list(complete_command, state, "")?;
150        state.counters.output_size += r.stdout.len() + r.stderr.len();
151        check_limits(state)?;
152        result.stdout.push_str(&r.stdout);
153        result.stderr.push_str(&r.stderr);
154        result.exit_code = r.exit_code;
155        state.last_exit_code = r.exit_code;
156    }
157
158    Ok(result)
159}
160
161fn execute_compound_list(
162    list: &ast::CompoundList,
163    state: &mut InterpreterState,
164    stdin: &str,
165) -> Result<ExecResult, RustBashError> {
166    let mut result = ExecResult::default();
167
168    for item in &list.0 {
169        if state.should_exit || state.control_flow.is_some() {
170            break;
171        }
172        let ast::CompoundListItem(and_or_list, _separator) = item;
173        let r = match execute_and_or_list(and_or_list, state, stdin) {
174            Ok(r) => r,
175            Err(RustBashError::Execution(msg)) if msg.contains("unbound variable") => {
176                // nounset errors: print to stderr, exit with code 1
177                state.should_exit = true;
178                state.last_exit_code = 1;
179                ExecResult {
180                    stderr: format!("rust-bash: {msg}\n"),
181                    exit_code: 1,
182                    ..Default::default()
183                }
184            }
185            Err(e) => return Err(e),
186        };
187        result.stdout.push_str(&r.stdout);
188        result.stderr.push_str(&r.stderr);
189        result.exit_code = r.exit_code;
190        state.last_exit_code = r.exit_code;
191
192        // Fire ERR trap on non-zero exit code (but not when errexit is suppressed,
193        // e.g. inside `if`/`while`/`until` conditions or `&&`/`||` chains).
194        if r.exit_code != 0
195            && !state.in_trap
196            && state.errexit_suppressed == 0
197            && let Some(err_cmd) = state.traps.get("ERR").cloned()
198            && !err_cmd.is_empty()
199        {
200            let trap_r = execute_trap(&err_cmd, state)?;
201            result.stdout.push_str(&trap_r.stdout);
202            result.stderr.push_str(&trap_r.stderr);
203        }
204    }
205
206    Ok(result)
207}
208
209fn execute_and_or_list(
210    aol: &ast::AndOrList,
211    state: &mut InterpreterState,
212    stdin: &str,
213) -> Result<ExecResult, RustBashError> {
214    // If there are && or || operators, the first pipeline is on the left side
215    // of the chain, so errexit is suppressed for it.
216    let has_chain = !aol.additional.is_empty();
217    if has_chain {
218        state.errexit_suppressed += 1;
219    }
220    let mut result = execute_pipeline(&aol.first, state, stdin)?;
221    if has_chain {
222        state.errexit_suppressed -= 1;
223    }
224    state.last_exit_code = result.exit_code;
225
226    // Check errexit after the first pipeline if it's standalone (no chain)
227    if !has_chain {
228        check_errexit(state);
229        if state.should_exit {
230            return Ok(result);
231        }
232    }
233
234    for (idx, and_or) in aol.additional.iter().enumerate() {
235        if state.should_exit || state.control_flow.is_some() {
236            break;
237        }
238        let (should_run, pipeline) = match and_or {
239            ast::AndOr::And(p) => (result.exit_code == 0, p),
240            ast::AndOr::Or(p) => (result.exit_code != 0, p),
241        };
242        if should_run {
243            // Suppress errexit for all but the last pipeline in the chain
244            let is_last = idx == aol.additional.len() - 1;
245            if !is_last {
246                state.errexit_suppressed += 1;
247            }
248            let r = execute_pipeline(pipeline, state, stdin)?;
249            if !is_last {
250                state.errexit_suppressed -= 1;
251            }
252            result.stdout.push_str(&r.stdout);
253            result.stderr.push_str(&r.stderr);
254            result.exit_code = r.exit_code;
255            state.last_exit_code = r.exit_code;
256
257            if is_last {
258                check_errexit(state);
259            }
260        }
261    }
262
263    Ok(result)
264}
265
266fn execute_pipeline(
267    pipeline: &ast::Pipeline,
268    state: &mut InterpreterState,
269    stdin: &str,
270) -> Result<ExecResult, RustBashError> {
271    let timed = pipeline.timed.is_some();
272    let start = if timed {
273        Some(crate::platform::Instant::now())
274    } else {
275        None
276    };
277
278    let mut pipe_data = stdin.to_string();
279    let mut pipe_data_bytes: Option<Vec<u8>> = None;
280    let mut combined_stderr = String::new();
281    let mut exit_code = 0;
282    let mut exit_codes: Vec<i32> = Vec::new();
283    let is_actual_pipe = pipeline.seq.len() > 1;
284    let saved_stdin_offset = state.stdin_offset;
285
286    // Negated pipelines (`! cmd`) suppress errexit for the inner commands
287    if pipeline.bang {
288        state.errexit_suppressed += 1;
289    }
290
291    for (idx, command) in pipeline.seq.iter().enumerate() {
292        if state.should_exit || state.control_flow.is_some() {
293            break;
294        }
295        // Reset stdin offset when entering a new pipe stage with fresh data
296        if idx > 0 {
297            state.stdin_offset = 0;
298        }
299        // Propagate binary data from previous stage via interpreter state
300        state.pipe_stdin_bytes = pipe_data_bytes.take();
301        let r = execute_command(command, state, &pipe_data)?;
302        // If the command produced binary output, use it for next stage
303        if let Some(bytes) = r.stdout_bytes {
304            pipe_data_bytes = Some(bytes);
305            pipe_data = String::new();
306        } else {
307            pipe_data = r.stdout;
308            pipe_data_bytes = None;
309        }
310        combined_stderr.push_str(&r.stderr);
311        exit_code = r.exit_code;
312        exit_codes.push(r.exit_code);
313    }
314    // Clear any leftover binary state
315    state.pipe_stdin_bytes = None;
316
317    // Multi-stage pipelines operate on ephemeral pipe data — restore the
318    // caller's stdin offset so enclosing loops (e.g. `while read`) are not
319    // corrupted by inner pipe stages resetting the offset.
320    if is_actual_pipe {
321        state.stdin_offset = saved_stdin_offset;
322    }
323
324    if pipeline.bang {
325        state.errexit_suppressed -= 1;
326    }
327
328    // pipefail: exit code = rightmost non-zero, or 0 if all succeeded
329    if state.shell_opts.pipefail {
330        exit_code = exit_codes
331            .iter()
332            .rev()
333            .copied()
334            .find(|&c| c != 0)
335            .unwrap_or(0);
336    }
337
338    let exit_code = if pipeline.bang {
339        i32::from(exit_code == 0)
340    } else {
341        exit_code
342    };
343
344    // Set PIPESTATUS indexed array with each command's exit code.
345    // Overwritten on every pipeline (including single commands).
346    let mut pipestatus_map = std::collections::BTreeMap::new();
347    for (i, code) in exit_codes.iter().enumerate() {
348        pipestatus_map.insert(i, code.to_string());
349    }
350    state.env.insert(
351        "PIPESTATUS".to_string(),
352        Variable {
353            value: VariableValue::IndexedArray(pipestatus_map),
354            attrs: VariableAttrs::empty(),
355        },
356    );
357
358    // Emit timing output for `time` keyword
359    if let Some(start) = start {
360        let elapsed = start.elapsed();
361        let total_secs = elapsed.as_secs_f64();
362        let mins = total_secs as u64 / 60;
363        let secs = total_secs - (mins as f64 * 60.0);
364        combined_stderr.push_str(&format!(
365            "\nreal\t{}m{:.3}s\nuser\t0m0.000s\nsys\t0m0.000s\n",
366            mins, secs
367        ));
368    }
369
370    // At the pipeline boundary, convert binary output to lossy string if needed
371    let final_stdout = if let Some(bytes) = pipe_data_bytes {
372        String::from_utf8_lossy(&bytes).into_owned()
373    } else {
374        pipe_data
375    };
376
377    Ok(ExecResult {
378        stdout: final_stdout,
379        stderr: combined_stderr,
380        exit_code,
381        stdout_bytes: None,
382    })
383}
384
385fn execute_command(
386    command: &ast::Command,
387    state: &mut InterpreterState,
388    stdin: &str,
389) -> Result<ExecResult, RustBashError> {
390    // Update LINENO from the AST node's source position.
391    if let Some(loc) = command.location() {
392        state.current_lineno = loc.start.line;
393    }
394
395    // noexec: skip all commands except simple commands named "set"
396    if state.shell_opts.noexec && !matches!(command, ast::Command::Simple(_)) {
397        return Ok(ExecResult::default());
398    }
399
400    let result = match command {
401        ast::Command::Simple(simple_cmd) => execute_simple_command(simple_cmd, state, stdin),
402        ast::Command::Compound(compound, redirects) => {
403            execute_compound_command(compound, redirects.as_ref(), state, stdin)
404        }
405        ast::Command::Function(func_def) => {
406            match expand_word_to_string_mut(&func_def.fname, state) {
407                Ok(name) => {
408                    state.functions.insert(
409                        name,
410                        FunctionDef {
411                            body: func_def.body.clone(),
412                        },
413                    );
414                    Ok(ExecResult::default())
415                }
416                Err(e) => Err(e),
417            }
418        }
419        ast::Command::ExtendedTest(ext_test) => execute_extended_test(&ext_test.expr, state),
420    };
421
422    match result {
423        Err(RustBashError::ExpansionError {
424            message,
425            exit_code,
426            should_exit,
427        }) => {
428            state.last_exit_code = exit_code;
429            if should_exit {
430                state.should_exit = true;
431            }
432            Ok(ExecResult {
433                stderr: format!("rust-bash: {message}\n"),
434                exit_code,
435                ..Default::default()
436            })
437        }
438        Err(RustBashError::FailGlob { pattern }) => {
439            state.last_exit_code = 1;
440            Ok(ExecResult {
441                stderr: format!("rust-bash: no match: {pattern}\n"),
442                exit_code: 1,
443                ..Default::default()
444            })
445        }
446        other => other,
447    }
448}
449
450// ── Assignment processing ────────────────────────────────────────────
451
452/// A processed assignment ready to be applied to the interpreter state.
453#[derive(Debug, Clone)]
454enum Assignment {
455    /// `name=value` — simple scalar assignment
456    Scalar { name: String, value: String },
457    /// `name=(val1 val2 ...)` — indexed array assignment
458    IndexedArray {
459        name: String,
460        elements: Vec<(Option<usize>, String)>,
461    },
462    /// `declare -A name=([k]=v ...)` — associative array assignment
463    AssocArray {
464        name: String,
465        elements: Vec<(String, String)>,
466    },
467    /// `name[index]=value` — single array element
468    ArrayElement {
469        name: String,
470        index: String,
471        value: String,
472    },
473    /// `name[index]+=value` — append to single array element
474    AppendArrayElement {
475        name: String,
476        index: String,
477        value: String,
478    },
479    /// `name+=(val1 val2 ...)` — append to array
480    AppendArray {
481        name: String,
482        elements: Vec<(Option<usize>, String)>,
483    },
484    /// `name+=(val1 val2 ...)` — append to associative array
485    AppendAssocArray {
486        name: String,
487        elements: Vec<(String, String)>,
488    },
489    /// `name+=value` — append to scalar
490    AppendScalar { name: String, value: String },
491}
492
493impl Assignment {
494    fn name(&self) -> &str {
495        match self {
496            Assignment::Scalar { name, .. }
497            | Assignment::IndexedArray { name, .. }
498            | Assignment::AssocArray { name, .. }
499            | Assignment::ArrayElement { name, .. }
500            | Assignment::AppendArrayElement { name, .. }
501            | Assignment::AppendArray { name, .. }
502            | Assignment::AppendAssocArray { name, .. }
503            | Assignment::AppendScalar { name, .. } => name,
504        }
505    }
506}
507
508/// Process an AST assignment into our internal Assignment type.
509fn process_assignment(
510    assignment: &ast::Assignment,
511    append: bool,
512    state: &mut InterpreterState,
513) -> Result<Assignment, RustBashError> {
514    match (&assignment.name, &assignment.value) {
515        (ast::AssignmentName::VariableName(name), ast::AssignmentValue::Scalar(w)) => {
516            let value = expand_word_to_string_mut(w, state)?;
517            if append {
518                Ok(Assignment::AppendScalar {
519                    name: name.clone(),
520                    value,
521                })
522            } else {
523                Ok(Assignment::Scalar {
524                    name: name.clone(),
525                    value,
526                })
527            }
528        }
529        (ast::AssignmentName::VariableName(name), ast::AssignmentValue::Array(items)) => {
530            // Check if target is an associative array
531            let is_assoc = state
532                .env
533                .get(name)
534                .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));
535            if is_assoc {
536                let mut elements = Vec::new();
537                for (opt_idx_word, val_word) in items {
538                    let key = if let Some(idx_word) = opt_idx_word {
539                        expand_word_to_string_mut(idx_word, state)?
540                    } else {
541                        // Assoc arrays require explicit keys
542                        String::new()
543                    };
544                    let val = expand_word_to_string_mut(val_word, state)?;
545                    elements.push((key, val));
546                }
547                if append {
548                    Ok(Assignment::AppendAssocArray {
549                        name: name.clone(),
550                        elements,
551                    })
552                } else {
553                    Ok(Assignment::AssocArray {
554                        name: name.clone(),
555                        elements,
556                    })
557                }
558            } else {
559                let mut elements = Vec::new();
560                for (opt_idx_word, val_word) in items {
561                    let idx = if let Some(idx_word) = opt_idx_word {
562                        let idx_str = expand_word_to_string_mut(idx_word, state)?;
563                        let idx_val =
564                            crate::interpreter::arithmetic::eval_arithmetic(&idx_str, state)?;
565                        if idx_val < 0 {
566                            return Err(RustBashError::Execution(format!(
567                                "negative array subscript: {idx_val}"
568                            )));
569                        }
570                        Some(idx_val as usize)
571                    } else {
572                        None
573                    };
574                    // Use expand_word_mut so brace expansion works inside array literals:
575                    // a=( v{0..9} ) should expand to 10 separate elements.
576                    let vals = expand_word_mut(val_word, state)?;
577                    if vals.is_empty() {
578                        elements.push((idx, String::new()));
579                    } else {
580                        for (i, val) in vals.into_iter().enumerate() {
581                            if i == 0 {
582                                elements.push((idx, val));
583                            } else {
584                                // Subsequent brace-expanded words get auto-indexed (None)
585                                elements.push((None, val));
586                            }
587                        }
588                    }
589                }
590                if append {
591                    Ok(Assignment::AppendArray {
592                        name: name.clone(),
593                        elements,
594                    })
595                } else {
596                    Ok(Assignment::IndexedArray {
597                        name: name.clone(),
598                        elements,
599                    })
600                }
601            }
602        }
603        (
604            ast::AssignmentName::ArrayElementName(name, index_str),
605            ast::AssignmentValue::Scalar(w),
606        ) => {
607            let value = expand_word_to_string_mut(w, state)?;
608            // Expand index — it may contain variable references
609            let index_word = ast::Word {
610                value: index_str.clone(),
611                loc: None,
612            };
613            let expanded_index = expand_word_to_string_mut(&index_word, state)?;
614            if append {
615                Ok(Assignment::AppendArrayElement {
616                    name: name.clone(),
617                    index: expanded_index,
618                    value,
619                })
620            } else {
621                Ok(Assignment::ArrayElement {
622                    name: name.clone(),
623                    index: expanded_index,
624                    value,
625                })
626            }
627        }
628        (ast::AssignmentName::ArrayElementName(name, _), ast::AssignmentValue::Array(_)) => Err(
629            RustBashError::Execution(format!("{name}: cannot assign array to array element")),
630        ),
631    }
632}
633
634/// Apply a processed assignment to the interpreter state.
635fn apply_assignment(
636    assignment: Assignment,
637    state: &mut InterpreterState,
638) -> Result<(), RustBashError> {
639    match assignment {
640        Assignment::Scalar { name, value } => {
641            set_variable(state, &name, value)?;
642        }
643        Assignment::IndexedArray { name, elements } => {
644            if let Some(var) = state.env.get(&name)
645                && var.readonly()
646            {
647                return Err(RustBashError::Execution(format!(
648                    "{name}: readonly variable"
649                )));
650            }
651            let limit = state.limits.max_array_elements;
652            let mut map = std::collections::BTreeMap::new();
653            let mut auto_idx: usize = 0;
654            for (opt_idx, val) in elements {
655                let idx = opt_idx.unwrap_or(auto_idx);
656                if map.len() >= limit {
657                    return Err(RustBashError::LimitExceeded {
658                        limit_name: "max_array_elements",
659                        limit_value: limit,
660                        actual_value: map.len() + 1,
661                    });
662                }
663                map.insert(idx, val);
664                auto_idx = idx + 1;
665            }
666            let attrs = state
667                .env
668                .get(&name)
669                .map(|v| v.attrs)
670                .unwrap_or(VariableAttrs::empty());
671            state.env.insert(
672                name,
673                Variable {
674                    value: VariableValue::IndexedArray(map),
675                    attrs,
676                },
677            );
678        }
679        Assignment::AssocArray { name, elements } => {
680            if let Some(var) = state.env.get(&name)
681                && var.readonly()
682            {
683                return Err(RustBashError::Execution(format!(
684                    "{name}: readonly variable"
685                )));
686            }
687            let limit = state.limits.max_array_elements;
688            let mut map = std::collections::BTreeMap::new();
689            for (key, val) in elements {
690                if map.len() >= limit {
691                    return Err(RustBashError::LimitExceeded {
692                        limit_name: "max_array_elements",
693                        limit_value: limit,
694                        actual_value: map.len() + 1,
695                    });
696                }
697                map.insert(key, val);
698            }
699            let attrs = state
700                .env
701                .get(&name)
702                .map(|v| v.attrs)
703                .unwrap_or(VariableAttrs::empty());
704            state.env.insert(
705                name,
706                Variable {
707                    value: VariableValue::AssociativeArray(map),
708                    attrs,
709                },
710            );
711        }
712        Assignment::ArrayElement { name, index, value } => {
713            // Check if target is an associative array
714            let is_assoc = state
715                .env
716                .get(&name)
717                .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));
718            if is_assoc {
719                crate::interpreter::set_assoc_element(state, &name, index, value)?;
720            } else {
721                // Evaluate index as arithmetic expression
722                let idx = crate::interpreter::arithmetic::eval_arithmetic(&index, state)?;
723                let uidx = resolve_negative_array_index(idx, &name, state)?;
724                set_array_element(state, &name, uidx, value)?;
725            }
726        }
727        Assignment::AppendArrayElement { name, index, value } => {
728            let is_assoc = state
729                .env
730                .get(&name)
731                .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));
732            if is_assoc {
733                let current = state
734                    .env
735                    .get(&name)
736                    .and_then(|v| match &v.value {
737                        VariableValue::AssociativeArray(map) => map.get(&index).cloned(),
738                        _ => None,
739                    })
740                    .unwrap_or_default();
741                let new_val = format!("{current}{value}");
742                crate::interpreter::set_assoc_element(state, &name, index, new_val)?;
743            } else {
744                let idx = crate::interpreter::arithmetic::eval_arithmetic(&index, state)?;
745                let uidx = resolve_negative_array_index(idx, &name, state)?;
746                let current = state
747                    .env
748                    .get(&name)
749                    .and_then(|v| match &v.value {
750                        VariableValue::IndexedArray(map) => map.get(&uidx).cloned(),
751                        VariableValue::Scalar(s) if uidx == 0 => Some(s.clone()),
752                        _ => None,
753                    })
754                    .unwrap_or_default();
755                let new_val = format!("{current}{value}");
756                set_array_element(state, &name, uidx, new_val)?;
757            }
758        }
759        Assignment::AppendArray { name, elements } => {
760            // Find current max index + 1
761            let start_idx = match state.env.get(&name) {
762                Some(var) => match &var.value {
763                    VariableValue::IndexedArray(map) => {
764                        map.keys().next_back().map(|k| k + 1).unwrap_or(0)
765                    }
766                    VariableValue::Scalar(s) if s.is_empty() => 0,
767                    VariableValue::Scalar(_) => 1,
768                    VariableValue::AssociativeArray(_) => 0,
769                },
770                None => 0,
771            };
772
773            // If the variable doesn't exist yet, create it
774            if !state.env.contains_key(&name) {
775                state.env.insert(
776                    name.clone(),
777                    Variable {
778                        value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
779                        attrs: VariableAttrs::empty(),
780                    },
781                );
782            }
783
784            // Convert scalar to array if needed
785            if let Some(var) = state.env.get_mut(&name)
786                && let VariableValue::Scalar(s) = &var.value
787            {
788                let mut map = std::collections::BTreeMap::new();
789                if !s.is_empty() {
790                    map.insert(0, s.clone());
791                }
792                var.value = VariableValue::IndexedArray(map);
793            }
794
795            let mut auto_idx = start_idx;
796            for (opt_idx, val) in elements {
797                let idx = opt_idx.unwrap_or(auto_idx);
798                set_array_element(state, &name, idx, val)?;
799                auto_idx = idx + 1;
800            }
801        }
802        Assignment::AppendAssocArray { name, elements } => {
803            // If the variable doesn't exist yet, create it as assoc
804            if !state.env.contains_key(&name) {
805                state.env.insert(
806                    name.clone(),
807                    Variable {
808                        value: VariableValue::AssociativeArray(std::collections::BTreeMap::new()),
809                        attrs: VariableAttrs::empty(),
810                    },
811                );
812            }
813            for (key, val) in elements {
814                crate::interpreter::set_assoc_element(state, &name, key, val)?;
815            }
816        }
817        Assignment::AppendScalar { name, value } => {
818            // For integer variables, += performs arithmetic addition.
819            let target = crate::interpreter::resolve_nameref(&name, state)?;
820            let is_integer = state
821                .env
822                .get(&target)
823                .is_some_and(|v| v.attrs.contains(VariableAttrs::INTEGER));
824            if is_integer {
825                let current = state
826                    .env
827                    .get(&target)
828                    .map(|v| v.value.as_scalar().to_string())
829                    .unwrap_or_else(|| "0".to_string());
830                let expr = format!("{current}+{value}");
831                set_variable(state, &name, expr)?;
832            } else {
833                match state.env.get(&target) {
834                    Some(var) => {
835                        let new_val = format!("{}{}", var.value.as_scalar(), value);
836                        set_variable(state, &name, new_val)?;
837                    }
838                    None => {
839                        set_variable(state, &name, value)?;
840                    }
841                }
842            }
843        }
844    }
845    Ok(())
846}
847
848/// Resolve a negative array index to a positive one based on the current max key.
849/// In bash, `a[-1]` refers to the last element (max_key), `a[-2]` to the one before, etc.
850fn resolve_negative_array_index(
851    idx: i64,
852    name: &str,
853    state: &InterpreterState,
854) -> Result<usize, RustBashError> {
855    if idx >= 0 {
856        return Ok(idx as usize);
857    }
858    let max_key = state.env.get(name).and_then(|v| match &v.value {
859        VariableValue::IndexedArray(map) => map.keys().next_back().copied(),
860        VariableValue::Scalar(_) => Some(0),
861        _ => None,
862    });
863    match max_key {
864        Some(mk) => {
865            let resolved = mk as i64 + 1 + idx;
866            if resolved < 0 {
867                Err(RustBashError::Execution(format!(
868                    "{name}: bad array subscript"
869                )))
870            } else {
871                Ok(resolved as usize)
872            }
873        }
874        None => Err(RustBashError::Execution(format!(
875            "{name}: bad array subscript"
876        ))),
877    }
878}
879
880/// Apply an assignment, converting `Execution` errors to shell errors (stderr +
881/// exit code 1) instead of propagating them as fatal `Err`. This matches bash
882/// behavior where assignment errors (e.g. nameref cycles, readonly) in bare
883/// assignment context print an error and set `$?` but do not abort the script.
884fn apply_assignment_shell_error(
885    assignment: Assignment,
886    state: &mut InterpreterState,
887    result: &mut ExecResult,
888) -> Result<(), RustBashError> {
889    match apply_assignment(assignment, state) {
890        Ok(()) => Ok(()),
891        Err(RustBashError::Execution(msg)) => {
892            result.stderr.push_str(&format!("rust-bash: {msg}\n"));
893            result.exit_code = 1;
894            state.last_exit_code = 1;
895            Ok(())
896        }
897        Err(other) => Err(other),
898    }
899}
900
901fn execute_simple_command(
902    cmd: &ast::SimpleCommand,
903    state: &mut InterpreterState,
904    stdin: &str,
905) -> Result<ExecResult, RustBashError> {
906    // noexec: skip all simple commands (bash behavior: once set -n is active, nothing runs)
907    if state.shell_opts.noexec {
908        return Ok(ExecResult::default());
909    }
910
911    // 1. Collect redirections and assignments from prefix
912    let mut assignments: Vec<Assignment> = Vec::new();
913    let mut redirects: Vec<&ast::IoRedirect> = Vec::new();
914    // Track process substitution temp files for cleanup
915    let mut proc_sub_temps: Vec<String> = Vec::new();
916    // Track deferred write process substitutions: (inner command list, temp path)
917    let mut deferred_write_subs: Vec<(&ast::CompoundList, String)> = Vec::new();
918
919    if let Some(prefix) = &cmd.prefix {
920        for item in &prefix.0 {
921            match item {
922                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
923                    let a = process_assignment(assignment, assignment.append, state)?;
924                    assignments.push(a);
925                }
926                ast::CommandPrefixOrSuffixItem::IoRedirect(redir) => {
927                    redirects.push(redir);
928                }
929                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell) => {
930                    let path = expand_process_substitution(
931                        kind,
932                        &subshell.list,
933                        state,
934                        &mut deferred_write_subs,
935                    )?;
936                    proc_sub_temps.push(path);
937                }
938                _ => {}
939            }
940        }
941    }
942
943    // 2. Expand command name
944    let cmd_name = cmd
945        .word_or_name
946        .as_ref()
947        .map(|w| expand_word_to_string_mut(w, state))
948        .transpose()?;
949
950    // 3. Expand arguments and collect redirections from suffix
951    let mut args: Vec<String> = Vec::new();
952    if let Some(suffix) = &cmd.suffix {
953        for item in &suffix.0 {
954            match item {
955                ast::CommandPrefixOrSuffixItem::Word(w) => match expand_word_mut(w, state) {
956                    Ok(expanded) => args.extend(expanded),
957                    Err(RustBashError::FailGlob { pattern }) => {
958                        state.last_exit_code = 1;
959                        return Ok(ExecResult {
960                            stderr: format!("rust-bash: no match: {pattern}\n"),
961                            exit_code: 1,
962                            ..Default::default()
963                        });
964                    }
965                    Err(e) => return Err(e),
966                },
967                ast::CommandPrefixOrSuffixItem::IoRedirect(redir) => {
968                    redirects.push(redir);
969                }
970                ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, _word) => {
971                    let name = match &assignment.name {
972                        ast::AssignmentName::VariableName(n) => n.clone(),
973                        ast::AssignmentName::ArrayElementName(n, idx) => {
974                            let idx_word = ast::Word {
975                                value: idx.clone(),
976                                loc: None,
977                            };
978                            let expanded_idx = expand_word_to_string_mut(&idx_word, state)?;
979                            format!("{n}[{expanded_idx}]")
980                        }
981                    };
982                    match &assignment.value {
983                        ast::AssignmentValue::Scalar(w) => {
984                            let value = expand_word_to_string_mut(w, state)?;
985                            args.push(format!("{name}={value}"));
986                        }
987                        ast::AssignmentValue::Array(items) => {
988                            let mut parts = Vec::new();
989                            for (opt_idx_word, val_word) in items {
990                                let vals = expand_word_mut(val_word, state)?;
991                                if let Some(idx_word) = opt_idx_word {
992                                    let idx_str = expand_word_to_string_mut(idx_word, state)?;
993                                    let first = vals.first().cloned().unwrap_or_default();
994                                    parts.push(format!("[{idx_str}]={first}"));
995                                    for v in vals.into_iter().skip(1) {
996                                        parts.push(v);
997                                    }
998                                } else {
999                                    parts.extend(vals);
1000                                }
1001                            }
1002                            args.push(format!("{name}=({})", parts.join(" ")));
1003                        }
1004                    }
1005                }
1006                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell) => {
1007                    let path = expand_process_substitution(
1008                        kind,
1009                        &subshell.list,
1010                        state,
1011                        &mut deferred_write_subs,
1012                    )?;
1013                    proc_sub_temps.push(path.clone());
1014                    args.push(path);
1015                }
1016            }
1017        }
1018    }
1019
1020    // 4. No command name → persist assignments in environment
1021    let Some(cmd_name) = cmd_name else {
1022        // xtrace for bare assignments (e.g. `X=1`)
1023        if state.shell_opts.xtrace && !assignments.is_empty() {
1024            let ps4 = expand_ps4(state);
1025            let mut trace = String::new();
1026            for a in &assignments {
1027                let part = match a {
1028                    Assignment::Scalar { name, value } => format!("{name}={value}"),
1029                    Assignment::IndexedArray { name, elements, .. } => {
1030                        let vals: Vec<String> =
1031                            elements.iter().map(|(_, v)| xtrace_quote(v)).collect();
1032                        format!("{name}=({})", vals.join(" "))
1033                    }
1034                    Assignment::ArrayElement {
1035                        name, index, value, ..
1036                    } => format!("{name}[{index}]={value}"),
1037                    Assignment::AppendArrayElement {
1038                        name, index, value, ..
1039                    } => format!("{name}[{index}]+={value}"),
1040                    Assignment::AppendArray { name, elements, .. } => {
1041                        let vals: Vec<String> =
1042                            elements.iter().map(|(_, v)| xtrace_quote(v)).collect();
1043                        format!("{name}+=({})", vals.join(" "))
1044                    }
1045                    Assignment::AssocArray { name, .. } => format!("{name}=(...)"),
1046                    Assignment::AppendAssocArray { name, .. } => format!("{name}+=(...)"),
1047                    Assignment::AppendScalar { name, value } => format!("{name}+={value}"),
1048                };
1049                trace.push_str(&format!("{ps4}{part}\n"));
1050            }
1051            // Bare assignments produce no output, but emit xtrace to stderr
1052            let mut result = ExecResult {
1053                stderr: trace,
1054                ..ExecResult::default()
1055            };
1056            for a in assignments {
1057                apply_assignment_shell_error(a, state, &mut result)?;
1058            }
1059            if !state.pending_cmdsub_stderr.is_empty() {
1060                let cmdsub_stderr = std::mem::take(&mut state.pending_cmdsub_stderr);
1061                result.stderr = format!("{cmdsub_stderr}{}", result.stderr);
1062            }
1063            return Ok(result);
1064        }
1065        let mut result = ExecResult::default();
1066        for a in assignments {
1067            apply_assignment_shell_error(a, state, &mut result)?;
1068        }
1069        if !state.pending_cmdsub_stderr.is_empty() {
1070            let cmdsub_stderr = std::mem::take(&mut state.pending_cmdsub_stderr);
1071            result.stderr = format!("{cmdsub_stderr}{}", result.stderr);
1072        }
1073        return Ok(result);
1074    };
1075
1076    // 4b. Empty command name (e.g. from `$(false)`) → no command, persist assignments
1077    if cmd_name.is_empty() && args.is_empty() {
1078        let mut result = ExecResult {
1079            exit_code: state.last_exit_code,
1080            ..ExecResult::default()
1081        };
1082        for a in assignments {
1083            apply_assignment_shell_error(a, state, &mut result)?;
1084        }
1085        if !state.pending_cmdsub_stderr.is_empty() {
1086            let cmdsub_stderr = std::mem::take(&mut state.pending_cmdsub_stderr);
1087            result.stderr = format!("{cmdsub_stderr}{}", result.stderr);
1088        }
1089        return Ok(result);
1090    }
1091
1092    // 4c. Alias expansion: if expand_aliases is on and the command name is an alias,
1093    // substitute the alias value. Multi-word aliases produce a new command + extra args.
1094    let (cmd_name, args) = if state.shopt_opts.expand_aliases {
1095        if let Some(expansion) = state.aliases.get(&cmd_name).cloned() {
1096            let mut parts: Vec<String> = expansion
1097                .split_whitespace()
1098                .map(|s| s.to_string())
1099                .collect();
1100            if parts.is_empty() {
1101                (cmd_name, args)
1102            } else {
1103                let new_cmd = parts.remove(0);
1104                parts.extend(args);
1105                (new_cmd, parts)
1106            }
1107        } else {
1108            (cmd_name, args)
1109        }
1110    } else {
1111        (cmd_name, args)
1112    };
1113
1114    // 4d. Handle `exec` builtin specially — it needs access to redirects.
1115    //     Prefix assignments before `exec` are permanent (no subshell).
1116    if cmd_name == "exec" {
1117        // Intercept --help before exec dispatch
1118        if args.first().map(|a| a.as_str()) == Some("--help")
1119            && let Some(meta) = builtins::builtin_meta("exec")
1120            && meta.supports_help_flag
1121        {
1122            return Ok(ExecResult {
1123                stdout: crate::commands::format_help(meta),
1124                stderr: String::new(),
1125                exit_code: 0,
1126                stdout_bytes: None,
1127            });
1128        }
1129        for a in &assignments {
1130            let mut dummy = ExecResult::default();
1131            apply_assignment_shell_error(a.clone(), state, &mut dummy)?;
1132            if dummy.exit_code != 0 {
1133                return Ok(dummy);
1134            }
1135        }
1136        return execute_exec_builtin(&args, &redirects, state, stdin);
1137    }
1138
1139    // 5. Apply temporary pre-command assignments
1140    // On error (e.g. readonly): print the error but still execute the command.
1141    // Bash skips the failing assignment but runs the command; $? is from the
1142    // command, not the assignment error.
1143    let mut saved: Vec<(String, Option<Variable>)> = Vec::new();
1144    let mut prefix_stderr = String::new();
1145    for a in &assignments {
1146        saved.push((a.name().to_string(), state.env.get(a.name()).cloned()));
1147        let mut dummy = ExecResult::default();
1148        apply_assignment_shell_error(a.clone(), state, &mut dummy)?;
1149        if dummy.exit_code != 0 {
1150            prefix_stderr.push_str(&dummy.stderr);
1151        }
1152        // Temporary per-command bindings are exported for the duration of
1153        // the command (bash behavior: `FOO=bar cmd` exports FOO to cmd).
1154        if let Some(var) = state.env.get_mut(a.name()) {
1155            var.attrs.insert(VariableAttrs::EXPORTED);
1156        }
1157    }
1158
1159    // 5b–8c are wrapped in an immediately-invoked closure so cleanup (8d) and
1160    // assignment restore (9) run on every exit path, including early `?` returns.
1161    struct RedirProcSub<'a> {
1162        temp_path: String,
1163        kind: &'a ast::ProcessSubstitutionKind,
1164        list: &'a ast::CompoundList,
1165    }
1166    let last_arg = args.last().cloned().unwrap_or_else(|| cmd_name.clone());
1167    let should_trace = state.shell_opts.xtrace;
1168    // Expand PS4 BEFORE dispatch so that `local PS4=...` is traced with the old value
1169    let pre_ps4 = if should_trace {
1170        Some(expand_ps4(state))
1171    } else {
1172        None
1173    };
1174
1175    let mut inner_result = (|| -> Result<ExecResult, RustBashError> {
1176        // 5b. Pre-allocate ALL redirect process substitution temp files in one pass.
1177        //     This ensures allocation order matches redirect-list order, avoiding
1178        //     counter mismatch when Read and Write proc-subs are mixed in the same
1179        //     redirect list (stdin Reads are processed before output Writes).
1180        let mut redir_proc_subs: Vec<RedirProcSub<'_>> = Vec::new();
1181        for redir in &redirects {
1182            if let ast::IoRedirect::File(
1183                _,
1184                _,
1185                target @ ast::IoFileRedirectTarget::ProcessSubstitution(kind, subshell),
1186            ) = redir
1187            {
1188                let temp_path = match kind {
1189                    ast::ProcessSubstitutionKind::Read => {
1190                        execute_read_process_substitution(&subshell.list, state)?
1191                    }
1192                    ast::ProcessSubstitutionKind::Write => allocate_proc_sub_temp_file(state, b"")?,
1193                };
1194                proc_sub_temps.push(temp_path.clone());
1195                // Key by AST-node address so redirect_target_filename resolves the
1196                // correct path regardless of the order redirects are visited.
1197                let key = std::ptr::from_ref(target) as usize;
1198                state.proc_sub_prealloc.insert(key, temp_path.clone());
1199                redir_proc_subs.push(RedirProcSub {
1200                    temp_path,
1201                    kind,
1202                    list: &subshell.list,
1203                });
1204            }
1205        }
1206
1207        // 6. Handle stdin redirection
1208        let effective_stdin = match get_stdin_from_redirects(&redirects, state, stdin) {
1209            Ok(s) => s,
1210            Err(RustBashError::RedirectFailed(msg)) => {
1211                let mut result = ExecResult {
1212                    stderr: format!("rust-bash: {msg}\n"),
1213                    exit_code: 1,
1214                    ..ExecResult::default()
1215                };
1216                state.last_exit_code = 1;
1217                apply_output_redirects(&redirects, &mut result, state)?;
1218                return Ok(result);
1219            }
1220            Err(e) => return Err(e),
1221        };
1222
1223        // Track last argument for $_ (last argument of the simple command).
1224        state.last_argument = last_arg.clone();
1225
1226        // 7a. Capture xtrace state before dispatch (so `set +x` is still traced)
1227        //     (captured outside closure as `should_trace`)
1228
1229        // 7. Dispatch command
1230        let mut result = dispatch_command(&cmd_name, &args, state, &effective_stdin)?;
1231
1232        // 7a. Drain command-substitution stderr accumulated during expansion
1233        if !state.pending_cmdsub_stderr.is_empty() {
1234            let cmdsub_stderr = std::mem::take(&mut state.pending_cmdsub_stderr);
1235            result.stderr = format!("{cmdsub_stderr}{}", result.stderr);
1236        }
1237
1238        // 7b. Emit xtrace to stderr
1239        if let Some(ref ps4) = pre_ps4 {
1240            let mut trace = format_xtrace_command(ps4, &cmd_name, &args);
1241            // Assignment builtins (readonly, declare, export, typeset)
1242            // also trace each assignment separately.
1243            // Note: local does NOT trace assignments separately.
1244            if matches!(
1245                cmd_name.as_str(),
1246                "readonly" | "declare" | "typeset" | "export"
1247            ) {
1248                for arg in &args {
1249                    if let Some(eq_pos) = arg.find('=') {
1250                        let name_part = &arg[..eq_pos];
1251                        // Only trace if it looks like a variable assignment (not a flag)
1252                        if !name_part.is_empty()
1253                            && !name_part.starts_with('-')
1254                            && name_part
1255                                .chars()
1256                                .all(|c| c.is_alphanumeric() || c == '_' || c == '+')
1257                        {
1258                            trace.push_str(&format!("{ps4}{arg}\n"));
1259                        }
1260                    }
1261                }
1262            }
1263            // Prepend trace so it appears before the command's own stderr
1264            result.stderr = format!("{trace}{}", result.stderr);
1265        }
1266
1267        // 8. Apply output redirections
1268        apply_output_redirects(&redirects, &mut result, state)?;
1269
1270        // 8b. Execute deferred write process substitutions from redirects.
1271        for rps in &redir_proc_subs {
1272            if matches!(rps.kind, ast::ProcessSubstitutionKind::Write) {
1273                let content = state
1274                    .fs
1275                    .read_file(Path::new(&rps.temp_path))
1276                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
1277                let stdin_data = String::from_utf8_lossy(&content).to_string();
1278                let mut sub_state = make_proc_sub_state(state);
1279                let inner_result = execute_compound_list(rps.list, &mut sub_state, &stdin_data)?;
1280                state.counters.command_count = sub_state.counters.command_count;
1281                state.counters.output_size = sub_state.counters.output_size;
1282                state.proc_sub_counter = sub_state.proc_sub_counter;
1283                result.stdout.push_str(&inner_result.stdout);
1284                result.stderr.push_str(&inner_result.stderr);
1285            }
1286        }
1287
1288        // 8c. Execute deferred write process substitutions from prefix/suffix args
1289        for (inner_list, temp_path) in &deferred_write_subs {
1290            let content = state
1291                .fs
1292                .read_file(Path::new(temp_path))
1293                .map_err(|e| RustBashError::Execution(e.to_string()))?;
1294            let stdin_data = String::from_utf8_lossy(&content).to_string();
1295            let mut sub_state = make_proc_sub_state(state);
1296            let inner_result = execute_compound_list(inner_list, &mut sub_state, &stdin_data)?;
1297            state.counters.command_count = sub_state.counters.command_count;
1298            state.counters.output_size = sub_state.counters.output_size;
1299            state.proc_sub_counter = sub_state.proc_sub_counter;
1300            result.stdout.push_str(&inner_result.stdout);
1301            result.stderr.push_str(&inner_result.stderr);
1302        }
1303
1304        Ok(result)
1305    })();
1306
1307    // 8d. Always clean up process substitution temp files (even on error)
1308    for temp_path in &proc_sub_temps {
1309        let _ = state.fs.remove_file(Path::new(temp_path));
1310    }
1311    state.proc_sub_prealloc.clear();
1312
1313    // 9. Restore pre-command assignments
1314    for (name, old_value) in saved {
1315        match old_value {
1316            Some(var) => {
1317                state.env.insert(name, var);
1318            }
1319            None => {
1320                state.env.remove(&name);
1321            }
1322        }
1323    }
1324
1325    // 9b. Prepend any prefix-assignment error messages to the result stderr.
1326    if let Ok(ref mut r) = inner_result
1327        && !prefix_stderr.is_empty()
1328    {
1329        r.stderr = format!("{prefix_stderr}{}", r.stderr);
1330    }
1331
1332    inner_result
1333}
1334
1335// ── Compound commands ───────────────────────────────────────────────
1336
1337fn execute_compound_command(
1338    compound: &ast::CompoundCommand,
1339    redirects: Option<&ast::RedirectList>,
1340    state: &mut InterpreterState,
1341    stdin: &str,
1342) -> Result<ExecResult, RustBashError> {
1343    let mut result = match compound {
1344        ast::CompoundCommand::IfClause(if_clause) => execute_if(if_clause, state, stdin)?,
1345        ast::CompoundCommand::ForClause(for_clause) => execute_for(for_clause, state, stdin)?,
1346        ast::CompoundCommand::WhileClause(wc) => execute_while_until(wc, false, state, stdin)?,
1347        ast::CompoundCommand::UntilClause(uc) => execute_while_until(uc, true, state, stdin)?,
1348        ast::CompoundCommand::BraceGroup(bg) => execute_compound_list(&bg.list, state, stdin)?,
1349        ast::CompoundCommand::Subshell(sub) => execute_subshell(&sub.list, state, stdin)?,
1350        ast::CompoundCommand::CaseClause(cc) => execute_case(cc, state, stdin)?,
1351        ast::CompoundCommand::Arithmetic(arith) => execute_arithmetic(arith, state)?,
1352        ast::CompoundCommand::ArithmeticForClause(afc) => {
1353            execute_arithmetic_for(afc, state, stdin)?
1354        }
1355    };
1356
1357    // Drain command-substitution stderr accumulated during expansion
1358    if !state.pending_cmdsub_stderr.is_empty() {
1359        let cmdsub_stderr = std::mem::take(&mut state.pending_cmdsub_stderr);
1360        result.stderr = format!("{cmdsub_stderr}{}", result.stderr);
1361    }
1362
1363    // Apply redirections attached to the compound command
1364    if let Some(redir_list) = redirects {
1365        let redir_refs: Vec<&ast::IoRedirect> = redir_list.0.iter().collect();
1366        apply_output_redirects(&redir_refs, &mut result, state)?;
1367    }
1368
1369    state.last_exit_code = result.exit_code;
1370    Ok(result)
1371}
1372
1373fn execute_if(
1374    if_clause: &ast::IfClauseCommand,
1375    state: &mut InterpreterState,
1376    stdin: &str,
1377) -> Result<ExecResult, RustBashError> {
1378    let mut result = ExecResult::default();
1379
1380    // Suppress errexit for condition evaluation
1381    state.errexit_suppressed += 1;
1382    let cond = execute_compound_list(&if_clause.condition, state, stdin)?;
1383    state.errexit_suppressed -= 1;
1384    result.stdout.push_str(&cond.stdout);
1385    result.stderr.push_str(&cond.stderr);
1386
1387    if cond.exit_code == 0 {
1388        let body = execute_compound_list(&if_clause.then, state, stdin)?;
1389        result.stdout.push_str(&body.stdout);
1390        result.stderr.push_str(&body.stderr);
1391        result.exit_code = body.exit_code;
1392        return Ok(result);
1393    }
1394
1395    // Evaluate elif/else branches
1396    if let Some(elses) = &if_clause.elses {
1397        for else_clause in elses {
1398            if let Some(condition) = &else_clause.condition {
1399                // elif — suppress errexit for condition
1400                state.errexit_suppressed += 1;
1401                let cond = execute_compound_list(condition, state, stdin)?;
1402                state.errexit_suppressed -= 1;
1403                result.stdout.push_str(&cond.stdout);
1404                result.stderr.push_str(&cond.stderr);
1405                if cond.exit_code == 0 {
1406                    let body = execute_compound_list(&else_clause.body, state, stdin)?;
1407                    result.stdout.push_str(&body.stdout);
1408                    result.stderr.push_str(&body.stderr);
1409                    result.exit_code = body.exit_code;
1410                    return Ok(result);
1411                }
1412            } else {
1413                // else
1414                let body = execute_compound_list(&else_clause.body, state, stdin)?;
1415                result.stdout.push_str(&body.stdout);
1416                result.stderr.push_str(&body.stderr);
1417                result.exit_code = body.exit_code;
1418                return Ok(result);
1419            }
1420        }
1421    }
1422
1423    // No branch matched — exit code 0 per POSIX
1424    result.exit_code = 0;
1425    Ok(result)
1426}
1427
1428fn execute_for(
1429    for_clause: &ast::ForClauseCommand,
1430    state: &mut InterpreterState,
1431    stdin: &str,
1432) -> Result<ExecResult, RustBashError> {
1433    use crate::interpreter::ControlFlow;
1434
1435    let mut result = ExecResult::default();
1436
1437    // Bash reports an error when the variable name is invalid
1438    // (e.g. `for i.j in a b c` → "not a valid identifier").
1439    let var_name = &for_clause.variable_name;
1440    if !var_name.is_empty()
1441        && (!var_name
1442            .chars()
1443            .all(|c| c.is_ascii_alphanumeric() || c == '_')
1444            || var_name.starts_with(|c: char| c.is_ascii_digit()))
1445    {
1446        result.stderr = format!("rust-bash: `{var_name}': not a valid identifier\n");
1447        result.exit_code = 1;
1448        state.last_exit_code = 1;
1449        return Ok(result);
1450    }
1451
1452    let values: Vec<String> = if let Some(words) = &for_clause.values {
1453        let mut vals = Vec::new();
1454        for w in words {
1455            vals.extend(expand_word_mut(w, state)?);
1456        }
1457        vals
1458    } else {
1459        // No word list → iterate over positional parameters
1460        state.positional_params.clone()
1461    };
1462
1463    state.loop_depth += 1;
1464    let mut iterations: usize = 0;
1465    for val in &values {
1466        if state.should_exit {
1467            break;
1468        }
1469        iterations += 1;
1470        if iterations > state.limits.max_loop_iterations {
1471            state.loop_depth -= 1;
1472            return Err(RustBashError::LimitExceeded {
1473                limit_name: "max_loop_iterations",
1474                limit_value: state.limits.max_loop_iterations,
1475                actual_value: iterations,
1476            });
1477        }
1478
1479        set_variable(state, &for_clause.variable_name, val.clone())?;
1480        let r = execute_compound_list(&for_clause.body.list, state, stdin)?;
1481        result.stdout.push_str(&r.stdout);
1482        result.stderr.push_str(&r.stderr);
1483        result.exit_code = r.exit_code;
1484
1485        match state.control_flow.take() {
1486            Some(ControlFlow::Break(n)) => {
1487                if n > 1 {
1488                    state.control_flow = Some(ControlFlow::Break(n - 1));
1489                }
1490                break;
1491            }
1492            Some(ControlFlow::Continue(n)) => {
1493                if n > 1 {
1494                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1495                    break;
1496                }
1497                // n == 1: skip rest, continue to next iteration
1498            }
1499            Some(ret @ ControlFlow::Return(_)) => {
1500                state.control_flow = Some(ret);
1501                break;
1502            }
1503            None => {}
1504        }
1505    }
1506    state.loop_depth -= 1;
1507
1508    Ok(result)
1509}
1510
1511fn execute_arithmetic(
1512    arith: &ast::ArithmeticCommand,
1513    state: &mut InterpreterState,
1514) -> Result<ExecResult, RustBashError> {
1515    let expanded =
1516        crate::interpreter::expansion::expand_arith_expression(&arith.expr.value, state)?;
1517    let val = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
1518    let mut result = ExecResult {
1519        exit_code: if val != 0 { 0 } else { 1 },
1520        ..Default::default()
1521    };
1522    if state.shell_opts.xtrace {
1523        let ps4 = expand_ps4(state);
1524        result.stderr = format!(
1525            "{ps4}(({}))\n{}",
1526            arith.expr.value.trim_end(),
1527            result.stderr
1528        );
1529    }
1530    Ok(result)
1531}
1532
1533fn execute_arithmetic_for(
1534    afc: &ast::ArithmeticForClauseCommand,
1535    state: &mut InterpreterState,
1536    stdin: &str,
1537) -> Result<ExecResult, RustBashError> {
1538    use crate::interpreter::ControlFlow;
1539
1540    // Evaluate initializer
1541    if let Some(init) = &afc.initializer {
1542        let expanded = crate::interpreter::expansion::expand_arith_expression(&init.value, state)?;
1543        crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
1544    }
1545
1546    let mut result = ExecResult::default();
1547    let mut iterations: usize = 0;
1548
1549    state.loop_depth += 1;
1550    loop {
1551        if state.should_exit {
1552            break;
1553        }
1554        iterations += 1;
1555        if iterations > state.limits.max_loop_iterations {
1556            state.loop_depth -= 1;
1557            return Err(RustBashError::LimitExceeded {
1558                limit_name: "max_loop_iterations",
1559                limit_value: state.limits.max_loop_iterations,
1560                actual_value: iterations,
1561            });
1562        }
1563
1564        // Evaluate condition (empty condition = always true)
1565        if let Some(cond) = &afc.condition {
1566            let expanded =
1567                crate::interpreter::expansion::expand_arith_expression(&cond.value, state)?;
1568            let val = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
1569            if val == 0 {
1570                break;
1571            }
1572        }
1573
1574        // Execute body
1575        let body = execute_compound_list(&afc.body.list, state, stdin)?;
1576        result.stdout.push_str(&body.stdout);
1577        result.stderr.push_str(&body.stderr);
1578        result.exit_code = body.exit_code;
1579
1580        match state.control_flow.take() {
1581            Some(ControlFlow::Break(n)) => {
1582                if n > 1 {
1583                    state.control_flow = Some(ControlFlow::Break(n - 1));
1584                }
1585                break;
1586            }
1587            Some(ControlFlow::Continue(n)) => {
1588                if n > 1 {
1589                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1590                    break;
1591                }
1592            }
1593            Some(ret @ ControlFlow::Return(_)) => {
1594                state.control_flow = Some(ret);
1595                break;
1596            }
1597            None => {}
1598        }
1599
1600        // Evaluate updater
1601        if let Some(upd) = &afc.updater {
1602            let expanded =
1603                crate::interpreter::expansion::expand_arith_expression(&upd.value, state)?;
1604            crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
1605        }
1606    }
1607    state.loop_depth -= 1;
1608
1609    Ok(result)
1610}
1611
1612fn execute_while_until(
1613    clause: &ast::WhileOrUntilClauseCommand,
1614    is_until: bool,
1615    state: &mut InterpreterState,
1616    stdin: &str,
1617) -> Result<ExecResult, RustBashError> {
1618    use crate::interpreter::ControlFlow;
1619
1620    let mut result = ExecResult::default();
1621    let mut iterations: usize = 0;
1622
1623    state.loop_depth += 1;
1624    loop {
1625        if state.should_exit {
1626            break;
1627        }
1628        iterations += 1;
1629        if iterations > state.limits.max_loop_iterations {
1630            state.loop_depth -= 1;
1631            return Err(RustBashError::LimitExceeded {
1632                limit_name: "max_loop_iterations",
1633                limit_value: state.limits.max_loop_iterations,
1634                actual_value: iterations,
1635            });
1636        }
1637
1638        // Suppress errexit for the loop condition
1639        state.errexit_suppressed += 1;
1640        let cond = execute_compound_list(&clause.0, state, stdin)?;
1641        state.errexit_suppressed -= 1;
1642        result.stdout.push_str(&cond.stdout);
1643        result.stderr.push_str(&cond.stderr);
1644
1645        let should_continue = if is_until {
1646            cond.exit_code != 0
1647        } else {
1648            cond.exit_code == 0
1649        };
1650
1651        if !should_continue {
1652            break;
1653        }
1654
1655        let body = execute_compound_list(&clause.1.list, state, stdin)?;
1656        result.stdout.push_str(&body.stdout);
1657        result.stderr.push_str(&body.stderr);
1658        result.exit_code = body.exit_code;
1659
1660        match state.control_flow.take() {
1661            Some(ControlFlow::Break(n)) => {
1662                if n > 1 {
1663                    state.control_flow = Some(ControlFlow::Break(n - 1));
1664                }
1665                break;
1666            }
1667            Some(ControlFlow::Continue(n)) => {
1668                if n > 1 {
1669                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1670                    break;
1671                }
1672                // n == 1: skip rest, continue to next iteration
1673            }
1674            Some(ret @ ControlFlow::Return(_)) => {
1675                state.control_flow = Some(ret);
1676                break;
1677            }
1678            None => {}
1679        }
1680    }
1681    state.loop_depth -= 1;
1682
1683    Ok(result)
1684}
1685
1686fn execute_subshell(
1687    list: &ast::CompoundList,
1688    state: &mut InterpreterState,
1689    stdin: &str,
1690) -> Result<ExecResult, RustBashError> {
1691    // Deep-clone the filesystem so mutations in the subshell are isolated
1692    let cloned_fs = state.fs.deep_clone();
1693
1694    let mut sub_state = InterpreterState {
1695        fs: cloned_fs,
1696        env: state.env.clone(),
1697        cwd: state.cwd.clone(),
1698        functions: state.functions.clone(),
1699        last_exit_code: state.last_exit_code,
1700        commands: clone_commands(&state.commands),
1701        shell_opts: state.shell_opts.clone(),
1702        shopt_opts: state.shopt_opts.clone(),
1703        limits: state.limits.clone(),
1704        counters: ExecutionCounters {
1705            command_count: state.counters.command_count,
1706            output_size: state.counters.output_size,
1707            start_time: state.counters.start_time,
1708            substitution_depth: state.counters.substitution_depth,
1709            call_depth: 0,
1710        },
1711        network_policy: state.network_policy.clone(),
1712        should_exit: false,
1713        loop_depth: 0,
1714        control_flow: None,
1715        positional_params: state.positional_params.clone(),
1716        shell_name: state.shell_name.clone(),
1717        random_seed: state.random_seed,
1718        local_scopes: Vec::new(),
1719        in_function_depth: 0,
1720        traps: state.traps.clone(),
1721        in_trap: false,
1722        errexit_suppressed: 0,
1723        stdin_offset: 0,
1724        dir_stack: state.dir_stack.clone(),
1725        command_hash: state.command_hash.clone(),
1726        aliases: state.aliases.clone(),
1727        current_lineno: state.current_lineno,
1728        shell_start_time: state.shell_start_time,
1729        last_argument: state.last_argument.clone(),
1730        call_stack: state.call_stack.clone(),
1731        machtype: state.machtype.clone(),
1732        hosttype: state.hosttype.clone(),
1733        persistent_fds: state.persistent_fds.clone(),
1734        next_auto_fd: state.next_auto_fd,
1735        proc_sub_counter: state.proc_sub_counter,
1736        proc_sub_prealloc: HashMap::new(),
1737        pipe_stdin_bytes: None,
1738        pending_cmdsub_stderr: String::new(),
1739    };
1740
1741    let result = execute_compound_list(list, &mut sub_state, stdin);
1742
1743    // Fold shared counters back into parent
1744    state.counters.command_count = sub_state.counters.command_count;
1745    state.counters.output_size = sub_state.counters.output_size;
1746
1747    let result = result?;
1748
1749    // Only the exit code propagates back; all other state changes are discarded
1750    Ok(result)
1751}
1752
1753fn execute_case(
1754    case_clause: &ast::CaseClauseCommand,
1755    state: &mut InterpreterState,
1756    stdin: &str,
1757) -> Result<ExecResult, RustBashError> {
1758    let value = expand_word_to_string_mut(&case_clause.value, state)?;
1759    let mut result = ExecResult::default();
1760
1761    let mut i = 0;
1762    let mut fall_through = false;
1763    while i < case_clause.cases.len() {
1764        let case_item = &case_clause.cases[i];
1765
1766        let matched = if fall_through {
1767            fall_through = false;
1768            true
1769        } else {
1770            let mut m = false;
1771            for pattern_word in &case_item.patterns {
1772                let pattern = expand_word_to_string_mut(pattern_word, state)?;
1773                let matched_pattern = if state.shopt_opts.nocasematch {
1774                    if state.shopt_opts.extglob {
1775                        crate::interpreter::pattern::extglob_match_nocase(&pattern, &value)
1776                    } else {
1777                        crate::interpreter::pattern::glob_match_nocase(&pattern, &value)
1778                    }
1779                } else if state.shopt_opts.extglob {
1780                    crate::interpreter::pattern::extglob_match(&pattern, &value)
1781                } else {
1782                    crate::interpreter::pattern::glob_match(&pattern, &value)
1783                };
1784                if matched_pattern {
1785                    m = true;
1786                    break;
1787                }
1788            }
1789            m
1790        };
1791
1792        if matched {
1793            if let Some(cmd) = &case_item.cmd {
1794                let r = execute_compound_list(cmd, state, stdin)?;
1795                result.stdout.push_str(&r.stdout);
1796                result.stderr.push_str(&r.stderr);
1797                result.exit_code = r.exit_code;
1798            }
1799
1800            match case_item.post_action {
1801                ast::CaseItemPostAction::ExitCase => break,
1802                ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem => {
1803                    // ;& — fall through: execute next body unconditionally
1804                    fall_through = true;
1805                    i += 1;
1806                    continue;
1807                }
1808                ast::CaseItemPostAction::ContinueEvaluatingCases => {
1809                    // ;;& — continue matching remaining patterns
1810                    i += 1;
1811                    continue;
1812                }
1813            }
1814        }
1815        i += 1;
1816    }
1817
1818    Ok(result)
1819}
1820
1821/// Clone the command registry for subshell isolation.
1822///
1823/// Uses `Arc<dyn VirtualCommand>` so that custom commands registered via the
1824/// public API are preserved in subshells and command substitutions.
1825pub(crate) fn clone_commands(
1826    commands: &HashMap<String, Arc<dyn crate::commands::VirtualCommand>>,
1827) -> HashMap<String, Arc<dyn crate::commands::VirtualCommand>> {
1828    commands.clone()
1829}
1830
1831/// Create an exec callback that commands can use to invoke sub-commands.
1832/// The callback parses and executes a command string in an isolated subshell state.
1833///
1834/// Note: The callback captures `start_time` so wall-clock limits apply globally.
1835/// Per-invocation `command_count` resets because the `Fn` closure signature cannot
1836/// fold counters back to the parent. The parent's `dispatch_command` still counts
1837/// the top-level command (e.g., `xargs`/`find`) itself.
1838fn make_exec_callback(
1839    state: &InterpreterState,
1840) -> impl Fn(&str) -> Result<CommandResult, RustBashError> {
1841    let cloned_fs = state.fs.deep_clone();
1842    let env = state.env.clone();
1843    let cwd = state.cwd.clone();
1844    let functions = state.functions.clone();
1845    let last_exit_code = state.last_exit_code;
1846    let commands = clone_commands(&state.commands);
1847    let shell_opts = state.shell_opts.clone();
1848    let shopt_opts = state.shopt_opts.clone();
1849    let limits = state.limits.clone();
1850    let network_policy = state.network_policy.clone();
1851    let positional_params = state.positional_params.clone();
1852    let shell_name = state.shell_name.clone();
1853    let random_seed = state.random_seed;
1854    let start_time = state.counters.start_time;
1855    let shell_start_time = state.shell_start_time;
1856    let last_argument = state.last_argument.clone();
1857    let call_stack = state.call_stack.clone();
1858    let machtype = state.machtype.clone();
1859    let hosttype = state.hosttype.clone();
1860
1861    move |cmd_str: &str| {
1862        let program = parse(cmd_str)?;
1863
1864        let sub_fs = cloned_fs.deep_clone();
1865
1866        let mut sub_state = InterpreterState {
1867            fs: sub_fs,
1868            env: env.clone(),
1869            cwd: cwd.clone(),
1870            functions: functions.clone(),
1871            last_exit_code,
1872            commands: clone_commands(&commands),
1873            shell_opts: shell_opts.clone(),
1874            shopt_opts: shopt_opts.clone(),
1875            limits: limits.clone(),
1876            counters: ExecutionCounters {
1877                command_count: 0,
1878                output_size: 0,
1879                start_time,
1880                substitution_depth: 0,
1881                call_depth: 0,
1882            },
1883            network_policy: network_policy.clone(),
1884            should_exit: false,
1885            loop_depth: 0,
1886            control_flow: None,
1887            positional_params: positional_params.clone(),
1888            shell_name: shell_name.clone(),
1889            random_seed,
1890            local_scopes: Vec::new(),
1891            in_function_depth: 0,
1892            traps: HashMap::new(),
1893            in_trap: false,
1894            errexit_suppressed: 0,
1895            stdin_offset: 0,
1896            dir_stack: Vec::new(),
1897            command_hash: HashMap::new(),
1898            aliases: HashMap::new(),
1899            current_lineno: 0,
1900            shell_start_time,
1901            last_argument: last_argument.clone(),
1902            call_stack: call_stack.clone(),
1903            machtype: machtype.clone(),
1904            hosttype: hosttype.clone(),
1905            persistent_fds: HashMap::new(),
1906            next_auto_fd: 10,
1907            proc_sub_counter: 0,
1908            proc_sub_prealloc: HashMap::new(),
1909            pipe_stdin_bytes: None,
1910            pending_cmdsub_stderr: String::new(),
1911        };
1912
1913        let result = execute_program(&program, &mut sub_state)?;
1914        Ok(CommandResult {
1915            stdout: result.stdout,
1916            stderr: result.stderr,
1917            exit_code: result.exit_code,
1918            stdout_bytes: None,
1919        })
1920    }
1921}
1922
1923// ── Function calls ──────────────────────────────────────────────────
1924
1925fn execute_function_call(
1926    name: &str,
1927    args: &[String],
1928    state: &mut InterpreterState,
1929) -> Result<ExecResult, RustBashError> {
1930    use crate::interpreter::ControlFlow;
1931
1932    // Check call depth limit
1933    state.counters.call_depth += 1;
1934    if state.counters.call_depth > state.limits.max_call_depth {
1935        let actual = state.counters.call_depth;
1936        state.counters.call_depth -= 1;
1937        return Err(RustBashError::LimitExceeded {
1938            limit_name: "max_call_depth",
1939            limit_value: state.limits.max_call_depth,
1940            actual_value: actual,
1941        });
1942    }
1943
1944    // Clone the function body so we don't hold a borrow on state.functions
1945    let func_def = state.functions.get(name).unwrap().clone();
1946
1947    // Save and replace positional parameters
1948    let saved_params = std::mem::replace(&mut state.positional_params, args.to_vec());
1949
1950    // Push call stack frame for FUNCNAME/BASH_SOURCE/BASH_LINENO.
1951    // BASH_LINENO records the line where the call was made (current LINENO).
1952    state.call_stack.push(CallFrame {
1953        func_name: name.to_string(),
1954        source: String::new(),
1955        lineno: state.current_lineno,
1956    });
1957
1958    // Push a new local scope for this function call
1959    state.local_scopes.push(HashMap::new());
1960    state.in_function_depth += 1;
1961
1962    // Execute the function body (CompoundCommand inside FunctionBody)
1963    let result = execute_compound_command(&func_def.body.0, func_def.body.1.as_ref(), state, "");
1964
1965    // Determine exit code: if Return was signaled, use its code
1966    let exit_code = match state.control_flow.take() {
1967        Some(ControlFlow::Return(code)) => code,
1968        Some(other) => {
1969            // Re-set non-return control flow (break/continue should propagate)
1970            state.control_flow = Some(other);
1971            result.as_ref().map(|r| r.exit_code).unwrap_or(1)
1972        }
1973        None => result.as_ref().map(|r| r.exit_code).unwrap_or(1),
1974    };
1975
1976    // Pop the call stack frame.
1977    state.call_stack.pop();
1978
1979    // Restore local variables
1980    state.in_function_depth -= 1;
1981    if let Some(restore_map) = state.local_scopes.pop() {
1982        for (var_name, old_value) in restore_map {
1983            match old_value {
1984                Some(var) => {
1985                    state.env.insert(var_name, var);
1986                }
1987                None => {
1988                    state.env.remove(&var_name);
1989                }
1990            }
1991        }
1992    }
1993
1994    // Restore positional parameters
1995    state.positional_params = saved_params;
1996
1997    state.counters.call_depth -= 1;
1998
1999    let mut result = result?;
2000    result.exit_code = exit_code;
2001    Ok(result)
2002}
2003
2004fn dispatch_command(
2005    name: &str,
2006    args: &[String],
2007    state: &mut InterpreterState,
2008    stdin: &str,
2009) -> Result<ExecResult, RustBashError> {
2010    state.counters.command_count += 1;
2011    check_limits(state)?;
2012
2013    // 0. --help interception (before builtin dispatch, function lookup, etc.)
2014    if args.first().map(|a| a.as_str()) == Some("--help") {
2015        // Check builtins first
2016        if let Some(meta) = builtins::builtin_meta(name)
2017            && meta.supports_help_flag
2018        {
2019            return Ok(ExecResult {
2020                stdout: crate::commands::format_help(meta),
2021                stderr: String::new(),
2022                exit_code: 0,
2023                stdout_bytes: None,
2024            });
2025        }
2026        // Check registered commands
2027        if let Some(cmd) = state.commands.get(name)
2028            && let Some(meta) = cmd.meta()
2029            && meta.supports_help_flag
2030        {
2031            return Ok(ExecResult {
2032                stdout: crate::commands::format_help(meta),
2033                stderr: String::new(),
2034                exit_code: 0,
2035                stdout_bytes: None,
2036            });
2037        }
2038        // No meta or supports_help_flag == false → fall through to normal dispatch
2039    }
2040
2041    // 1. Special shell builtins (unshadowable)
2042    if let Some(result) = builtins::execute_builtin(name, args, state, stdin)? {
2043        return Ok(result);
2044    }
2045
2046    // 2. User-defined functions
2047    if state.functions.contains_key(name) {
2048        return execute_function_call(name, args, state);
2049    }
2050
2051    // 3. Registered commands
2052    if let Some(cmd) = state.commands.get(name) {
2053        let env: HashMap<String, String> = state
2054            .env
2055            .iter()
2056            .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
2057            .collect();
2058        // Clone variables for `test -v` array element checks (before mutable borrow).
2059        let vars_clone = state.env.clone();
2060        let fs = Arc::clone(&state.fs);
2061        let cwd = state.cwd.clone();
2062        let limits = state.limits.clone();
2063        let network_policy = state.network_policy.clone();
2064
2065        // Take binary pipe data from interpreter state before borrowing state for callback
2066        let binary_stdin = state.pipe_stdin_bytes.take();
2067        let exec_callback = make_exec_callback(state);
2068
2069        let ctx = CommandContext {
2070            fs: &*fs,
2071            cwd: &cwd,
2072            env: &env,
2073            variables: Some(&vars_clone),
2074            stdin,
2075            stdin_bytes: binary_stdin.as_deref(),
2076            limits: &limits,
2077            network_policy: &network_policy,
2078            exec: Some(&exec_callback),
2079            shell_opts: Some(&state.shell_opts),
2080        };
2081
2082        // xpg_echo: when enabled, echo interprets backslash escapes by default
2083        let effective_args: Vec<String>;
2084        let cmd_args: &[String] = if name == "echo" && state.shopt_opts.xpg_echo {
2085            effective_args = std::iter::once("-e".to_string())
2086                .chain(args.iter().cloned())
2087                .collect();
2088            &effective_args
2089        } else {
2090            args
2091        };
2092
2093        let cmd_result = cmd.execute(cmd_args, &ctx);
2094        return Ok(ExecResult {
2095            stdout: cmd_result.stdout,
2096            stderr: cmd_result.stderr,
2097            exit_code: cmd_result.exit_code,
2098            stdout_bytes: cmd_result.stdout_bytes,
2099        });
2100    }
2101
2102    // 4. Command not found
2103    Ok(ExecResult {
2104        stdout: String::new(),
2105        stderr: format!("{name}: command not found\n"),
2106        exit_code: 127,
2107        stdout_bytes: None,
2108    })
2109}
2110
2111// ── exec builtin ────────────────────────────────────────────────────
2112
2113/// Extract a `{varname}` FD allocation prefix from command args.
2114/// Returns `Some(varname)` if the first arg matches `{identifier}`.
2115fn extract_fd_varname(arg: &str) -> Option<&str> {
2116    let trimmed = arg.strip_prefix('{')?.strip_suffix('}')?;
2117    if !trimmed.is_empty()
2118        && trimmed
2119            .chars()
2120            .next()
2121            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
2122        && trimmed
2123            .chars()
2124            .all(|c| c.is_ascii_alphanumeric() || c == '_')
2125    {
2126        Some(trimmed)
2127    } else {
2128        None
2129    }
2130}
2131
2132/// Handle the `exec` builtin which has three modes:
2133/// 1. `exec` with only redirects → persistent FD redirections
2134/// 2. `exec {varname}>file` → FD variable allocation (persistent)
2135/// 3. `exec cmd args` → replace shell with command
2136fn execute_exec_builtin(
2137    args: &[String],
2138    redirects: &[&ast::IoRedirect],
2139    state: &mut InterpreterState,
2140    stdin: &str,
2141) -> Result<ExecResult, RustBashError> {
2142    // Check for {varname} FD allocation syntax: first arg is {name}, rest is empty
2143    if let Some(first_arg) = args.first()
2144        && let Some(varname) = extract_fd_varname(first_arg)
2145    {
2146        return exec_fd_variable_alloc(varname, args.get(1..), redirects, state);
2147    }
2148
2149    // No real args → persistent FD redirections
2150    if args.is_empty() {
2151        return exec_persistent_redirects(redirects, state);
2152    }
2153
2154    // Has command args → execute and exit
2155    let effective_stdin = match get_stdin_from_redirects(redirects, state, stdin) {
2156        Ok(s) => s,
2157        Err(RustBashError::RedirectFailed(msg)) => {
2158            let result = ExecResult {
2159                stderr: format!("rust-bash: {msg}\n"),
2160                exit_code: 1,
2161                ..ExecResult::default()
2162            };
2163            state.last_exit_code = 1;
2164            state.should_exit = true;
2165            return Ok(result);
2166        }
2167        Err(e) => return Err(e),
2168    };
2169    let mut result = dispatch_command(&args[0], &args[1..], state, &effective_stdin)?;
2170    apply_output_redirects(redirects, &mut result, state)?;
2171    state.last_exit_code = result.exit_code;
2172    state.should_exit = true;
2173    Ok(result)
2174}
2175
2176/// Apply persistent FD redirections from `exec > file`, `exec 3< file`, etc.
2177fn exec_persistent_redirects(
2178    redirects: &[&ast::IoRedirect],
2179    state: &mut InterpreterState,
2180) -> Result<ExecResult, RustBashError> {
2181    for redir in redirects {
2182        match redir {
2183            ast::IoRedirect::File(fd, kind, target) => {
2184                let filename = match redirect_target_filename(target, state) {
2185                    Ok(f) => f,
2186                    Err(RustBashError::RedirectFailed(msg)) => {
2187                        return Ok(ExecResult {
2188                            stderr: format!("rust-bash: {msg}\n"),
2189                            exit_code: 1,
2190                            ..ExecResult::default()
2191                        });
2192                    }
2193                    Err(e) => return Err(e),
2194                };
2195                let path = resolve_path(&state.cwd, &filename);
2196                match kind {
2197                    ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2198                        let fd_num = fd.unwrap_or(1);
2199                        if is_dev_null(&path) {
2200                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2201                        } else if is_dev_stdout(&path) {
2202                            // exec > /dev/stdout restores normal stdout
2203                            state.persistent_fds.remove(&fd_num);
2204                        } else if is_dev_stderr(&path) {
2205                            // exec > /dev/stderr is unusual but valid
2206                            state.persistent_fds.remove(&fd_num);
2207                        } else {
2208                            // Create/truncate the file
2209                            state
2210                                .fs
2211                                .write_file(Path::new(&path), b"")
2212                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2213                            state
2214                                .persistent_fds
2215                                .insert(fd_num, PersistentFd::OutputFile(path));
2216                        }
2217                    }
2218                    ast::IoFileRedirectKind::Append => {
2219                        let fd_num = fd.unwrap_or(1);
2220                        if is_dev_null(&path) {
2221                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2222                        } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2223                            state.persistent_fds.remove(&fd_num);
2224                        } else {
2225                            state
2226                                .persistent_fds
2227                                .insert(fd_num, PersistentFd::OutputFile(path));
2228                        }
2229                    }
2230                    ast::IoFileRedirectKind::Read => {
2231                        let fd_num = fd.unwrap_or(0);
2232                        if is_dev_null(&path) {
2233                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2234                        } else {
2235                            state
2236                                .persistent_fds
2237                                .insert(fd_num, PersistentFd::InputFile(path));
2238                        }
2239                    }
2240                    ast::IoFileRedirectKind::ReadAndWrite => {
2241                        let fd_num = fd.unwrap_or(0);
2242                        if !state.fs.exists(Path::new(&path)) {
2243                            state
2244                                .fs
2245                                .write_file(Path::new(&path), b"")
2246                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2247                        }
2248                        state
2249                            .persistent_fds
2250                            .insert(fd_num, PersistentFd::ReadWriteFile(path));
2251                    }
2252                    ast::IoFileRedirectKind::DuplicateOutput => {
2253                        let fd_num = fd.unwrap_or(1);
2254                        let dup_target = redirect_target_filename(target, state)?;
2255                        // Handle close: >&-
2256                        if dup_target == "-" {
2257                            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2258                        } else if let Some(stripped) = dup_target.strip_suffix('-') {
2259                            // FD move: N>&M-
2260                            if let Ok(source_fd) = stripped.parse::<i32>() {
2261                                if let Some(entry) = state.persistent_fds.get(&source_fd).cloned() {
2262                                    state.persistent_fds.insert(fd_num, entry);
2263                                }
2264                                state.persistent_fds.insert(source_fd, PersistentFd::Closed);
2265                            }
2266                        } else if let Ok(target_fd) = dup_target.parse::<i32>() {
2267                            // Dup: N>&M — copy M's destination to N
2268                            if let Some(entry) = state.persistent_fds.get(&target_fd).cloned() {
2269                                state.persistent_fds.insert(fd_num, entry);
2270                            } else if target_fd == 0 || target_fd == 1 || target_fd == 2 {
2271                                // Standard fd without persistent redirect — store as dup
2272                                state
2273                                    .persistent_fds
2274                                    .insert(fd_num, PersistentFd::DupStdFd(target_fd));
2275                            } else {
2276                                state.persistent_fds.remove(&fd_num);
2277                            }
2278                        }
2279                    }
2280                    ast::IoFileRedirectKind::DuplicateInput => {
2281                        let fd_num = fd.unwrap_or(0);
2282                        let dup_target = redirect_target_filename(target, state)?;
2283                        if dup_target == "-" {
2284                            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2285                        }
2286                    }
2287                }
2288            }
2289            ast::IoRedirect::OutputAndError(word, _append) => {
2290                let filename = expand_word_to_string_mut(word, state)?;
2291                let path = resolve_path(&state.cwd, &filename);
2292                if is_dev_null(&path) {
2293                    state.persistent_fds.insert(1, PersistentFd::DevNull);
2294                    state.persistent_fds.insert(2, PersistentFd::DevNull);
2295                } else {
2296                    let pfd = PersistentFd::OutputFile(path);
2297                    state.persistent_fds.insert(1, pfd.clone());
2298                    state.persistent_fds.insert(2, pfd);
2299                }
2300            }
2301            _ => {}
2302        }
2303    }
2304    Ok(ExecResult::default())
2305}
2306
2307/// Handle `exec {varname}>file` — allocate an FD number and store in variable.
2308fn exec_fd_variable_alloc(
2309    varname: &str,
2310    extra_args: Option<&[String]>,
2311    redirects: &[&ast::IoRedirect],
2312    state: &mut InterpreterState,
2313) -> Result<ExecResult, RustBashError> {
2314    // Check for close syntax: `exec {fd}>&-`
2315    let is_close = redirects.iter().any(|r| {
2316        matches!(
2317            r,
2318            ast::IoRedirect::File(_, ast::IoFileRedirectKind::DuplicateOutput, ast::IoFileRedirectTarget::Duplicate(w)) if w.value == "-"
2319        )
2320    });
2321
2322    if is_close {
2323        // Close the FD stored in the variable
2324        if let Some(var) = state.env.get(varname)
2325            && let Ok(fd_num) = var.value.as_scalar().parse::<i32>()
2326        {
2327            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2328        }
2329        return Ok(ExecResult::default());
2330    }
2331
2332    // Check for extra args after {varname} — not supported, but handle gracefully
2333    if extra_args.is_some_and(|a| !a.is_empty()) {
2334        return Ok(ExecResult {
2335            stderr: "rust-bash: exec: too many arguments\n".to_string(),
2336            exit_code: 1,
2337            ..Default::default()
2338        });
2339    }
2340
2341    // Allocate a new FD number
2342    let fd_num = state.next_auto_fd;
2343    state.next_auto_fd += 1;
2344
2345    // Store the allocated FD number in the named variable
2346    set_variable(state, varname, fd_num.to_string())?;
2347
2348    // Apply the redirect to the allocated FD
2349    for redir in redirects {
2350        if let ast::IoRedirect::File(_fd, kind, target) = redir {
2351            let filename = redirect_target_filename(target, state)?;
2352            let path = resolve_path(&state.cwd, &filename);
2353            match kind {
2354                ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2355                    if is_dev_null(&path) {
2356                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2357                    } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2358                        state.persistent_fds.remove(&fd_num);
2359                    } else {
2360                        state
2361                            .fs
2362                            .write_file(Path::new(&path), b"")
2363                            .map_err(|e| RustBashError::Execution(e.to_string()))?;
2364                        state
2365                            .persistent_fds
2366                            .insert(fd_num, PersistentFd::OutputFile(path));
2367                    }
2368                }
2369                ast::IoFileRedirectKind::Append => {
2370                    if is_dev_null(&path) {
2371                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2372                    } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2373                        state.persistent_fds.remove(&fd_num);
2374                    } else {
2375                        state
2376                            .persistent_fds
2377                            .insert(fd_num, PersistentFd::OutputFile(path));
2378                    }
2379                }
2380                ast::IoFileRedirectKind::Read => {
2381                    if is_dev_null(&path) {
2382                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2383                    } else {
2384                        state
2385                            .persistent_fds
2386                            .insert(fd_num, PersistentFd::InputFile(path));
2387                    }
2388                }
2389                ast::IoFileRedirectKind::ReadAndWrite => {
2390                    if is_dev_null(&path) {
2391                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2392                    } else {
2393                        if !state.fs.exists(Path::new(&path)) {
2394                            state
2395                                .fs
2396                                .write_file(Path::new(&path), b"")
2397                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2398                        }
2399                        state
2400                            .persistent_fds
2401                            .insert(fd_num, PersistentFd::ReadWriteFile(path));
2402                    }
2403                }
2404                _ => {}
2405            }
2406            break; // Only process the first file redirect
2407        }
2408    }
2409
2410    Ok(ExecResult::default())
2411}
2412
2413// ── Special device paths ────────────────────────────────────────────
2414
2415fn is_dev_stdout(path: &str) -> bool {
2416    path == "/dev/stdout"
2417}
2418
2419fn is_dev_stderr(path: &str) -> bool {
2420    path == "/dev/stderr"
2421}
2422
2423fn is_dev_stdin(path: &str) -> bool {
2424    path == "/dev/stdin"
2425}
2426
2427fn is_dev_zero(path: &str) -> bool {
2428    path == "/dev/zero"
2429}
2430
2431fn is_dev_full(path: &str) -> bool {
2432    path == "/dev/full"
2433}
2434
2435fn is_special_dev_path(path: &str) -> bool {
2436    is_dev_null(path)
2437        || is_dev_stdout(path)
2438        || is_dev_stderr(path)
2439        || is_dev_stdin(path)
2440        || is_dev_zero(path)
2441        || is_dev_full(path)
2442}
2443
2444fn get_stdin_from_redirects(
2445    redirects: &[&ast::IoRedirect],
2446    state: &mut InterpreterState,
2447    default_stdin: &str,
2448) -> Result<String, RustBashError> {
2449    for redir in redirects {
2450        match redir {
2451            ast::IoRedirect::File(fd, kind, target) => {
2452                let fd_num = fd.unwrap_or(0);
2453                if fd_num == 0
2454                    && matches!(
2455                        kind,
2456                        ast::IoFileRedirectKind::Read | ast::IoFileRedirectKind::ReadAndWrite
2457                    )
2458                {
2459                    let filename = redirect_target_filename(target, state)?;
2460                    let path = resolve_path(&state.cwd, &filename);
2461                    if is_dev_stdin(&path) {
2462                        return Ok(default_stdin.to_string());
2463                    }
2464                    if is_dev_null(&path) || is_dev_zero(&path) || is_dev_full(&path) {
2465                        return Ok(String::new());
2466                    }
2467                    // Validate empty filename
2468                    if filename.is_empty() {
2469                        return Err(RustBashError::RedirectFailed(
2470                            ": No such file or directory".to_string(),
2471                        ));
2472                    }
2473                    let content = state.fs.read_file(Path::new(&path)).map_err(|_| {
2474                        RustBashError::RedirectFailed(format!(
2475                            "{filename}: No such file or directory"
2476                        ))
2477                    })?;
2478                    return Ok(String::from_utf8_lossy(&content).to_string());
2479                }
2480                // Handle <&N (DuplicateInput) for reading from persistent FDs
2481                if fd_num == 0 && matches!(kind, ast::IoFileRedirectKind::DuplicateInput) {
2482                    let dup_target = redirect_target_filename(target, state)?;
2483                    if let Ok(source_fd) = dup_target.parse::<i32>()
2484                        && let Some(pfd) = state.persistent_fds.get(&source_fd)
2485                    {
2486                        match pfd {
2487                            PersistentFd::InputFile(path) | PersistentFd::ReadWriteFile(path) => {
2488                                let content = state
2489                                    .fs
2490                                    .read_file(Path::new(path))
2491                                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
2492                                return Ok(String::from_utf8_lossy(&content).to_string());
2493                            }
2494                            PersistentFd::DevNull | PersistentFd::Closed => {
2495                                return Ok(String::new());
2496                            }
2497                            PersistentFd::OutputFile(_) | PersistentFd::DupStdFd(_) => {}
2498                        }
2499                    }
2500                }
2501            }
2502            ast::IoRedirect::HereString(fd, word) => {
2503                let fd_num = fd.unwrap_or(0);
2504                if fd_num == 0 {
2505                    let val = expand_word_to_string_mut(word, state)?;
2506                    if val.len() > state.limits.max_heredoc_size {
2507                        return Err(RustBashError::LimitExceeded {
2508                            limit_name: "max_heredoc_size",
2509                            limit_value: state.limits.max_heredoc_size,
2510                            actual_value: val.len(),
2511                        });
2512                    }
2513                    return Ok(format!("{val}\n"));
2514                }
2515            }
2516            ast::IoRedirect::HereDocument(fd, heredoc) => {
2517                let fd_num = fd.unwrap_or(0);
2518                if fd_num == 0 {
2519                    let body = if heredoc.requires_expansion {
2520                        expand_word_to_string_mut(&heredoc.doc, state)?
2521                    } else {
2522                        heredoc.doc.value.clone()
2523                    };
2524                    if body.len() > state.limits.max_heredoc_size {
2525                        return Err(RustBashError::LimitExceeded {
2526                            limit_name: "max_heredoc_size",
2527                            limit_value: state.limits.max_heredoc_size,
2528                            actual_value: body.len(),
2529                        });
2530                    }
2531                    if heredoc.remove_tabs {
2532                        return Ok(body
2533                            .lines()
2534                            .map(|l| l.trim_start_matches('\t'))
2535                            .collect::<Vec<_>>()
2536                            .join("\n")
2537                            + if body.ends_with('\n') { "\n" } else { "" });
2538                    }
2539                    return Ok(body);
2540                }
2541            }
2542            _ => {}
2543        }
2544    }
2545    Ok(default_stdin.to_string())
2546}
2547
2548fn apply_output_redirects(
2549    redirects: &[&ast::IoRedirect],
2550    result: &mut ExecResult,
2551    state: &mut InterpreterState,
2552) -> Result<(), RustBashError> {
2553    // Track which FDs have explicit per-command redirects
2554    let mut redirected_fds = std::collections::HashSet::new();
2555    // Redirect errors (e.g. /dev/full) bypass the redirect chain — they go to
2556    // the shell's own stderr, not to the command's possibly-redirected stderr.
2557    let mut deferred_errors: Vec<String> = Vec::new();
2558
2559    for redir in redirects {
2560        match redir {
2561            ast::IoRedirect::File(fd, kind, target) => {
2562                let fd_num = match kind {
2563                    ast::IoFileRedirectKind::Read
2564                    | ast::IoFileRedirectKind::ReadAndWrite
2565                    | ast::IoFileRedirectKind::DuplicateInput => fd.unwrap_or(0),
2566                    _ => fd.unwrap_or(1),
2567                };
2568                redirected_fds.insert(fd_num);
2569                let cont =
2570                    apply_file_redirect(*fd, kind, target, result, state, &mut deferred_errors)?;
2571                if !cont {
2572                    break;
2573                }
2574            }
2575            ast::IoRedirect::OutputAndError(word, append) => {
2576                redirected_fds.insert(1);
2577                redirected_fds.insert(2);
2578                let filename = expand_word_to_string_mut(word, state)?;
2579                if filename.is_empty() {
2580                    result
2581                        .stderr
2582                        .push_str("rust-bash: : No such file or directory\n");
2583                    result.exit_code = 1;
2584                    break;
2585                }
2586                let path = resolve_path(&state.cwd, &filename);
2587
2588                // noclobber: block &> on existing file (append &>> is fine)
2589                if state.shell_opts.noclobber
2590                    && !*append
2591                    && !is_dev_null(&path)
2592                    && state.fs.exists(Path::new(&path))
2593                {
2594                    result.stderr.push_str(&format!(
2595                        "rust-bash: {filename}: cannot overwrite existing file\n"
2596                    ));
2597                    result.stdout.clear();
2598                    result.exit_code = 1;
2599                    break;
2600                }
2601
2602                let combined = format!("{}{}", result.stdout, result.stderr);
2603
2604                if is_dev_null(&path) {
2605                    result.stdout.clear();
2606                    result.stderr.clear();
2607                } else if *append {
2608                    write_or_append(state, &path, &combined, true)?;
2609                    result.stdout.clear();
2610                    result.stderr.clear();
2611                } else {
2612                    write_or_append(state, &path, &combined, false)?;
2613                    result.stdout.clear();
2614                    result.stderr.clear();
2615                }
2616            }
2617            _ => {} // HereString/HereDocument handled in stdin
2618        }
2619    }
2620
2621    // Apply persistent FD redirections for FDs that don't have per-command redirects
2622    apply_persistent_fd_fallback(result, state, &redirected_fds)?;
2623
2624    // Append deferred redirect errors to stderr — these bypass the redirect
2625    // chain, mirroring how bash reports redirect failures on the shell's own
2626    // stderr (saved before redirect setup).
2627    for err in deferred_errors {
2628        result.stderr.push_str(&err);
2629    }
2630
2631    Ok(())
2632}
2633
2634/// Apply persistent FD redirections as fallback for FDs without per-command redirects.
2635fn apply_persistent_fd_fallback(
2636    result: &mut ExecResult,
2637    state: &InterpreterState,
2638    redirected_fds: &std::collections::HashSet<i32>,
2639) -> Result<(), RustBashError> {
2640    // Check persistent FD for stdout (FD 1)
2641    if !redirected_fds.contains(&1)
2642        && let Some(pfd) = state.persistent_fds.get(&1)
2643    {
2644        match pfd {
2645            PersistentFd::OutputFile(path) => {
2646                if !result.stdout.is_empty() {
2647                    write_or_append(state, path, &result.stdout, true)?;
2648                    result.stdout.clear();
2649                }
2650            }
2651            PersistentFd::DevNull | PersistentFd::Closed => {
2652                result.stdout.clear();
2653            }
2654            _ => {}
2655        }
2656    }
2657
2658    // Check persistent FD for stderr (FD 2)
2659    if !redirected_fds.contains(&2)
2660        && let Some(pfd) = state.persistent_fds.get(&2)
2661    {
2662        match pfd {
2663            PersistentFd::OutputFile(path) => {
2664                if !result.stderr.is_empty() {
2665                    write_or_append(state, path, &result.stderr, true)?;
2666                    result.stderr.clear();
2667                }
2668            }
2669            PersistentFd::DevNull | PersistentFd::Closed => {
2670                result.stderr.clear();
2671            }
2672            _ => {}
2673        }
2674    }
2675
2676    Ok(())
2677}
2678
2679/// Apply a single file redirect. Returns `Ok(true)` to continue processing
2680/// more redirects, or `Ok(false)` to stop (e.g., noclobber failure).
2681fn apply_file_redirect(
2682    fd: Option<i32>,
2683    kind: &ast::IoFileRedirectKind,
2684    target: &ast::IoFileRedirectTarget,
2685    result: &mut ExecResult,
2686    state: &mut InterpreterState,
2687    deferred_errors: &mut Vec<String>,
2688) -> Result<bool, RustBashError> {
2689    // Helper macro to catch RedirectFailed from redirect_target_filename
2690    macro_rules! try_filename {
2691        ($target:expr, $state:expr, $result:expr) => {
2692            match redirect_target_filename($target, $state) {
2693                Ok(f) => f,
2694                Err(RustBashError::RedirectFailed(msg)) => {
2695                    // Clear output for the redirected fd (since no file to write to)
2696                    let fd_num = fd.unwrap_or(1);
2697                    if fd_num == 1 {
2698                        $result.stdout.clear();
2699                    } else if fd_num == 2 {
2700                        $result.stderr.clear();
2701                    }
2702                    $result.stderr.push_str(&format!("rust-bash: {msg}\n"));
2703                    $result.exit_code = 1;
2704                    return Ok(false);
2705                }
2706                Err(e) => return Err(e),
2707            }
2708        };
2709    }
2710
2711    match kind {
2712        ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2713            let fd_num = fd.unwrap_or(1);
2714            let filename = try_filename!(target, state, result);
2715            let path = resolve_path(&state.cwd, &filename);
2716
2717            // noclobber: `>` on existing file is an error; `>|` (Clobber) bypasses
2718            if state.shell_opts.noclobber
2719                && matches!(kind, ast::IoFileRedirectKind::Write)
2720                && !is_dev_null(&path)
2721                && !is_special_dev_path(&path)
2722                && state.fs.exists(Path::new(&path))
2723            {
2724                result.stderr.push_str(&format!(
2725                    "rust-bash: {filename}: cannot overwrite existing file\n"
2726                ));
2727                if fd_num == 1 {
2728                    result.stdout.clear();
2729                }
2730                result.exit_code = 1;
2731                return Ok(false);
2732            }
2733
2734            apply_write_redirect(fd_num, &path, result, state, false, deferred_errors)?;
2735        }
2736        ast::IoFileRedirectKind::Append => {
2737            let fd_num = fd.unwrap_or(1);
2738            let filename = try_filename!(target, state, result);
2739            let path = resolve_path(&state.cwd, &filename);
2740            apply_write_redirect(fd_num, &path, result, state, true, deferred_errors)?;
2741        }
2742        ast::IoFileRedirectKind::DuplicateOutput => {
2743            let fd_num = fd.unwrap_or(1);
2744            if !apply_duplicate_output(fd_num, target, result, state)? {
2745                return Ok(false);
2746            }
2747        }
2748        ast::IoFileRedirectKind::DuplicateInput => {
2749            let fd_num = fd.unwrap_or(0);
2750            if fd_num == 0 {
2751                // <&N for stdin — handled in get_stdin_from_redirects
2752            } else {
2753                // N<&M where N != 0 — acts like N>&M (duplicate)
2754                if !apply_duplicate_output(fd_num, target, result, state)? {
2755                    return Ok(false);
2756                }
2757            }
2758        }
2759        ast::IoFileRedirectKind::Read => {
2760            // Handled in get_stdin_from_redirects
2761        }
2762        ast::IoFileRedirectKind::ReadAndWrite => {
2763            let fd_num = fd.unwrap_or(0);
2764            let filename = try_filename!(target, state, result);
2765            let path = resolve_path(&state.cwd, &filename);
2766            if !state.fs.exists(Path::new(&path)) {
2767                state
2768                    .fs
2769                    .write_file(Path::new(&path), b"")
2770                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
2771            }
2772            // For FD 0, input is handled in get_stdin_from_redirects.
2773            // For output FDs, write content to the file.
2774            if fd_num == 1 {
2775                write_or_append(state, &path, &result.stdout, false)?;
2776                result.stdout.clear();
2777            } else if fd_num == 2 {
2778                write_or_append(state, &path, &result.stderr, false)?;
2779                result.stderr.clear();
2780            }
2781        }
2782    }
2783    Ok(true)
2784}
2785
2786/// Apply a write/append redirect for a given FD to a path, handling special devices.
2787fn apply_write_redirect(
2788    fd_num: i32,
2789    path: &str,
2790    result: &mut ExecResult,
2791    state: &InterpreterState,
2792    append: bool,
2793    deferred_errors: &mut Vec<String>,
2794) -> Result<(), RustBashError> {
2795    if is_dev_null(path) || is_dev_zero(path) {
2796        if fd_num == 1 {
2797            result.stdout.clear();
2798            result.stdout_bytes = None;
2799        } else if fd_num == 2 {
2800            result.stderr.clear();
2801        }
2802    } else if is_dev_stdout(path) {
2803        // > /dev/stdout → output stays on stdout (no-op for fd 1)
2804        if fd_num == 2 {
2805            result.stdout.push_str(&result.stderr);
2806            result.stderr.clear();
2807        }
2808    } else if is_dev_stderr(path) {
2809        // > /dev/stderr → output goes to stderr
2810        if fd_num == 1 {
2811            result.stderr.push_str(&result.stdout);
2812            result.stdout.clear();
2813        }
2814    } else if is_dev_full(path) {
2815        // Writing to /dev/full always fails with ENOSPC.
2816        // The error goes to the shell's own stderr (deferred), mirroring how
2817        // bash reports redirect failures on the pre-redirect stderr.
2818        deferred_errors
2819            .push("rust-bash: write error: /dev/full: No space left on device\n".to_string());
2820        if fd_num == 1 {
2821            result.stdout.clear();
2822        } else if fd_num == 2 {
2823            result.stderr.clear();
2824        }
2825        result.exit_code = 1;
2826    } else {
2827        // Check if path is a directory — redirect to directory should fail gracefully
2828        let p = Path::new(path);
2829        if state.fs.exists(p)
2830            && let Ok(meta) = state.fs.stat(p)
2831            && meta.node_type == crate::vfs::NodeType::Directory
2832        {
2833            let basename = path.rsplit('/').next().unwrap_or(path);
2834            let display = if basename.is_empty() { path } else { basename };
2835            deferred_errors.push(format!("rust-bash: {display}: Is a directory\n"));
2836            if fd_num == 1 {
2837                result.stdout.clear();
2838            } else if fd_num == 2 {
2839                result.stderr.clear();
2840            }
2841            result.exit_code = 1;
2842            return Ok(());
2843        }
2844        let content_bytes: Vec<u8> = if fd_num == 1 {
2845            // Prefer binary bytes when available (e.g. gzip output)
2846            if let Some(bytes) = result.stdout_bytes.take() {
2847                bytes
2848            } else {
2849                result.stdout.as_bytes().to_vec()
2850            }
2851        } else if fd_num == 2 {
2852            result.stderr.as_bytes().to_vec()
2853        } else {
2854            return write_to_persistent_fd(fd_num, result, state);
2855        };
2856        write_or_append_bytes(state, path, &content_bytes, append)?;
2857        if fd_num == 1 {
2858            result.stdout.clear();
2859            result.stdout_bytes = None;
2860        } else if fd_num == 2 {
2861            result.stderr.clear();
2862        }
2863    }
2864    Ok(())
2865}
2866
2867/// Write to a persistent FD's target file (for higher-numbered FDs like >&10).
2868fn write_to_persistent_fd(
2869    _fd_num: i32,
2870    _result: &mut ExecResult,
2871    _state: &InterpreterState,
2872) -> Result<(), RustBashError> {
2873    // Higher FDs with no persistent mapping are silently ignored
2874    Ok(())
2875}
2876
2877/// Handle DuplicateOutput redirect (>&N, 2>&1, N>&M-, etc.)
2878/// Returns Ok(true) to continue, Ok(false) if the redirect failed.
2879fn apply_duplicate_output(
2880    fd_num: i32,
2881    target: &ast::IoFileRedirectTarget,
2882    result: &mut ExecResult,
2883    state: &mut InterpreterState,
2884) -> Result<bool, RustBashError> {
2885    let dup_target_str = match target {
2886        ast::IoFileRedirectTarget::Duplicate(word) => expand_word_to_string_mut(word, state)?,
2887        ast::IoFileRedirectTarget::Fd(target_fd) => target_fd.to_string(),
2888        _ => return Ok(true),
2889    };
2890
2891    // Handle close: >&-
2892    if dup_target_str == "-" {
2893        if fd_num == 1 {
2894            result.stdout.clear();
2895        } else if fd_num == 2 {
2896            result.stderr.clear();
2897        }
2898        return Ok(true);
2899    }
2900
2901    // Handle FD move: N>&M-
2902    if let Some(source_str) = dup_target_str.strip_suffix('-') {
2903        if let Ok(source_fd) = source_str.parse::<i32>() {
2904            // Duplicate: copy source to dest
2905            apply_dup_fd(fd_num, source_fd, result, state)?;
2906            // Close source
2907            if source_fd == 1 {
2908                result.stdout.clear();
2909            } else if source_fd == 2 {
2910                result.stderr.clear();
2911            } else {
2912                state.persistent_fds.insert(source_fd, PersistentFd::Closed);
2913            }
2914        }
2915        return Ok(true);
2916    }
2917
2918    // Standard duplication: N>&M
2919    if let Ok(target_fd) = dup_target_str.parse::<i32>() {
2920        // Validate the target FD exists (0=stdin, 1=stdout, 2=stderr, or a persistent fd)
2921        if target_fd != 0
2922            && target_fd != 1
2923            && target_fd != 2
2924            && !state.persistent_fds.contains_key(&target_fd)
2925        {
2926            if fd_num == 1 {
2927                result.stdout.clear();
2928            }
2929            result
2930                .stderr
2931                .push_str(&format!("rust-bash: {fd_num}: Bad file descriptor\n"));
2932            result.exit_code = 1;
2933            return Ok(false);
2934        }
2935        apply_dup_fd(fd_num, target_fd, result, state)?;
2936    }
2937    Ok(true)
2938}
2939
2940/// Duplicate target_fd to fd_num in the result streams.
2941fn apply_dup_fd(
2942    fd_num: i32,
2943    target_fd: i32,
2944    result: &mut ExecResult,
2945    state: &InterpreterState,
2946) -> Result<(), RustBashError> {
2947    // Standard FD duplication
2948    if target_fd == 1 && fd_num == 2 {
2949        // 2>&1: merge stderr into stdout
2950        result.stdout.push_str(&result.stderr);
2951        result.stderr.clear();
2952    } else if target_fd == 2 && fd_num == 1 {
2953        // 1>&2: merge stdout into stderr
2954        result.stderr.push_str(&result.stdout);
2955        result.stdout.clear();
2956    } else if fd_num == 1 || fd_num == 2 {
2957        // Redirect stdout/stderr to a persistent FD target
2958        if let Some(pfd) = state.persistent_fds.get(&target_fd) {
2959            match pfd {
2960                PersistentFd::OutputFile(path) => {
2961                    let content = if fd_num == 1 {
2962                        let c = result.stdout.clone();
2963                        result.stdout.clear();
2964                        c
2965                    } else {
2966                        let c = result.stderr.clone();
2967                        result.stderr.clear();
2968                        c
2969                    };
2970                    write_or_append(state, path, &content, true)?;
2971                }
2972                PersistentFd::DevNull | PersistentFd::Closed => {
2973                    if fd_num == 1 {
2974                        result.stdout.clear();
2975                    } else {
2976                        result.stderr.clear();
2977                    }
2978                }
2979                PersistentFd::DupStdFd(std_fd) => {
2980                    // Redirect fd_num to the standard fd that target_fd points to
2981                    if *std_fd == 1 && fd_num == 2 {
2982                        result.stdout.push_str(&result.stderr);
2983                        result.stderr.clear();
2984                    } else if *std_fd == 2 && fd_num == 1 {
2985                        result.stderr.push_str(&result.stdout);
2986                        result.stdout.clear();
2987                    }
2988                    // fd_num == *std_fd is a no-op (already going there)
2989                }
2990                _ => {}
2991            }
2992        }
2993    }
2994    Ok(())
2995}
2996
2997/// Handle a process substitution in a command prefix or suffix.
2998/// For `<(cmd)`: execute inner command and write stdout to a temp file.
2999/// For `>(cmd)`: create empty temp file and record inner command for deferred execution.
3000/// Returns the temp file path to use as a command argument.
3001fn expand_process_substitution<'a>(
3002    kind: &ast::ProcessSubstitutionKind,
3003    list: &'a ast::CompoundList,
3004    state: &mut InterpreterState,
3005    deferred_write_subs: &mut Vec<(&'a ast::CompoundList, String)>,
3006) -> Result<String, RustBashError> {
3007    match kind {
3008        ast::ProcessSubstitutionKind::Read => execute_read_process_substitution(list, state),
3009        ast::ProcessSubstitutionKind::Write => {
3010            let path = allocate_proc_sub_temp_file(state, b"")?;
3011            deferred_write_subs.push((list, path.clone()));
3012            Ok(path)
3013        }
3014    }
3015}
3016
3017fn redirect_target_filename(
3018    target: &ast::IoFileRedirectTarget,
3019    state: &mut InterpreterState,
3020) -> Result<String, RustBashError> {
3021    match target {
3022        ast::IoFileRedirectTarget::Filename(word) => {
3023            let filename = expand_word_to_string_mut(word, state)?;
3024            if filename.is_empty() {
3025                return Err(RustBashError::RedirectFailed(
3026                    ": No such file or directory".to_string(),
3027                ));
3028            }
3029            Ok(filename)
3030        }
3031        ast::IoFileRedirectTarget::Fd(fd) => Ok(fd.to_string()),
3032        ast::IoFileRedirectTarget::Duplicate(word) => expand_word_to_string_mut(word, state),
3033        // Only valid when called from within execute_simple_command's closure,
3034        // where proc_sub_prealloc has been populated in step 5b.
3035        ast::IoFileRedirectTarget::ProcessSubstitution(_, _) => {
3036            // Look up pre-allocated path by AST-node address (populated in execute_simple_command).
3037            let key = std::ptr::from_ref(target) as usize;
3038            state.proc_sub_prealloc.remove(&key).ok_or_else(|| {
3039                RustBashError::Execution(
3040                    "process substitution: no pre-allocated path available".into(),
3041                )
3042            })
3043        }
3044    }
3045}
3046
3047/// Execute a `<(cmd)` process substitution: run the inner command, capture stdout,
3048/// write to a temp VFS file, and return the temp file path.
3049fn execute_read_process_substitution(
3050    list: &ast::CompoundList,
3051    state: &mut InterpreterState,
3052) -> Result<String, RustBashError> {
3053    let mut sub_state = make_proc_sub_state(state);
3054    let result = execute_compound_list(list, &mut sub_state, "")?;
3055
3056    // Fold shared counters back
3057    state.counters.command_count = sub_state.counters.command_count;
3058    state.counters.output_size = sub_state.counters.output_size;
3059    state.proc_sub_counter = sub_state.proc_sub_counter;
3060
3061    allocate_proc_sub_temp_file(state, result.stdout.as_bytes())
3062}
3063
3064/// Allocate a unique temp VFS file with the given content and return its path.
3065fn allocate_proc_sub_temp_file(
3066    state: &mut InterpreterState,
3067    content: &[u8],
3068) -> Result<String, RustBashError> {
3069    let path = format!("/tmp/.proc_sub_{}", state.proc_sub_counter);
3070    state.proc_sub_counter += 1;
3071
3072    // Ensure /tmp exists
3073    let tmp = Path::new("/tmp");
3074    if !state.fs.exists(tmp) {
3075        state
3076            .fs
3077            .mkdir_p(tmp)
3078            .map_err(|e| RustBashError::Execution(e.to_string()))?;
3079    }
3080
3081    state
3082        .fs
3083        .write_file(Path::new(&path), content)
3084        .map_err(|e| RustBashError::Execution(e.to_string()))?;
3085
3086    Ok(path)
3087}
3088
3089/// Create a subshell `InterpreterState` that shares the parent's filesystem.
3090/// Unlike command substitution which deep-clones the fs, process substitution
3091/// needs the temp file to be visible to the outer command.
3092fn make_proc_sub_state(state: &mut InterpreterState) -> InterpreterState {
3093    InterpreterState {
3094        fs: Arc::clone(&state.fs),
3095        env: state.env.clone(),
3096        cwd: state.cwd.clone(),
3097        functions: state.functions.clone(),
3098        last_exit_code: state.last_exit_code,
3099        commands: clone_commands(&state.commands),
3100        shell_opts: state.shell_opts.clone(),
3101        shopt_opts: state.shopt_opts.clone(),
3102        limits: state.limits.clone(),
3103        counters: ExecutionCounters {
3104            command_count: state.counters.command_count,
3105            output_size: state.counters.output_size,
3106            start_time: state.counters.start_time,
3107            substitution_depth: state.counters.substitution_depth,
3108            call_depth: 0,
3109        },
3110        network_policy: state.network_policy.clone(),
3111        should_exit: false,
3112        loop_depth: 0,
3113        control_flow: None,
3114        positional_params: state.positional_params.clone(),
3115        shell_name: state.shell_name.clone(),
3116        random_seed: state.random_seed,
3117        local_scopes: Vec::new(),
3118        in_function_depth: 0,
3119        traps: HashMap::new(),
3120        in_trap: false,
3121        errexit_suppressed: 0,
3122        stdin_offset: 0,
3123        dir_stack: state.dir_stack.clone(),
3124        command_hash: state.command_hash.clone(),
3125        aliases: state.aliases.clone(),
3126        current_lineno: state.current_lineno,
3127        shell_start_time: state.shell_start_time,
3128        last_argument: state.last_argument.clone(),
3129        call_stack: state.call_stack.clone(),
3130        machtype: state.machtype.clone(),
3131        hosttype: state.hosttype.clone(),
3132        persistent_fds: HashMap::new(),
3133        next_auto_fd: 10,
3134        proc_sub_counter: state.proc_sub_counter,
3135        proc_sub_prealloc: HashMap::new(),
3136        pipe_stdin_bytes: None,
3137        pending_cmdsub_stderr: String::new(),
3138    }
3139}
3140
3141fn is_dev_null(path: &str) -> bool {
3142    path == "/dev/null"
3143}
3144
3145fn write_or_append(
3146    state: &InterpreterState,
3147    path: &str,
3148    content: &str,
3149    append: bool,
3150) -> Result<(), RustBashError> {
3151    write_or_append_bytes(state, path, content.as_bytes(), append)
3152}
3153
3154fn write_or_append_bytes(
3155    state: &InterpreterState,
3156    path: &str,
3157    content: &[u8],
3158    append: bool,
3159) -> Result<(), RustBashError> {
3160    let p = Path::new(path);
3161
3162    if append {
3163        if state.fs.exists(p) {
3164            state
3165                .fs
3166                .append_file(p, content)
3167                .map_err(|e| RustBashError::Execution(e.to_string()))?;
3168        } else {
3169            state
3170                .fs
3171                .write_file(p, content)
3172                .map_err(|e| RustBashError::Execution(e.to_string()))?;
3173        }
3174    } else {
3175        state
3176            .fs
3177            .write_file(p, content)
3178            .map_err(|e| RustBashError::Execution(e.to_string()))?;
3179    }
3180    Ok(())
3181}
3182
3183// ── Extended test ([[ ]]) ──────────────────────────────────────────
3184
3185fn execute_extended_test(
3186    expr: &ast::ExtendedTestExpr,
3187    state: &mut InterpreterState,
3188) -> Result<ExecResult, RustBashError> {
3189    let should_trace = state.shell_opts.xtrace;
3190    let mut exec_result = match eval_extended_test_expr(expr, state) {
3191        Ok(result) => ExecResult {
3192            exit_code: if result { 0 } else { 1 },
3193            ..ExecResult::default()
3194        },
3195        Err(RustBashError::Execution(ref msg)) => {
3196            let exit_code = if msg.contains("invalid regex") { 2 } else { 1 };
3197            state.last_exit_code = exit_code;
3198            ExecResult {
3199                stderr: format!("rust-bash: {msg}\n"),
3200                exit_code,
3201                ..ExecResult::default()
3202            }
3203        }
3204        Err(e) => return Err(e),
3205    };
3206    if should_trace {
3207        let repr = format_extended_test_expr_expanded(expr, state);
3208        let ps4 = expand_ps4(state);
3209        exec_result.stderr = format!("{ps4}[[ {repr} ]]\n{}", exec_result.stderr);
3210    }
3211    Ok(exec_result)
3212}
3213
3214/// Format an extended test expression for xtrace output, expanding variables.
3215fn format_extended_test_expr_expanded(
3216    expr: &ast::ExtendedTestExpr,
3217    state: &mut InterpreterState,
3218) -> String {
3219    match expr {
3220        ast::ExtendedTestExpr::And(l, r) => {
3221            format!(
3222                "{} && {}",
3223                format_extended_test_expr_expanded(l, state),
3224                format_extended_test_expr_expanded(r, state)
3225            )
3226        }
3227        ast::ExtendedTestExpr::Or(l, r) => {
3228            format!(
3229                "{} || {}",
3230                format_extended_test_expr_expanded(l, state),
3231                format_extended_test_expr_expanded(r, state)
3232            )
3233        }
3234        ast::ExtendedTestExpr::Not(inner) => {
3235            format!("! {}", format_extended_test_expr_expanded(inner, state))
3236        }
3237        ast::ExtendedTestExpr::Parenthesized(inner) => {
3238            format_extended_test_expr_expanded(inner, state)
3239        }
3240        ast::ExtendedTestExpr::UnaryTest(pred, word) => {
3241            let expanded = expand_word_to_string_mut(word, state).unwrap_or_default();
3242            format!("{} {}", format_unary_pred(pred), expanded)
3243        }
3244        ast::ExtendedTestExpr::BinaryTest(pred, l, r) => {
3245            let l_exp = expand_word_to_string_mut(l, state).unwrap_or_default();
3246            let r_exp = expand_word_to_string_mut(r, state).unwrap_or_default();
3247            format!("{} {} {}", l_exp, format_binary_pred(pred), r_exp)
3248        }
3249    }
3250}
3251
3252fn format_unary_pred(pred: &ast::UnaryPredicate) -> &'static str {
3253    use brush_parser::ast::UnaryPredicate;
3254    match pred {
3255        UnaryPredicate::FileExists => "-a",
3256        UnaryPredicate::FileExistsAndIsBlockSpecialFile => "-b",
3257        UnaryPredicate::FileExistsAndIsCharSpecialFile => "-c",
3258        UnaryPredicate::FileExistsAndIsDir => "-d",
3259        UnaryPredicate::FileExistsAndIsRegularFile => "-f",
3260        UnaryPredicate::FileExistsAndIsSetgid => "-g",
3261        UnaryPredicate::FileExistsAndIsSymlink => "-h",
3262        UnaryPredicate::FileExistsAndHasStickyBit => "-k",
3263        UnaryPredicate::FileExistsAndIsFifo => "-p",
3264        UnaryPredicate::FileExistsAndIsReadable => "-r",
3265        UnaryPredicate::FileExistsAndIsNotZeroLength => "-s",
3266        UnaryPredicate::FdIsOpenTerminal => "-t",
3267        UnaryPredicate::FileExistsAndIsSetuid => "-u",
3268        UnaryPredicate::FileExistsAndIsWritable => "-w",
3269        UnaryPredicate::FileExistsAndIsExecutable => "-x",
3270        UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => "-G",
3271        UnaryPredicate::FileExistsAndModifiedSinceLastRead => "-N",
3272        UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => "-O",
3273        UnaryPredicate::FileExistsAndIsSocket => "-S",
3274        UnaryPredicate::StringHasZeroLength => "-z",
3275        UnaryPredicate::StringHasNonZeroLength => "-n",
3276        UnaryPredicate::ShellOptionEnabled => "-o",
3277        UnaryPredicate::ShellVariableIsSetAndAssigned => "-v",
3278        UnaryPredicate::ShellVariableIsSetAndNameRef => "-R",
3279    }
3280}
3281
3282fn format_binary_pred(pred: &ast::BinaryPredicate) -> &'static str {
3283    use brush_parser::ast::BinaryPredicate;
3284    match pred {
3285        BinaryPredicate::StringExactlyMatchesPattern => "==",
3286        BinaryPredicate::StringDoesNotExactlyMatchPattern => "!=",
3287        BinaryPredicate::StringExactlyMatchesString => "==",
3288        BinaryPredicate::StringDoesNotExactlyMatchString => "!=",
3289        BinaryPredicate::StringMatchesRegex => "=~",
3290        BinaryPredicate::StringContainsSubstring => "=~",
3291        BinaryPredicate::ArithmeticEqualTo => "-eq",
3292        BinaryPredicate::ArithmeticNotEqualTo => "-ne",
3293        BinaryPredicate::ArithmeticLessThan => "-lt",
3294        BinaryPredicate::ArithmeticGreaterThan => "-gt",
3295        BinaryPredicate::ArithmeticLessThanOrEqualTo => "-le",
3296        BinaryPredicate::ArithmeticGreaterThanOrEqualTo => "-ge",
3297        BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => "-ef",
3298        BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => "-nt",
3299        BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => "-ot",
3300        _ => "?",
3301    }
3302}
3303
3304fn eval_extended_test_expr(
3305    expr: &ast::ExtendedTestExpr,
3306    state: &mut InterpreterState,
3307) -> Result<bool, RustBashError> {
3308    match expr {
3309        ast::ExtendedTestExpr::And(left, right) => {
3310            let l = eval_extended_test_expr(left, state)?;
3311            if !l {
3312                return Ok(false);
3313            }
3314            eval_extended_test_expr(right, state)
3315        }
3316        ast::ExtendedTestExpr::Or(left, right) => {
3317            let l = eval_extended_test_expr(left, state)?;
3318            if l {
3319                return Ok(true);
3320            }
3321            eval_extended_test_expr(right, state)
3322        }
3323        ast::ExtendedTestExpr::Not(inner) => {
3324            let val = eval_extended_test_expr(inner, state)?;
3325            Ok(!val)
3326        }
3327        ast::ExtendedTestExpr::Parenthesized(inner) => eval_extended_test_expr(inner, state),
3328        ast::ExtendedTestExpr::UnaryTest(pred, word) => {
3329            use brush_parser::ast::UnaryPredicate;
3330            // Handle -v specially: we need access to full interpreter state for array elements
3331            if matches!(pred, UnaryPredicate::ShellVariableIsSetAndAssigned) {
3332                let operand = expand_word_to_string_mut(word, state)?;
3333                return Ok(test_variable_is_set(&operand, state));
3334            }
3335            let operand = expand_word_to_string_mut(word, state)?;
3336            let env: HashMap<String, String> = state
3337                .env
3338                .iter()
3339                .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
3340                .collect();
3341            Ok(crate::commands::test_cmd::eval_unary_predicate(
3342                pred,
3343                &operand,
3344                &*state.fs,
3345                &state.cwd,
3346                &env,
3347                Some(&state.shell_opts),
3348            ))
3349        }
3350        ast::ExtendedTestExpr::BinaryTest(pred, left_word, right_word) => {
3351            let left = expand_word_to_string_mut(left_word, state)?;
3352
3353            // Regex matching needs special handling
3354            if matches!(
3355                pred,
3356                ast::BinaryPredicate::StringMatchesRegex
3357                    | ast::BinaryPredicate::StringContainsSubstring
3358            ) {
3359                // For =~, if the pattern is entirely quoted, treat as literal string match.
3360                // In bash, [[ str =~ 'pat' ]] uses literal matching, not regex.
3361                let raw = &right_word.value;
3362                let is_fully_quoted = is_word_fully_quoted(raw);
3363                let pattern = expand_word_to_string_mut(right_word, state)?;
3364                if is_fully_quoted {
3365                    return Ok(left.contains(&pattern));
3366                }
3367                // Check for partial quoting — extract quoted portions and escape them
3368                let effective_pattern = build_regex_with_quoted_literals(raw, state)?;
3369                return eval_regex_match(&left, &effective_pattern, state);
3370            }
3371
3372            let right = expand_word_to_string_mut(right_word, state)?;
3373
3374            // Arithmetic predicates (-eq, -ne, -lt, -gt, -le, -ge) evaluate
3375            // operands as arithmetic expressions in [[ ]] context.
3376            // Try parse_bash_int first (handles octal, hex, base-N), then
3377            // fall back to simple_arith_eval for expressions like "1+2".
3378            use brush_parser::ast::BinaryPredicate;
3379            if matches!(
3380                pred,
3381                BinaryPredicate::ArithmeticEqualTo
3382                    | BinaryPredicate::ArithmeticNotEqualTo
3383                    | BinaryPredicate::ArithmeticLessThan
3384                    | BinaryPredicate::ArithmeticGreaterThan
3385                    | BinaryPredicate::ArithmeticLessThanOrEqualTo
3386                    | BinaryPredicate::ArithmeticGreaterThanOrEqualTo
3387            ) {
3388                let lval =
3389                    crate::commands::test_cmd::parse_bash_int_pub(&left).unwrap_or_else(|| {
3390                        crate::interpreter::arithmetic::eval_arithmetic(&left, state).unwrap_or(0)
3391                    });
3392                let rval =
3393                    crate::commands::test_cmd::parse_bash_int_pub(&right).unwrap_or_else(|| {
3394                        crate::interpreter::arithmetic::eval_arithmetic(&right, state).unwrap_or(0)
3395                    });
3396                let result = match pred {
3397                    BinaryPredicate::ArithmeticEqualTo => lval == rval,
3398                    BinaryPredicate::ArithmeticNotEqualTo => lval != rval,
3399                    BinaryPredicate::ArithmeticLessThan => lval < rval,
3400                    BinaryPredicate::ArithmeticGreaterThan => lval > rval,
3401                    BinaryPredicate::ArithmeticLessThanOrEqualTo => lval <= rval,
3402                    BinaryPredicate::ArithmeticGreaterThanOrEqualTo => lval >= rval,
3403                    _ => unreachable!(),
3404                };
3405                return Ok(result);
3406            }
3407
3408            // Pattern matching (glob) for == and != inside [[
3409            // Extglob is always active in [[ ]] pattern context
3410            if state.shopt_opts.nocasematch {
3411                // Case-insensitive pattern matching
3412                let result = match pred {
3413                    ast::BinaryPredicate::StringExactlyMatchesPattern => {
3414                        crate::interpreter::pattern::extglob_match_nocase(&right, &left)
3415                    }
3416                    ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
3417                        !crate::interpreter::pattern::extglob_match_nocase(&right, &left)
3418                    }
3419                    ast::BinaryPredicate::StringExactlyMatchesString => {
3420                        left.eq_ignore_ascii_case(&right)
3421                    }
3422                    ast::BinaryPredicate::StringDoesNotExactlyMatchString => {
3423                        !left.eq_ignore_ascii_case(&right)
3424                    }
3425                    _ => crate::commands::test_cmd::eval_binary_predicate(
3426                        pred, &left, &right, true, &*state.fs, &state.cwd,
3427                    ),
3428                };
3429                Ok(result)
3430            } else {
3431                // Use extglob-aware matching for pattern predicates
3432                let result = match pred {
3433                    ast::BinaryPredicate::StringExactlyMatchesPattern => {
3434                        crate::interpreter::pattern::extglob_match(&right, &left)
3435                    }
3436                    ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
3437                        !crate::interpreter::pattern::extglob_match(&right, &left)
3438                    }
3439                    _ => crate::commands::test_cmd::eval_binary_predicate(
3440                        pred, &left, &right, true, &*state.fs, &state.cwd,
3441                    ),
3442                };
3443                Ok(result)
3444            }
3445        }
3446    }
3447}
3448
3449/// Check if a variable (possibly an array element) is set.
3450/// Handles `a[i]` syntax for array element checks.
3451fn test_variable_is_set(operand: &str, state: &mut InterpreterState) -> bool {
3452    // Check for array subscript syntax: name[index]
3453    if let Some(bracket_pos) = operand.find('[')
3454        && operand.ends_with(']')
3455    {
3456        let name = &operand[..bracket_pos];
3457        let index = &operand[bracket_pos + 1..operand.len() - 1];
3458        let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
3459
3460        if index == "@" || index == "*" {
3461            return state
3462                .env
3463                .get(&resolved)
3464                .is_some_and(|var| match &var.value {
3465                    VariableValue::IndexedArray(map) => !map.is_empty(),
3466                    VariableValue::AssociativeArray(map) => !map.is_empty(),
3467                    _ => false,
3468                });
3469        }
3470
3471        // Determine variable type before evaluating arithmetic.
3472        let var_type = state.env.get(&resolved).map(|var| match &var.value {
3473            VariableValue::IndexedArray(_) => 0,
3474            VariableValue::AssociativeArray(_) => 1,
3475            VariableValue::Scalar(_) => 2,
3476        });
3477
3478        return match var_type {
3479            Some(0) => {
3480                // Indexed array: evaluate index as arithmetic.
3481                let idx = eval_index_arithmetic(index, state);
3482                let Some(var) = state.env.get(&resolved) else {
3483                    return false;
3484                };
3485                if let VariableValue::IndexedArray(map) = &var.value {
3486                    let actual_idx = if idx < 0 {
3487                        let max_key = map.keys().next_back().copied().unwrap_or(0);
3488                        let resolved_idx = max_key as i64 + 1 + idx;
3489                        if resolved_idx < 0 {
3490                            return false;
3491                        }
3492                        resolved_idx as usize
3493                    } else {
3494                        idx as usize
3495                    };
3496                    map.contains_key(&actual_idx)
3497                } else {
3498                    false
3499                }
3500            }
3501            Some(1) => {
3502                // Associative array: use index as string key.
3503                state
3504                    .env
3505                    .get(&resolved)
3506                    .and_then(|var| {
3507                        if let VariableValue::AssociativeArray(map) = &var.value {
3508                            Some(map.contains_key(index))
3509                        } else {
3510                            None
3511                        }
3512                    })
3513                    .unwrap_or(false)
3514            }
3515            Some(2) => {
3516                // Scalar: index 0 or -1 means it's set.
3517                let idx = eval_index_arithmetic(index, state);
3518                idx == 0 || idx == -1
3519            }
3520            _ => false,
3521        };
3522    }
3523    // Plain variable name
3524    let resolved = crate::interpreter::resolve_nameref_or_self(operand, state);
3525    state.env.contains_key(&resolved)
3526}
3527
3528/// Evaluate an array index expression using full arithmetic evaluation.
3529/// Falls back to simple_arith_eval on errors.
3530fn eval_index_arithmetic(index: &str, state: &mut InterpreterState) -> i64 {
3531    crate::interpreter::arithmetic::eval_arithmetic(index, state)
3532        .unwrap_or_else(|_| crate::interpreter::expansion::simple_arith_eval(index, state))
3533}
3534
3535fn eval_regex_match(
3536    string: &str,
3537    pattern: &str,
3538    state: &mut InterpreterState,
3539) -> Result<bool, RustBashError> {
3540    // When nocasematch is on, prepend (?i) to make the regex case-insensitive
3541    let effective_pattern = if state.shopt_opts.nocasematch {
3542        format!("(?i){pattern}")
3543    } else {
3544        pattern.to_string()
3545    };
3546    let re = regex::Regex::new(&effective_pattern)
3547        .map_err(|e| RustBashError::Execution(format!("invalid regex '{pattern}': {e}")))?;
3548
3549    if let Some(captures) = re.captures(string) {
3550        // Store BASH_REMATCH as a proper indexed array:
3551        // index 0 = whole match, index 1..N = capture groups
3552        let mut map = std::collections::BTreeMap::new();
3553        let whole = captures.get(0).map(|m| m.as_str()).unwrap_or("");
3554        map.insert(0, whole.to_string());
3555        for i in 1..captures.len() {
3556            let val = captures.get(i).map(|m| m.as_str()).unwrap_or("");
3557            map.insert(i, val.to_string());
3558        }
3559        state.env.insert(
3560            "BASH_REMATCH".to_string(),
3561            Variable {
3562                value: VariableValue::IndexedArray(map),
3563                attrs: VariableAttrs::empty(),
3564            },
3565        );
3566        Ok(true)
3567    } else {
3568        // Clear BASH_REMATCH on non-match
3569        state.env.insert(
3570            "BASH_REMATCH".to_string(),
3571            Variable {
3572                value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
3573                attrs: VariableAttrs::empty(),
3574            },
3575        );
3576        Ok(false)
3577    }
3578}
3579
3580/// Check if a raw word value is entirely wrapped in quotes.
3581fn is_word_fully_quoted(raw: &str) -> bool {
3582    let trimmed = raw.trim();
3583    if trimmed.len() < 2 {
3584        return false;
3585    }
3586    // Single quotes: 'content'
3587    if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
3588        return true;
3589    }
3590    // Double quotes: "content"
3591    if trimmed.starts_with('"') && trimmed.ends_with('"') {
3592        return true;
3593    }
3594    // $'content' or $"content"
3595    if (trimmed.starts_with("$'") && trimmed.ends_with('\''))
3596        || (trimmed.starts_with("$\"") && trimmed.ends_with('"'))
3597    {
3598        return true;
3599    }
3600    false
3601}
3602
3603/// Build a regex pattern from a raw word, escaping quoted portions as literals.
3604/// In bash, quoted parts of a regex pattern are treated as literal text.
3605fn build_regex_with_quoted_literals(
3606    raw: &str,
3607    state: &mut InterpreterState,
3608) -> Result<String, RustBashError> {
3609    let mut result = String::new();
3610    let chars: Vec<char> = raw.chars().collect();
3611    let mut i = 0;
3612    while i < chars.len() {
3613        match chars[i] {
3614            '\'' => {
3615                // Single-quoted: everything until next ' is literal
3616                i += 1;
3617                let mut literal = String::new();
3618                while i < chars.len() && chars[i] != '\'' {
3619                    literal.push(chars[i]);
3620                    i += 1;
3621                }
3622                if i < chars.len() {
3623                    i += 1; // skip closing '
3624                }
3625                result.push_str(&regex::escape(&literal));
3626            }
3627            '"' => {
3628                // Double-quoted: until matching " (expand variables inside)
3629                i += 1;
3630                let mut content = String::new();
3631                while i < chars.len() && chars[i] != '"' {
3632                    if chars[i] == '\\' && i + 1 < chars.len() {
3633                        content.push(chars[i + 1]);
3634                        i += 2;
3635                    } else {
3636                        content.push(chars[i]);
3637                        i += 1;
3638                    }
3639                }
3640                if i < chars.len() {
3641                    i += 1; // skip closing "
3642                }
3643                // Expand variables in the content
3644                let word = ast::Word {
3645                    value: content,
3646                    loc: None,
3647                };
3648                let expanded = expand_word_to_string_mut(&word, state)?;
3649                result.push_str(&regex::escape(&expanded));
3650            }
3651            '\\' if i + 1 < chars.len() => {
3652                // Escaped character: treat as literal
3653                result.push_str(&regex::escape(&chars[i + 1].to_string()));
3654                i += 2;
3655            }
3656            '$' => {
3657                // Variable expansion — expand and keep as regex
3658                let mut var_text = String::new();
3659                var_text.push('$');
3660                i += 1;
3661                if i < chars.len() && chars[i] == '{' {
3662                    // ${...}
3663                    var_text.push('{');
3664                    i += 1;
3665                    let mut depth = 1;
3666                    while i < chars.len() && depth > 0 {
3667                        if chars[i] == '{' {
3668                            depth += 1;
3669                        } else if chars[i] == '}' {
3670                            depth -= 1;
3671                        }
3672                        var_text.push(chars[i]);
3673                        i += 1;
3674                    }
3675                } else {
3676                    // $name
3677                    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
3678                        var_text.push(chars[i]);
3679                        i += 1;
3680                    }
3681                }
3682                let word = ast::Word {
3683                    value: var_text,
3684                    loc: None,
3685                };
3686                let expanded = expand_word_to_string_mut(&word, state)?;
3687                result.push_str(&expanded);
3688            }
3689            c => {
3690                result.push(c);
3691                i += 1;
3692            }
3693        }
3694    }
3695    Ok(result)
3696}