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                    // For declaration builtins (export, readonly, declare, local),
972                    // assignments in suffix are forwarded as "NAME=VALUE" args.
973                    let name = match &assignment.name {
974                        ast::AssignmentName::VariableName(n) => n.clone(),
975                        ast::AssignmentName::ArrayElementName(n, _) => n.clone(),
976                    };
977                    match &assignment.value {
978                        ast::AssignmentValue::Scalar(w) => {
979                            let value = expand_word_to_string_mut(w, state)?;
980                            args.push(format!("{name}={value}"));
981                        }
982                        ast::AssignmentValue::Array(items) => {
983                            let mut parts = Vec::new();
984                            for (opt_idx_word, val_word) in items {
985                                let vals = expand_word_mut(val_word, state)?;
986                                if let Some(idx_word) = opt_idx_word {
987                                    let idx_str = expand_word_to_string_mut(idx_word, state)?;
988                                    let first = vals.first().cloned().unwrap_or_default();
989                                    parts.push(format!("[{idx_str}]={first}"));
990                                    for v in vals.into_iter().skip(1) {
991                                        parts.push(v);
992                                    }
993                                } else {
994                                    parts.extend(vals);
995                                }
996                            }
997                            args.push(format!("{name}=({})", parts.join(" ")));
998                        }
999                    }
1000                }
1001                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell) => {
1002                    let path = expand_process_substitution(
1003                        kind,
1004                        &subshell.list,
1005                        state,
1006                        &mut deferred_write_subs,
1007                    )?;
1008                    proc_sub_temps.push(path.clone());
1009                    args.push(path);
1010                }
1011            }
1012        }
1013    }
1014
1015    // 4. No command name → persist assignments in environment
1016    let Some(cmd_name) = cmd_name else {
1017        // xtrace for bare assignments (e.g. `X=1`)
1018        if state.shell_opts.xtrace && !assignments.is_empty() {
1019            let ps4 = expand_ps4(state);
1020            let mut trace = String::new();
1021            for a in &assignments {
1022                let part = match a {
1023                    Assignment::Scalar { name, value } => format!("{name}={value}"),
1024                    Assignment::IndexedArray { name, elements, .. } => {
1025                        let vals: Vec<String> =
1026                            elements.iter().map(|(_, v)| xtrace_quote(v)).collect();
1027                        format!("{name}=({})", vals.join(" "))
1028                    }
1029                    Assignment::ArrayElement {
1030                        name, index, value, ..
1031                    } => format!("{name}[{index}]={value}"),
1032                    Assignment::AppendArrayElement {
1033                        name, index, value, ..
1034                    } => format!("{name}[{index}]+={value}"),
1035                    Assignment::AppendArray { name, elements, .. } => {
1036                        let vals: Vec<String> =
1037                            elements.iter().map(|(_, v)| xtrace_quote(v)).collect();
1038                        format!("{name}+=({})", vals.join(" "))
1039                    }
1040                    Assignment::AssocArray { name, .. } => format!("{name}=(...)"),
1041                    Assignment::AppendAssocArray { name, .. } => format!("{name}+=(...)"),
1042                    Assignment::AppendScalar { name, value } => format!("{name}+={value}"),
1043                };
1044                trace.push_str(&format!("{ps4}{part}\n"));
1045            }
1046            // Bare assignments produce no output, but emit xtrace to stderr
1047            let mut result = ExecResult {
1048                stderr: trace,
1049                ..ExecResult::default()
1050            };
1051            for a in assignments {
1052                apply_assignment_shell_error(a, state, &mut result)?;
1053            }
1054            return Ok(result);
1055        }
1056        let mut result = ExecResult::default();
1057        for a in assignments {
1058            apply_assignment_shell_error(a, state, &mut result)?;
1059        }
1060        return Ok(result);
1061    };
1062
1063    // 4b. Empty command name (e.g. from `$(false)`) → no command, persist assignments
1064    if cmd_name.is_empty() && args.is_empty() {
1065        let mut result = ExecResult {
1066            exit_code: state.last_exit_code,
1067            ..ExecResult::default()
1068        };
1069        for a in assignments {
1070            apply_assignment_shell_error(a, state, &mut result)?;
1071        }
1072        return Ok(result);
1073    }
1074
1075    // 4c. Alias expansion: if expand_aliases is on and the command name is an alias,
1076    // substitute the alias value. Multi-word aliases produce a new command + extra args.
1077    let (cmd_name, args) = if state.shopt_opts.expand_aliases {
1078        if let Some(expansion) = state.aliases.get(&cmd_name).cloned() {
1079            let mut parts: Vec<String> = expansion
1080                .split_whitespace()
1081                .map(|s| s.to_string())
1082                .collect();
1083            if parts.is_empty() {
1084                (cmd_name, args)
1085            } else {
1086                let new_cmd = parts.remove(0);
1087                parts.extend(args);
1088                (new_cmd, parts)
1089            }
1090        } else {
1091            (cmd_name, args)
1092        }
1093    } else {
1094        (cmd_name, args)
1095    };
1096
1097    // 4d. Handle `exec` builtin specially — it needs access to redirects.
1098    //     Prefix assignments before `exec` are permanent (no subshell).
1099    if cmd_name == "exec" {
1100        // Intercept --help before exec dispatch
1101        if args.first().map(|a| a.as_str()) == Some("--help")
1102            && let Some(meta) = builtins::builtin_meta("exec")
1103            && meta.supports_help_flag
1104        {
1105            return Ok(ExecResult {
1106                stdout: crate::commands::format_help(meta),
1107                stderr: String::new(),
1108                exit_code: 0,
1109                stdout_bytes: None,
1110            });
1111        }
1112        for a in &assignments {
1113            let mut dummy = ExecResult::default();
1114            apply_assignment_shell_error(a.clone(), state, &mut dummy)?;
1115            if dummy.exit_code != 0 {
1116                return Ok(dummy);
1117            }
1118        }
1119        return execute_exec_builtin(&args, &redirects, state, stdin);
1120    }
1121
1122    // 5. Apply temporary pre-command assignments
1123    // On error (e.g. readonly): print the error but still execute the command.
1124    // Bash skips the failing assignment but runs the command; $? is from the
1125    // command, not the assignment error.
1126    let mut saved: Vec<(String, Option<Variable>)> = Vec::new();
1127    let mut prefix_stderr = String::new();
1128    for a in &assignments {
1129        saved.push((a.name().to_string(), state.env.get(a.name()).cloned()));
1130        let mut dummy = ExecResult::default();
1131        apply_assignment_shell_error(a.clone(), state, &mut dummy)?;
1132        if dummy.exit_code != 0 {
1133            prefix_stderr.push_str(&dummy.stderr);
1134        }
1135    }
1136
1137    // 5b–8c are wrapped in an immediately-invoked closure so cleanup (8d) and
1138    // assignment restore (9) run on every exit path, including early `?` returns.
1139    struct RedirProcSub<'a> {
1140        temp_path: String,
1141        kind: &'a ast::ProcessSubstitutionKind,
1142        list: &'a ast::CompoundList,
1143    }
1144    let last_arg = args.last().cloned().unwrap_or_else(|| cmd_name.clone());
1145    let should_trace = state.shell_opts.xtrace;
1146    // Expand PS4 BEFORE dispatch so that `local PS4=...` is traced with the old value
1147    let pre_ps4 = if should_trace {
1148        Some(expand_ps4(state))
1149    } else {
1150        None
1151    };
1152
1153    let mut inner_result = (|| -> Result<ExecResult, RustBashError> {
1154        // 5b. Pre-allocate ALL redirect process substitution temp files in one pass.
1155        //     This ensures allocation order matches redirect-list order, avoiding
1156        //     counter mismatch when Read and Write proc-subs are mixed in the same
1157        //     redirect list (stdin Reads are processed before output Writes).
1158        let mut redir_proc_subs: Vec<RedirProcSub<'_>> = Vec::new();
1159        for redir in &redirects {
1160            if let ast::IoRedirect::File(
1161                _,
1162                _,
1163                target @ ast::IoFileRedirectTarget::ProcessSubstitution(kind, subshell),
1164            ) = redir
1165            {
1166                let temp_path = match kind {
1167                    ast::ProcessSubstitutionKind::Read => {
1168                        execute_read_process_substitution(&subshell.list, state)?
1169                    }
1170                    ast::ProcessSubstitutionKind::Write => allocate_proc_sub_temp_file(state, b"")?,
1171                };
1172                proc_sub_temps.push(temp_path.clone());
1173                // Key by AST-node address so redirect_target_filename resolves the
1174                // correct path regardless of the order redirects are visited.
1175                let key = std::ptr::from_ref(target) as usize;
1176                state.proc_sub_prealloc.insert(key, temp_path.clone());
1177                redir_proc_subs.push(RedirProcSub {
1178                    temp_path,
1179                    kind,
1180                    list: &subshell.list,
1181                });
1182            }
1183        }
1184
1185        // 6. Handle stdin redirection
1186        let effective_stdin = match get_stdin_from_redirects(&redirects, state, stdin) {
1187            Ok(s) => s,
1188            Err(RustBashError::RedirectFailed(msg)) => {
1189                let mut result = ExecResult {
1190                    stderr: format!("rust-bash: {msg}\n"),
1191                    exit_code: 1,
1192                    ..ExecResult::default()
1193                };
1194                state.last_exit_code = 1;
1195                apply_output_redirects(&redirects, &mut result, state)?;
1196                return Ok(result);
1197            }
1198            Err(e) => return Err(e),
1199        };
1200
1201        // Track last argument for $_ (last argument of the simple command).
1202        state.last_argument = last_arg.clone();
1203
1204        // 7a. Capture xtrace state before dispatch (so `set +x` is still traced)
1205        //     (captured outside closure as `should_trace`)
1206
1207        // 7. Dispatch command
1208        let mut result = dispatch_command(&cmd_name, &args, state, &effective_stdin)?;
1209
1210        // 7b. Emit xtrace to stderr
1211        if let Some(ref ps4) = pre_ps4 {
1212            let mut trace = format_xtrace_command(ps4, &cmd_name, &args);
1213            // Assignment builtins (readonly, declare, export, typeset)
1214            // also trace each assignment separately.
1215            // Note: local does NOT trace assignments separately.
1216            if matches!(
1217                cmd_name.as_str(),
1218                "readonly" | "declare" | "typeset" | "export"
1219            ) {
1220                for arg in &args {
1221                    if let Some(eq_pos) = arg.find('=') {
1222                        let name_part = &arg[..eq_pos];
1223                        // Only trace if it looks like a variable assignment (not a flag)
1224                        if !name_part.is_empty()
1225                            && !name_part.starts_with('-')
1226                            && name_part
1227                                .chars()
1228                                .all(|c| c.is_alphanumeric() || c == '_' || c == '+')
1229                        {
1230                            trace.push_str(&format!("{ps4}{arg}\n"));
1231                        }
1232                    }
1233                }
1234            }
1235            // Prepend trace so it appears before the command's own stderr
1236            result.stderr = format!("{trace}{}", result.stderr);
1237        }
1238
1239        // 8. Apply output redirections
1240        apply_output_redirects(&redirects, &mut result, state)?;
1241
1242        // 8b. Execute deferred write process substitutions from redirects.
1243        for rps in &redir_proc_subs {
1244            if matches!(rps.kind, ast::ProcessSubstitutionKind::Write) {
1245                let content = state
1246                    .fs
1247                    .read_file(Path::new(&rps.temp_path))
1248                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
1249                let stdin_data = String::from_utf8_lossy(&content).to_string();
1250                let mut sub_state = make_proc_sub_state(state);
1251                let inner_result = execute_compound_list(rps.list, &mut sub_state, &stdin_data)?;
1252                state.counters.command_count = sub_state.counters.command_count;
1253                state.counters.output_size = sub_state.counters.output_size;
1254                state.proc_sub_counter = sub_state.proc_sub_counter;
1255                result.stdout.push_str(&inner_result.stdout);
1256                result.stderr.push_str(&inner_result.stderr);
1257            }
1258        }
1259
1260        // 8c. Execute deferred write process substitutions from prefix/suffix args
1261        for (inner_list, temp_path) in &deferred_write_subs {
1262            let content = state
1263                .fs
1264                .read_file(Path::new(temp_path))
1265                .map_err(|e| RustBashError::Execution(e.to_string()))?;
1266            let stdin_data = String::from_utf8_lossy(&content).to_string();
1267            let mut sub_state = make_proc_sub_state(state);
1268            let inner_result = execute_compound_list(inner_list, &mut sub_state, &stdin_data)?;
1269            state.counters.command_count = sub_state.counters.command_count;
1270            state.counters.output_size = sub_state.counters.output_size;
1271            state.proc_sub_counter = sub_state.proc_sub_counter;
1272            result.stdout.push_str(&inner_result.stdout);
1273            result.stderr.push_str(&inner_result.stderr);
1274        }
1275
1276        Ok(result)
1277    })();
1278
1279    // 8d. Always clean up process substitution temp files (even on error)
1280    for temp_path in &proc_sub_temps {
1281        let _ = state.fs.remove_file(Path::new(temp_path));
1282    }
1283    state.proc_sub_prealloc.clear();
1284
1285    // 9. Restore pre-command assignments
1286    for (name, old_value) in saved {
1287        match old_value {
1288            Some(var) => {
1289                state.env.insert(name, var);
1290            }
1291            None => {
1292                state.env.remove(&name);
1293            }
1294        }
1295    }
1296
1297    // 9b. Prepend any prefix-assignment error messages to the result stderr.
1298    if let Ok(ref mut r) = inner_result
1299        && !prefix_stderr.is_empty()
1300    {
1301        r.stderr = format!("{prefix_stderr}{}", r.stderr);
1302    }
1303
1304    inner_result
1305}
1306
1307// ── Compound commands ───────────────────────────────────────────────
1308
1309fn execute_compound_command(
1310    compound: &ast::CompoundCommand,
1311    redirects: Option<&ast::RedirectList>,
1312    state: &mut InterpreterState,
1313    stdin: &str,
1314) -> Result<ExecResult, RustBashError> {
1315    let mut result = match compound {
1316        ast::CompoundCommand::IfClause(if_clause) => execute_if(if_clause, state, stdin)?,
1317        ast::CompoundCommand::ForClause(for_clause) => execute_for(for_clause, state, stdin)?,
1318        ast::CompoundCommand::WhileClause(wc) => execute_while_until(wc, false, state, stdin)?,
1319        ast::CompoundCommand::UntilClause(uc) => execute_while_until(uc, true, state, stdin)?,
1320        ast::CompoundCommand::BraceGroup(bg) => execute_compound_list(&bg.list, state, stdin)?,
1321        ast::CompoundCommand::Subshell(sub) => execute_subshell(&sub.list, state, stdin)?,
1322        ast::CompoundCommand::CaseClause(cc) => execute_case(cc, state, stdin)?,
1323        ast::CompoundCommand::Arithmetic(arith) => execute_arithmetic(arith, state)?,
1324        ast::CompoundCommand::ArithmeticForClause(afc) => {
1325            execute_arithmetic_for(afc, state, stdin)?
1326        }
1327    };
1328
1329    // Apply redirections attached to the compound command
1330    if let Some(redir_list) = redirects {
1331        let redir_refs: Vec<&ast::IoRedirect> = redir_list.0.iter().collect();
1332        apply_output_redirects(&redir_refs, &mut result, state)?;
1333    }
1334
1335    state.last_exit_code = result.exit_code;
1336    Ok(result)
1337}
1338
1339fn execute_if(
1340    if_clause: &ast::IfClauseCommand,
1341    state: &mut InterpreterState,
1342    stdin: &str,
1343) -> Result<ExecResult, RustBashError> {
1344    let mut result = ExecResult::default();
1345
1346    // Suppress errexit for condition evaluation
1347    state.errexit_suppressed += 1;
1348    let cond = execute_compound_list(&if_clause.condition, state, stdin)?;
1349    state.errexit_suppressed -= 1;
1350    result.stdout.push_str(&cond.stdout);
1351    result.stderr.push_str(&cond.stderr);
1352
1353    if cond.exit_code == 0 {
1354        let body = execute_compound_list(&if_clause.then, state, stdin)?;
1355        result.stdout.push_str(&body.stdout);
1356        result.stderr.push_str(&body.stderr);
1357        result.exit_code = body.exit_code;
1358        return Ok(result);
1359    }
1360
1361    // Evaluate elif/else branches
1362    if let Some(elses) = &if_clause.elses {
1363        for else_clause in elses {
1364            if let Some(condition) = &else_clause.condition {
1365                // elif — suppress errexit for condition
1366                state.errexit_suppressed += 1;
1367                let cond = execute_compound_list(condition, state, stdin)?;
1368                state.errexit_suppressed -= 1;
1369                result.stdout.push_str(&cond.stdout);
1370                result.stderr.push_str(&cond.stderr);
1371                if cond.exit_code == 0 {
1372                    let body = execute_compound_list(&else_clause.body, state, stdin)?;
1373                    result.stdout.push_str(&body.stdout);
1374                    result.stderr.push_str(&body.stderr);
1375                    result.exit_code = body.exit_code;
1376                    return Ok(result);
1377                }
1378            } else {
1379                // else
1380                let body = execute_compound_list(&else_clause.body, state, stdin)?;
1381                result.stdout.push_str(&body.stdout);
1382                result.stderr.push_str(&body.stderr);
1383                result.exit_code = body.exit_code;
1384                return Ok(result);
1385            }
1386        }
1387    }
1388
1389    // No branch matched — exit code 0 per POSIX
1390    result.exit_code = 0;
1391    Ok(result)
1392}
1393
1394fn execute_for(
1395    for_clause: &ast::ForClauseCommand,
1396    state: &mut InterpreterState,
1397    stdin: &str,
1398) -> Result<ExecResult, RustBashError> {
1399    use crate::interpreter::ControlFlow;
1400
1401    let mut result = ExecResult::default();
1402
1403    let values: Vec<String> = if let Some(words) = &for_clause.values {
1404        let mut vals = Vec::new();
1405        for w in words {
1406            vals.extend(expand_word_mut(w, state)?);
1407        }
1408        vals
1409    } else {
1410        // No word list → iterate over positional parameters
1411        state.positional_params.clone()
1412    };
1413
1414    state.loop_depth += 1;
1415    let mut iterations: usize = 0;
1416    for val in &values {
1417        if state.should_exit {
1418            break;
1419        }
1420        iterations += 1;
1421        if iterations > state.limits.max_loop_iterations {
1422            state.loop_depth -= 1;
1423            return Err(RustBashError::LimitExceeded {
1424                limit_name: "max_loop_iterations",
1425                limit_value: state.limits.max_loop_iterations,
1426                actual_value: iterations,
1427            });
1428        }
1429
1430        set_variable(state, &for_clause.variable_name, val.clone())?;
1431        let r = execute_compound_list(&for_clause.body.list, state, stdin)?;
1432        result.stdout.push_str(&r.stdout);
1433        result.stderr.push_str(&r.stderr);
1434        result.exit_code = r.exit_code;
1435
1436        match state.control_flow.take() {
1437            Some(ControlFlow::Break(n)) => {
1438                if n > 1 {
1439                    state.control_flow = Some(ControlFlow::Break(n - 1));
1440                }
1441                break;
1442            }
1443            Some(ControlFlow::Continue(n)) => {
1444                if n > 1 {
1445                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1446                    break;
1447                }
1448                // n == 1: skip rest, continue to next iteration
1449            }
1450            Some(ret @ ControlFlow::Return(_)) => {
1451                state.control_flow = Some(ret);
1452                break;
1453            }
1454            None => {}
1455        }
1456    }
1457    state.loop_depth -= 1;
1458
1459    Ok(result)
1460}
1461
1462fn execute_arithmetic(
1463    arith: &ast::ArithmeticCommand,
1464    state: &mut InterpreterState,
1465) -> Result<ExecResult, RustBashError> {
1466    let val = crate::interpreter::arithmetic::eval_arithmetic(&arith.expr.value, state)?;
1467    let mut result = ExecResult {
1468        exit_code: if val != 0 { 0 } else { 1 },
1469        ..Default::default()
1470    };
1471    if state.shell_opts.xtrace {
1472        let ps4 = expand_ps4(state);
1473        result.stderr = format!(
1474            "{ps4}(({}))\n{}",
1475            arith.expr.value.trim_end(),
1476            result.stderr
1477        );
1478    }
1479    Ok(result)
1480}
1481
1482fn execute_arithmetic_for(
1483    afc: &ast::ArithmeticForClauseCommand,
1484    state: &mut InterpreterState,
1485    stdin: &str,
1486) -> Result<ExecResult, RustBashError> {
1487    use crate::interpreter::ControlFlow;
1488
1489    // Evaluate initializer
1490    if let Some(init) = &afc.initializer {
1491        crate::interpreter::arithmetic::eval_arithmetic(&init.value, state)?;
1492    }
1493
1494    let mut result = ExecResult::default();
1495    let mut iterations: usize = 0;
1496
1497    state.loop_depth += 1;
1498    loop {
1499        if state.should_exit {
1500            break;
1501        }
1502        iterations += 1;
1503        if iterations > state.limits.max_loop_iterations {
1504            state.loop_depth -= 1;
1505            return Err(RustBashError::LimitExceeded {
1506                limit_name: "max_loop_iterations",
1507                limit_value: state.limits.max_loop_iterations,
1508                actual_value: iterations,
1509            });
1510        }
1511
1512        // Evaluate condition (empty condition = always true)
1513        if let Some(cond) = &afc.condition {
1514            let val = crate::interpreter::arithmetic::eval_arithmetic(&cond.value, state)?;
1515            if val == 0 {
1516                break;
1517            }
1518        }
1519
1520        // Execute body
1521        let body = execute_compound_list(&afc.body.list, state, stdin)?;
1522        result.stdout.push_str(&body.stdout);
1523        result.stderr.push_str(&body.stderr);
1524        result.exit_code = body.exit_code;
1525
1526        match state.control_flow.take() {
1527            Some(ControlFlow::Break(n)) => {
1528                if n > 1 {
1529                    state.control_flow = Some(ControlFlow::Break(n - 1));
1530                }
1531                break;
1532            }
1533            Some(ControlFlow::Continue(n)) => {
1534                if n > 1 {
1535                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1536                    break;
1537                }
1538            }
1539            Some(ret @ ControlFlow::Return(_)) => {
1540                state.control_flow = Some(ret);
1541                break;
1542            }
1543            None => {}
1544        }
1545
1546        // Evaluate updater
1547        if let Some(upd) = &afc.updater {
1548            crate::interpreter::arithmetic::eval_arithmetic(&upd.value, state)?;
1549        }
1550    }
1551    state.loop_depth -= 1;
1552
1553    Ok(result)
1554}
1555
1556fn execute_while_until(
1557    clause: &ast::WhileOrUntilClauseCommand,
1558    is_until: bool,
1559    state: &mut InterpreterState,
1560    stdin: &str,
1561) -> Result<ExecResult, RustBashError> {
1562    use crate::interpreter::ControlFlow;
1563
1564    let mut result = ExecResult::default();
1565    let mut iterations: usize = 0;
1566
1567    state.loop_depth += 1;
1568    loop {
1569        if state.should_exit {
1570            break;
1571        }
1572        iterations += 1;
1573        if iterations > state.limits.max_loop_iterations {
1574            state.loop_depth -= 1;
1575            return Err(RustBashError::LimitExceeded {
1576                limit_name: "max_loop_iterations",
1577                limit_value: state.limits.max_loop_iterations,
1578                actual_value: iterations,
1579            });
1580        }
1581
1582        // Suppress errexit for the loop condition
1583        state.errexit_suppressed += 1;
1584        let cond = execute_compound_list(&clause.0, state, stdin)?;
1585        state.errexit_suppressed -= 1;
1586        result.stdout.push_str(&cond.stdout);
1587        result.stderr.push_str(&cond.stderr);
1588
1589        let should_continue = if is_until {
1590            cond.exit_code != 0
1591        } else {
1592            cond.exit_code == 0
1593        };
1594
1595        if !should_continue {
1596            break;
1597        }
1598
1599        let body = execute_compound_list(&clause.1.list, state, stdin)?;
1600        result.stdout.push_str(&body.stdout);
1601        result.stderr.push_str(&body.stderr);
1602        result.exit_code = body.exit_code;
1603
1604        match state.control_flow.take() {
1605            Some(ControlFlow::Break(n)) => {
1606                if n > 1 {
1607                    state.control_flow = Some(ControlFlow::Break(n - 1));
1608                }
1609                break;
1610            }
1611            Some(ControlFlow::Continue(n)) => {
1612                if n > 1 {
1613                    state.control_flow = Some(ControlFlow::Continue(n - 1));
1614                    break;
1615                }
1616                // n == 1: skip rest, continue to next iteration
1617            }
1618            Some(ret @ ControlFlow::Return(_)) => {
1619                state.control_flow = Some(ret);
1620                break;
1621            }
1622            None => {}
1623        }
1624    }
1625    state.loop_depth -= 1;
1626
1627    Ok(result)
1628}
1629
1630fn execute_subshell(
1631    list: &ast::CompoundList,
1632    state: &mut InterpreterState,
1633    stdin: &str,
1634) -> Result<ExecResult, RustBashError> {
1635    // Deep-clone the filesystem so mutations in the subshell are isolated
1636    let cloned_fs = state.fs.deep_clone();
1637
1638    let mut sub_state = InterpreterState {
1639        fs: cloned_fs,
1640        env: state.env.clone(),
1641        cwd: state.cwd.clone(),
1642        functions: state.functions.clone(),
1643        last_exit_code: state.last_exit_code,
1644        commands: clone_commands(&state.commands),
1645        shell_opts: state.shell_opts.clone(),
1646        shopt_opts: state.shopt_opts.clone(),
1647        limits: state.limits.clone(),
1648        counters: ExecutionCounters {
1649            command_count: state.counters.command_count,
1650            output_size: state.counters.output_size,
1651            start_time: state.counters.start_time,
1652            substitution_depth: state.counters.substitution_depth,
1653            call_depth: 0,
1654        },
1655        network_policy: state.network_policy.clone(),
1656        should_exit: false,
1657        loop_depth: 0,
1658        control_flow: None,
1659        positional_params: state.positional_params.clone(),
1660        shell_name: state.shell_name.clone(),
1661        random_seed: state.random_seed,
1662        local_scopes: Vec::new(),
1663        in_function_depth: 0,
1664        traps: state.traps.clone(),
1665        in_trap: false,
1666        errexit_suppressed: 0,
1667        stdin_offset: 0,
1668        dir_stack: state.dir_stack.clone(),
1669        command_hash: state.command_hash.clone(),
1670        aliases: state.aliases.clone(),
1671        current_lineno: state.current_lineno,
1672        shell_start_time: state.shell_start_time,
1673        last_argument: state.last_argument.clone(),
1674        call_stack: state.call_stack.clone(),
1675        machtype: state.machtype.clone(),
1676        hosttype: state.hosttype.clone(),
1677        persistent_fds: state.persistent_fds.clone(),
1678        next_auto_fd: state.next_auto_fd,
1679        proc_sub_counter: state.proc_sub_counter,
1680        proc_sub_prealloc: HashMap::new(),
1681        pipe_stdin_bytes: None,
1682    };
1683
1684    let result = execute_compound_list(list, &mut sub_state, stdin);
1685
1686    // Fold shared counters back into parent
1687    state.counters.command_count = sub_state.counters.command_count;
1688    state.counters.output_size = sub_state.counters.output_size;
1689
1690    let result = result?;
1691
1692    // Only the exit code propagates back; all other state changes are discarded
1693    Ok(result)
1694}
1695
1696fn execute_case(
1697    case_clause: &ast::CaseClauseCommand,
1698    state: &mut InterpreterState,
1699    stdin: &str,
1700) -> Result<ExecResult, RustBashError> {
1701    let value = expand_word_to_string_mut(&case_clause.value, state)?;
1702    let mut result = ExecResult::default();
1703
1704    let mut i = 0;
1705    let mut fall_through = false;
1706    while i < case_clause.cases.len() {
1707        let case_item = &case_clause.cases[i];
1708
1709        let matched = if fall_through {
1710            fall_through = false;
1711            true
1712        } else {
1713            let mut m = false;
1714            for pattern_word in &case_item.patterns {
1715                let pattern = expand_word_to_string_mut(pattern_word, state)?;
1716                let matched_pattern = if state.shopt_opts.nocasematch {
1717                    if state.shopt_opts.extglob {
1718                        crate::interpreter::pattern::extglob_match_nocase(&pattern, &value)
1719                    } else {
1720                        crate::interpreter::pattern::glob_match_nocase(&pattern, &value)
1721                    }
1722                } else if state.shopt_opts.extglob {
1723                    crate::interpreter::pattern::extglob_match(&pattern, &value)
1724                } else {
1725                    crate::interpreter::pattern::glob_match(&pattern, &value)
1726                };
1727                if matched_pattern {
1728                    m = true;
1729                    break;
1730                }
1731            }
1732            m
1733        };
1734
1735        if matched {
1736            if let Some(cmd) = &case_item.cmd {
1737                let r = execute_compound_list(cmd, state, stdin)?;
1738                result.stdout.push_str(&r.stdout);
1739                result.stderr.push_str(&r.stderr);
1740                result.exit_code = r.exit_code;
1741            }
1742
1743            match case_item.post_action {
1744                ast::CaseItemPostAction::ExitCase => break,
1745                ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem => {
1746                    // ;& — fall through: execute next body unconditionally
1747                    fall_through = true;
1748                    i += 1;
1749                    continue;
1750                }
1751                ast::CaseItemPostAction::ContinueEvaluatingCases => {
1752                    // ;;& — continue matching remaining patterns
1753                    i += 1;
1754                    continue;
1755                }
1756            }
1757        }
1758        i += 1;
1759    }
1760
1761    Ok(result)
1762}
1763
1764/// Clone the command registry for subshell isolation.
1765///
1766/// **Limitation:** Custom commands registered via the public API are not
1767/// preserved in subshells because `Box<dyn VirtualCommand>` is not `Clone`.
1768/// Only the default built-in command set is available inside subshells.
1769/// A future improvement could use `Arc<dyn VirtualCommand>` to share
1770/// command instances across subshell boundaries.
1771pub(crate) fn clone_commands(
1772    _commands: &HashMap<String, Box<dyn crate::commands::VirtualCommand>>,
1773) -> HashMap<String, Box<dyn crate::commands::VirtualCommand>> {
1774    crate::commands::register_default_commands()
1775}
1776
1777/// Create an exec callback that commands can use to invoke sub-commands.
1778/// The callback parses and executes a command string in an isolated subshell state.
1779///
1780/// Note: The callback captures `start_time` so wall-clock limits apply globally.
1781/// Per-invocation `command_count` resets because the `Fn` closure signature cannot
1782/// fold counters back to the parent. The parent's `dispatch_command` still counts
1783/// the top-level command (e.g., `xargs`/`find`) itself.
1784fn make_exec_callback(
1785    state: &InterpreterState,
1786) -> impl Fn(&str) -> Result<CommandResult, RustBashError> {
1787    let cloned_fs = state.fs.deep_clone();
1788    let env = state.env.clone();
1789    let cwd = state.cwd.clone();
1790    let functions = state.functions.clone();
1791    let last_exit_code = state.last_exit_code;
1792    let commands = clone_commands(&state.commands);
1793    let shell_opts = state.shell_opts.clone();
1794    let shopt_opts = state.shopt_opts.clone();
1795    let limits = state.limits.clone();
1796    let network_policy = state.network_policy.clone();
1797    let positional_params = state.positional_params.clone();
1798    let shell_name = state.shell_name.clone();
1799    let random_seed = state.random_seed;
1800    let start_time = state.counters.start_time;
1801    let shell_start_time = state.shell_start_time;
1802    let last_argument = state.last_argument.clone();
1803    let call_stack = state.call_stack.clone();
1804    let machtype = state.machtype.clone();
1805    let hosttype = state.hosttype.clone();
1806
1807    move |cmd_str: &str| {
1808        let program = parse(cmd_str)?;
1809
1810        let sub_fs = cloned_fs.deep_clone();
1811
1812        let mut sub_state = InterpreterState {
1813            fs: sub_fs,
1814            env: env.clone(),
1815            cwd: cwd.clone(),
1816            functions: functions.clone(),
1817            last_exit_code,
1818            commands: clone_commands(&commands),
1819            shell_opts: shell_opts.clone(),
1820            shopt_opts: shopt_opts.clone(),
1821            limits: limits.clone(),
1822            counters: ExecutionCounters {
1823                command_count: 0,
1824                output_size: 0,
1825                start_time,
1826                substitution_depth: 0,
1827                call_depth: 0,
1828            },
1829            network_policy: network_policy.clone(),
1830            should_exit: false,
1831            loop_depth: 0,
1832            control_flow: None,
1833            positional_params: positional_params.clone(),
1834            shell_name: shell_name.clone(),
1835            random_seed,
1836            local_scopes: Vec::new(),
1837            in_function_depth: 0,
1838            traps: HashMap::new(),
1839            in_trap: false,
1840            errexit_suppressed: 0,
1841            stdin_offset: 0,
1842            dir_stack: Vec::new(),
1843            command_hash: HashMap::new(),
1844            aliases: HashMap::new(),
1845            current_lineno: 0,
1846            shell_start_time,
1847            last_argument: last_argument.clone(),
1848            call_stack: call_stack.clone(),
1849            machtype: machtype.clone(),
1850            hosttype: hosttype.clone(),
1851            persistent_fds: HashMap::new(),
1852            next_auto_fd: 10,
1853            proc_sub_counter: 0,
1854            proc_sub_prealloc: HashMap::new(),
1855            pipe_stdin_bytes: None,
1856        };
1857
1858        let result = execute_program(&program, &mut sub_state)?;
1859        Ok(CommandResult {
1860            stdout: result.stdout,
1861            stderr: result.stderr,
1862            exit_code: result.exit_code,
1863            stdout_bytes: None,
1864        })
1865    }
1866}
1867
1868// ── Function calls ──────────────────────────────────────────────────
1869
1870fn execute_function_call(
1871    name: &str,
1872    args: &[String],
1873    state: &mut InterpreterState,
1874) -> Result<ExecResult, RustBashError> {
1875    use crate::interpreter::ControlFlow;
1876
1877    // Check call depth limit
1878    state.counters.call_depth += 1;
1879    if state.counters.call_depth > state.limits.max_call_depth {
1880        let actual = state.counters.call_depth;
1881        state.counters.call_depth -= 1;
1882        return Err(RustBashError::LimitExceeded {
1883            limit_name: "max_call_depth",
1884            limit_value: state.limits.max_call_depth,
1885            actual_value: actual,
1886        });
1887    }
1888
1889    // Clone the function body so we don't hold a borrow on state.functions
1890    let func_def = state.functions.get(name).unwrap().clone();
1891
1892    // Save and replace positional parameters
1893    let saved_params = std::mem::replace(&mut state.positional_params, args.to_vec());
1894
1895    // Push call stack frame for FUNCNAME/BASH_SOURCE/BASH_LINENO.
1896    // BASH_LINENO records the line where the call was made (current LINENO).
1897    state.call_stack.push(CallFrame {
1898        func_name: name.to_string(),
1899        source: String::new(),
1900        lineno: state.current_lineno,
1901    });
1902
1903    // Push a new local scope for this function call
1904    state.local_scopes.push(HashMap::new());
1905    state.in_function_depth += 1;
1906
1907    // Execute the function body (CompoundCommand inside FunctionBody)
1908    let result = execute_compound_command(&func_def.body.0, func_def.body.1.as_ref(), state, "");
1909
1910    // Determine exit code: if Return was signaled, use its code
1911    let exit_code = match state.control_flow.take() {
1912        Some(ControlFlow::Return(code)) => code,
1913        Some(other) => {
1914            // Re-set non-return control flow (break/continue should propagate)
1915            state.control_flow = Some(other);
1916            result.as_ref().map(|r| r.exit_code).unwrap_or(1)
1917        }
1918        None => result.as_ref().map(|r| r.exit_code).unwrap_or(1),
1919    };
1920
1921    // Pop the call stack frame.
1922    state.call_stack.pop();
1923
1924    // Restore local variables
1925    state.in_function_depth -= 1;
1926    if let Some(restore_map) = state.local_scopes.pop() {
1927        for (var_name, old_value) in restore_map {
1928            match old_value {
1929                Some(var) => {
1930                    state.env.insert(var_name, var);
1931                }
1932                None => {
1933                    state.env.remove(&var_name);
1934                }
1935            }
1936        }
1937    }
1938
1939    // Restore positional parameters
1940    state.positional_params = saved_params;
1941
1942    state.counters.call_depth -= 1;
1943
1944    let mut result = result?;
1945    result.exit_code = exit_code;
1946    Ok(result)
1947}
1948
1949fn dispatch_command(
1950    name: &str,
1951    args: &[String],
1952    state: &mut InterpreterState,
1953    stdin: &str,
1954) -> Result<ExecResult, RustBashError> {
1955    state.counters.command_count += 1;
1956    check_limits(state)?;
1957
1958    // 0. --help interception (before builtin dispatch, function lookup, etc.)
1959    if args.first().map(|a| a.as_str()) == Some("--help") {
1960        // Check builtins first
1961        if let Some(meta) = builtins::builtin_meta(name)
1962            && meta.supports_help_flag
1963        {
1964            return Ok(ExecResult {
1965                stdout: crate::commands::format_help(meta),
1966                stderr: String::new(),
1967                exit_code: 0,
1968                stdout_bytes: None,
1969            });
1970        }
1971        // Check registered commands
1972        if let Some(cmd) = state.commands.get(name)
1973            && let Some(meta) = cmd.meta()
1974            && meta.supports_help_flag
1975        {
1976            return Ok(ExecResult {
1977                stdout: crate::commands::format_help(meta),
1978                stderr: String::new(),
1979                exit_code: 0,
1980                stdout_bytes: None,
1981            });
1982        }
1983        // No meta or supports_help_flag == false → fall through to normal dispatch
1984    }
1985
1986    // 1. Special shell builtins (unshadowable)
1987    if let Some(result) = builtins::execute_builtin(name, args, state, stdin)? {
1988        return Ok(result);
1989    }
1990
1991    // 2. User-defined functions
1992    if state.functions.contains_key(name) {
1993        return execute_function_call(name, args, state);
1994    }
1995
1996    // 3. Registered commands
1997    if let Some(cmd) = state.commands.get(name) {
1998        let env: HashMap<String, String> = state
1999            .env
2000            .iter()
2001            .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
2002            .collect();
2003        // Clone variables for `test -v` array element checks (before mutable borrow).
2004        let vars_clone = state.env.clone();
2005        let fs = Arc::clone(&state.fs);
2006        let cwd = state.cwd.clone();
2007        let limits = state.limits.clone();
2008        let network_policy = state.network_policy.clone();
2009
2010        // Take binary pipe data from interpreter state before borrowing state for callback
2011        let binary_stdin = state.pipe_stdin_bytes.take();
2012        let exec_callback = make_exec_callback(state);
2013
2014        let ctx = CommandContext {
2015            fs: &*fs,
2016            cwd: &cwd,
2017            env: &env,
2018            variables: Some(&vars_clone),
2019            stdin,
2020            stdin_bytes: binary_stdin.as_deref(),
2021            limits: &limits,
2022            network_policy: &network_policy,
2023            exec: Some(&exec_callback),
2024            shell_opts: Some(&state.shell_opts),
2025        };
2026
2027        // xpg_echo: when enabled, echo interprets backslash escapes by default
2028        let effective_args: Vec<String>;
2029        let cmd_args: &[String] = if name == "echo" && state.shopt_opts.xpg_echo {
2030            effective_args = std::iter::once("-e".to_string())
2031                .chain(args.iter().cloned())
2032                .collect();
2033            &effective_args
2034        } else {
2035            args
2036        };
2037
2038        let cmd_result = cmd.execute(cmd_args, &ctx);
2039        return Ok(ExecResult {
2040            stdout: cmd_result.stdout,
2041            stderr: cmd_result.stderr,
2042            exit_code: cmd_result.exit_code,
2043            stdout_bytes: cmd_result.stdout_bytes,
2044        });
2045    }
2046
2047    // 4. Command not found
2048    Ok(ExecResult {
2049        stdout: String::new(),
2050        stderr: format!("{name}: command not found\n"),
2051        exit_code: 127,
2052        stdout_bytes: None,
2053    })
2054}
2055
2056// ── exec builtin ────────────────────────────────────────────────────
2057
2058/// Extract a `{varname}` FD allocation prefix from command args.
2059/// Returns `Some(varname)` if the first arg matches `{identifier}`.
2060fn extract_fd_varname(arg: &str) -> Option<&str> {
2061    let trimmed = arg.strip_prefix('{')?.strip_suffix('}')?;
2062    if !trimmed.is_empty()
2063        && trimmed
2064            .chars()
2065            .next()
2066            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
2067        && trimmed
2068            .chars()
2069            .all(|c| c.is_ascii_alphanumeric() || c == '_')
2070    {
2071        Some(trimmed)
2072    } else {
2073        None
2074    }
2075}
2076
2077/// Handle the `exec` builtin which has three modes:
2078/// 1. `exec` with only redirects → persistent FD redirections
2079/// 2. `exec {varname}>file` → FD variable allocation (persistent)
2080/// 3. `exec cmd args` → replace shell with command
2081fn execute_exec_builtin(
2082    args: &[String],
2083    redirects: &[&ast::IoRedirect],
2084    state: &mut InterpreterState,
2085    stdin: &str,
2086) -> Result<ExecResult, RustBashError> {
2087    // Check for {varname} FD allocation syntax: first arg is {name}, rest is empty
2088    if let Some(first_arg) = args.first()
2089        && let Some(varname) = extract_fd_varname(first_arg)
2090    {
2091        return exec_fd_variable_alloc(varname, args.get(1..), redirects, state);
2092    }
2093
2094    // No real args → persistent FD redirections
2095    if args.is_empty() {
2096        return exec_persistent_redirects(redirects, state);
2097    }
2098
2099    // Has command args → execute and exit
2100    let effective_stdin = match get_stdin_from_redirects(redirects, state, stdin) {
2101        Ok(s) => s,
2102        Err(RustBashError::RedirectFailed(msg)) => {
2103            let result = ExecResult {
2104                stderr: format!("rust-bash: {msg}\n"),
2105                exit_code: 1,
2106                ..ExecResult::default()
2107            };
2108            state.last_exit_code = 1;
2109            state.should_exit = true;
2110            return Ok(result);
2111        }
2112        Err(e) => return Err(e),
2113    };
2114    let mut result = dispatch_command(&args[0], &args[1..], state, &effective_stdin)?;
2115    apply_output_redirects(redirects, &mut result, state)?;
2116    state.last_exit_code = result.exit_code;
2117    state.should_exit = true;
2118    Ok(result)
2119}
2120
2121/// Apply persistent FD redirections from `exec > file`, `exec 3< file`, etc.
2122fn exec_persistent_redirects(
2123    redirects: &[&ast::IoRedirect],
2124    state: &mut InterpreterState,
2125) -> Result<ExecResult, RustBashError> {
2126    for redir in redirects {
2127        match redir {
2128            ast::IoRedirect::File(fd, kind, target) => {
2129                let filename = match redirect_target_filename(target, state) {
2130                    Ok(f) => f,
2131                    Err(RustBashError::RedirectFailed(msg)) => {
2132                        return Ok(ExecResult {
2133                            stderr: format!("rust-bash: {msg}\n"),
2134                            exit_code: 1,
2135                            ..ExecResult::default()
2136                        });
2137                    }
2138                    Err(e) => return Err(e),
2139                };
2140                let path = resolve_path(&state.cwd, &filename);
2141                match kind {
2142                    ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2143                        let fd_num = fd.unwrap_or(1);
2144                        if is_dev_null(&path) {
2145                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2146                        } else if is_dev_stdout(&path) {
2147                            // exec > /dev/stdout restores normal stdout
2148                            state.persistent_fds.remove(&fd_num);
2149                        } else if is_dev_stderr(&path) {
2150                            // exec > /dev/stderr is unusual but valid
2151                            state.persistent_fds.remove(&fd_num);
2152                        } else {
2153                            // Create/truncate the file
2154                            state
2155                                .fs
2156                                .write_file(Path::new(&path), b"")
2157                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2158                            state
2159                                .persistent_fds
2160                                .insert(fd_num, PersistentFd::OutputFile(path));
2161                        }
2162                    }
2163                    ast::IoFileRedirectKind::Append => {
2164                        let fd_num = fd.unwrap_or(1);
2165                        if is_dev_null(&path) {
2166                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2167                        } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2168                            state.persistent_fds.remove(&fd_num);
2169                        } else {
2170                            state
2171                                .persistent_fds
2172                                .insert(fd_num, PersistentFd::OutputFile(path));
2173                        }
2174                    }
2175                    ast::IoFileRedirectKind::Read => {
2176                        let fd_num = fd.unwrap_or(0);
2177                        if is_dev_null(&path) {
2178                            state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2179                        } else {
2180                            state
2181                                .persistent_fds
2182                                .insert(fd_num, PersistentFd::InputFile(path));
2183                        }
2184                    }
2185                    ast::IoFileRedirectKind::ReadAndWrite => {
2186                        let fd_num = fd.unwrap_or(0);
2187                        if !state.fs.exists(Path::new(&path)) {
2188                            state
2189                                .fs
2190                                .write_file(Path::new(&path), b"")
2191                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2192                        }
2193                        state
2194                            .persistent_fds
2195                            .insert(fd_num, PersistentFd::ReadWriteFile(path));
2196                    }
2197                    ast::IoFileRedirectKind::DuplicateOutput => {
2198                        let fd_num = fd.unwrap_or(1);
2199                        let dup_target = redirect_target_filename(target, state)?;
2200                        // Handle close: >&-
2201                        if dup_target == "-" {
2202                            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2203                        } else if let Some(stripped) = dup_target.strip_suffix('-') {
2204                            // FD move: N>&M-
2205                            if let Ok(source_fd) = stripped.parse::<i32>() {
2206                                if let Some(entry) = state.persistent_fds.get(&source_fd).cloned() {
2207                                    state.persistent_fds.insert(fd_num, entry);
2208                                }
2209                                state.persistent_fds.insert(source_fd, PersistentFd::Closed);
2210                            }
2211                        } else if let Ok(target_fd) = dup_target.parse::<i32>() {
2212                            // Dup: N>&M — copy M's destination to N
2213                            if let Some(entry) = state.persistent_fds.get(&target_fd).cloned() {
2214                                state.persistent_fds.insert(fd_num, entry);
2215                            } else if target_fd == 0 || target_fd == 1 || target_fd == 2 {
2216                                // Standard fd without persistent redirect — store as dup
2217                                state
2218                                    .persistent_fds
2219                                    .insert(fd_num, PersistentFd::DupStdFd(target_fd));
2220                            } else {
2221                                state.persistent_fds.remove(&fd_num);
2222                            }
2223                        }
2224                    }
2225                    ast::IoFileRedirectKind::DuplicateInput => {
2226                        let fd_num = fd.unwrap_or(0);
2227                        let dup_target = redirect_target_filename(target, state)?;
2228                        if dup_target == "-" {
2229                            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2230                        }
2231                    }
2232                }
2233            }
2234            ast::IoRedirect::OutputAndError(word, _append) => {
2235                let filename = expand_word_to_string_mut(word, state)?;
2236                let path = resolve_path(&state.cwd, &filename);
2237                if is_dev_null(&path) {
2238                    state.persistent_fds.insert(1, PersistentFd::DevNull);
2239                    state.persistent_fds.insert(2, PersistentFd::DevNull);
2240                } else {
2241                    let pfd = PersistentFd::OutputFile(path);
2242                    state.persistent_fds.insert(1, pfd.clone());
2243                    state.persistent_fds.insert(2, pfd);
2244                }
2245            }
2246            _ => {}
2247        }
2248    }
2249    Ok(ExecResult::default())
2250}
2251
2252/// Handle `exec {varname}>file` — allocate an FD number and store in variable.
2253fn exec_fd_variable_alloc(
2254    varname: &str,
2255    extra_args: Option<&[String]>,
2256    redirects: &[&ast::IoRedirect],
2257    state: &mut InterpreterState,
2258) -> Result<ExecResult, RustBashError> {
2259    // Check for close syntax: `exec {fd}>&-`
2260    let is_close = redirects.iter().any(|r| {
2261        matches!(
2262            r,
2263            ast::IoRedirect::File(_, ast::IoFileRedirectKind::DuplicateOutput, ast::IoFileRedirectTarget::Duplicate(w)) if w.value == "-"
2264        )
2265    });
2266
2267    if is_close {
2268        // Close the FD stored in the variable
2269        if let Some(var) = state.env.get(varname)
2270            && let Ok(fd_num) = var.value.as_scalar().parse::<i32>()
2271        {
2272            state.persistent_fds.insert(fd_num, PersistentFd::Closed);
2273        }
2274        return Ok(ExecResult::default());
2275    }
2276
2277    // Check for extra args after {varname} — not supported, but handle gracefully
2278    if extra_args.is_some_and(|a| !a.is_empty()) {
2279        return Ok(ExecResult {
2280            stderr: "rust-bash: exec: too many arguments\n".to_string(),
2281            exit_code: 1,
2282            ..Default::default()
2283        });
2284    }
2285
2286    // Allocate a new FD number
2287    let fd_num = state.next_auto_fd;
2288    state.next_auto_fd += 1;
2289
2290    // Store the allocated FD number in the named variable
2291    set_variable(state, varname, fd_num.to_string())?;
2292
2293    // Apply the redirect to the allocated FD
2294    for redir in redirects {
2295        if let ast::IoRedirect::File(_fd, kind, target) = redir {
2296            let filename = redirect_target_filename(target, state)?;
2297            let path = resolve_path(&state.cwd, &filename);
2298            match kind {
2299                ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2300                    if is_dev_null(&path) {
2301                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2302                    } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2303                        state.persistent_fds.remove(&fd_num);
2304                    } else {
2305                        state
2306                            .fs
2307                            .write_file(Path::new(&path), b"")
2308                            .map_err(|e| RustBashError::Execution(e.to_string()))?;
2309                        state
2310                            .persistent_fds
2311                            .insert(fd_num, PersistentFd::OutputFile(path));
2312                    }
2313                }
2314                ast::IoFileRedirectKind::Append => {
2315                    if is_dev_null(&path) {
2316                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2317                    } else if is_dev_stdout(&path) || is_dev_stderr(&path) {
2318                        state.persistent_fds.remove(&fd_num);
2319                    } else {
2320                        state
2321                            .persistent_fds
2322                            .insert(fd_num, PersistentFd::OutputFile(path));
2323                    }
2324                }
2325                ast::IoFileRedirectKind::Read => {
2326                    if is_dev_null(&path) {
2327                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2328                    } else {
2329                        state
2330                            .persistent_fds
2331                            .insert(fd_num, PersistentFd::InputFile(path));
2332                    }
2333                }
2334                ast::IoFileRedirectKind::ReadAndWrite => {
2335                    if is_dev_null(&path) {
2336                        state.persistent_fds.insert(fd_num, PersistentFd::DevNull);
2337                    } else {
2338                        if !state.fs.exists(Path::new(&path)) {
2339                            state
2340                                .fs
2341                                .write_file(Path::new(&path), b"")
2342                                .map_err(|e| RustBashError::Execution(e.to_string()))?;
2343                        }
2344                        state
2345                            .persistent_fds
2346                            .insert(fd_num, PersistentFd::ReadWriteFile(path));
2347                    }
2348                }
2349                _ => {}
2350            }
2351            break; // Only process the first file redirect
2352        }
2353    }
2354
2355    Ok(ExecResult::default())
2356}
2357
2358// ── Special device paths ────────────────────────────────────────────
2359
2360fn is_dev_stdout(path: &str) -> bool {
2361    path == "/dev/stdout"
2362}
2363
2364fn is_dev_stderr(path: &str) -> bool {
2365    path == "/dev/stderr"
2366}
2367
2368fn is_dev_stdin(path: &str) -> bool {
2369    path == "/dev/stdin"
2370}
2371
2372fn is_dev_zero(path: &str) -> bool {
2373    path == "/dev/zero"
2374}
2375
2376fn is_dev_full(path: &str) -> bool {
2377    path == "/dev/full"
2378}
2379
2380fn is_special_dev_path(path: &str) -> bool {
2381    is_dev_null(path)
2382        || is_dev_stdout(path)
2383        || is_dev_stderr(path)
2384        || is_dev_stdin(path)
2385        || is_dev_zero(path)
2386        || is_dev_full(path)
2387}
2388
2389fn get_stdin_from_redirects(
2390    redirects: &[&ast::IoRedirect],
2391    state: &mut InterpreterState,
2392    default_stdin: &str,
2393) -> Result<String, RustBashError> {
2394    for redir in redirects {
2395        match redir {
2396            ast::IoRedirect::File(fd, kind, target) => {
2397                let fd_num = fd.unwrap_or(0);
2398                if fd_num == 0
2399                    && matches!(
2400                        kind,
2401                        ast::IoFileRedirectKind::Read | ast::IoFileRedirectKind::ReadAndWrite
2402                    )
2403                {
2404                    let filename = redirect_target_filename(target, state)?;
2405                    let path = resolve_path(&state.cwd, &filename);
2406                    if is_dev_stdin(&path) {
2407                        return Ok(default_stdin.to_string());
2408                    }
2409                    if is_dev_null(&path) || is_dev_zero(&path) || is_dev_full(&path) {
2410                        return Ok(String::new());
2411                    }
2412                    // Validate empty filename
2413                    if filename.is_empty() {
2414                        return Err(RustBashError::RedirectFailed(
2415                            ": No such file or directory".to_string(),
2416                        ));
2417                    }
2418                    let content = state.fs.read_file(Path::new(&path)).map_err(|_| {
2419                        RustBashError::RedirectFailed(format!(
2420                            "{filename}: No such file or directory"
2421                        ))
2422                    })?;
2423                    return Ok(String::from_utf8_lossy(&content).to_string());
2424                }
2425                // Handle <&N (DuplicateInput) for reading from persistent FDs
2426                if fd_num == 0 && matches!(kind, ast::IoFileRedirectKind::DuplicateInput) {
2427                    let dup_target = redirect_target_filename(target, state)?;
2428                    if let Ok(source_fd) = dup_target.parse::<i32>()
2429                        && let Some(pfd) = state.persistent_fds.get(&source_fd)
2430                    {
2431                        match pfd {
2432                            PersistentFd::InputFile(path) | PersistentFd::ReadWriteFile(path) => {
2433                                let content = state
2434                                    .fs
2435                                    .read_file(Path::new(path))
2436                                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
2437                                return Ok(String::from_utf8_lossy(&content).to_string());
2438                            }
2439                            PersistentFd::DevNull | PersistentFd::Closed => {
2440                                return Ok(String::new());
2441                            }
2442                            PersistentFd::OutputFile(_) | PersistentFd::DupStdFd(_) => {}
2443                        }
2444                    }
2445                }
2446            }
2447            ast::IoRedirect::HereString(fd, word) => {
2448                let fd_num = fd.unwrap_or(0);
2449                if fd_num == 0 {
2450                    let val = expand_word_to_string_mut(word, state)?;
2451                    if val.len() > state.limits.max_heredoc_size {
2452                        return Err(RustBashError::LimitExceeded {
2453                            limit_name: "max_heredoc_size",
2454                            limit_value: state.limits.max_heredoc_size,
2455                            actual_value: val.len(),
2456                        });
2457                    }
2458                    return Ok(format!("{val}\n"));
2459                }
2460            }
2461            ast::IoRedirect::HereDocument(fd, heredoc) => {
2462                let fd_num = fd.unwrap_or(0);
2463                if fd_num == 0 {
2464                    let body = if heredoc.requires_expansion {
2465                        expand_word_to_string_mut(&heredoc.doc, state)?
2466                    } else {
2467                        heredoc.doc.value.clone()
2468                    };
2469                    if body.len() > state.limits.max_heredoc_size {
2470                        return Err(RustBashError::LimitExceeded {
2471                            limit_name: "max_heredoc_size",
2472                            limit_value: state.limits.max_heredoc_size,
2473                            actual_value: body.len(),
2474                        });
2475                    }
2476                    if heredoc.remove_tabs {
2477                        return Ok(body
2478                            .lines()
2479                            .map(|l| l.trim_start_matches('\t'))
2480                            .collect::<Vec<_>>()
2481                            .join("\n")
2482                            + if body.ends_with('\n') { "\n" } else { "" });
2483                    }
2484                    return Ok(body);
2485                }
2486            }
2487            _ => {}
2488        }
2489    }
2490    Ok(default_stdin.to_string())
2491}
2492
2493fn apply_output_redirects(
2494    redirects: &[&ast::IoRedirect],
2495    result: &mut ExecResult,
2496    state: &mut InterpreterState,
2497) -> Result<(), RustBashError> {
2498    // Track which FDs have explicit per-command redirects
2499    let mut redirected_fds = std::collections::HashSet::new();
2500    // Redirect errors (e.g. /dev/full) bypass the redirect chain — they go to
2501    // the shell's own stderr, not to the command's possibly-redirected stderr.
2502    let mut deferred_errors: Vec<String> = Vec::new();
2503
2504    for redir in redirects {
2505        match redir {
2506            ast::IoRedirect::File(fd, kind, target) => {
2507                let fd_num = match kind {
2508                    ast::IoFileRedirectKind::Read
2509                    | ast::IoFileRedirectKind::ReadAndWrite
2510                    | ast::IoFileRedirectKind::DuplicateInput => fd.unwrap_or(0),
2511                    _ => fd.unwrap_or(1),
2512                };
2513                redirected_fds.insert(fd_num);
2514                let cont =
2515                    apply_file_redirect(*fd, kind, target, result, state, &mut deferred_errors)?;
2516                if !cont {
2517                    break;
2518                }
2519            }
2520            ast::IoRedirect::OutputAndError(word, append) => {
2521                redirected_fds.insert(1);
2522                redirected_fds.insert(2);
2523                let filename = expand_word_to_string_mut(word, state)?;
2524                if filename.is_empty() {
2525                    result
2526                        .stderr
2527                        .push_str("rust-bash: : No such file or directory\n");
2528                    result.exit_code = 1;
2529                    break;
2530                }
2531                let path = resolve_path(&state.cwd, &filename);
2532
2533                // noclobber: block &> on existing file (append &>> is fine)
2534                if state.shell_opts.noclobber
2535                    && !*append
2536                    && !is_dev_null(&path)
2537                    && state.fs.exists(Path::new(&path))
2538                {
2539                    result.stderr.push_str(&format!(
2540                        "rust-bash: {filename}: cannot overwrite existing file\n"
2541                    ));
2542                    result.stdout.clear();
2543                    result.exit_code = 1;
2544                    break;
2545                }
2546
2547                let combined = format!("{}{}", result.stdout, result.stderr);
2548
2549                if is_dev_null(&path) {
2550                    result.stdout.clear();
2551                    result.stderr.clear();
2552                } else if *append {
2553                    write_or_append(state, &path, &combined, true)?;
2554                    result.stdout.clear();
2555                    result.stderr.clear();
2556                } else {
2557                    write_or_append(state, &path, &combined, false)?;
2558                    result.stdout.clear();
2559                    result.stderr.clear();
2560                }
2561            }
2562            _ => {} // HereString/HereDocument handled in stdin
2563        }
2564    }
2565
2566    // Apply persistent FD redirections for FDs that don't have per-command redirects
2567    apply_persistent_fd_fallback(result, state, &redirected_fds)?;
2568
2569    // Append deferred redirect errors to stderr — these bypass the redirect
2570    // chain, mirroring how bash reports redirect failures on the shell's own
2571    // stderr (saved before redirect setup).
2572    for err in deferred_errors {
2573        result.stderr.push_str(&err);
2574    }
2575
2576    Ok(())
2577}
2578
2579/// Apply persistent FD redirections as fallback for FDs without per-command redirects.
2580fn apply_persistent_fd_fallback(
2581    result: &mut ExecResult,
2582    state: &InterpreterState,
2583    redirected_fds: &std::collections::HashSet<i32>,
2584) -> Result<(), RustBashError> {
2585    // Check persistent FD for stdout (FD 1)
2586    if !redirected_fds.contains(&1)
2587        && let Some(pfd) = state.persistent_fds.get(&1)
2588    {
2589        match pfd {
2590            PersistentFd::OutputFile(path) => {
2591                if !result.stdout.is_empty() {
2592                    write_or_append(state, path, &result.stdout, true)?;
2593                    result.stdout.clear();
2594                }
2595            }
2596            PersistentFd::DevNull | PersistentFd::Closed => {
2597                result.stdout.clear();
2598            }
2599            _ => {}
2600        }
2601    }
2602
2603    // Check persistent FD for stderr (FD 2)
2604    if !redirected_fds.contains(&2)
2605        && let Some(pfd) = state.persistent_fds.get(&2)
2606    {
2607        match pfd {
2608            PersistentFd::OutputFile(path) => {
2609                if !result.stderr.is_empty() {
2610                    write_or_append(state, path, &result.stderr, true)?;
2611                    result.stderr.clear();
2612                }
2613            }
2614            PersistentFd::DevNull | PersistentFd::Closed => {
2615                result.stderr.clear();
2616            }
2617            _ => {}
2618        }
2619    }
2620
2621    Ok(())
2622}
2623
2624/// Apply a single file redirect. Returns `Ok(true)` to continue processing
2625/// more redirects, or `Ok(false)` to stop (e.g., noclobber failure).
2626fn apply_file_redirect(
2627    fd: Option<i32>,
2628    kind: &ast::IoFileRedirectKind,
2629    target: &ast::IoFileRedirectTarget,
2630    result: &mut ExecResult,
2631    state: &mut InterpreterState,
2632    deferred_errors: &mut Vec<String>,
2633) -> Result<bool, RustBashError> {
2634    // Helper macro to catch RedirectFailed from redirect_target_filename
2635    macro_rules! try_filename {
2636        ($target:expr, $state:expr, $result:expr) => {
2637            match redirect_target_filename($target, $state) {
2638                Ok(f) => f,
2639                Err(RustBashError::RedirectFailed(msg)) => {
2640                    // Clear output for the redirected fd (since no file to write to)
2641                    let fd_num = fd.unwrap_or(1);
2642                    if fd_num == 1 {
2643                        $result.stdout.clear();
2644                    } else if fd_num == 2 {
2645                        $result.stderr.clear();
2646                    }
2647                    $result.stderr.push_str(&format!("rust-bash: {msg}\n"));
2648                    $result.exit_code = 1;
2649                    return Ok(false);
2650                }
2651                Err(e) => return Err(e),
2652            }
2653        };
2654    }
2655
2656    match kind {
2657        ast::IoFileRedirectKind::Write | ast::IoFileRedirectKind::Clobber => {
2658            let fd_num = fd.unwrap_or(1);
2659            let filename = try_filename!(target, state, result);
2660            let path = resolve_path(&state.cwd, &filename);
2661
2662            // noclobber: `>` on existing file is an error; `>|` (Clobber) bypasses
2663            if state.shell_opts.noclobber
2664                && matches!(kind, ast::IoFileRedirectKind::Write)
2665                && !is_dev_null(&path)
2666                && !is_special_dev_path(&path)
2667                && state.fs.exists(Path::new(&path))
2668            {
2669                result.stderr.push_str(&format!(
2670                    "rust-bash: {filename}: cannot overwrite existing file\n"
2671                ));
2672                if fd_num == 1 {
2673                    result.stdout.clear();
2674                }
2675                result.exit_code = 1;
2676                return Ok(false);
2677            }
2678
2679            apply_write_redirect(fd_num, &path, result, state, false, deferred_errors)?;
2680        }
2681        ast::IoFileRedirectKind::Append => {
2682            let fd_num = fd.unwrap_or(1);
2683            let filename = try_filename!(target, state, result);
2684            let path = resolve_path(&state.cwd, &filename);
2685            apply_write_redirect(fd_num, &path, result, state, true, deferred_errors)?;
2686        }
2687        ast::IoFileRedirectKind::DuplicateOutput => {
2688            let fd_num = fd.unwrap_or(1);
2689            if !apply_duplicate_output(fd_num, target, result, state)? {
2690                return Ok(false);
2691            }
2692        }
2693        ast::IoFileRedirectKind::DuplicateInput => {
2694            let fd_num = fd.unwrap_or(0);
2695            if fd_num == 0 {
2696                // <&N for stdin — handled in get_stdin_from_redirects
2697            } else {
2698                // N<&M where N != 0 — acts like N>&M (duplicate)
2699                if !apply_duplicate_output(fd_num, target, result, state)? {
2700                    return Ok(false);
2701                }
2702            }
2703        }
2704        ast::IoFileRedirectKind::Read => {
2705            // Handled in get_stdin_from_redirects
2706        }
2707        ast::IoFileRedirectKind::ReadAndWrite => {
2708            let fd_num = fd.unwrap_or(0);
2709            let filename = try_filename!(target, state, result);
2710            let path = resolve_path(&state.cwd, &filename);
2711            if !state.fs.exists(Path::new(&path)) {
2712                state
2713                    .fs
2714                    .write_file(Path::new(&path), b"")
2715                    .map_err(|e| RustBashError::Execution(e.to_string()))?;
2716            }
2717            // For FD 0, input is handled in get_stdin_from_redirects.
2718            // For output FDs, write content to the file.
2719            if fd_num == 1 {
2720                write_or_append(state, &path, &result.stdout, false)?;
2721                result.stdout.clear();
2722            } else if fd_num == 2 {
2723                write_or_append(state, &path, &result.stderr, false)?;
2724                result.stderr.clear();
2725            }
2726        }
2727    }
2728    Ok(true)
2729}
2730
2731/// Apply a write/append redirect for a given FD to a path, handling special devices.
2732fn apply_write_redirect(
2733    fd_num: i32,
2734    path: &str,
2735    result: &mut ExecResult,
2736    state: &InterpreterState,
2737    append: bool,
2738    deferred_errors: &mut Vec<String>,
2739) -> Result<(), RustBashError> {
2740    if is_dev_null(path) || is_dev_zero(path) {
2741        if fd_num == 1 {
2742            result.stdout.clear();
2743            result.stdout_bytes = None;
2744        } else if fd_num == 2 {
2745            result.stderr.clear();
2746        }
2747    } else if is_dev_stdout(path) {
2748        // > /dev/stdout → output stays on stdout (no-op for fd 1)
2749        if fd_num == 2 {
2750            result.stdout.push_str(&result.stderr);
2751            result.stderr.clear();
2752        }
2753    } else if is_dev_stderr(path) {
2754        // > /dev/stderr → output goes to stderr
2755        if fd_num == 1 {
2756            result.stderr.push_str(&result.stdout);
2757            result.stdout.clear();
2758        }
2759    } else if is_dev_full(path) {
2760        // Writing to /dev/full always fails with ENOSPC.
2761        // The error goes to the shell's own stderr (deferred), mirroring how
2762        // bash reports redirect failures on the pre-redirect stderr.
2763        deferred_errors
2764            .push("rust-bash: write error: /dev/full: No space left on device\n".to_string());
2765        if fd_num == 1 {
2766            result.stdout.clear();
2767        } else if fd_num == 2 {
2768            result.stderr.clear();
2769        }
2770        result.exit_code = 1;
2771    } else {
2772        // Check if path is a directory — redirect to directory should fail gracefully
2773        let p = Path::new(path);
2774        if state.fs.exists(p)
2775            && let Ok(meta) = state.fs.stat(p)
2776            && meta.node_type == crate::vfs::NodeType::Directory
2777        {
2778            let basename = path.rsplit('/').next().unwrap_or(path);
2779            let display = if basename.is_empty() { path } else { basename };
2780            deferred_errors.push(format!("rust-bash: {display}: Is a directory\n"));
2781            if fd_num == 1 {
2782                result.stdout.clear();
2783            } else if fd_num == 2 {
2784                result.stderr.clear();
2785            }
2786            result.exit_code = 1;
2787            return Ok(());
2788        }
2789        let content_bytes: Vec<u8> = if fd_num == 1 {
2790            // Prefer binary bytes when available (e.g. gzip output)
2791            if let Some(bytes) = result.stdout_bytes.take() {
2792                bytes
2793            } else {
2794                result.stdout.as_bytes().to_vec()
2795            }
2796        } else if fd_num == 2 {
2797            result.stderr.as_bytes().to_vec()
2798        } else {
2799            return write_to_persistent_fd(fd_num, result, state);
2800        };
2801        write_or_append_bytes(state, path, &content_bytes, append)?;
2802        if fd_num == 1 {
2803            result.stdout.clear();
2804            result.stdout_bytes = None;
2805        } else if fd_num == 2 {
2806            result.stderr.clear();
2807        }
2808    }
2809    Ok(())
2810}
2811
2812/// Write to a persistent FD's target file (for higher-numbered FDs like >&10).
2813fn write_to_persistent_fd(
2814    _fd_num: i32,
2815    _result: &mut ExecResult,
2816    _state: &InterpreterState,
2817) -> Result<(), RustBashError> {
2818    // Higher FDs with no persistent mapping are silently ignored
2819    Ok(())
2820}
2821
2822/// Handle DuplicateOutput redirect (>&N, 2>&1, N>&M-, etc.)
2823/// Returns Ok(true) to continue, Ok(false) if the redirect failed.
2824fn apply_duplicate_output(
2825    fd_num: i32,
2826    target: &ast::IoFileRedirectTarget,
2827    result: &mut ExecResult,
2828    state: &mut InterpreterState,
2829) -> Result<bool, RustBashError> {
2830    let dup_target_str = match target {
2831        ast::IoFileRedirectTarget::Duplicate(word) => expand_word_to_string_mut(word, state)?,
2832        ast::IoFileRedirectTarget::Fd(target_fd) => target_fd.to_string(),
2833        _ => return Ok(true),
2834    };
2835
2836    // Handle close: >&-
2837    if dup_target_str == "-" {
2838        if fd_num == 1 {
2839            result.stdout.clear();
2840        } else if fd_num == 2 {
2841            result.stderr.clear();
2842        }
2843        return Ok(true);
2844    }
2845
2846    // Handle FD move: N>&M-
2847    if let Some(source_str) = dup_target_str.strip_suffix('-') {
2848        if let Ok(source_fd) = source_str.parse::<i32>() {
2849            // Duplicate: copy source to dest
2850            apply_dup_fd(fd_num, source_fd, result, state)?;
2851            // Close source
2852            if source_fd == 1 {
2853                result.stdout.clear();
2854            } else if source_fd == 2 {
2855                result.stderr.clear();
2856            } else {
2857                state.persistent_fds.insert(source_fd, PersistentFd::Closed);
2858            }
2859        }
2860        return Ok(true);
2861    }
2862
2863    // Standard duplication: N>&M
2864    if let Ok(target_fd) = dup_target_str.parse::<i32>() {
2865        // Validate the target FD exists (0=stdin, 1=stdout, 2=stderr, or a persistent fd)
2866        if target_fd != 0
2867            && target_fd != 1
2868            && target_fd != 2
2869            && !state.persistent_fds.contains_key(&target_fd)
2870        {
2871            if fd_num == 1 {
2872                result.stdout.clear();
2873            }
2874            result
2875                .stderr
2876                .push_str(&format!("rust-bash: {fd_num}: Bad file descriptor\n"));
2877            result.exit_code = 1;
2878            return Ok(false);
2879        }
2880        apply_dup_fd(fd_num, target_fd, result, state)?;
2881    }
2882    Ok(true)
2883}
2884
2885/// Duplicate target_fd to fd_num in the result streams.
2886fn apply_dup_fd(
2887    fd_num: i32,
2888    target_fd: i32,
2889    result: &mut ExecResult,
2890    state: &InterpreterState,
2891) -> Result<(), RustBashError> {
2892    // Standard FD duplication
2893    if target_fd == 1 && fd_num == 2 {
2894        // 2>&1: merge stderr into stdout
2895        result.stdout.push_str(&result.stderr);
2896        result.stderr.clear();
2897    } else if target_fd == 2 && fd_num == 1 {
2898        // 1>&2: merge stdout into stderr
2899        result.stderr.push_str(&result.stdout);
2900        result.stdout.clear();
2901    } else if fd_num == 1 || fd_num == 2 {
2902        // Redirect stdout/stderr to a persistent FD target
2903        if let Some(pfd) = state.persistent_fds.get(&target_fd) {
2904            match pfd {
2905                PersistentFd::OutputFile(path) => {
2906                    let content = if fd_num == 1 {
2907                        let c = result.stdout.clone();
2908                        result.stdout.clear();
2909                        c
2910                    } else {
2911                        let c = result.stderr.clone();
2912                        result.stderr.clear();
2913                        c
2914                    };
2915                    write_or_append(state, path, &content, true)?;
2916                }
2917                PersistentFd::DevNull | PersistentFd::Closed => {
2918                    if fd_num == 1 {
2919                        result.stdout.clear();
2920                    } else {
2921                        result.stderr.clear();
2922                    }
2923                }
2924                PersistentFd::DupStdFd(std_fd) => {
2925                    // Redirect fd_num to the standard fd that target_fd points to
2926                    if *std_fd == 1 && fd_num == 2 {
2927                        result.stdout.push_str(&result.stderr);
2928                        result.stderr.clear();
2929                    } else if *std_fd == 2 && fd_num == 1 {
2930                        result.stderr.push_str(&result.stdout);
2931                        result.stdout.clear();
2932                    }
2933                    // fd_num == *std_fd is a no-op (already going there)
2934                }
2935                _ => {}
2936            }
2937        }
2938    }
2939    Ok(())
2940}
2941
2942/// Handle a process substitution in a command prefix or suffix.
2943/// For `<(cmd)`: execute inner command and write stdout to a temp file.
2944/// For `>(cmd)`: create empty temp file and record inner command for deferred execution.
2945/// Returns the temp file path to use as a command argument.
2946fn expand_process_substitution<'a>(
2947    kind: &ast::ProcessSubstitutionKind,
2948    list: &'a ast::CompoundList,
2949    state: &mut InterpreterState,
2950    deferred_write_subs: &mut Vec<(&'a ast::CompoundList, String)>,
2951) -> Result<String, RustBashError> {
2952    match kind {
2953        ast::ProcessSubstitutionKind::Read => execute_read_process_substitution(list, state),
2954        ast::ProcessSubstitutionKind::Write => {
2955            let path = allocate_proc_sub_temp_file(state, b"")?;
2956            deferred_write_subs.push((list, path.clone()));
2957            Ok(path)
2958        }
2959    }
2960}
2961
2962fn redirect_target_filename(
2963    target: &ast::IoFileRedirectTarget,
2964    state: &mut InterpreterState,
2965) -> Result<String, RustBashError> {
2966    match target {
2967        ast::IoFileRedirectTarget::Filename(word) => {
2968            let filename = expand_word_to_string_mut(word, state)?;
2969            if filename.is_empty() {
2970                return Err(RustBashError::RedirectFailed(
2971                    ": No such file or directory".to_string(),
2972                ));
2973            }
2974            Ok(filename)
2975        }
2976        ast::IoFileRedirectTarget::Fd(fd) => Ok(fd.to_string()),
2977        ast::IoFileRedirectTarget::Duplicate(word) => expand_word_to_string_mut(word, state),
2978        // Only valid when called from within execute_simple_command's closure,
2979        // where proc_sub_prealloc has been populated in step 5b.
2980        ast::IoFileRedirectTarget::ProcessSubstitution(_, _) => {
2981            // Look up pre-allocated path by AST-node address (populated in execute_simple_command).
2982            let key = std::ptr::from_ref(target) as usize;
2983            state.proc_sub_prealloc.remove(&key).ok_or_else(|| {
2984                RustBashError::Execution(
2985                    "process substitution: no pre-allocated path available".into(),
2986                )
2987            })
2988        }
2989    }
2990}
2991
2992/// Execute a `<(cmd)` process substitution: run the inner command, capture stdout,
2993/// write to a temp VFS file, and return the temp file path.
2994fn execute_read_process_substitution(
2995    list: &ast::CompoundList,
2996    state: &mut InterpreterState,
2997) -> Result<String, RustBashError> {
2998    let mut sub_state = make_proc_sub_state(state);
2999    let result = execute_compound_list(list, &mut sub_state, "")?;
3000
3001    // Fold shared counters back
3002    state.counters.command_count = sub_state.counters.command_count;
3003    state.counters.output_size = sub_state.counters.output_size;
3004    state.proc_sub_counter = sub_state.proc_sub_counter;
3005
3006    allocate_proc_sub_temp_file(state, result.stdout.as_bytes())
3007}
3008
3009/// Allocate a unique temp VFS file with the given content and return its path.
3010fn allocate_proc_sub_temp_file(
3011    state: &mut InterpreterState,
3012    content: &[u8],
3013) -> Result<String, RustBashError> {
3014    let path = format!("/tmp/.proc_sub_{}", state.proc_sub_counter);
3015    state.proc_sub_counter += 1;
3016
3017    // Ensure /tmp exists
3018    let tmp = Path::new("/tmp");
3019    if !state.fs.exists(tmp) {
3020        state
3021            .fs
3022            .mkdir_p(tmp)
3023            .map_err(|e| RustBashError::Execution(e.to_string()))?;
3024    }
3025
3026    state
3027        .fs
3028        .write_file(Path::new(&path), content)
3029        .map_err(|e| RustBashError::Execution(e.to_string()))?;
3030
3031    Ok(path)
3032}
3033
3034/// Create a subshell `InterpreterState` that shares the parent's filesystem.
3035/// Unlike command substitution which deep-clones the fs, process substitution
3036/// needs the temp file to be visible to the outer command.
3037fn make_proc_sub_state(state: &mut InterpreterState) -> InterpreterState {
3038    InterpreterState {
3039        fs: Arc::clone(&state.fs),
3040        env: state.env.clone(),
3041        cwd: state.cwd.clone(),
3042        functions: state.functions.clone(),
3043        last_exit_code: state.last_exit_code,
3044        commands: clone_commands(&state.commands),
3045        shell_opts: state.shell_opts.clone(),
3046        shopt_opts: state.shopt_opts.clone(),
3047        limits: state.limits.clone(),
3048        counters: ExecutionCounters {
3049            command_count: state.counters.command_count,
3050            output_size: state.counters.output_size,
3051            start_time: state.counters.start_time,
3052            substitution_depth: state.counters.substitution_depth,
3053            call_depth: 0,
3054        },
3055        network_policy: state.network_policy.clone(),
3056        should_exit: false,
3057        loop_depth: 0,
3058        control_flow: None,
3059        positional_params: state.positional_params.clone(),
3060        shell_name: state.shell_name.clone(),
3061        random_seed: state.random_seed,
3062        local_scopes: Vec::new(),
3063        in_function_depth: 0,
3064        traps: HashMap::new(),
3065        in_trap: false,
3066        errexit_suppressed: 0,
3067        stdin_offset: 0,
3068        dir_stack: state.dir_stack.clone(),
3069        command_hash: state.command_hash.clone(),
3070        aliases: state.aliases.clone(),
3071        current_lineno: state.current_lineno,
3072        shell_start_time: state.shell_start_time,
3073        last_argument: state.last_argument.clone(),
3074        call_stack: state.call_stack.clone(),
3075        machtype: state.machtype.clone(),
3076        hosttype: state.hosttype.clone(),
3077        persistent_fds: HashMap::new(),
3078        next_auto_fd: 10,
3079        proc_sub_counter: state.proc_sub_counter,
3080        proc_sub_prealloc: HashMap::new(),
3081        pipe_stdin_bytes: None,
3082    }
3083}
3084
3085fn is_dev_null(path: &str) -> bool {
3086    path == "/dev/null"
3087}
3088
3089fn write_or_append(
3090    state: &InterpreterState,
3091    path: &str,
3092    content: &str,
3093    append: bool,
3094) -> Result<(), RustBashError> {
3095    write_or_append_bytes(state, path, content.as_bytes(), append)
3096}
3097
3098fn write_or_append_bytes(
3099    state: &InterpreterState,
3100    path: &str,
3101    content: &[u8],
3102    append: bool,
3103) -> Result<(), RustBashError> {
3104    let p = Path::new(path);
3105
3106    if append {
3107        if state.fs.exists(p) {
3108            state
3109                .fs
3110                .append_file(p, content)
3111                .map_err(|e| RustBashError::Execution(e.to_string()))?;
3112        } else {
3113            state
3114                .fs
3115                .write_file(p, content)
3116                .map_err(|e| RustBashError::Execution(e.to_string()))?;
3117        }
3118    } else {
3119        state
3120            .fs
3121            .write_file(p, content)
3122            .map_err(|e| RustBashError::Execution(e.to_string()))?;
3123    }
3124    Ok(())
3125}
3126
3127// ── Extended test ([[ ]]) ──────────────────────────────────────────
3128
3129fn execute_extended_test(
3130    expr: &ast::ExtendedTestExpr,
3131    state: &mut InterpreterState,
3132) -> Result<ExecResult, RustBashError> {
3133    let should_trace = state.shell_opts.xtrace;
3134    let mut exec_result = match eval_extended_test_expr(expr, state) {
3135        Ok(result) => ExecResult {
3136            exit_code: if result { 0 } else { 1 },
3137            ..ExecResult::default()
3138        },
3139        Err(RustBashError::Execution(ref msg)) => {
3140            let exit_code = if msg.contains("invalid regex") { 2 } else { 1 };
3141            state.last_exit_code = exit_code;
3142            ExecResult {
3143                stderr: format!("rust-bash: {msg}\n"),
3144                exit_code,
3145                ..ExecResult::default()
3146            }
3147        }
3148        Err(e) => return Err(e),
3149    };
3150    if should_trace {
3151        let repr = format_extended_test_expr_expanded(expr, state);
3152        let ps4 = expand_ps4(state);
3153        exec_result.stderr = format!("{ps4}[[ {repr} ]]\n{}", exec_result.stderr);
3154    }
3155    Ok(exec_result)
3156}
3157
3158/// Format an extended test expression for xtrace output, expanding variables.
3159fn format_extended_test_expr_expanded(
3160    expr: &ast::ExtendedTestExpr,
3161    state: &mut InterpreterState,
3162) -> String {
3163    match expr {
3164        ast::ExtendedTestExpr::And(l, r) => {
3165            format!(
3166                "{} && {}",
3167                format_extended_test_expr_expanded(l, state),
3168                format_extended_test_expr_expanded(r, state)
3169            )
3170        }
3171        ast::ExtendedTestExpr::Or(l, r) => {
3172            format!(
3173                "{} || {}",
3174                format_extended_test_expr_expanded(l, state),
3175                format_extended_test_expr_expanded(r, state)
3176            )
3177        }
3178        ast::ExtendedTestExpr::Not(inner) => {
3179            format!("! {}", format_extended_test_expr_expanded(inner, state))
3180        }
3181        ast::ExtendedTestExpr::Parenthesized(inner) => {
3182            format_extended_test_expr_expanded(inner, state)
3183        }
3184        ast::ExtendedTestExpr::UnaryTest(pred, word) => {
3185            let expanded = expand_word_to_string_mut(word, state).unwrap_or_default();
3186            format!("{} {}", format_unary_pred(pred), expanded)
3187        }
3188        ast::ExtendedTestExpr::BinaryTest(pred, l, r) => {
3189            let l_exp = expand_word_to_string_mut(l, state).unwrap_or_default();
3190            let r_exp = expand_word_to_string_mut(r, state).unwrap_or_default();
3191            format!("{} {} {}", l_exp, format_binary_pred(pred), r_exp)
3192        }
3193    }
3194}
3195
3196fn format_unary_pred(pred: &ast::UnaryPredicate) -> &'static str {
3197    use brush_parser::ast::UnaryPredicate;
3198    match pred {
3199        UnaryPredicate::FileExists => "-a",
3200        UnaryPredicate::FileExistsAndIsBlockSpecialFile => "-b",
3201        UnaryPredicate::FileExistsAndIsCharSpecialFile => "-c",
3202        UnaryPredicate::FileExistsAndIsDir => "-d",
3203        UnaryPredicate::FileExistsAndIsRegularFile => "-f",
3204        UnaryPredicate::FileExistsAndIsSetgid => "-g",
3205        UnaryPredicate::FileExistsAndIsSymlink => "-h",
3206        UnaryPredicate::FileExistsAndHasStickyBit => "-k",
3207        UnaryPredicate::FileExistsAndIsFifo => "-p",
3208        UnaryPredicate::FileExistsAndIsReadable => "-r",
3209        UnaryPredicate::FileExistsAndIsNotZeroLength => "-s",
3210        UnaryPredicate::FdIsOpenTerminal => "-t",
3211        UnaryPredicate::FileExistsAndIsSetuid => "-u",
3212        UnaryPredicate::FileExistsAndIsWritable => "-w",
3213        UnaryPredicate::FileExistsAndIsExecutable => "-x",
3214        UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => "-G",
3215        UnaryPredicate::FileExistsAndModifiedSinceLastRead => "-N",
3216        UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => "-O",
3217        UnaryPredicate::FileExistsAndIsSocket => "-S",
3218        UnaryPredicate::StringHasZeroLength => "-z",
3219        UnaryPredicate::StringHasNonZeroLength => "-n",
3220        UnaryPredicate::ShellOptionEnabled => "-o",
3221        UnaryPredicate::ShellVariableIsSetAndAssigned => "-v",
3222        UnaryPredicate::ShellVariableIsSetAndNameRef => "-R",
3223    }
3224}
3225
3226fn format_binary_pred(pred: &ast::BinaryPredicate) -> &'static str {
3227    use brush_parser::ast::BinaryPredicate;
3228    match pred {
3229        BinaryPredicate::StringExactlyMatchesPattern => "==",
3230        BinaryPredicate::StringDoesNotExactlyMatchPattern => "!=",
3231        BinaryPredicate::StringExactlyMatchesString => "==",
3232        BinaryPredicate::StringDoesNotExactlyMatchString => "!=",
3233        BinaryPredicate::StringMatchesRegex => "=~",
3234        BinaryPredicate::StringContainsSubstring => "=~",
3235        BinaryPredicate::ArithmeticEqualTo => "-eq",
3236        BinaryPredicate::ArithmeticNotEqualTo => "-ne",
3237        BinaryPredicate::ArithmeticLessThan => "-lt",
3238        BinaryPredicate::ArithmeticGreaterThan => "-gt",
3239        BinaryPredicate::ArithmeticLessThanOrEqualTo => "-le",
3240        BinaryPredicate::ArithmeticGreaterThanOrEqualTo => "-ge",
3241        BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => "-ef",
3242        BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => "-nt",
3243        BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => "-ot",
3244        _ => "?",
3245    }
3246}
3247
3248fn eval_extended_test_expr(
3249    expr: &ast::ExtendedTestExpr,
3250    state: &mut InterpreterState,
3251) -> Result<bool, RustBashError> {
3252    match expr {
3253        ast::ExtendedTestExpr::And(left, right) => {
3254            let l = eval_extended_test_expr(left, state)?;
3255            if !l {
3256                return Ok(false);
3257            }
3258            eval_extended_test_expr(right, state)
3259        }
3260        ast::ExtendedTestExpr::Or(left, right) => {
3261            let l = eval_extended_test_expr(left, state)?;
3262            if l {
3263                return Ok(true);
3264            }
3265            eval_extended_test_expr(right, state)
3266        }
3267        ast::ExtendedTestExpr::Not(inner) => {
3268            let val = eval_extended_test_expr(inner, state)?;
3269            Ok(!val)
3270        }
3271        ast::ExtendedTestExpr::Parenthesized(inner) => eval_extended_test_expr(inner, state),
3272        ast::ExtendedTestExpr::UnaryTest(pred, word) => {
3273            use brush_parser::ast::UnaryPredicate;
3274            // Handle -v specially: we need access to full interpreter state for array elements
3275            if matches!(pred, UnaryPredicate::ShellVariableIsSetAndAssigned) {
3276                let operand = expand_word_to_string_mut(word, state)?;
3277                return Ok(test_variable_is_set(&operand, state));
3278            }
3279            let operand = expand_word_to_string_mut(word, state)?;
3280            let env: HashMap<String, String> = state
3281                .env
3282                .iter()
3283                .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
3284                .collect();
3285            Ok(crate::commands::test_cmd::eval_unary_predicate(
3286                pred,
3287                &operand,
3288                &*state.fs,
3289                &state.cwd,
3290                &env,
3291                Some(&state.shell_opts),
3292            ))
3293        }
3294        ast::ExtendedTestExpr::BinaryTest(pred, left_word, right_word) => {
3295            let left = expand_word_to_string_mut(left_word, state)?;
3296
3297            // Regex matching needs special handling
3298            if matches!(
3299                pred,
3300                ast::BinaryPredicate::StringMatchesRegex
3301                    | ast::BinaryPredicate::StringContainsSubstring
3302            ) {
3303                // For =~, if the pattern is entirely quoted, treat as literal string match.
3304                // In bash, [[ str =~ 'pat' ]] uses literal matching, not regex.
3305                let raw = &right_word.value;
3306                let is_fully_quoted = is_word_fully_quoted(raw);
3307                let pattern = expand_word_to_string_mut(right_word, state)?;
3308                if is_fully_quoted {
3309                    return Ok(left.contains(&pattern));
3310                }
3311                // Check for partial quoting — extract quoted portions and escape them
3312                let effective_pattern = build_regex_with_quoted_literals(raw, state)?;
3313                return eval_regex_match(&left, &effective_pattern, state);
3314            }
3315
3316            let right = expand_word_to_string_mut(right_word, state)?;
3317
3318            // Arithmetic predicates (-eq, -ne, -lt, -gt, -le, -ge) evaluate
3319            // operands as arithmetic expressions in [[ ]] context.
3320            // Try parse_bash_int first (handles octal, hex, base-N), then
3321            // fall back to simple_arith_eval for expressions like "1+2".
3322            use brush_parser::ast::BinaryPredicate;
3323            if matches!(
3324                pred,
3325                BinaryPredicate::ArithmeticEqualTo
3326                    | BinaryPredicate::ArithmeticNotEqualTo
3327                    | BinaryPredicate::ArithmeticLessThan
3328                    | BinaryPredicate::ArithmeticGreaterThan
3329                    | BinaryPredicate::ArithmeticLessThanOrEqualTo
3330                    | BinaryPredicate::ArithmeticGreaterThanOrEqualTo
3331            ) {
3332                let lval =
3333                    crate::commands::test_cmd::parse_bash_int_pub(&left).unwrap_or_else(|| {
3334                        crate::interpreter::arithmetic::eval_arithmetic(&left, state).unwrap_or(0)
3335                    });
3336                let rval =
3337                    crate::commands::test_cmd::parse_bash_int_pub(&right).unwrap_or_else(|| {
3338                        crate::interpreter::arithmetic::eval_arithmetic(&right, state).unwrap_or(0)
3339                    });
3340                let result = match pred {
3341                    BinaryPredicate::ArithmeticEqualTo => lval == rval,
3342                    BinaryPredicate::ArithmeticNotEqualTo => lval != rval,
3343                    BinaryPredicate::ArithmeticLessThan => lval < rval,
3344                    BinaryPredicate::ArithmeticGreaterThan => lval > rval,
3345                    BinaryPredicate::ArithmeticLessThanOrEqualTo => lval <= rval,
3346                    BinaryPredicate::ArithmeticGreaterThanOrEqualTo => lval >= rval,
3347                    _ => unreachable!(),
3348                };
3349                return Ok(result);
3350            }
3351
3352            // Pattern matching (glob) for == and != inside [[
3353            // Extglob is always active in [[ ]] pattern context
3354            if state.shopt_opts.nocasematch {
3355                // Case-insensitive pattern matching
3356                let result = match pred {
3357                    ast::BinaryPredicate::StringExactlyMatchesPattern => {
3358                        crate::interpreter::pattern::extglob_match_nocase(&right, &left)
3359                    }
3360                    ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
3361                        !crate::interpreter::pattern::extglob_match_nocase(&right, &left)
3362                    }
3363                    ast::BinaryPredicate::StringExactlyMatchesString => {
3364                        left.eq_ignore_ascii_case(&right)
3365                    }
3366                    ast::BinaryPredicate::StringDoesNotExactlyMatchString => {
3367                        !left.eq_ignore_ascii_case(&right)
3368                    }
3369                    _ => crate::commands::test_cmd::eval_binary_predicate(
3370                        pred, &left, &right, true, &*state.fs, &state.cwd,
3371                    ),
3372                };
3373                Ok(result)
3374            } else {
3375                // Use extglob-aware matching for pattern predicates
3376                let result = match pred {
3377                    ast::BinaryPredicate::StringExactlyMatchesPattern => {
3378                        crate::interpreter::pattern::extglob_match(&right, &left)
3379                    }
3380                    ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
3381                        !crate::interpreter::pattern::extglob_match(&right, &left)
3382                    }
3383                    _ => crate::commands::test_cmd::eval_binary_predicate(
3384                        pred, &left, &right, true, &*state.fs, &state.cwd,
3385                    ),
3386                };
3387                Ok(result)
3388            }
3389        }
3390    }
3391}
3392
3393/// Check if a variable (possibly an array element) is set.
3394/// Handles `a[i]` syntax for array element checks.
3395fn test_variable_is_set(operand: &str, state: &mut InterpreterState) -> bool {
3396    // Check for array subscript syntax: name[index]
3397    if let Some(bracket_pos) = operand.find('[')
3398        && operand.ends_with(']')
3399    {
3400        let name = &operand[..bracket_pos];
3401        let index = &operand[bracket_pos + 1..operand.len() - 1];
3402        let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
3403
3404        if index == "@" || index == "*" {
3405            return state
3406                .env
3407                .get(&resolved)
3408                .is_some_and(|var| match &var.value {
3409                    VariableValue::IndexedArray(map) => !map.is_empty(),
3410                    VariableValue::AssociativeArray(map) => !map.is_empty(),
3411                    _ => false,
3412                });
3413        }
3414
3415        // Determine variable type before evaluating arithmetic.
3416        let var_type = state.env.get(&resolved).map(|var| match &var.value {
3417            VariableValue::IndexedArray(_) => 0,
3418            VariableValue::AssociativeArray(_) => 1,
3419            VariableValue::Scalar(_) => 2,
3420        });
3421
3422        return match var_type {
3423            Some(0) => {
3424                // Indexed array: evaluate index as arithmetic.
3425                let idx = eval_index_arithmetic(index, state);
3426                let Some(var) = state.env.get(&resolved) else {
3427                    return false;
3428                };
3429                if let VariableValue::IndexedArray(map) = &var.value {
3430                    let actual_idx = if idx < 0 {
3431                        let max_key = map.keys().next_back().copied().unwrap_or(0);
3432                        let resolved_idx = max_key as i64 + 1 + idx;
3433                        if resolved_idx < 0 {
3434                            return false;
3435                        }
3436                        resolved_idx as usize
3437                    } else {
3438                        idx as usize
3439                    };
3440                    map.contains_key(&actual_idx)
3441                } else {
3442                    false
3443                }
3444            }
3445            Some(1) => {
3446                // Associative array: use index as string key.
3447                state
3448                    .env
3449                    .get(&resolved)
3450                    .and_then(|var| {
3451                        if let VariableValue::AssociativeArray(map) = &var.value {
3452                            Some(map.contains_key(index))
3453                        } else {
3454                            None
3455                        }
3456                    })
3457                    .unwrap_or(false)
3458            }
3459            Some(2) => {
3460                // Scalar: index 0 or -1 means it's set.
3461                let idx = eval_index_arithmetic(index, state);
3462                idx == 0 || idx == -1
3463            }
3464            _ => false,
3465        };
3466    }
3467    // Plain variable name
3468    let resolved = crate::interpreter::resolve_nameref_or_self(operand, state);
3469    state.env.contains_key(&resolved)
3470}
3471
3472/// Evaluate an array index expression using full arithmetic evaluation.
3473/// Falls back to simple_arith_eval on errors.
3474fn eval_index_arithmetic(index: &str, state: &mut InterpreterState) -> i64 {
3475    crate::interpreter::arithmetic::eval_arithmetic(index, state)
3476        .unwrap_or_else(|_| crate::interpreter::expansion::simple_arith_eval(index, state))
3477}
3478
3479fn eval_regex_match(
3480    string: &str,
3481    pattern: &str,
3482    state: &mut InterpreterState,
3483) -> Result<bool, RustBashError> {
3484    // When nocasematch is on, prepend (?i) to make the regex case-insensitive
3485    let effective_pattern = if state.shopt_opts.nocasematch {
3486        format!("(?i){pattern}")
3487    } else {
3488        pattern.to_string()
3489    };
3490    let re = regex::Regex::new(&effective_pattern)
3491        .map_err(|e| RustBashError::Execution(format!("invalid regex '{pattern}': {e}")))?;
3492
3493    if let Some(captures) = re.captures(string) {
3494        // Store BASH_REMATCH as a proper indexed array:
3495        // index 0 = whole match, index 1..N = capture groups
3496        let mut map = std::collections::BTreeMap::new();
3497        let whole = captures.get(0).map(|m| m.as_str()).unwrap_or("");
3498        map.insert(0, whole.to_string());
3499        for i in 1..captures.len() {
3500            let val = captures.get(i).map(|m| m.as_str()).unwrap_or("");
3501            map.insert(i, val.to_string());
3502        }
3503        state.env.insert(
3504            "BASH_REMATCH".to_string(),
3505            Variable {
3506                value: VariableValue::IndexedArray(map),
3507                attrs: VariableAttrs::empty(),
3508            },
3509        );
3510        Ok(true)
3511    } else {
3512        // Clear BASH_REMATCH on non-match
3513        state.env.insert(
3514            "BASH_REMATCH".to_string(),
3515            Variable {
3516                value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
3517                attrs: VariableAttrs::empty(),
3518            },
3519        );
3520        Ok(false)
3521    }
3522}
3523
3524/// Check if a raw word value is entirely wrapped in quotes.
3525fn is_word_fully_quoted(raw: &str) -> bool {
3526    let trimmed = raw.trim();
3527    if trimmed.len() < 2 {
3528        return false;
3529    }
3530    // Single quotes: 'content'
3531    if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
3532        return true;
3533    }
3534    // Double quotes: "content"
3535    if trimmed.starts_with('"') && trimmed.ends_with('"') {
3536        return true;
3537    }
3538    // $'content' or $"content"
3539    if (trimmed.starts_with("$'") && trimmed.ends_with('\''))
3540        || (trimmed.starts_with("$\"") && trimmed.ends_with('"'))
3541    {
3542        return true;
3543    }
3544    false
3545}
3546
3547/// Build a regex pattern from a raw word, escaping quoted portions as literals.
3548/// In bash, quoted parts of a regex pattern are treated as literal text.
3549fn build_regex_with_quoted_literals(
3550    raw: &str,
3551    state: &mut InterpreterState,
3552) -> Result<String, RustBashError> {
3553    let mut result = String::new();
3554    let chars: Vec<char> = raw.chars().collect();
3555    let mut i = 0;
3556    while i < chars.len() {
3557        match chars[i] {
3558            '\'' => {
3559                // Single-quoted: everything until next ' is literal
3560                i += 1;
3561                let mut literal = String::new();
3562                while i < chars.len() && chars[i] != '\'' {
3563                    literal.push(chars[i]);
3564                    i += 1;
3565                }
3566                if i < chars.len() {
3567                    i += 1; // skip closing '
3568                }
3569                result.push_str(&regex::escape(&literal));
3570            }
3571            '"' => {
3572                // Double-quoted: until matching " (expand variables inside)
3573                i += 1;
3574                let mut content = String::new();
3575                while i < chars.len() && chars[i] != '"' {
3576                    if chars[i] == '\\' && i + 1 < chars.len() {
3577                        content.push(chars[i + 1]);
3578                        i += 2;
3579                    } else {
3580                        content.push(chars[i]);
3581                        i += 1;
3582                    }
3583                }
3584                if i < chars.len() {
3585                    i += 1; // skip closing "
3586                }
3587                // Expand variables in the content
3588                let word = ast::Word {
3589                    value: content,
3590                    loc: None,
3591                };
3592                let expanded = expand_word_to_string_mut(&word, state)?;
3593                result.push_str(&regex::escape(&expanded));
3594            }
3595            '\\' if i + 1 < chars.len() => {
3596                // Escaped character: treat as literal
3597                result.push_str(&regex::escape(&chars[i + 1].to_string()));
3598                i += 2;
3599            }
3600            '$' => {
3601                // Variable expansion — expand and keep as regex
3602                let mut var_text = String::new();
3603                var_text.push('$');
3604                i += 1;
3605                if i < chars.len() && chars[i] == '{' {
3606                    // ${...}
3607                    var_text.push('{');
3608                    i += 1;
3609                    let mut depth = 1;
3610                    while i < chars.len() && depth > 0 {
3611                        if chars[i] == '{' {
3612                            depth += 1;
3613                        } else if chars[i] == '}' {
3614                            depth -= 1;
3615                        }
3616                        var_text.push(chars[i]);
3617                        i += 1;
3618                    }
3619                } else {
3620                    // $name
3621                    while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
3622                        var_text.push(chars[i]);
3623                        i += 1;
3624                    }
3625                }
3626                let word = ast::Word {
3627                    value: var_text,
3628                    loc: None,
3629                };
3630                let expanded = expand_word_to_string_mut(&word, state)?;
3631                result.push_str(&expanded);
3632            }
3633            c => {
3634                result.push(c);
3635                i += 1;
3636            }
3637        }
3638    }
3639    Ok(result)
3640}