Skip to main content

wasmsh_runtime/
lib.rs

1//! Shared shell runtime core for wasmsh.
2//!
3//! Platform-agnostic execution engine: parse → HIR → expand → execute.
4//! Used by `wasmsh-browser` (standalone WASM) and future embedding crates.
5
6use indexmap::IndexMap;
7
8use wasmsh_ast::CaseTerminator;
9use wasmsh_ast::RedirectionOp;
10use wasmsh_expand::expand_words;
11use wasmsh_fs::{BackendFs, OpenOptions, Vfs};
12use wasmsh_hir::{
13    HirAndOr, HirAndOrOp, HirCommand, HirCompleteCommand, HirPipeline, HirRedirection,
14};
15use wasmsh_protocol::{DiagnosticLevel, HostCommand, WorkerEvent, PROTOCOL_VERSION};
16use wasmsh_state::ShellState;
17use wasmsh_utils::{UtilContext, UtilRegistry, VecOutput as UtilOutput};
18use wasmsh_vm::Vm;
19
20/// Sentinel FD value for `&>` (redirect both stdout and stderr).
21const FD_BOTH: u32 = u32::MAX;
22
23// Runtime-level command names dispatched before builtins.
24const CMD_LOCAL: &str = "local";
25const CMD_BREAK: &str = "break";
26const CMD_CONTINUE: &str = "continue";
27const CMD_EXIT: &str = "exit";
28const CMD_EVAL: &str = "eval";
29const CMD_SOURCE: &str = "source";
30const CMD_DOT: &str = ".";
31const CMD_DECLARE: &str = "declare";
32const CMD_TYPESET: &str = "typeset";
33const CMD_LET: &str = "let";
34const CMD_SHOPT: &str = "shopt";
35const CMD_ALIAS: &str = "alias";
36const CMD_UNALIAS: &str = "unalias";
37const CMD_BUILTIN: &str = "builtin";
38const CMD_MAPFILE: &str = "mapfile";
39const CMD_READARRAY: &str = "readarray";
40const CMD_TYPE: &str = "type";
41
42/// Configuration for the browser runtime.
43#[derive(Debug, Clone)]
44pub struct BrowserConfig {
45    pub step_budget: u64,
46}
47
48impl Default for BrowserConfig {
49    fn default() -> Self {
50        Self {
51            step_budget: 100_000,
52        }
53    }
54}
55
56/// Maximum recursion depth for eval, source, and command substitution.
57const MAX_RECURSION_DEPTH: u32 = 100;
58
59/// Transient execution state, reset between top-level commands.
60struct ExecState {
61    break_depth: u32,
62    loop_continue: bool,
63    exit_requested: Option<i32>,
64    errexit_suppressed: bool,
65    local_save_stack: Vec<(smol_str::SmolStr, Option<smol_str::SmolStr>)>,
66    recursion_depth: u32,
67    /// Set when a resource limit (step budget, output limit, cancel) is hit.
68    resource_exhausted: bool,
69}
70
71impl ExecState {
72    fn new() -> Self {
73        Self {
74            break_depth: 0,
75            loop_continue: false,
76            exit_requested: None,
77            errexit_suppressed: false,
78            local_save_stack: Vec::new(),
79            recursion_depth: 0,
80            resource_exhausted: false,
81        }
82    }
83
84    fn reset(&mut self) {
85        self.break_depth = 0;
86        self.loop_continue = false;
87        self.exit_requested = None;
88        self.errexit_suppressed = false;
89        self.resource_exhausted = false;
90    }
91}
92
93/// Result from an external command handler.
94#[derive(Debug)]
95pub struct ExternalCommandResult {
96    /// Data written to stdout.
97    pub stdout: Vec<u8>,
98    /// Data written to stderr.
99    pub stderr: Vec<u8>,
100    /// Exit code (0 = success).
101    pub status: i32,
102}
103
104/// Callback type for external (host-provided) commands.
105///
106/// Called with `(command_name, argv, stdin)`. Returns `Some(result)` if
107/// the command was handled, `None` to fall through to "command not found".
108pub type ExternalCommandHandler =
109    Box<dyn FnMut(&str, &[String], Option<&[u8]>) -> Option<ExternalCommandResult>>;
110
111/// The worker-side runtime that processes host commands.
112#[allow(missing_debug_implementations)]
113pub struct WorkerRuntime {
114    config: BrowserConfig,
115    vm: Vm,
116    fs: BackendFs,
117    utils: UtilRegistry,
118    builtins: wasmsh_builtins::BuiltinRegistry,
119    initialized: bool,
120    /// Pending stdin data for the next command (from here-doc or pipe).
121    pending_stdin: Option<Vec<u8>>,
122    /// Registered shell functions (name → HIR body).
123    functions: IndexMap<String, HirCommand>,
124    /// Transient execution state (loop control, exit, locals).
125    exec: ExecState,
126    /// Shell aliases (name → replacement text).
127    aliases: IndexMap<String, String>,
128    /// Optional handler for external commands (e.g. python3 in Pyodide).
129    external_handler: Option<ExternalCommandHandler>,
130}
131
132/// Action to take for a character during array element parsing.
133enum ArrayCharAction {
134    Append(char),
135    Skip,
136    SplitField,
137}
138
139/// Quoting state for parsing array elements.
140#[derive(Default)]
141struct ArrayParseState {
142    in_single_quote: bool,
143    in_double_quote: bool,
144    escape_next: bool,
145}
146
147impl ArrayParseState {
148    fn process_char(&mut self, ch: char) -> ArrayCharAction {
149        if self.escape_next {
150            self.escape_next = false;
151            return ArrayCharAction::Append(ch);
152        }
153        if ch == '\\' && !self.in_single_quote {
154            self.escape_next = true;
155            return ArrayCharAction::Skip;
156        }
157        if ch == '\'' && !self.in_double_quote {
158            self.in_single_quote = !self.in_single_quote;
159            return ArrayCharAction::Skip;
160        }
161        if ch == '"' && !self.in_single_quote {
162            self.in_double_quote = !self.in_double_quote;
163            return ArrayCharAction::Skip;
164        }
165        if ch.is_ascii_whitespace() && !self.in_single_quote && !self.in_double_quote {
166            return ArrayCharAction::SplitField;
167        }
168        ArrayCharAction::Append(ch)
169    }
170}
171
172/// Parsed flags for `declare`/`typeset`.
173#[allow(clippy::struct_excessive_bools)]
174struct DeclareFlags {
175    is_assoc: bool,
176    is_indexed: bool,
177    is_integer: bool,
178    is_export: bool,
179    is_readonly: bool,
180    is_lower: bool,
181    is_upper: bool,
182    is_print: bool,
183    is_nameref: bool,
184}
185
186/// Parse declare/typeset flags from argv, returning (flags, `name_indices`).
187fn parse_declare_flags(argv: &[String]) -> (DeclareFlags, Vec<usize>) {
188    let mut flags = DeclareFlags {
189        is_assoc: false,
190        is_indexed: false,
191        is_integer: false,
192        is_export: false,
193        is_readonly: false,
194        is_lower: false,
195        is_upper: false,
196        is_print: false,
197        is_nameref: false,
198    };
199    let mut names = Vec::new();
200
201    for (i, arg) in argv[1..].iter().enumerate() {
202        if arg.starts_with('-') && arg.len() > 1 {
203            for ch in arg[1..].chars() {
204                match ch {
205                    'A' => flags.is_assoc = true,
206                    'a' => flags.is_indexed = true,
207                    'i' => flags.is_integer = true,
208                    'x' => flags.is_export = true,
209                    'r' => flags.is_readonly = true,
210                    'l' => flags.is_lower = true,
211                    'u' => flags.is_upper = true,
212                    'p' => flags.is_print = true,
213                    'n' => flags.is_nameref = true,
214                    _ => {}
215                }
216            }
217        } else {
218            names.push(i + 1);
219        }
220    }
221    (flags, names)
222}
223
224impl WorkerRuntime {
225    #[must_use]
226    pub fn new() -> Self {
227        Self {
228            config: BrowserConfig::default(),
229            vm: Vm::new(ShellState::new(), 0),
230            fs: BackendFs::new(),
231            utils: UtilRegistry::new(),
232            builtins: wasmsh_builtins::BuiltinRegistry::new(),
233            initialized: false,
234            pending_stdin: None,
235            functions: IndexMap::new(),
236            exec: ExecState::new(),
237            aliases: IndexMap::new(),
238            external_handler: None,
239        }
240    }
241
242    /// Register a handler for external commands (e.g. `python3` in Pyodide).
243    pub fn set_external_handler(&mut self, handler: ExternalCommandHandler) {
244        self.external_handler = Some(handler);
245    }
246
247    /// Process a host command and return a list of events to send back.
248    pub fn handle_command(&mut self, cmd: HostCommand) -> Vec<WorkerEvent> {
249        match cmd {
250            HostCommand::Init { step_budget } => {
251                self.config.step_budget = step_budget;
252                self.vm = Vm::new(ShellState::new(), step_budget);
253                self.fs = BackendFs::new();
254                self.pending_stdin = None;
255                self.functions = IndexMap::new();
256                self.exec.reset();
257                self.aliases = IndexMap::new();
258                self.initialized = true;
259                // Set default shopt options (bash defaults)
260                self.vm.state.set_var("SHOPT_extglob".into(), "1".into());
261                vec![WorkerEvent::Version(PROTOCOL_VERSION.to_string())]
262            }
263            HostCommand::Run { input } => {
264                if !self.initialized {
265                    return vec![WorkerEvent::Diagnostic(
266                        DiagnosticLevel::Error,
267                        "runtime not initialized".into(),
268                    )];
269                }
270                self.execute_input(&input)
271            }
272            HostCommand::Cancel => {
273                self.vm.cancellation_token().cancel();
274                vec![WorkerEvent::Diagnostic(
275                    DiagnosticLevel::Info,
276                    "cancel received".into(),
277                )]
278            }
279            HostCommand::ReadFile { path } => {
280                use wasmsh_fs::OpenOptions;
281                match self.fs.open(&path, OpenOptions::read()) {
282                    Ok(h) => match self.fs.read_file(h) {
283                        Ok(data) => {
284                            self.fs.close(h);
285                            vec![WorkerEvent::Stdout(data)]
286                        }
287                        Err(e) => {
288                            self.fs.close(h);
289                            vec![WorkerEvent::Diagnostic(
290                                DiagnosticLevel::Error,
291                                format!("read error: {path}: {e}"),
292                            )]
293                        }
294                    },
295                    Err(e) => vec![WorkerEvent::Diagnostic(
296                        DiagnosticLevel::Error,
297                        format!("read error: {e}"),
298                    )],
299                }
300            }
301            HostCommand::WriteFile { path, data } => {
302                use wasmsh_fs::OpenOptions;
303                match self.fs.open(&path, OpenOptions::write()) {
304                    Ok(h) => {
305                        if let Err(e) = self.fs.write_file(h, &data) {
306                            self.vm.stderr.extend_from_slice(
307                                format!("wasmsh: write error: {e}\n").as_bytes(),
308                            );
309                        }
310                        self.fs.close(h);
311                        vec![WorkerEvent::FsChanged(path)]
312                    }
313                    Err(e) => vec![WorkerEvent::Diagnostic(
314                        DiagnosticLevel::Error,
315                        format!("write error: {e}"),
316                    )],
317                }
318            }
319            HostCommand::ListDir { path } => match self.fs.read_dir(&path) {
320                Ok(entries) => {
321                    let names: Vec<u8> = entries
322                        .iter()
323                        .map(|e| e.name.as_str())
324                        .collect::<Vec<_>>()
325                        .join("\n")
326                        .into_bytes();
327                    vec![WorkerEvent::Stdout(names)]
328                }
329                Err(e) => vec![WorkerEvent::Diagnostic(
330                    DiagnosticLevel::Error,
331                    format!("readdir error: {e}"),
332                )],
333            },
334            HostCommand::Mount { .. } => {
335                vec![WorkerEvent::Diagnostic(
336                    DiagnosticLevel::Warning,
337                    "mount not yet implemented".into(),
338                )]
339            }
340            _ => {
341                vec![WorkerEvent::Diagnostic(
342                    DiagnosticLevel::Warning,
343                    "unknown command".into(),
344                )]
345            }
346        }
347    }
348
349    /// Execute input and return collected events (used by eval/source).
350    fn execute_input_inner(&mut self, input: &str) -> Vec<WorkerEvent> {
351        self.exec.recursion_depth += 1;
352        if self.exec.recursion_depth > MAX_RECURSION_DEPTH {
353            self.exec.recursion_depth -= 1;
354            return vec![WorkerEvent::Stderr(
355                b"wasmsh: maximum recursion depth exceeded\n".to_vec(),
356            )];
357        }
358        let result = self.execute_input_inner_impl(input);
359        self.exec.recursion_depth -= 1;
360        result
361    }
362
363    /// Inner implementation of `execute_input_inner` (after recursion check).
364    fn execute_input_inner_impl(&mut self, input: &str) -> Vec<WorkerEvent> {
365        let ast = match wasmsh_parse::parse(input) {
366            Ok(ast) => ast,
367            Err(e) => {
368                self.vm.state.last_status = 2;
369                return vec![WorkerEvent::Stderr(
370                    format!("wasmsh: parse error: {e}\n").into_bytes(),
371                )];
372            }
373        };
374        let hir = wasmsh_hir::lower(&ast);
375        for cc in &hir.items {
376            if self.exec.exit_requested.is_some() {
377                break;
378            }
379            // Update $LINENO from span position
380            let line = input
381                .as_bytes()
382                .iter()
383                .take(cc.span.start as usize)
384                .filter(|&&b| b == b'\n')
385                .count() as u32
386                + 1;
387            self.vm.state.lineno = line;
388            for and_or in &cc.list {
389                self.execute_pipeline_chain(and_or);
390                if self.exec.exit_requested.is_some() {
391                    break;
392                }
393                if self.should_errexit(and_or) {
394                    self.exec.exit_requested = Some(self.vm.state.last_status);
395                    break;
396                }
397            }
398        }
399        // Drain stdout/stderr into events
400        let mut events = Vec::new();
401        if !self.vm.stdout.is_empty() {
402            events.push(WorkerEvent::Stdout(std::mem::take(&mut self.vm.stdout)));
403        }
404        if !self.vm.stderr.is_empty() {
405            events.push(WorkerEvent::Stderr(std::mem::take(&mut self.vm.stderr)));
406        }
407        events
408    }
409
410    fn execute_input(&mut self, input: &str) -> Vec<WorkerEvent> {
411        let mut events = self.execute_input_inner(input);
412        self.run_exit_trap_if_needed(&mut events);
413        self.drain_io_events(&mut events);
414        self.drain_diagnostic_events(&mut events);
415        self.push_output_limit_warning(&mut events);
416
417        let exit_status = if self.exec.resource_exhausted {
418            // Resource exhaustion (step budget, output limit, cancellation)
419            // must produce a non-zero exit code.
420            128
421        } else {
422            self.exec
423                .exit_requested
424                .unwrap_or(self.vm.state.last_status)
425        };
426        events.push(WorkerEvent::Exit(exit_status));
427        events
428    }
429
430    fn run_exit_trap_if_needed(&mut self, events: &mut Vec<WorkerEvent>) {
431        let Some(exit_code) = self.exec.exit_requested else {
432            return;
433        };
434        let Some(handler_str) = self.take_exit_trap_handler() else {
435            return;
436        };
437        self.exec.exit_requested = None;
438        events.extend(self.execute_input_inner(&handler_str));
439        self.exec.exit_requested = Some(exit_code);
440    }
441
442    fn take_exit_trap_handler(&mut self) -> Option<String> {
443        let handler = self.vm.state.get_var("_TRAP_EXIT")?;
444        if handler.is_empty() {
445            return None;
446        }
447        let handler_str = handler.to_string();
448        self.vm.state.set_var(
449            smol_str::SmolStr::from("_TRAP_EXIT"),
450            smol_str::SmolStr::default(),
451        );
452        Some(handler_str)
453    }
454
455    fn drain_io_events(&mut self, events: &mut Vec<WorkerEvent>) {
456        self.push_buffer_event(events, true);
457        self.push_buffer_event(events, false);
458    }
459
460    fn push_buffer_event(&mut self, events: &mut Vec<WorkerEvent>, stdout: bool) {
461        let buffer = if stdout {
462            &mut self.vm.stdout
463        } else {
464            &mut self.vm.stderr
465        };
466        if buffer.is_empty() {
467            return;
468        }
469
470        let data = std::mem::take(buffer);
471        events.push(if stdout {
472            WorkerEvent::Stdout(data)
473        } else {
474            WorkerEvent::Stderr(data)
475        });
476    }
477
478    fn drain_diagnostic_events(&mut self, events: &mut Vec<WorkerEvent>) {
479        for diag in self.vm.diagnostics.drain(..) {
480            events.push(WorkerEvent::Diagnostic(
481                Self::to_protocol_diag_level(diag.level),
482                diag.message,
483            ));
484        }
485    }
486
487    fn to_protocol_diag_level(level: wasmsh_vm::DiagLevel) -> DiagnosticLevel {
488        match level {
489            wasmsh_vm::DiagLevel::Trace => DiagnosticLevel::Trace,
490            wasmsh_vm::DiagLevel::Info => DiagnosticLevel::Info,
491            wasmsh_vm::DiagLevel::Warning => DiagnosticLevel::Warning,
492            wasmsh_vm::DiagLevel::Error => DiagnosticLevel::Error,
493        }
494    }
495
496    fn push_output_limit_warning(&self, events: &mut Vec<WorkerEvent>) {
497        if self.vm.limits.output_byte_limit == 0
498            || self.vm.output_bytes <= self.vm.limits.output_byte_limit
499        {
500            return;
501        }
502
503        events.push(WorkerEvent::Diagnostic(
504            DiagnosticLevel::Error,
505            format!(
506                "output limit exceeded: {} bytes (limit: {}); execution aborted",
507                self.vm.output_bytes, self.vm.limits.output_byte_limit
508            ),
509        ));
510    }
511
512    fn execute_pipeline_chain(&mut self, and_or: &HirAndOr) {
513        self.execute_pipeline(&and_or.first);
514        for (op, pipeline) in &and_or.rest {
515            match op {
516                HirAndOrOp::And => {
517                    if self.vm.state.last_status == 0 {
518                        self.execute_pipeline(pipeline);
519                    }
520                }
521                HirAndOrOp::Or => {
522                    if self.vm.state.last_status != 0 {
523                        self.execute_pipeline(pipeline);
524                    }
525                }
526            }
527        }
528    }
529
530    fn execute_pipeline(&mut self, pipeline: &HirPipeline) {
531        let cmds = &pipeline.commands;
532        if cmds.len() == 1 {
533            self.execute_single_pipeline(&cmds[0]);
534        } else {
535            self.execute_multi_pipeline(cmds, pipeline);
536        }
537        if pipeline.negated {
538            self.vm.state.last_status = i32::from(self.vm.state.last_status == 0);
539        }
540    }
541
542    fn execute_single_pipeline(&mut self, cmd: &HirCommand) {
543        self.execute_command(cmd);
544        self.set_pipestatus(&[self.vm.state.last_status]);
545    }
546
547    fn execute_multi_pipeline(&mut self, cmds: &[HirCommand], pipeline: &HirPipeline) {
548        let pipefail = self.vm.state.get_var("SHOPT_o_pipefail").as_deref() == Some("1");
549        let mut rightmost_failure: i32 = 0;
550        let mut statuses: Vec<i32> = Vec::new();
551
552        for (i, cmd) in cmds.iter().enumerate() {
553            let is_last = i == cmds.len() - 1;
554            let stdout_before = self.vm.stdout.len();
555            let stderr_before = self.vm.stderr.len();
556
557            self.execute_command(cmd);
558            statuses.push(self.vm.state.last_status);
559
560            if pipefail && self.vm.state.last_status != 0 {
561                rightmost_failure = self.vm.state.last_status;
562            }
563
564            if !is_last {
565                self.pipe_stage_output(
566                    stdout_before,
567                    stderr_before,
568                    pipeline.pipe_stderr.get(i).copied().unwrap_or(false),
569                );
570            }
571        }
572
573        self.set_pipestatus(&statuses);
574        if pipefail && rightmost_failure != 0 {
575            self.vm.state.last_status = rightmost_failure;
576        }
577    }
578
579    fn pipe_stage_output(&mut self, stdout_before: usize, stderr_before: usize, pipe_stderr: bool) {
580        use wasmsh_vm::pipe::PipeBuffer;
581
582        let mut stage_output = self.vm.stdout[stdout_before..].to_vec();
583        self.vm.stdout.truncate(stdout_before);
584
585        if pipe_stderr {
586            let stage_stderr = self.vm.stderr[stderr_before..].to_vec();
587            self.vm.stderr.truncate(stderr_before);
588            stage_output.extend_from_slice(&stage_stderr);
589        }
590
591        let mut pipe = PipeBuffer::default_size();
592        pipe.write_all(&stage_output);
593        pipe.close_write();
594        self.pending_stdin = Some(pipe.drain());
595    }
596
597    fn set_pipestatus(&mut self, statuses: &[i32]) {
598        let status_key = smol_str::SmolStr::from("PIPESTATUS");
599        self.vm.state.init_indexed_array(status_key.clone());
600        for (i, s) in statuses.iter().enumerate() {
601            self.vm.state.set_array_element(
602                status_key.clone(),
603                &i.to_string(),
604                smol_str::SmolStr::from(s.to_string()),
605            );
606        }
607    }
608
609    /// Execute a command substitution and return the trimmed output.
610    fn execute_subst(&mut self, inner: &str) -> smol_str::SmolStr {
611        let saved_stdout = std::mem::take(&mut self.vm.stdout);
612        let events = self.execute_input_inner(inner);
613        let mut result = String::new();
614        for e in &events {
615            if let WorkerEvent::Stdout(d) = e {
616                result.push_str(&String::from_utf8_lossy(d));
617            }
618        }
619        if !self.vm.stdout.is_empty() {
620            result.push_str(&String::from_utf8_lossy(&self.vm.stdout));
621            self.vm.stdout.clear();
622        }
623        self.vm.stdout = saved_stdout;
624        smol_str::SmolStr::from(result.trim_end_matches('\n'))
625    }
626
627    /// Resolve command substitutions in a list of words by executing them.
628    fn resolve_command_subst(&mut self, words: &[wasmsh_ast::Word]) -> Vec<wasmsh_ast::Word> {
629        words
630            .iter()
631            .map(|w| {
632                let parts: Vec<wasmsh_ast::WordPart> = w
633                    .parts
634                    .iter()
635                    .map(|p| match p {
636                        wasmsh_ast::WordPart::CommandSubstitution(inner) => {
637                            wasmsh_ast::WordPart::Literal(self.execute_subst(inner))
638                        }
639                        wasmsh_ast::WordPart::DoubleQuoted(inner_parts) => {
640                            let resolved: Vec<wasmsh_ast::WordPart> = inner_parts
641                                .iter()
642                                .map(|ip| {
643                                    if let wasmsh_ast::WordPart::CommandSubstitution(inner) = ip {
644                                        wasmsh_ast::WordPart::Literal(self.execute_subst(inner))
645                                    } else {
646                                        ip.clone()
647                                    }
648                                })
649                                .collect();
650                            wasmsh_ast::WordPart::DoubleQuoted(resolved)
651                        }
652                        other => other.clone(),
653                    })
654                    .collect();
655                wasmsh_ast::Word {
656                    parts,
657                    span: w.span,
658                }
659            })
660            .collect()
661    }
662
663    fn execute_command(&mut self, cmd: &HirCommand) {
664        match cmd {
665            HirCommand::Exec(exec) => self.execute_exec(exec),
666            HirCommand::Assign(assign) => {
667                for a in &assign.assignments {
668                    self.execute_assignment(&a.name, a.value.as_ref());
669                }
670                let stdout_before = self.vm.stdout.len();
671                self.apply_redirections(&assign.redirections, stdout_before);
672                self.vm.state.last_status = 0;
673            }
674            HirCommand::If(if_cmd) => self.execute_if(if_cmd),
675            HirCommand::While(loop_cmd) => self.execute_while_loop(loop_cmd),
676            HirCommand::Until(loop_cmd) => self.execute_until_loop(loop_cmd),
677            HirCommand::For(for_cmd) => self.execute_for_loop(for_cmd),
678            HirCommand::Group(block) => self.execute_body(&block.body),
679            HirCommand::Subshell(block) => {
680                self.vm.state.env.push_scope();
681                self.execute_body(&block.body);
682                self.vm.state.env.pop_scope();
683            }
684            HirCommand::Case(case_cmd) => self.execute_case(case_cmd),
685            HirCommand::FunctionDef(fd) => {
686                self.functions
687                    .insert(fd.name.to_string(), (*fd.body).clone());
688                self.vm.state.last_status = 0;
689            }
690            HirCommand::RedirectOnly(ro) => {
691                let stdout_before = self.vm.stdout.len();
692                self.apply_redirections(&ro.redirections, stdout_before);
693                self.vm.state.last_status = 0;
694            }
695            HirCommand::DoubleBracket(db) => {
696                let result = self.eval_double_bracket(&db.words);
697                self.vm.state.last_status = i32::from(!result);
698            }
699            HirCommand::ArithCommand(ac) => {
700                let result = wasmsh_expand::eval_arithmetic(&ac.expr, &mut self.vm.state);
701                self.vm.state.last_status = i32::from(result == 0);
702            }
703            HirCommand::ArithFor(af) => self.execute_arith_for(af),
704            HirCommand::Select(sel) => self.execute_select(sel),
705            _ => {}
706        }
707    }
708
709    /// Execute a simple command (`HirCommand::Exec`).
710    fn execute_exec(&mut self, exec: &wasmsh_hir::HirExec) {
711        let resolved = self.resolve_command_subst(&exec.argv);
712        let argv = expand_words(&resolved, &mut self.vm.state);
713
714        if self.check_nounset_error() {
715            return;
716        }
717        if argv.is_empty() {
718            return;
719        }
720
721        let argv: Vec<String> = argv
722            .into_iter()
723            .flat_map(|arg| wasmsh_expand::expand_braces(&arg))
724            .collect();
725        let argv = self.expand_globs(argv);
726
727        for assignment in &exec.env {
728            self.execute_assignment(&assignment.name, assignment.value.as_ref());
729        }
730
731        if self.collect_stdin_from_redirections(&exec.redirections) {
732            return;
733        }
734
735        if self.try_alias_expansion(&argv) {
736            return;
737        }
738
739        let stdout_before = self.vm.stdout.len();
740        let cmd_name = &argv[0];
741        self.trace_command(&argv);
742
743        if self.try_runtime_command(cmd_name, &argv) {
744            return;
745        }
746
747        self.dispatch_command(cmd_name, &argv);
748        self.apply_redirections(&exec.redirections, stdout_before);
749    }
750
751    /// Check for nounset errors from expansion. Returns true if an error was found.
752    fn check_nounset_error(&mut self) -> bool {
753        if let Some(var_name) = self.vm.state.get_var("_NOUNSET_ERROR") {
754            if !var_name.is_empty() {
755                let msg = format!("wasmsh: {var_name}: unbound variable\n");
756                self.vm.stderr.extend_from_slice(msg.as_bytes());
757                self.vm.state.set_var(
758                    smol_str::SmolStr::from("_NOUNSET_ERROR"),
759                    smol_str::SmolStr::default(),
760                );
761                self.vm.state.last_status = 1;
762                return true;
763            }
764        }
765        false
766    }
767
768    /// Collect stdin from here-doc bodies or input redirections. Returns true if
769    /// an error occurred and execution should stop.
770    fn collect_stdin_from_redirections(&mut self, redirections: &[HirRedirection]) -> bool {
771        for redir in redirections {
772            match redir.op {
773                RedirectionOp::HereDoc | RedirectionOp::HereDocStrip => {
774                    if let Some(body) = &redir.here_doc_body {
775                        let expanded =
776                            wasmsh_expand::expand_string(&body.content, &mut self.vm.state);
777                        self.pending_stdin = Some(expanded.into_bytes());
778                    }
779                }
780                RedirectionOp::HereString => {
781                    let resolved = self.resolve_command_subst(std::slice::from_ref(&redir.target));
782                    let resolved_target = resolved.first().unwrap_or(&redir.target);
783                    let content = wasmsh_expand::expand_word(resolved_target, &mut self.vm.state);
784                    let mut data = content.into_bytes();
785                    data.push(b'\n');
786                    self.pending_stdin = Some(data);
787                }
788                RedirectionOp::Input => {
789                    let resolved = self.resolve_command_subst(std::slice::from_ref(&redir.target));
790                    let resolved_target = resolved.first().unwrap_or(&redir.target);
791                    let target = wasmsh_expand::expand_word(resolved_target, &mut self.vm.state);
792                    let path = self.resolve_cwd_path(&target);
793                    if let Ok(h) = self.fs.open(&path, OpenOptions::read()) {
794                        match self.fs.read_file(h) {
795                            Ok(data) => {
796                                self.pending_stdin = Some(data);
797                            }
798                            Err(e) => {
799                                let msg = format!("wasmsh: {target}: read error: {e}\n");
800                                self.vm.stderr.extend_from_slice(msg.as_bytes());
801                                self.vm.state.last_status = 1;
802                                self.fs.close(h);
803                                return true;
804                            }
805                        }
806                        self.fs.close(h);
807                    } else {
808                        let msg = format!("wasmsh: {target}: No such file or directory\n");
809                        self.vm.stderr.extend_from_slice(msg.as_bytes());
810                        self.vm.state.last_status = 1;
811                        return true;
812                    }
813                }
814                _ => {}
815            }
816        }
817        false
818    }
819
820    /// Try alias expansion for the command. Returns true if an alias was expanded.
821    fn try_alias_expansion(&mut self, argv: &[String]) -> bool {
822        if let Some(alias_val) = self.aliases.get(&argv[0]).cloned() {
823            let rest = if argv.len() > 1 {
824                format!(" {}", argv[1..].join(" "))
825            } else {
826                String::new()
827            };
828            let expanded = format!("{alias_val}{rest}");
829            let sub_events = self.execute_input_inner(&expanded);
830            self.merge_sub_events(sub_events);
831            return true;
832        }
833        false
834    }
835
836    /// Print xtrace output if enabled.
837    fn trace_command(&mut self, argv: &[String]) {
838        if self.vm.state.get_var("SHOPT_x").as_deref() == Some("1") {
839            let ps4 = self
840                .vm
841                .state
842                .get_var("PS4")
843                .unwrap_or_else(|| smol_str::SmolStr::from("+ "));
844            let trace_line = format!("{}{}\n", ps4, argv.join(" "));
845            self.vm.stderr.extend_from_slice(trace_line.as_bytes());
846        }
847    }
848
849    /// Try to handle a runtime-level command (local, break, continue, exit, eval,
850    /// source, declare, etc.). Returns true if handled.
851    fn try_runtime_command(&mut self, cmd_name: &str, argv: &[String]) -> bool {
852        match cmd_name {
853            CMD_LOCAL => {
854                self.execute_local(argv);
855                true
856            }
857            CMD_BREAK => {
858                self.exec.break_depth = argv.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
859                self.vm.state.last_status = 0;
860                true
861            }
862            CMD_CONTINUE => {
863                self.exec.loop_continue = true;
864                self.vm.state.last_status = 0;
865                true
866            }
867            CMD_EXIT => {
868                let code = argv
869                    .get(1)
870                    .and_then(|s| s.parse().ok())
871                    .unwrap_or(self.vm.state.last_status);
872                self.exec.exit_requested = Some(code);
873                self.vm.state.last_status = code;
874                true
875            }
876            CMD_EVAL => {
877                let code = argv[1..].join(" ");
878                let sub_events = self.execute_input_inner(&code);
879                self.merge_sub_events_with_diagnostics(sub_events);
880                true
881            }
882            CMD_SOURCE | CMD_DOT => {
883                self.execute_source(argv);
884                true
885            }
886            CMD_DECLARE | CMD_TYPESET => {
887                self.execute_declare(argv);
888                true
889            }
890            CMD_LET => {
891                self.execute_let(argv);
892                true
893            }
894            CMD_SHOPT => {
895                self.execute_shopt(argv);
896                true
897            }
898            CMD_ALIAS => {
899                self.execute_alias(argv);
900                true
901            }
902            CMD_UNALIAS => {
903                self.execute_unalias(argv);
904                true
905            }
906            CMD_BUILTIN => {
907                self.execute_builtin_keyword(argv);
908                true
909            }
910            CMD_MAPFILE | CMD_READARRAY => {
911                self.execute_mapfile(argv);
912                true
913            }
914            CMD_TYPE => {
915                self.execute_type(argv);
916                true
917            }
918            _ => false,
919        }
920    }
921
922    /// Execute `local` — save old variable values and set new ones.
923    fn execute_local(&mut self, argv: &[String]) {
924        for arg in &argv[1..] {
925            let (name, value) = if let Some(eq) = arg.find('=') {
926                (&arg[..eq], Some(&arg[eq + 1..]))
927            } else {
928                (arg.as_str(), None)
929            };
930            let old = self.vm.state.get_var(name);
931            self.exec
932                .local_save_stack
933                .push((smol_str::SmolStr::from(name), old));
934            let val = value.map_or(smol_str::SmolStr::default(), smol_str::SmolStr::from);
935            self.vm.state.set_var(smol_str::SmolStr::from(name), val);
936        }
937        self.vm.state.last_status = 0;
938    }
939
940    /// Execute `source`/`.` — read and execute a file.
941    fn execute_source(&mut self, argv: &[String]) {
942        let Some(path) = argv.get(1) else { return };
943        let resolved = if path.contains('/') {
944            Some(self.resolve_cwd_path(path))
945        } else {
946            let direct = self.resolve_cwd_path(path);
947            if self.fs.stat(&direct).is_ok() {
948                Some(direct)
949            } else {
950                self.search_path_for_file(path)
951            }
952        };
953        let Some(full) = resolved else {
954            let msg = format!("source: {path}: not found\n");
955            self.vm.stderr.extend_from_slice(msg.as_bytes());
956            self.vm.state.last_status = 1;
957            return;
958        };
959        let Ok(h) = self.fs.open(&full, OpenOptions::read()) else {
960            let msg = format!("source: {path}: not found\n");
961            self.vm.stderr.extend_from_slice(msg.as_bytes());
962            self.vm.state.last_status = 1;
963            return;
964        };
965        match self.fs.read_file(h) {
966            Ok(data) => {
967                self.fs.close(h);
968                self.vm
969                    .state
970                    .source_stack
971                    .push(smol_str::SmolStr::from(full.as_str()));
972                let code = String::from_utf8_lossy(&data).to_string();
973                let sub_events = self.execute_input_inner(&code);
974                self.vm.state.source_stack.pop();
975                self.merge_sub_events_with_diagnostics(sub_events);
976            }
977            Err(e) => {
978                self.fs.close(h);
979                let msg = format!("source: {path}: read error: {e}\n");
980                self.vm.stderr.extend_from_slice(msg.as_bytes());
981                self.vm.state.last_status = 1;
982            }
983        }
984    }
985
986    /// Merge sub-events (stdout/stderr only) into the current VM buffers.
987    fn merge_sub_events(&mut self, events: Vec<WorkerEvent>) {
988        for e in events {
989            match e {
990                WorkerEvent::Stdout(d) => self.vm.stdout.extend_from_slice(&d),
991                WorkerEvent::Stderr(d) => self.vm.stderr.extend_from_slice(&d),
992                _ => {}
993            }
994        }
995    }
996
997    /// Merge sub-events including diagnostics into the current VM buffers.
998    fn merge_sub_events_with_diagnostics(&mut self, events: Vec<WorkerEvent>) {
999        for e in events {
1000            match e {
1001                WorkerEvent::Stdout(d) => self.vm.stdout.extend_from_slice(&d),
1002                WorkerEvent::Stderr(d) => self.vm.stderr.extend_from_slice(&d),
1003                WorkerEvent::Diagnostic(level, msg) => {
1004                    self.vm.diagnostics.push(wasmsh_vm::DiagnosticEvent {
1005                        level: convert_diag_level(level),
1006                        category: wasmsh_vm::DiagCategory::Runtime,
1007                        message: msg,
1008                    });
1009                }
1010                _ => {}
1011            }
1012        }
1013    }
1014
1015    /// Dispatch a command to a shell function, builtin, utility, or report not found.
1016    fn dispatch_command(&mut self, cmd_name: &str, argv: &[String]) {
1017        if self.check_resource_limits() {
1018            return;
1019        }
1020        if let Some(body) = self.functions.get(cmd_name).cloned() {
1021            self.call_shell_function(cmd_name, argv, &body);
1022        } else if self.builtins.is_builtin(cmd_name) {
1023            self.call_builtin(cmd_name, argv);
1024        } else if self.utils.is_utility(cmd_name) {
1025            if cmd_name == "find" && argv.iter().any(|a| a == "-exec") {
1026                self.call_find_with_exec(argv);
1027            } else if cmd_name == "xargs" {
1028                self.call_xargs_with_exec(argv);
1029            } else {
1030                self.call_utility(cmd_name, argv);
1031            }
1032        } else if let Some(ref mut handler) = self.external_handler {
1033            let stdin_data = self.pending_stdin.take();
1034            if let Some(result) = handler(cmd_name, argv, stdin_data.as_deref()) {
1035                self.vm.stdout.extend_from_slice(&result.stdout);
1036                self.vm.stderr.extend_from_slice(&result.stderr);
1037                self.vm.output_bytes += (result.stdout.len() + result.stderr.len()) as u64;
1038                self.vm.state.last_status = result.status;
1039            } else {
1040                let msg = format!("wasmsh: {cmd_name}: command not found\n");
1041                self.vm.stderr.extend_from_slice(msg.as_bytes());
1042                self.vm.state.last_status = 127;
1043            }
1044        } else {
1045            let msg = format!("wasmsh: {cmd_name}: command not found\n");
1046            self.vm.stderr.extend_from_slice(msg.as_bytes());
1047            self.vm.state.last_status = 127;
1048        }
1049    }
1050
1051    /// Invoke a shell function.
1052    fn call_shell_function(&mut self, cmd_name: &str, argv: &[String], body: &HirCommand) {
1053        let old_positional = std::mem::take(&mut self.vm.state.positional);
1054        self.vm.state.positional = argv[1..]
1055            .iter()
1056            .map(|s| smol_str::SmolStr::from(s.as_str()))
1057            .collect();
1058        self.vm
1059            .state
1060            .func_stack
1061            .push(smol_str::SmolStr::from(cmd_name));
1062        let locals_before = self.exec.local_save_stack.len();
1063        self.execute_command(body);
1064        let new_locals: Vec<_> = self.exec.local_save_stack.drain(locals_before..).collect();
1065        for (name, old_val) in new_locals.into_iter().rev() {
1066            if let Some(val) = old_val {
1067                self.vm.state.set_var(name, val);
1068            } else {
1069                self.vm.state.unset_var(&name).ok();
1070            }
1071        }
1072        self.vm.state.func_stack.pop();
1073        self.vm.state.positional = old_positional;
1074    }
1075
1076    /// Invoke a builtin command.
1077    fn call_builtin(&mut self, cmd_name: &str, argv: &[String]) {
1078        let builtin_fn = self.builtins.get(cmd_name).unwrap();
1079        let stdin_data = self.pending_stdin.take();
1080        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
1081        let mut sink = wasmsh_builtins::VecSink::default();
1082        let status = {
1083            let mut ctx = wasmsh_builtins::BuiltinContext {
1084                state: &mut self.vm.state,
1085                output: &mut sink,
1086                fs: Some(&self.fs),
1087                stdin: stdin_data.as_deref(),
1088            };
1089            builtin_fn(&mut ctx, &argv_refs)
1090        };
1091        self.vm.stdout.extend_from_slice(&sink.stdout);
1092        self.vm.stderr.extend_from_slice(&sink.stderr);
1093        self.vm.output_bytes += (sink.stdout.len() + sink.stderr.len()) as u64;
1094        self.vm.state.last_status = status;
1095        self.pending_stdin = None;
1096    }
1097
1098    /// Extract `-exec CMD [args...] {} \;` from find argv.
1099    /// Returns `(exec_template, cleaned_argv)` or `None` if no `-exec` present.
1100    fn extract_find_exec(argv: &[String]) -> Option<(Vec<String>, Vec<String>)> {
1101        let exec_pos = argv.iter().position(|a| a == "-exec")?;
1102        // Find the terminator: \; or ;
1103        let term_pos = argv[exec_pos + 1..]
1104            .iter()
1105            .position(|a| a == "\\;" || a == ";")
1106            .map(|p| p + exec_pos + 1)?;
1107        let template: Vec<String> = argv[exec_pos + 1..term_pos].to_vec();
1108        if template.is_empty() {
1109            return None;
1110        }
1111        let mut cleaned: Vec<String> = argv[..exec_pos].to_vec();
1112        cleaned.extend_from_slice(&argv[term_pos + 1..]);
1113        Some((template, cleaned))
1114    }
1115
1116    /// Shell-quote a path for safe interpolation into a command string.
1117    fn shell_quote(s: &str) -> String {
1118        if s.chars()
1119            .all(|c| c.is_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
1120        {
1121            s.to_string()
1122        } else {
1123            format!("'{}'", s.replace('\'', "'\\''"))
1124        }
1125    }
1126
1127    /// Handle `find ... -exec CMD {} \;` by running find for paths, then executing
1128    /// the command for each matched path via the shell.
1129    fn call_find_with_exec(&mut self, argv: &[String]) {
1130        let Some((template, cleaned_argv)) = Self::extract_find_exec(argv) else {
1131            // Malformed -exec (missing \;), fall through to normal find
1132            self.call_utility("find", argv);
1133            return;
1134        };
1135
1136        // Phase 1: run find with cleaned argv, capturing stdout
1137        let saved_stdout = std::mem::take(&mut self.vm.stdout);
1138        self.call_utility("find", &cleaned_argv);
1139        let find_output = std::mem::take(&mut self.vm.stdout);
1140        self.vm.stdout = saved_stdout;
1141
1142        // Phase 2: parse matched paths
1143        let paths_str = String::from_utf8_lossy(&find_output);
1144        let paths: Vec<&str> = paths_str.lines().filter(|l| !l.is_empty()).collect();
1145
1146        // Phase 3: execute the command for each path
1147        let mut last_status = 0i32;
1148        for path in paths {
1149            let cmd_line: String = template
1150                .iter()
1151                .map(|t| {
1152                    if t == "{}" {
1153                        Self::shell_quote(path)
1154                    } else {
1155                        t.clone()
1156                    }
1157                })
1158                .collect::<Vec<_>>()
1159                .join(" ");
1160            let sub_events = self.execute_input_inner(&cmd_line);
1161            self.merge_sub_events(sub_events);
1162            if self.vm.state.last_status != 0 {
1163                last_status = self.vm.state.last_status;
1164            }
1165        }
1166        self.vm.state.last_status = last_status;
1167    }
1168
1169    /// Handle `xargs` with actual command execution for non-echo commands.
1170    /// The existing xargs utility already formats correct command lines for
1171    /// non-echo; we capture those and execute them via the shell.
1172    fn call_xargs_with_exec(&mut self, argv: &[String]) {
1173        // Determine if xargs has a non-echo command by scanning past flags
1174        let mut has_non_echo = false;
1175        let mut i = 1;
1176        while i < argv.len() {
1177            let arg = &argv[i];
1178            if matches!(arg.as_str(), "-I" | "-n" | "-d" | "-P" | "-L") && i + 1 < argv.len() {
1179                i += 2;
1180            } else if matches!(arg.as_str(), "-0" | "--null" | "-t" | "-p") || arg.starts_with('-')
1181            {
1182                i += 1;
1183            } else {
1184                // First non-flag arg is the command
1185                if arg != "echo" {
1186                    has_non_echo = true;
1187                }
1188                break;
1189            }
1190        }
1191
1192        if !has_non_echo {
1193            self.call_utility("xargs", argv);
1194            return;
1195        }
1196
1197        // Run xargs utility — it outputs formatted command lines for non-echo
1198        let saved_stdout = std::mem::take(&mut self.vm.stdout);
1199        self.call_utility("xargs", argv);
1200        let xargs_output = std::mem::take(&mut self.vm.stdout);
1201        self.vm.stdout = saved_stdout;
1202
1203        // Execute each output line as a command
1204        let output_str = String::from_utf8_lossy(&xargs_output);
1205        let mut last_status = 0i32;
1206        for line in output_str.lines().filter(|l| !l.is_empty()) {
1207            let sub_events = self.execute_input_inner(line);
1208            self.merge_sub_events(sub_events);
1209            if self.vm.state.last_status != 0 {
1210                last_status = self.vm.state.last_status;
1211            }
1212        }
1213        self.vm.state.last_status = last_status;
1214    }
1215
1216    /// Invoke a utility command.
1217    fn call_utility(&mut self, cmd_name: &str, argv: &[String]) {
1218        let stdin_data = self.pending_stdin.take();
1219        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
1220        let mut output = UtilOutput::default();
1221        let cwd = self.vm.state.cwd.clone();
1222        let status = {
1223            let util_fn = self.utils.get(cmd_name).unwrap();
1224            let mut ctx = UtilContext {
1225                fs: &mut self.fs,
1226                output: &mut output,
1227                cwd: &cwd,
1228                stdin: stdin_data.as_deref(),
1229                state: Some(&self.vm.state),
1230            };
1231            util_fn(&mut ctx, &argv_refs)
1232        };
1233        self.vm.stdout.extend_from_slice(&output.stdout);
1234        self.vm.stderr.extend_from_slice(&output.stderr);
1235        self.vm.output_bytes += (output.stdout.len() + output.stderr.len()) as u64;
1236        self.vm.state.last_status = status;
1237    }
1238
1239    /// Execute an `if` command.
1240    fn execute_if(&mut self, if_cmd: &wasmsh_hir::HirIf) {
1241        let saved_suppress = self.exec.errexit_suppressed;
1242        self.exec.errexit_suppressed = true;
1243        self.execute_body(&if_cmd.condition);
1244        self.exec.errexit_suppressed = saved_suppress;
1245        if self.vm.state.last_status == 0 {
1246            self.execute_body(&if_cmd.then_body);
1247            return;
1248        }
1249        for elif in &if_cmd.elifs {
1250            let saved = self.exec.errexit_suppressed;
1251            self.exec.errexit_suppressed = true;
1252            self.execute_body(&elif.condition);
1253            self.exec.errexit_suppressed = saved;
1254            if self.vm.state.last_status == 0 {
1255                self.execute_body(&elif.then_body);
1256                return;
1257            }
1258        }
1259        if let Some(else_body) = &if_cmd.else_body {
1260            self.execute_body(else_body);
1261        }
1262    }
1263
1264    /// Execute a `while` loop.
1265    fn execute_while_loop(&mut self, loop_cmd: &wasmsh_hir::HirLoop) {
1266        loop {
1267            if self.check_resource_limits() {
1268                break;
1269            }
1270            let saved = self.exec.errexit_suppressed;
1271            self.exec.errexit_suppressed = true;
1272            self.execute_body(&loop_cmd.condition);
1273            self.exec.errexit_suppressed = saved;
1274            if self.vm.state.last_status != 0 {
1275                break;
1276            }
1277            self.execute_body(&loop_cmd.body);
1278            if self.handle_loop_control() {
1279                break;
1280            }
1281        }
1282    }
1283
1284    /// Execute an `until` loop.
1285    fn execute_until_loop(&mut self, loop_cmd: &wasmsh_hir::HirLoop) {
1286        loop {
1287            if self.check_resource_limits() {
1288                break;
1289            }
1290            let saved = self.exec.errexit_suppressed;
1291            self.exec.errexit_suppressed = true;
1292            self.execute_body(&loop_cmd.condition);
1293            self.exec.errexit_suppressed = saved;
1294            if self.vm.state.last_status == 0 {
1295                break;
1296            }
1297            self.execute_body(&loop_cmd.body);
1298            if self.handle_loop_control() {
1299                break;
1300            }
1301        }
1302    }
1303
1304    /// Handle loop control flow (break/continue/exit). Returns true if the loop should break.
1305    fn handle_loop_control(&mut self) -> bool {
1306        if self.exec.break_depth > 0 {
1307            self.exec.break_depth -= 1;
1308            return true;
1309        }
1310        if self.exec.loop_continue {
1311            self.exec.loop_continue = false;
1312        }
1313        self.exec.exit_requested.is_some()
1314    }
1315
1316    /// Execute a `for` loop.
1317    fn execute_for_loop(&mut self, for_cmd: &wasmsh_hir::HirFor) {
1318        let words = self.expand_for_words(for_cmd.words.as_deref());
1319        for word in words {
1320            if self.check_resource_limits() {
1321                break;
1322            }
1323            self.vm.state.set_var(for_cmd.var_name.clone(), word.into());
1324            self.execute_body(&for_cmd.body);
1325            if self.exec.break_depth > 0 {
1326                self.exec.break_depth -= 1;
1327                break;
1328            }
1329            if self.exec.loop_continue {
1330                self.exec.loop_continue = false;
1331                continue;
1332            }
1333            if self.exec.exit_requested.is_some() {
1334                break;
1335            }
1336        }
1337    }
1338
1339    /// Expand word list for `for` and `select` commands.
1340    fn expand_for_words(&mut self, words: Option<&[wasmsh_ast::Word]>) -> Vec<String> {
1341        if let Some(ws) = words {
1342            let resolved = self.resolve_command_subst(ws);
1343            let mut result = Vec::new();
1344            for w in &resolved {
1345                let expanded = wasmsh_expand::expand_word_split(w, &mut self.vm.state);
1346                result.extend(expanded.fields);
1347            }
1348            let result: Vec<String> = result
1349                .into_iter()
1350                .flat_map(|arg| wasmsh_expand::expand_braces(&arg))
1351                .collect();
1352            self.expand_globs(result)
1353        } else {
1354            self.vm
1355                .state
1356                .positional
1357                .iter()
1358                .map(ToString::to_string)
1359                .collect()
1360        }
1361    }
1362
1363    /// Execute a `case` command.
1364    fn execute_case(&mut self, case_cmd: &wasmsh_hir::HirCase) {
1365        let nocasematch = self.vm.state.get_var("SHOPT_nocasematch").as_deref() == Some("1");
1366        let value = wasmsh_expand::expand_word(&case_cmd.word, &mut self.vm.state);
1367        let mut i = 0;
1368        let mut fallthrough = false;
1369        while i < case_cmd.items.len() {
1370            let item = &case_cmd.items[i];
1371            let pattern_matched = if fallthrough {
1372                true
1373            } else {
1374                item.patterns.iter().any(|pattern| {
1375                    let pat = wasmsh_expand::expand_word(pattern, &mut self.vm.state);
1376                    if nocasematch {
1377                        glob_match_inner(
1378                            pat.to_lowercase().as_bytes(),
1379                            value.to_lowercase().as_bytes(),
1380                        )
1381                    } else {
1382                        glob_match_inner(pat.as_bytes(), value.as_bytes())
1383                    }
1384                })
1385            };
1386            if pattern_matched {
1387                self.execute_body(&item.body);
1388                match item.terminator {
1389                    CaseTerminator::Break => break,
1390                    CaseTerminator::Fallthrough => {
1391                        fallthrough = true;
1392                        i += 1;
1393                    }
1394                    CaseTerminator::ContinueTesting => {
1395                        fallthrough = false;
1396                        i += 1;
1397                    }
1398                }
1399            } else {
1400                fallthrough = false;
1401                i += 1;
1402            }
1403        }
1404    }
1405
1406    /// Execute a C-style `for (( init; cond; step ))` loop.
1407    fn execute_arith_for(&mut self, af: &wasmsh_hir::HirArithFor) {
1408        if !af.init.is_empty() {
1409            wasmsh_expand::eval_arithmetic(&af.init, &mut self.vm.state);
1410        }
1411        loop {
1412            if self.check_resource_limits() {
1413                break;
1414            }
1415            if !af.cond.is_empty() {
1416                let cond_val = wasmsh_expand::eval_arithmetic(&af.cond, &mut self.vm.state);
1417                if cond_val == 0 {
1418                    break;
1419                }
1420            }
1421            self.execute_body(&af.body);
1422            if self.handle_loop_control() {
1423                break;
1424            }
1425            if !af.step.is_empty() {
1426                wasmsh_expand::eval_arithmetic(&af.step, &mut self.vm.state);
1427            }
1428        }
1429    }
1430
1431    /// Execute a `select` command.
1432    fn execute_select(&mut self, sel: &wasmsh_hir::HirSelect) {
1433        self.collect_stdin_from_redirections(&sel.redirections);
1434
1435        let words: Vec<String> = if let Some(ws) = &sel.words {
1436            let resolved = self.resolve_command_subst(ws);
1437            let mut result = Vec::new();
1438            for w in &resolved {
1439                let expanded = wasmsh_expand::expand_word_split(w, &mut self.vm.state);
1440                result.extend(expanded.fields);
1441            }
1442            result
1443        } else {
1444            self.vm
1445                .state
1446                .positional
1447                .iter()
1448                .map(ToString::to_string)
1449                .collect()
1450        };
1451
1452        if words.is_empty() {
1453            return;
1454        }
1455        for (idx, w) in words.iter().enumerate() {
1456            let line = format!("{}) {}\n", idx + 1, w);
1457            self.vm.stderr.extend_from_slice(line.as_bytes());
1458        }
1459
1460        let stdin_data = self.pending_stdin.take().unwrap_or_default();
1461        let input = String::from_utf8_lossy(&stdin_data);
1462        let first_line = input.lines().next().unwrap_or("");
1463
1464        self.vm.state.set_var(
1465            smol_str::SmolStr::from("REPLY"),
1466            smol_str::SmolStr::from(first_line.trim()),
1467        );
1468
1469        let selected = first_line.trim().parse::<usize>().ok().and_then(|n| {
1470            if n >= 1 && n <= words.len() {
1471                Some(&words[n - 1])
1472            } else {
1473                None
1474            }
1475        });
1476
1477        if let Some(word) = selected {
1478            self.vm
1479                .state
1480                .set_var(sel.var_name.clone(), smol_str::SmolStr::from(word.as_str()));
1481        } else {
1482            self.vm
1483                .state
1484                .set_var(sel.var_name.clone(), smol_str::SmolStr::default());
1485        }
1486
1487        self.execute_body(&sel.body);
1488    }
1489
1490    // ---- [[ ]] extended test evaluation ----
1491
1492    /// Expand a word inside `[[ ]]` — no word splitting or glob expansion.
1493    fn dbl_bracket_expand(&mut self, word: &wasmsh_ast::Word) -> String {
1494        let resolved = self.resolve_command_subst(std::slice::from_ref(word));
1495        wasmsh_expand::expand_word(&resolved[0], &mut self.vm.state)
1496    }
1497
1498    /// Evaluate a `[[ expression ]]` command. Returns true for exit-status 0.
1499    fn eval_double_bracket(&mut self, words: &[wasmsh_ast::Word]) -> bool {
1500        // Expand all words (no splitting/globbing) into string tokens for the evaluator
1501        let tokens: Vec<String> = words.iter().map(|w| self.dbl_bracket_expand(w)).collect();
1502        let mut pos = 0;
1503        dbl_bracket_eval_or(&tokens, &mut pos, &self.fs, &mut self.vm.state)
1504    }
1505
1506    fn resolve_cwd_path(&self, path: &str) -> String {
1507        if path.starts_with('/') {
1508            wasmsh_fs::normalize_path(path)
1509        } else {
1510            wasmsh_fs::normalize_path(&format!("{}/{}", self.vm.state.cwd, path))
1511        }
1512    }
1513
1514    /// Execute `alias [name[='value'] ...]`.
1515    fn execute_alias(&mut self, argv: &[String]) {
1516        let args = &argv[1..];
1517        if args.is_empty() {
1518            // List all aliases
1519            for (name, value) in &self.aliases {
1520                let line = format!("alias {name}='{value}'\n");
1521                self.vm.stdout.extend_from_slice(line.as_bytes());
1522            }
1523            self.vm.state.last_status = 0;
1524            return;
1525        }
1526        for arg in args {
1527            if let Some(eq_pos) = arg.find('=') {
1528                let name = &arg[..eq_pos];
1529                let value = &arg[eq_pos + 1..];
1530                self.aliases.insert(name.to_string(), value.to_string());
1531            } else {
1532                // Show specific alias
1533                if let Some(value) = self.aliases.get(arg.as_str()) {
1534                    let line = format!("alias {arg}='{value}'\n");
1535                    self.vm.stdout.extend_from_slice(line.as_bytes());
1536                } else {
1537                    let msg = format!("alias: {arg}: not found\n");
1538                    self.vm.stderr.extend_from_slice(msg.as_bytes());
1539                    self.vm.state.last_status = 1;
1540                    return;
1541                }
1542            }
1543        }
1544        self.vm.state.last_status = 0;
1545    }
1546
1547    /// Execute `unalias [-a] name ...`.
1548    fn execute_unalias(&mut self, argv: &[String]) {
1549        let args = &argv[1..];
1550        if args.is_empty() {
1551            self.vm
1552                .stderr
1553                .extend_from_slice(b"unalias: usage: unalias [-a] name ...\n");
1554            self.vm.state.last_status = 1;
1555            return;
1556        }
1557        for arg in args {
1558            if arg == "-a" {
1559                self.aliases.clear();
1560            } else if self.aliases.shift_remove(arg.as_str()).is_none() {
1561                let msg = format!("unalias: {arg}: not found\n");
1562                self.vm.stderr.extend_from_slice(msg.as_bytes());
1563                self.vm.state.last_status = 1;
1564                return;
1565            }
1566        }
1567        self.vm.state.last_status = 0;
1568    }
1569
1570    /// Execute `type name ...` — report how each name would be interpreted.
1571    /// Checks aliases, functions, builtins, and utilities in that order.
1572    fn execute_type(&mut self, argv: &[String]) {
1573        let mut status = 0;
1574        for name in &argv[1..] {
1575            if self.aliases.contains_key(name.as_str()) {
1576                let val = self.aliases.get(name.as_str()).unwrap();
1577                let msg = format!("{name} is aliased to `{val}'\n");
1578                self.vm.stdout.extend_from_slice(msg.as_bytes());
1579            } else if self.functions.contains_key(name.as_str()) {
1580                let msg = format!("{name} is a function\n");
1581                self.vm.stdout.extend_from_slice(msg.as_bytes());
1582            } else if self.builtins.is_builtin(name) {
1583                let msg = format!("{name} is a shell builtin\n");
1584                self.vm.stdout.extend_from_slice(msg.as_bytes());
1585            } else if self.utils.is_utility(name) {
1586                let msg = format!("{name} is a shell utility\n");
1587                self.vm.stdout.extend_from_slice(msg.as_bytes());
1588            } else {
1589                let msg = format!("wasmsh: type: {name}: not found\n");
1590                self.vm.stderr.extend_from_slice(msg.as_bytes());
1591                status = 1;
1592            }
1593        }
1594        self.vm.state.last_status = status;
1595    }
1596
1597    /// Execute `builtin name [args...]` — skip alias and function lookup,
1598    /// invoke the named builtin directly.
1599    fn execute_builtin_keyword(&mut self, argv: &[String]) {
1600        if argv.len() < 2 {
1601            self.vm.state.last_status = 0;
1602            return;
1603        }
1604        let cmd_name = &argv[1];
1605        let builtin_argv: Vec<String> = argv[1..].to_vec();
1606        if let Some(builtin_fn) = self.builtins.get(cmd_name) {
1607            let stdin_data = self.pending_stdin.take();
1608            let argv_refs: Vec<&str> = builtin_argv.iter().map(String::as_str).collect();
1609            let mut sink = wasmsh_builtins::VecSink::default();
1610            let status = {
1611                let mut ctx = wasmsh_builtins::BuiltinContext {
1612                    state: &mut self.vm.state,
1613                    output: &mut sink,
1614                    fs: Some(&self.fs),
1615                    stdin: stdin_data.as_deref(),
1616                };
1617                builtin_fn(&mut ctx, &argv_refs)
1618            };
1619            self.vm.stdout.extend_from_slice(&sink.stdout);
1620            self.vm.stderr.extend_from_slice(&sink.stderr);
1621            self.vm.output_bytes += (sink.stdout.len() + sink.stderr.len()) as u64;
1622            self.vm.state.last_status = status;
1623        } else {
1624            let msg = format!("builtin: {cmd_name}: not a shell builtin\n");
1625            self.vm.stderr.extend_from_slice(msg.as_bytes());
1626            self.vm.state.last_status = 1;
1627        }
1628    }
1629
1630    /// Execute `mapfile`/`readarray` — read stdin lines into an indexed array.
1631    /// Supports `-t` (strip trailing newline). Default array: MAPFILE.
1632    fn execute_mapfile(&mut self, argv: &[String]) {
1633        let (strip_newline, array_name) = Self::parse_mapfile_args(&argv[1..]);
1634        let data = self.pending_stdin.take().unwrap_or_default();
1635        let text = String::from_utf8_lossy(&data);
1636
1637        let name_key = smol_str::SmolStr::from(array_name.as_str());
1638        self.vm.state.init_indexed_array(name_key.clone());
1639        self.populate_mapfile_array(&name_key, &text, strip_newline);
1640        self.vm.state.last_status = 0;
1641    }
1642
1643    fn parse_mapfile_args(args: &[String]) -> (bool, String) {
1644        let mut strip_newline = false;
1645        let mut positional: Vec<&str> = Vec::new();
1646        for arg in args {
1647            match arg.as_str() {
1648                "-t" => strip_newline = true,
1649                _ => positional.push(arg),
1650            }
1651        }
1652        let array_name = positional
1653            .last()
1654            .map_or("MAPFILE".to_string(), ToString::to_string);
1655        (strip_newline, array_name)
1656    }
1657
1658    fn populate_mapfile_array(
1659        &mut self,
1660        name_key: &smol_str::SmolStr,
1661        text: &str,
1662        strip_newline: bool,
1663    ) {
1664        let mut idx = 0;
1665        for line in text.split('\n') {
1666            if line.is_empty() && idx > 0 {
1667                continue;
1668            }
1669            let value = if strip_newline {
1670                line.to_string()
1671            } else {
1672                format!("{line}\n")
1673            };
1674            self.vm.state.set_array_element(
1675                name_key.clone(),
1676                &idx.to_string(),
1677                smol_str::SmolStr::from(value.as_str()),
1678            );
1679            idx += 1;
1680        }
1681    }
1682
1683    /// Search `$PATH` directories in the VFS for a file. Returns the first match.
1684    fn search_path_for_file(&self, filename: &str) -> Option<String> {
1685        let path_var = self.vm.state.get_var("PATH")?;
1686        for dir in path_var.split(':') {
1687            if dir.is_empty() {
1688                continue;
1689            }
1690            let candidate = format!("{dir}/{filename}");
1691            let full = self.resolve_cwd_path(&candidate);
1692            if self.fs.stat(&full).is_ok() {
1693                return Some(full);
1694            }
1695        }
1696        None
1697    }
1698
1699    fn should_errexit(&self, and_or: &HirAndOr) -> bool {
1700        !self.exec.errexit_suppressed
1701            && and_or.rest.is_empty()
1702            && !and_or.first.negated
1703            && self.vm.state.get_var("SHOPT_e").as_deref() == Some("1")
1704            && self.vm.state.last_status != 0
1705            && self.exec.exit_requested.is_none()
1706    }
1707
1708    /// Execute `let expr1 expr2 ...` — evaluate each as arithmetic.
1709    /// Exit status: 0 if the last expression is non-zero, 1 if zero.
1710    fn execute_let(&mut self, argv: &[String]) {
1711        if argv.len() < 2 {
1712            self.vm
1713                .stderr
1714                .extend_from_slice(b"let: expression expected\n");
1715            self.vm.state.last_status = 1;
1716            return;
1717        }
1718        let mut last_val: i64 = 0;
1719        for expr in &argv[1..] {
1720            last_val = wasmsh_expand::eval_arithmetic(expr, &mut self.vm.state);
1721        }
1722        self.vm.state.last_status = i32::from(last_val == 0);
1723    }
1724
1725    /// Known `shopt` option names.
1726    const SHOPT_OPTIONS: &'static [&'static str] = &[
1727        "extglob",
1728        "nullglob",
1729        "dotglob",
1730        "globstar",
1731        "nocasematch",
1732        "nocaseglob",
1733        "failglob",
1734        "lastpipe",
1735        "expand_aliases",
1736    ];
1737
1738    /// Execute `shopt [-s|-u] [optname ...]`.
1739    fn execute_shopt(&mut self, argv: &[String]) {
1740        let (set_mode, names) = Self::parse_shopt_args(&argv[1..]);
1741        if let Some(enable) = set_mode {
1742            self.shopt_set_options(&names, enable);
1743        } else {
1744            self.shopt_print_options(&names);
1745        }
1746    }
1747
1748    fn parse_shopt_args(args: &[String]) -> (Option<bool>, Vec<&str>) {
1749        let mut set_mode = None;
1750        let mut names = Vec::new();
1751
1752        for arg in args {
1753            match arg.as_str() {
1754                "-s" => set_mode = Some(true),
1755                "-u" => set_mode = Some(false),
1756                _ => names.push(arg.as_str()),
1757            }
1758        }
1759
1760        (set_mode, names)
1761    }
1762
1763    /// Set shopt options (`-s` or `-u`).
1764    fn shopt_set_options(&mut self, names: &[&str], enable: bool) {
1765        if names.is_empty() {
1766            self.vm
1767                .stderr
1768                .extend_from_slice(b"shopt: option name required\n");
1769            self.vm.state.last_status = 1;
1770            return;
1771        }
1772        let val = if enable { "1" } else { "0" };
1773        for name in names {
1774            if self.reject_invalid_shopt_name(name) {
1775                return;
1776            }
1777            self.set_shopt_value(name, val);
1778        }
1779        self.vm.state.last_status = 0;
1780    }
1781
1782    /// Print shopt option statuses. If `names` is empty, print all.
1783    fn shopt_print_options(&mut self, names: &[&str]) {
1784        let options_to_print: Vec<&str> = if names.is_empty() {
1785            Self::SHOPT_OPTIONS.to_vec()
1786        } else {
1787            names.to_vec()
1788        };
1789        for name in &options_to_print {
1790            if self.reject_invalid_shopt_name(name) {
1791                return;
1792            }
1793            let enabled = self.get_shopt_value(name);
1794            let status_str = if enabled { "on" } else { "off" };
1795            let line = format!("{name}\t{status_str}\n");
1796            self.vm.stdout.extend_from_slice(line.as_bytes());
1797        }
1798        self.vm.state.last_status = 0;
1799    }
1800
1801    fn reject_invalid_shopt_name(&mut self, name: &str) -> bool {
1802        if Self::SHOPT_OPTIONS.contains(&name) {
1803            return false;
1804        }
1805
1806        let msg = format!("shopt: {name}: invalid shell option name\n");
1807        self.vm.stderr.extend_from_slice(msg.as_bytes());
1808        self.vm.state.last_status = 1;
1809        true
1810    }
1811
1812    fn shopt_var_name(name: &str) -> String {
1813        format!("SHOPT_{name}")
1814    }
1815
1816    fn set_shopt_value(&mut self, name: &str, value: &str) {
1817        let var = Self::shopt_var_name(name);
1818        self.vm.state.set_var(
1819            smol_str::SmolStr::from(var.as_str()),
1820            smol_str::SmolStr::from(value),
1821        );
1822    }
1823
1824    fn get_shopt_value(&self, name: &str) -> bool {
1825        let var = Self::shopt_var_name(name);
1826        self.vm.state.get_var(&var).as_deref() == Some("1")
1827    }
1828
1829    /// Execute `declare`/`typeset` with flag parsing.
1830    /// Supports: -i, -a, -A, -x, -r, -l, -u, -p, -n, name=value.
1831    fn execute_declare(&mut self, argv: &[String]) {
1832        let (flags, names) = parse_declare_flags(argv);
1833
1834        if flags.is_print {
1835            self.declare_print(argv, &names);
1836            return;
1837        }
1838
1839        for &idx in &names {
1840            self.declare_one_name(argv, idx, &flags);
1841        }
1842        self.vm.state.last_status = 0;
1843    }
1844
1845    /// Handle `declare -p` printing.
1846    fn declare_print(&mut self, argv: &[String], names: &[usize]) {
1847        if names.is_empty() {
1848            let vars: Vec<(String, String)> = self
1849                .vm
1850                .state
1851                .env
1852                .scopes
1853                .iter()
1854                .flat_map(|scope| {
1855                    scope
1856                        .iter()
1857                        .map(|(n, v)| (n.to_string(), v.value.as_scalar().to_string()))
1858                })
1859                .collect();
1860            for (name, val) in &vars {
1861                let line = format!("declare -- {name}=\"{val}\"\n");
1862                self.vm.stdout.extend_from_slice(line.as_bytes());
1863            }
1864        } else {
1865            for &idx in names {
1866                let name_arg = &argv[idx];
1867                let name = name_arg
1868                    .find('=')
1869                    .map_or(name_arg.as_str(), |eq| &name_arg[..eq]);
1870                if let Some(var) = self.vm.state.env.get(name) {
1871                    let val = var.value.as_scalar();
1872                    let line = format!("declare -- {name}=\"{val}\"\n");
1873                    self.vm.stdout.extend_from_slice(line.as_bytes());
1874                }
1875            }
1876        }
1877        self.vm.state.last_status = 0;
1878    }
1879
1880    /// Process a single name in a `declare`/`typeset` command.
1881    fn declare_one_name(&mut self, argv: &[String], idx: usize, flags: &DeclareFlags) {
1882        let name_arg = &argv[idx];
1883        let (name, value) = if let Some(eq) = name_arg.find('=') {
1884            (&name_arg[..eq], Some(&name_arg[eq + 1..]))
1885        } else {
1886            (name_arg.as_str(), None)
1887        };
1888
1889        if flags.is_assoc {
1890            self.vm
1891                .state
1892                .init_assoc_array(smol_str::SmolStr::from(name));
1893        } else if flags.is_indexed {
1894            self.vm
1895                .state
1896                .init_indexed_array(smol_str::SmolStr::from(name));
1897        }
1898
1899        if let Some(val) = value {
1900            self.declare_assign_value(name, val, flags);
1901        } else if !flags.is_assoc && !flags.is_indexed && self.vm.state.get_var(name).is_none() {
1902            self.vm
1903                .state
1904                .set_var(smol_str::SmolStr::from(name), smol_str::SmolStr::default());
1905        }
1906
1907        self.declare_apply_attributes(name, flags);
1908
1909        if flags.is_nameref {
1910            self.declare_apply_nameref(name);
1911        }
1912    }
1913
1914    /// Assign a value in `declare`, handling compound arrays and scalar transforms.
1915    fn declare_assign_value(&mut self, name: &str, val: &str, flags: &DeclareFlags) {
1916        if val.starts_with('(') && val.ends_with(')') {
1917            self.declare_assign_compound(name, &val[1..val.len() - 1], flags);
1918            return;
1919        }
1920        let final_val = Self::transform_declare_scalar(val, flags, &mut self.vm.state);
1921        self.vm.state.set_var(
1922            smol_str::SmolStr::from(name),
1923            smol_str::SmolStr::from(final_val.as_str()),
1924        );
1925    }
1926
1927    fn declare_assign_compound(&mut self, name: &str, inner: &str, flags: &DeclareFlags) {
1928        let name_key = smol_str::SmolStr::from(name);
1929        if flags.is_assoc || inner.contains("]=") {
1930            self.declare_assign_assoc_compound(&name_key, inner);
1931        } else {
1932            self.declare_assign_indexed_compound(&name_key, inner);
1933        }
1934    }
1935
1936    fn declare_assign_assoc_compound(&mut self, name_key: &smol_str::SmolStr, inner: &str) {
1937        self.vm.state.init_assoc_array(name_key.clone());
1938        for pair in Self::parse_assoc_pairs(inner) {
1939            self.vm.state.set_array_element(
1940                name_key.clone(),
1941                &pair.0,
1942                smol_str::SmolStr::from(pair.1.as_str()),
1943            );
1944        }
1945    }
1946
1947    fn declare_assign_indexed_compound(&mut self, name_key: &smol_str::SmolStr, inner: &str) {
1948        let elements = Self::parse_array_elements(inner);
1949        self.vm.state.init_indexed_array(name_key.clone());
1950        for (i, elem) in elements.iter().enumerate() {
1951            self.vm
1952                .state
1953                .set_array_element(name_key.clone(), &i.to_string(), elem.clone());
1954        }
1955    }
1956
1957    fn transform_declare_scalar(val: &str, flags: &DeclareFlags, state: &mut ShellState) -> String {
1958        if flags.is_integer {
1959            wasmsh_expand::eval_arithmetic(val, state).to_string()
1960        } else if flags.is_lower {
1961            val.to_lowercase()
1962        } else if flags.is_upper {
1963            val.to_uppercase()
1964        } else {
1965            val.to_string()
1966        }
1967    }
1968
1969    /// Apply export, readonly, integer attributes after declare assignment.
1970    fn declare_apply_attributes(&mut self, name: &str, flags: &DeclareFlags) {
1971        if let Some(var) = self.vm.state.env.get_mut(name) {
1972            if flags.is_export {
1973                var.exported = true;
1974            }
1975            if flags.is_readonly {
1976                var.readonly = true;
1977            }
1978            if flags.is_integer {
1979                var.integer = true;
1980            }
1981        }
1982    }
1983
1984    /// Apply nameref attribute for `declare -n`.
1985    fn declare_apply_nameref(&mut self, name: &str) {
1986        let target_value = if let Some(eq_pos) = name.find('=') {
1987            smol_str::SmolStr::from(&name[eq_pos + 1..])
1988        } else if let Some(var) = self.vm.state.env.get(name) {
1989            var.value.as_scalar()
1990        } else {
1991            smol_str::SmolStr::default()
1992        };
1993        let actual_name = name.find('=').map_or(name, |eq| &name[..eq]);
1994        self.vm.state.env.set(
1995            smol_str::SmolStr::from(actual_name),
1996            wasmsh_state::ShellVar {
1997                value: wasmsh_state::VarValue::Scalar(target_value),
1998                exported: false,
1999                readonly: false,
2000                integer: false,
2001                nameref: true,
2002            },
2003        );
2004    }
2005
2006    fn should_stop_execution(&self) -> bool {
2007        self.exec.break_depth > 0
2008            || self.exec.loop_continue
2009            || self.exec.exit_requested.is_some()
2010            || self.exec.resource_exhausted
2011    }
2012
2013    /// Check resource limits (step budget, output limit, cancellation).
2014    /// Returns true if execution should stop. Emits a diagnostic on first violation.
2015    fn check_resource_limits(&mut self) -> bool {
2016        if self.exec.resource_exhausted {
2017            return true;
2018        }
2019        // Step budget
2020        self.vm.steps += 1;
2021        if self.vm.limits.step_limit > 0 && self.vm.steps >= self.vm.limits.step_limit {
2022            self.exec.resource_exhausted = true;
2023            self.vm.diagnostics.push(wasmsh_vm::DiagnosticEvent {
2024                level: wasmsh_vm::DiagLevel::Error,
2025                category: wasmsh_vm::DiagCategory::Budget,
2026                message: format!(
2027                    "step budget exhausted: {} steps (limit: {})",
2028                    self.vm.steps, self.vm.limits.step_limit
2029                ),
2030            });
2031            return true;
2032        }
2033        // Cancellation
2034        if self.vm.cancellation_token().is_cancelled() {
2035            self.exec.resource_exhausted = true;
2036            self.vm.diagnostics.push(wasmsh_vm::DiagnosticEvent {
2037                level: wasmsh_vm::DiagLevel::Error,
2038                category: wasmsh_vm::DiagCategory::Budget,
2039                message: "execution cancelled".to_string(),
2040            });
2041            return true;
2042        }
2043        // Output limit
2044        if self.vm.limits.output_byte_limit > 0
2045            && self.vm.output_bytes > self.vm.limits.output_byte_limit
2046        {
2047            self.exec.resource_exhausted = true;
2048            self.vm.diagnostics.push(wasmsh_vm::DiagnosticEvent {
2049                level: wasmsh_vm::DiagLevel::Error,
2050                category: wasmsh_vm::DiagCategory::Budget,
2051                message: format!(
2052                    "output limit exceeded: {} bytes (limit: {})",
2053                    self.vm.output_bytes, self.vm.limits.output_byte_limit
2054                ),
2055            });
2056            return true;
2057        }
2058        false
2059    }
2060
2061    fn execute_body(&mut self, body: &[HirCompleteCommand]) {
2062        for cc in body {
2063            if self.should_stop_execution() || self.check_resource_limits() {
2064                break;
2065            }
2066            self.execute_complete_command(cc);
2067        }
2068    }
2069
2070    fn execute_complete_command(&mut self, cc: &HirCompleteCommand) {
2071        for and_or in &cc.list {
2072            if self.should_stop_execution() {
2073                break;
2074            }
2075            self.execute_pipeline_chain(and_or);
2076            if self.should_errexit(and_or) {
2077                self.exec.exit_requested = Some(self.vm.state.last_status);
2078            }
2079        }
2080    }
2081
2082    /// Expand a word value via command substitution and word expansion.
2083    fn expand_assignment_value(&mut self, value: Option<&wasmsh_ast::Word>) -> String {
2084        if let Some(w) = value {
2085            let resolved = self.resolve_command_subst(std::slice::from_ref(w));
2086            wasmsh_expand::expand_word(&resolved[0], &mut self.vm.state)
2087        } else {
2088            String::new()
2089        }
2090    }
2091
2092    /// Execute a variable assignment, handling array syntax:
2093    /// - `name=(val1 val2 ...)` -- indexed array compound assignment
2094    /// - `name[idx]=val` -- single element assignment
2095    /// - `name+=(val1 val2 ...)` -- array append
2096    /// - Plain `name=val` -- scalar assignment
2097    fn execute_assignment(
2098        &mut self,
2099        raw_name: &smol_str::SmolStr,
2100        value: Option<&wasmsh_ast::Word>,
2101    ) {
2102        let (name_str, is_append) = Self::split_assignment_name(raw_name.as_str());
2103        if self.try_assign_array_element(name_str, value) {
2104            return;
2105        }
2106
2107        let val_str = self.expand_assignment_value(value);
2108        if val_str.starts_with('(') && val_str.ends_with(')') {
2109            self.assign_compound_array(name_str, &val_str, is_append);
2110            return;
2111        }
2112
2113        let final_val = self.resolve_scalar_assignment_value(name_str, &val_str, is_append);
2114        self.vm
2115            .state
2116            .set_var(smol_str::SmolStr::from(name_str), final_val.into());
2117    }
2118
2119    fn split_assignment_name(name: &str) -> (&str, bool) {
2120        if let Some(stripped) = name.strip_suffix('+') {
2121            (stripped, true)
2122        } else {
2123            (name, false)
2124        }
2125    }
2126
2127    fn parse_array_element_assignment(name: &str) -> Option<(&str, &str)> {
2128        let bracket_pos = name.find('[')?;
2129        name.ends_with(']')
2130            .then_some((&name[..bracket_pos], &name[bracket_pos + 1..name.len() - 1]))
2131    }
2132
2133    fn try_assign_array_element(&mut self, name: &str, value: Option<&wasmsh_ast::Word>) -> bool {
2134        let Some((base, index)) = Self::parse_array_element_assignment(name) else {
2135            return false;
2136        };
2137        let val = self.expand_assignment_value(value);
2138        self.vm
2139            .state
2140            .set_array_element(smol_str::SmolStr::from(base), index, val.into());
2141        true
2142    }
2143
2144    fn resolve_scalar_assignment_value(
2145        &mut self,
2146        name: &str,
2147        value: &str,
2148        is_append: bool,
2149    ) -> String {
2150        if self.vm.state.env.get(name).is_some_and(|v| v.integer) {
2151            return self.eval_integer_assignment(name, value, is_append);
2152        }
2153        if is_append {
2154            return format!(
2155                "{}{}",
2156                self.vm.state.get_var(name).unwrap_or_default(),
2157                value
2158            );
2159        }
2160        value.to_string()
2161    }
2162
2163    fn eval_integer_assignment(&mut self, name: &str, value: &str, is_append: bool) -> String {
2164        let arith_input = if is_append {
2165            format!(
2166                "{}+{}",
2167                self.vm.state.get_var(name).unwrap_or_default(),
2168                value
2169            )
2170        } else {
2171            value.to_string()
2172        };
2173        wasmsh_expand::eval_arithmetic(&arith_input, &mut self.vm.state).to_string()
2174    }
2175
2176    /// Assign a compound array value `(...)` to a variable.
2177    fn assign_compound_array(&mut self, name_str: &str, val_str: &str, is_append: bool) {
2178        let inner = &val_str[1..val_str.len() - 1];
2179        let elements = Self::parse_array_elements(inner);
2180        let name_key = smol_str::SmolStr::from(name_str);
2181
2182        if is_append {
2183            self.vm.state.append_array(name_str, elements);
2184            return;
2185        }
2186
2187        if Self::is_assoc_array_assignment(inner, &elements) {
2188            self.assign_assoc_array(&name_key, inner);
2189            return;
2190        }
2191        self.assign_indexed_array(&name_key, &elements);
2192    }
2193
2194    fn is_assoc_array_assignment(inner: &str, elements: &[smol_str::SmolStr]) -> bool {
2195        !elements.is_empty() && inner.contains('[') && inner.contains("]=")
2196    }
2197
2198    fn assign_assoc_array(&mut self, name_key: &smol_str::SmolStr, inner: &str) {
2199        self.vm.state.init_assoc_array(name_key.clone());
2200        for (key, value) in Self::parse_assoc_pairs(inner) {
2201            self.vm.state.set_array_element(
2202                name_key.clone(),
2203                &key,
2204                smol_str::SmolStr::from(value.as_str()),
2205            );
2206        }
2207    }
2208
2209    fn assign_indexed_array(
2210        &mut self,
2211        name_key: &smol_str::SmolStr,
2212        elements: &[smol_str::SmolStr],
2213    ) {
2214        self.vm.state.init_indexed_array(name_key.clone());
2215        for (i, elem) in elements.iter().enumerate() {
2216            self.vm
2217                .state
2218                .set_array_element(name_key.clone(), &i.to_string(), elem.clone());
2219        }
2220    }
2221
2222    fn push_array_element(elements: &mut Vec<smol_str::SmolStr>, current: &mut String) {
2223        if current.is_empty() {
2224            return;
2225        }
2226        elements.push(smol_str::SmolStr::from(current.as_str()));
2227        current.clear();
2228    }
2229
2230    /// Parse space-separated array elements from the inner content of `(...)`.
2231    /// Respects quoting (single and double quotes).
2232    fn parse_array_elements(inner: &str) -> Vec<smol_str::SmolStr> {
2233        let mut elements = Vec::new();
2234        let mut current = String::new();
2235        let mut state = ArrayParseState::default();
2236
2237        for ch in inner.chars() {
2238            match state.process_char(ch) {
2239                ArrayCharAction::Append(c) => current.push(c),
2240                ArrayCharAction::Skip => {}
2241                ArrayCharAction::SplitField => {
2242                    Self::push_array_element(&mut elements, &mut current);
2243                }
2244            }
2245        }
2246        Self::push_array_element(&mut elements, &mut current);
2247        elements
2248    }
2249
2250    /// Parse `[key]=value` pairs from associative array compound assignment.
2251    fn parse_assoc_pairs(inner: &str) -> Vec<(String, String)> {
2252        let mut pairs = Vec::new();
2253        let mut pos = 0;
2254        let bytes = inner.as_bytes();
2255
2256        while pos < bytes.len() {
2257            Self::skip_ascii_whitespace(bytes, &mut pos);
2258            if pos >= bytes.len() {
2259                break;
2260            }
2261            if let Some(key) = Self::parse_assoc_key(inner, &mut pos) {
2262                pairs.push((key, Self::parse_assoc_value(inner, &mut pos)));
2263                continue;
2264            }
2265            Self::skip_non_whitespace(bytes, &mut pos);
2266        }
2267        pairs
2268    }
2269
2270    fn skip_ascii_whitespace(bytes: &[u8], pos: &mut usize) {
2271        while *pos < bytes.len() && bytes[*pos].is_ascii_whitespace() {
2272            *pos += 1;
2273        }
2274    }
2275
2276    fn skip_non_whitespace(bytes: &[u8], pos: &mut usize) {
2277        while *pos < bytes.len() && !bytes[*pos].is_ascii_whitespace() {
2278            *pos += 1;
2279        }
2280    }
2281
2282    fn parse_assoc_key(inner: &str, pos: &mut usize) -> Option<String> {
2283        let bytes = inner.as_bytes();
2284        if *pos >= bytes.len() || bytes[*pos] != b'[' {
2285            return None;
2286        }
2287
2288        *pos += 1;
2289        let key_start = *pos;
2290        while *pos < bytes.len() && bytes[*pos] != b']' {
2291            *pos += 1;
2292        }
2293        let key = inner[key_start..*pos].to_string();
2294        if *pos < bytes.len() {
2295            *pos += 1;
2296        }
2297        if *pos < bytes.len() && bytes[*pos] == b'=' {
2298            *pos += 1;
2299        }
2300        Some(key)
2301    }
2302
2303    /// Parse a single value in an associative array assignment (may be quoted).
2304    fn parse_assoc_value(inner: &str, pos: &mut usize) -> String {
2305        let bytes = inner.as_bytes();
2306        match bytes.get(*pos).copied() {
2307            Some(b'"') => Self::parse_double_quoted_assoc_value(bytes, pos),
2308            Some(b'\'') => Self::parse_single_quoted_assoc_value(bytes, pos),
2309            _ => Self::parse_unquoted_assoc_value(bytes, pos),
2310        }
2311    }
2312
2313    fn parse_double_quoted_assoc_value(bytes: &[u8], pos: &mut usize) -> String {
2314        let mut value = String::new();
2315        *pos += 1;
2316        while *pos < bytes.len() && bytes[*pos] != b'"' {
2317            if bytes[*pos] == b'\\' && *pos + 1 < bytes.len() {
2318                *pos += 1;
2319            }
2320            value.push(bytes[*pos] as char);
2321            *pos += 1;
2322        }
2323        if *pos < bytes.len() {
2324            *pos += 1;
2325        }
2326        value
2327    }
2328
2329    fn parse_single_quoted_assoc_value(bytes: &[u8], pos: &mut usize) -> String {
2330        let mut value = String::new();
2331        *pos += 1;
2332        while *pos < bytes.len() && bytes[*pos] != b'\'' {
2333            value.push(bytes[*pos] as char);
2334            *pos += 1;
2335        }
2336        if *pos < bytes.len() {
2337            *pos += 1;
2338        }
2339        value
2340    }
2341
2342    fn parse_unquoted_assoc_value(bytes: &[u8], pos: &mut usize) -> String {
2343        let mut value = String::new();
2344        while *pos < bytes.len() && !bytes[*pos].is_ascii_whitespace() {
2345            value.push(bytes[*pos] as char);
2346            *pos += 1;
2347        }
2348        value
2349    }
2350
2351    /// Maximum number of arguments after glob expansion.
2352    const MAX_GLOB_RESULTS: usize = 10_000;
2353
2354    /// Expand glob patterns in argv against the VFS.
2355    /// Supports: basic glob (`*`, `?`, `[...]`), globstar (`**`), nullglob,
2356    /// dotglob, and extglob patterns.
2357    /// When `set -f` (noglob) is active, glob expansion is skipped entirely.
2358    fn expand_globs(&mut self, argv: Vec<String>) -> Vec<String> {
2359        if self.vm.state.get_var("SHOPT_f").as_deref() == Some("1") {
2360            return argv;
2361        }
2362        let nullglob = self.get_shopt_value("nullglob");
2363        let dotglob = self.get_shopt_value("dotglob");
2364        let globstar = self.get_shopt_value("globstar");
2365        let extglob = self.get_shopt_value("extglob");
2366
2367        let mut result = Vec::new();
2368        for arg in argv {
2369            result.extend(self.expand_glob_arg(arg, nullglob, dotglob, globstar, extglob));
2370        }
2371        result.truncate(Self::MAX_GLOB_RESULTS);
2372        result
2373    }
2374
2375    #[allow(clippy::fn_params_excessive_bools)]
2376    fn expand_glob_arg(
2377        &self,
2378        arg: String,
2379        nullglob: bool,
2380        dotglob: bool,
2381        globstar: bool,
2382        extglob: bool,
2383    ) -> Vec<String> {
2384        if !Self::is_glob_pattern(&arg, extglob) {
2385            return vec![arg];
2386        }
2387        if globstar && arg.contains("**") {
2388            return self.expand_globstar_arg(arg, nullglob, dotglob, extglob);
2389        }
2390        self.expand_standard_glob_arg(arg, nullglob, dotglob, extglob)
2391    }
2392
2393    fn is_glob_pattern(arg: &str, extglob: bool) -> bool {
2394        let has_bracket_class = arg.contains('[') && arg.contains(']');
2395        arg.contains('*')
2396            || arg.contains('?')
2397            || has_bracket_class
2398            || (extglob && has_extglob_pattern(arg))
2399    }
2400
2401    fn expand_globstar_arg(
2402        &self,
2403        arg: String,
2404        nullglob: bool,
2405        dotglob: bool,
2406        extglob: bool,
2407    ) -> Vec<String> {
2408        let mut matches = self.expand_globstar(&arg, dotglob, extglob);
2409        matches.sort();
2410        self.finalize_glob_matches(arg, matches, nullglob)
2411    }
2412
2413    fn expand_standard_glob_arg(
2414        &self,
2415        arg: String,
2416        nullglob: bool,
2417        dotglob: bool,
2418        extglob: bool,
2419    ) -> Vec<String> {
2420        let Some((dir, pattern, prefix)) = self.split_glob_search(&arg) else {
2421            return self.finalize_glob_matches(arg.clone(), Vec::new(), nullglob);
2422        };
2423        let matches = self.read_glob_matches(&dir, &pattern, prefix.as_deref(), dotglob, extglob);
2424        self.finalize_glob_matches(arg, matches, nullglob)
2425    }
2426
2427    fn split_glob_search(&self, arg: &str) -> Option<(String, String, Option<String>)> {
2428        let Some(slash_pos) = arg.rfind('/') else {
2429            return Some((self.vm.state.cwd.clone(), arg.to_string(), None));
2430        };
2431
2432        let dir_part = &arg[..=slash_pos];
2433        if Self::path_segment_has_glob(dir_part) {
2434            return None;
2435        }
2436
2437        Some((
2438            self.resolve_cwd_path(dir_part),
2439            arg[slash_pos + 1..].to_string(),
2440            Some(dir_part.to_string()),
2441        ))
2442    }
2443
2444    fn path_segment_has_glob(path: &str) -> bool {
2445        path.contains('*') || path.contains('?') || path.contains('[')
2446    }
2447
2448    fn read_glob_matches(
2449        &self,
2450        dir: &str,
2451        pattern: &str,
2452        prefix: Option<&str>,
2453        dotglob: bool,
2454        extglob: bool,
2455    ) -> Vec<String> {
2456        let Ok(entries) = self.fs.read_dir(dir) else {
2457            return Vec::new();
2458        };
2459
2460        let mut matches: Vec<String> = entries
2461            .iter()
2462            .filter(|e| glob_match_ext(pattern, &e.name, dotglob, extglob))
2463            .map(|e| match prefix {
2464                Some(prefix) => format!("{prefix}{}", e.name),
2465                None => e.name.clone(),
2466            })
2467            .collect();
2468        matches.sort();
2469        matches
2470    }
2471
2472    #[allow(clippy::unused_self)]
2473    fn finalize_glob_matches(
2474        &self,
2475        arg: String,
2476        matches: Vec<String>,
2477        nullglob: bool,
2478    ) -> Vec<String> {
2479        if !matches.is_empty() {
2480            return matches;
2481        }
2482        if nullglob {
2483            Vec::new()
2484        } else {
2485            vec![arg]
2486        }
2487    }
2488
2489    /// Expand a globstar (**) pattern against the VFS with recursive directory traversal.
2490    fn expand_globstar(&self, pattern: &str, dotglob: bool, extglob: bool) -> Vec<String> {
2491        // Split pattern into segments by /
2492        let segments: Vec<&str> = pattern.split('/').collect();
2493        let base_dir = self.vm.state.cwd.clone();
2494        let mut matches = Vec::new();
2495        self.globstar_walk(&base_dir, &segments, 0, "", dotglob, extglob, &mut matches);
2496        matches
2497    }
2498
2499    /// Recursive walk for globstar expansion.
2500    fn globstar_walk(
2501        &self,
2502        dir: &str,
2503        segments: &[&str],
2504        seg_idx: usize,
2505        prefix: &str,
2506        dotglob: bool,
2507        extglob: bool,
2508        matches: &mut Vec<String>,
2509    ) {
2510        if seg_idx >= segments.len() {
2511            return;
2512        }
2513
2514        let seg = segments[seg_idx];
2515        if seg == "**" {
2516            self.globstar_walk_wildcard(dir, segments, seg_idx, prefix, dotglob, extglob, matches);
2517            return;
2518        }
2519        self.globstar_walk_segment(
2520            dir, seg, segments, seg_idx, prefix, dotglob, extglob, matches,
2521        );
2522    }
2523
2524    fn globstar_walk_wildcard(
2525        &self,
2526        dir: &str,
2527        segments: &[&str],
2528        seg_idx: usize,
2529        prefix: &str,
2530        dotglob: bool,
2531        extglob: bool,
2532        matches: &mut Vec<String>,
2533    ) {
2534        if seg_idx + 1 < segments.len() {
2535            self.globstar_walk(
2536                dir,
2537                segments,
2538                seg_idx + 1,
2539                prefix,
2540                dotglob,
2541                extglob,
2542                matches,
2543            );
2544        }
2545
2546        let Ok(entries) = self.fs.read_dir(dir) else {
2547            return;
2548        };
2549        for entry in &entries {
2550            if !dotglob && entry.name.starts_with('.') {
2551                continue;
2552            }
2553            let (child_path, child_prefix) = Self::globstar_child_paths(dir, prefix, &entry.name);
2554            if self.fs.stat(&child_path).map(|m| m.is_dir).unwrap_or(false) {
2555                self.globstar_walk(
2556                    &child_path,
2557                    segments,
2558                    seg_idx,
2559                    &child_prefix,
2560                    dotglob,
2561                    extglob,
2562                    matches,
2563                );
2564            }
2565        }
2566    }
2567
2568    #[allow(clippy::too_many_arguments)]
2569    fn globstar_walk_segment(
2570        &self,
2571        dir: &str,
2572        seg: &str,
2573        segments: &[&str],
2574        seg_idx: usize,
2575        prefix: &str,
2576        dotglob: bool,
2577        extglob: bool,
2578        matches: &mut Vec<String>,
2579    ) {
2580        let Ok(entries) = self.fs.read_dir(dir) else {
2581            return;
2582        };
2583        let is_last = seg_idx == segments.len() - 1;
2584
2585        for entry in &entries {
2586            if !glob_match_ext(seg, &entry.name, dotglob, extglob) {
2587                continue;
2588            }
2589            self.globstar_handle_matched_entry(
2590                dir,
2591                segments,
2592                seg_idx,
2593                prefix,
2594                dotglob,
2595                extglob,
2596                matches,
2597                &entry.name,
2598                is_last,
2599            );
2600        }
2601    }
2602
2603    #[allow(clippy::too_many_arguments)]
2604    fn globstar_handle_matched_entry(
2605        &self,
2606        dir: &str,
2607        segments: &[&str],
2608        seg_idx: usize,
2609        prefix: &str,
2610        dotglob: bool,
2611        extglob: bool,
2612        matches: &mut Vec<String>,
2613        name: &str,
2614        is_last: bool,
2615    ) {
2616        let (child_path, child_prefix) = Self::globstar_child_paths(dir, prefix, name);
2617        if is_last {
2618            matches.push(child_prefix);
2619            return;
2620        }
2621        let is_dir = self.fs.stat(&child_path).map(|m| m.is_dir).unwrap_or(false);
2622        if is_dir {
2623            self.globstar_walk(
2624                &child_path,
2625                segments,
2626                seg_idx + 1,
2627                &child_prefix,
2628                dotglob,
2629                extglob,
2630                matches,
2631            );
2632        }
2633    }
2634
2635    fn globstar_child_paths(dir: &str, prefix: &str, name: &str) -> (String, String) {
2636        let child_path = if dir == "/" {
2637            format!("/{name}")
2638        } else {
2639            format!("{dir}/{name}")
2640        };
2641        let child_prefix = if prefix.is_empty() {
2642            name.to_string()
2643        } else {
2644            format!("{prefix}/{name}")
2645        };
2646        (child_path, child_prefix)
2647    }
2648
2649    /// Write data to a file path, reporting errors to stderr.
2650    fn write_to_file(&mut self, path: &str, target: &str, data: &[u8], opts: OpenOptions) {
2651        match self.fs.open(path, opts) {
2652            Ok(h) => {
2653                if let Err(e) = self.fs.write_file(h, data) {
2654                    self.vm
2655                        .stderr
2656                        .extend_from_slice(format!("wasmsh: write error: {e}\n").as_bytes());
2657                }
2658                self.fs.close(h);
2659            }
2660            Err(e) => {
2661                self.vm
2662                    .stderr
2663                    .extend_from_slice(format!("wasmsh: {target}: {e}\n").as_bytes());
2664            }
2665        }
2666    }
2667
2668    /// Capture stdout data from the given position, truncating the stdout buffer.
2669    fn capture_stdout(&mut self, from: usize) -> Vec<u8> {
2670        let data = self.vm.stdout[from..].to_vec();
2671        self.vm.stdout.truncate(from);
2672        data
2673    }
2674
2675    /// Apply redirections: for `>` and `>>`, write captured stdout/stderr to file.
2676    /// For `<`, read file content (handled pre-execution).
2677    /// Supports fd-specific redirections (2>, 2>>) and &> (both stdout and stderr).
2678    fn apply_redirections(&mut self, redirections: &[HirRedirection], stdout_before: usize) {
2679        for redir in redirections {
2680            // Resolve command substitutions in redirect targets before expansion
2681            let resolved = self.resolve_command_subst(std::slice::from_ref(&redir.target));
2682            let resolved_target = resolved.first().unwrap_or(&redir.target);
2683            let target = wasmsh_expand::expand_word(resolved_target, &mut self.vm.state);
2684            let path = self.resolve_cwd_path(&target);
2685            let fd = redir.fd.unwrap_or(1);
2686
2687            match redir.op {
2688                RedirectionOp::Output => {
2689                    self.apply_output_redir(&path, &target, fd, stdout_before);
2690                }
2691                RedirectionOp::Append => {
2692                    self.apply_append_redir(&path, &target, fd, stdout_before);
2693                }
2694                RedirectionOp::DupOutput => {
2695                    let target_fd: u32 = target.parse().unwrap_or(1);
2696                    let source_fd = redir.fd.unwrap_or(1);
2697                    if source_fd == 2 && target_fd == 1 {
2698                        let stderr_data = std::mem::take(&mut self.vm.stderr);
2699                        self.vm.stdout.extend_from_slice(&stderr_data);
2700                    } else if source_fd == 1 && target_fd == 2 {
2701                        let stdout_data = self.capture_stdout(stdout_before);
2702                        self.vm.stderr.extend_from_slice(&stdout_data);
2703                    }
2704                }
2705                #[allow(unreachable_patterns)]
2706                _ => {}
2707            }
2708        }
2709    }
2710
2711    /// Apply `>` output redirection for a specific fd.
2712    fn apply_output_redir(&mut self, path: &str, target: &str, fd: u32, stdout_before: usize) {
2713        let data = if fd == FD_BOTH {
2714            let mut combined = self.capture_stdout(stdout_before);
2715            combined.extend_from_slice(&std::mem::take(&mut self.vm.stderr));
2716            combined
2717        } else if fd == 2 {
2718            std::mem::take(&mut self.vm.stderr)
2719        } else {
2720            self.capture_stdout(stdout_before)
2721        };
2722        self.write_to_file(path, target, &data, OpenOptions::write());
2723    }
2724
2725    /// Apply `>>` append redirection for a specific fd.
2726    fn apply_append_redir(&mut self, path: &str, target: &str, fd: u32, stdout_before: usize) {
2727        let data = if fd == 2 {
2728            std::mem::take(&mut self.vm.stderr)
2729        } else {
2730            self.capture_stdout(stdout_before)
2731        };
2732        self.write_to_file(path, target, &data, OpenOptions::append());
2733    }
2734}
2735
2736/// Convert a protocol diagnostic level to a VM diagnostic level.
2737fn convert_diag_level(level: DiagnosticLevel) -> wasmsh_vm::DiagLevel {
2738    match level {
2739        DiagnosticLevel::Trace => wasmsh_vm::DiagLevel::Trace,
2740        DiagnosticLevel::Warning => wasmsh_vm::DiagLevel::Warning,
2741        DiagnosticLevel::Error => wasmsh_vm::DiagLevel::Error,
2742        _ => wasmsh_vm::DiagLevel::Info,
2743    }
2744}
2745
2746// ---- [[ ]] expression evaluator (free functions) ----
2747
2748/// Evaluate an `||` expression (lowest precedence).
2749fn dbl_bracket_eval_or(
2750    tokens: &[String],
2751    pos: &mut usize,
2752    fs: &BackendFs,
2753    state: &mut ShellState,
2754) -> bool {
2755    let mut result = dbl_bracket_eval_and(tokens, pos, fs, state);
2756    while *pos < tokens.len() && tokens[*pos] == "||" {
2757        *pos += 1;
2758        let rhs = dbl_bracket_eval_and(tokens, pos, fs, state);
2759        result = result || rhs;
2760    }
2761    result
2762}
2763
2764/// Evaluate an `&&` expression.
2765fn dbl_bracket_eval_and(
2766    tokens: &[String],
2767    pos: &mut usize,
2768    fs: &BackendFs,
2769    state: &mut ShellState,
2770) -> bool {
2771    let mut result = dbl_bracket_eval_not(tokens, pos, fs, state);
2772    while *pos < tokens.len() && tokens[*pos] == "&&" {
2773        *pos += 1;
2774        let rhs = dbl_bracket_eval_not(tokens, pos, fs, state);
2775        result = result && rhs;
2776    }
2777    result
2778}
2779
2780/// Evaluate a `!` (negation) expression.
2781fn dbl_bracket_eval_not(
2782    tokens: &[String],
2783    pos: &mut usize,
2784    fs: &BackendFs,
2785    state: &mut ShellState,
2786) -> bool {
2787    if *pos < tokens.len() && tokens[*pos] == "!" {
2788        *pos += 1;
2789        return !dbl_bracket_eval_not(tokens, pos, fs, state);
2790    }
2791    dbl_bracket_eval_primary(tokens, pos, fs, state)
2792}
2793
2794/// Evaluate a primary expression: grouped `(expr)`, unary test, binary test, or string truth.
2795fn dbl_bracket_eval_primary(
2796    tokens: &[String],
2797    pos: &mut usize,
2798    fs: &BackendFs,
2799    state: &mut ShellState,
2800) -> bool {
2801    if *pos >= tokens.len() {
2802        return false;
2803    }
2804    if let Some(result) = dbl_bracket_try_group(tokens, pos, fs, state) {
2805        return result;
2806    }
2807    if let Some(result) = dbl_bracket_try_unary(tokens, pos, fs) {
2808        return result;
2809    }
2810    if *pos + 1 == tokens.len() {
2811        return dbl_bracket_take_truthy_token(tokens, pos);
2812    }
2813    if let Some(result) = dbl_bracket_try_binary(tokens, pos, state) {
2814        return result;
2815    }
2816    dbl_bracket_take_truthy_token(tokens, pos)
2817}
2818
2819fn dbl_bracket_try_group(
2820    tokens: &[String],
2821    pos: &mut usize,
2822    fs: &BackendFs,
2823    state: &mut ShellState,
2824) -> Option<bool> {
2825    if tokens.get(*pos).map(String::as_str) != Some("(") {
2826        return None;
2827    }
2828
2829    *pos += 1;
2830    let result = dbl_bracket_eval_or(tokens, pos, fs, state);
2831    if tokens.get(*pos).map(String::as_str) == Some(")") {
2832        *pos += 1;
2833    }
2834    Some(result)
2835}
2836
2837fn dbl_bracket_take_truthy_token(tokens: &[String], pos: &mut usize) -> bool {
2838    let Some(token) = tokens.get(*pos) else {
2839        return false;
2840    };
2841    *pos += 1;
2842    !token.is_empty()
2843}
2844
2845/// Try to evaluate a unary test (`-z`, `-n`, `-f`, etc.). Returns `None` if not a unary op.
2846fn dbl_bracket_try_unary(tokens: &[String], pos: &mut usize, fs: &BackendFs) -> Option<bool> {
2847    if *pos + 1 >= tokens.len() {
2848        return None;
2849    }
2850    let flag = dbl_bracket_parse_unary_flag(&tokens[*pos])?;
2851    match flag {
2852        b'z' | b'n' => Some(dbl_bracket_eval_string_test(tokens, pos, flag)),
2853        b'f' | b'd' | b'e' | b's' | b'r' | b'w' | b'x' => {
2854            dbl_bracket_eval_file_test(tokens, pos, flag, fs)
2855        }
2856        _ => None,
2857    }
2858}
2859
2860fn dbl_bracket_parse_unary_flag(op: &str) -> Option<u8> {
2861    if !op.starts_with('-') || op.len() != 2 {
2862        return None;
2863    }
2864    Some(op.as_bytes()[1])
2865}
2866
2867fn dbl_bracket_eval_string_test(tokens: &[String], pos: &mut usize, flag: u8) -> bool {
2868    *pos += 1;
2869    let arg = &tokens[*pos];
2870    *pos += 1;
2871    if flag == b'z' {
2872        arg.is_empty()
2873    } else {
2874        !arg.is_empty()
2875    }
2876}
2877
2878fn dbl_bracket_eval_file_test(
2879    tokens: &[String],
2880    pos: &mut usize,
2881    flag: u8,
2882    fs: &BackendFs,
2883) -> Option<bool> {
2884    if *pos + 2 < tokens.len() && is_binary_op(&tokens[*pos + 2]) {
2885        return None;
2886    }
2887    *pos += 1;
2888    let path_str = &tokens[*pos];
2889    *pos += 1;
2890    Some(eval_file_test(flag, path_str, fs))
2891}
2892
2893/// Try to evaluate a binary test. Returns `None` if no binary op at pos+1.
2894fn dbl_bracket_try_binary(
2895    tokens: &[String],
2896    pos: &mut usize,
2897    state: &mut ShellState,
2898) -> Option<bool> {
2899    if *pos + 2 > tokens.len() {
2900        return None;
2901    }
2902    let op_idx = *pos + 1;
2903    if op_idx >= tokens.len() || !is_binary_op(&tokens[op_idx]) {
2904        return None;
2905    }
2906    let lhs = tokens[*pos].clone();
2907    *pos += 1;
2908    let op = tokens[*pos].clone();
2909    *pos += 1;
2910
2911    let rhs = dbl_bracket_collect_rhs(tokens, pos, &op);
2912    Some(eval_binary_op(&lhs, &op, &rhs, state))
2913}
2914
2915/// Collect the right-hand side for a binary operator. For `=~`, the RHS extends
2916/// until `&&`, `||`, or end of tokens.
2917fn dbl_bracket_collect_rhs(tokens: &[String], pos: &mut usize, op: &str) -> String {
2918    if *pos >= tokens.len() {
2919        return String::new();
2920    }
2921    if op == "=~" {
2922        return dbl_bracket_collect_regex_rhs(tokens, pos);
2923    }
2924    let rhs = tokens[*pos].clone();
2925    *pos += 1;
2926    rhs
2927}
2928
2929fn dbl_bracket_collect_regex_rhs(tokens: &[String], pos: &mut usize) -> String {
2930    let mut rhs = String::new();
2931    while *pos < tokens.len() && tokens[*pos] != "&&" && tokens[*pos] != "||" {
2932        rhs.push_str(&tokens[*pos]);
2933        *pos += 1;
2934    }
2935    rhs
2936}
2937
2938/// Check whether a token is a binary operator in `[[ ]]` context.
2939fn is_binary_op(s: &str) -> bool {
2940    matches!(
2941        s,
2942        "==" | "!=" | "=~" | "=" | "<" | ">" | "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge"
2943    )
2944}
2945
2946/// Evaluate a binary operation.
2947fn eval_binary_op(lhs: &str, op: &str, rhs: &str, state: &mut ShellState) -> bool {
2948    match op {
2949        "==" | "=" => glob_cmp(lhs, rhs, state, false),
2950        "!=" => !glob_cmp(lhs, rhs, state, false),
2951        "=~" => eval_regex_match(lhs, rhs, state),
2952        "<" => lhs < rhs,
2953        ">" => lhs > rhs,
2954        _ => eval_int_cmp(lhs, op, rhs),
2955    }
2956}
2957
2958/// Glob-compare lhs against rhs pattern, respecting nocasematch.
2959fn glob_cmp(lhs: &str, rhs: &str, state: &ShellState, _negate: bool) -> bool {
2960    let nocasematch = state.get_var("SHOPT_nocasematch").as_deref() == Some("1");
2961    if nocasematch {
2962        glob_match_inner(rhs.to_lowercase().as_bytes(), lhs.to_lowercase().as_bytes())
2963    } else {
2964        glob_match_inner(rhs.as_bytes(), lhs.as_bytes())
2965    }
2966}
2967
2968/// Evaluate a regex match (`=~`) with capture groups for `BASH_REMATCH`.
2969fn eval_regex_match(lhs: &str, rhs: &str, state: &mut ShellState) -> bool {
2970    let captures = regex_match_with_captures(lhs, rhs);
2971    let br_name = smol_str::SmolStr::from("BASH_REMATCH");
2972    let Some(caps) = captures else {
2973        state.init_indexed_array(br_name);
2974        return false;
2975    };
2976    state.init_indexed_array(br_name.clone());
2977    for (i, cap) in caps.iter().enumerate() {
2978        state.set_array_element(
2979            br_name.clone(),
2980            &i.to_string(),
2981            smol_str::SmolStr::from(cap.as_str()),
2982        );
2983    }
2984    true
2985}
2986
2987/// Evaluate an integer comparison operator (`-eq`, `-ne`, `-lt`, `-le`, `-gt`, `-ge`).
2988fn eval_int_cmp(lhs: &str, op: &str, rhs: &str) -> bool {
2989    let a: i64 = lhs.parse().unwrap_or(0);
2990    let b: i64 = rhs.parse().unwrap_or(0);
2991    match op {
2992        "-eq" => a == b,
2993        "-ne" => a != b,
2994        "-lt" => a < b,
2995        "-le" => a <= b,
2996        "-gt" => a > b,
2997        "-ge" => a >= b,
2998        _ => false,
2999    }
3000}
3001
3002/// Evaluate a unary file test.
3003fn eval_file_test(flag: u8, path: &str, fs: &BackendFs) -> bool {
3004    use wasmsh_fs::Vfs;
3005    match fs.stat(path) {
3006        Ok(meta) => match flag {
3007            b'f' => !meta.is_dir,
3008            b'd' => meta.is_dir,
3009            b's' => meta.size > 0,
3010            // -e, -r, -w, -x: in the VFS all existing files are accessible
3011            b'e' | b'r' | b'w' | b'x' => true,
3012            _ => false,
3013        },
3014        Err(_) => false,
3015    }
3016}
3017
3018/// Strip anchoring from a regex pattern, returning (core, `anchored_start`, `anchored_end`).
3019fn regex_strip_anchors(pattern: &str) -> (&str, bool, bool) {
3020    let anchored_start = pattern.starts_with('^');
3021    let anchored_end = pattern.ends_with('$') && !pattern.ends_with("\\$");
3022    let core = match (anchored_start, anchored_end) {
3023        (true, true) if pattern.len() >= 2 => &pattern[1..pattern.len() - 1],
3024        (true, _) => &pattern[1..],
3025        (_, true) => &pattern[..pattern.len() - 1],
3026        _ => pattern,
3027    };
3028    (core, anchored_start, anchored_end)
3029}
3030
3031/// Check if a regex core has any special regex metacharacters.
3032fn has_regex_metachar(core: &str) -> bool {
3033    core.contains('.')
3034        || core.contains('+')
3035        || core.contains('*')
3036        || core.contains('?')
3037        || core.contains('[')
3038        || core.contains('(')
3039        || core.contains('|')
3040}
3041
3042/// Find match range for a literal pattern with anchoring.
3043fn literal_match_range(text: &str, core: &str, start: bool, end: bool) -> Option<(usize, usize)> {
3044    match (start, end) {
3045        (true, true) if text == core => Some((0, text.len())),
3046        (true, false) if text.starts_with(core) => Some((0, core.len())),
3047        (false, true) if text.ends_with(core) => Some((text.len() - core.len(), text.len())),
3048        (false, false) => text.find(core).map(|pos| (pos, pos + core.len())),
3049        _ => None,
3050    }
3051}
3052
3053/// Regex match with capture group support.
3054///
3055/// Returns `Some(captures)` if the pattern matches, where `captures[0]` is the
3056/// full match and `captures[1..]` are the parenthesized subgroup matches.
3057/// Returns `None` if no match.
3058fn regex_match_with_captures(text: &str, pattern: &str) -> Option<Vec<String>> {
3059    let (core, anchored_start, anchored_end) = regex_strip_anchors(pattern);
3060
3061    if !has_regex_metachar(core) {
3062        return regex_match_literal_with_captures(text, core, anchored_start, anchored_end);
3063    }
3064
3065    regex_find_first_match(text, core, anchored_start, anchored_end)
3066}
3067
3068fn regex_find_first_match(
3069    text: &str,
3070    core: &str,
3071    anchored_start: bool,
3072    anchored_end: bool,
3073) -> Option<Vec<String>> {
3074    let end = if anchored_start { 0 } else { text.len() };
3075    for start in 0..=end {
3076        if let Some(result) = regex_match_from_start(text, core, anchored_end, start) {
3077            return Some(result);
3078        }
3079    }
3080    None
3081}
3082
3083fn regex_match_literal_with_captures(
3084    text: &str,
3085    core: &str,
3086    anchored_start: bool,
3087    anchored_end: bool,
3088) -> Option<Vec<String>> {
3089    literal_match_range(text, core, anchored_start, anchored_end)
3090        .map(|(s, e)| vec![text[s..e].to_string()])
3091}
3092
3093fn regex_match_from_start(
3094    text: &str,
3095    core: &str,
3096    anchored_end: bool,
3097    start: usize,
3098) -> Option<Vec<String>> {
3099    let mut group_caps: Vec<(usize, usize)> = Vec::new();
3100    let end = regex_match_capturing(
3101        text.as_bytes(),
3102        start,
3103        core.as_bytes(),
3104        0,
3105        anchored_end,
3106        &mut group_caps,
3107    )?;
3108    Some(regex_build_capture_list(text, start, end, &group_caps))
3109}
3110
3111fn regex_build_capture_list(
3112    text: &str,
3113    start: usize,
3114    end: usize,
3115    group_caps: &[(usize, usize)],
3116) -> Vec<String> {
3117    let mut result = vec![text[start..end].to_string()];
3118    for &(gs, ge) in group_caps {
3119        result.push(text[gs..ge].to_string());
3120    }
3121    result
3122}
3123
3124/// Backtracking regex matcher with capture group support.
3125/// Returns `Some(end_position)` on match, `None` on no match.
3126/// `captures` accumulates (start, end) pairs for each parenthesized group.
3127fn regex_match_capturing(
3128    text: &[u8],
3129    ti: usize,
3130    pat: &[u8],
3131    pi: usize,
3132    must_end: bool,
3133    captures: &mut Vec<(usize, usize)>,
3134) -> Option<usize> {
3135    if pi >= pat.len() {
3136        return regex_check_end(ti, text.len(), must_end);
3137    }
3138
3139    if pat[pi] == b'(' {
3140        return regex_match_group(text, ti, pat, pi, must_end, captures);
3141    }
3142
3143    regex_match_elem(text, ti, pat, pi, must_end, captures)
3144}
3145
3146/// Check if end-of-pattern is valid given anchoring.
3147fn regex_check_end(ti: usize, text_len: usize, must_end: bool) -> Option<usize> {
3148    if must_end && ti < text_len {
3149        None
3150    } else {
3151        Some(ti)
3152    }
3153}
3154
3155/// Handle a parenthesized group in the regex, dispatching by quantifier.
3156fn regex_match_group(
3157    text: &[u8],
3158    ti: usize,
3159    pat: &[u8],
3160    pi: usize,
3161    must_end: bool,
3162    captures: &mut Vec<(usize, usize)>,
3163) -> Option<usize> {
3164    let close = find_matching_paren_bytes(pat, pi + 1)?;
3165    let inner = &pat[pi + 1..close];
3166    let rest = &pat[close + 1..];
3167    let (quant, after_quant_offset) = parse_group_quantifier(pat, close);
3168    let after_quant = &pat[after_quant_offset..];
3169    let alternatives = split_alternatives_bytes(inner);
3170
3171    regex_dispatch_group_quant(
3172        text,
3173        ti,
3174        rest,
3175        after_quant,
3176        must_end,
3177        captures,
3178        &alternatives,
3179        quant,
3180    )
3181}
3182
3183fn parse_group_quantifier(pat: &[u8], close: usize) -> (u8, usize) {
3184    if close + 1 < pat.len() {
3185        match pat[close + 1] {
3186            q @ (b'*' | b'+' | b'?') => (q, close + 2),
3187            _ => (0, close + 1),
3188        }
3189    } else {
3190        (0, close + 1)
3191    }
3192}
3193
3194#[allow(clippy::too_many_arguments)]
3195fn regex_dispatch_group_quant(
3196    text: &[u8],
3197    ti: usize,
3198    rest: &[u8],
3199    after_quant: &[u8],
3200    must_end: bool,
3201    captures: &mut Vec<(usize, usize)>,
3202    alternatives: &[Vec<u8>],
3203    quant: u8,
3204) -> Option<usize> {
3205    match quant {
3206        b'+' => regex_match_group_rep(text, ti, after_quant, must_end, captures, alternatives, 1),
3207        b'*' => regex_match_group_rep(text, ti, after_quant, must_end, captures, alternatives, 0),
3208        b'?' => regex_match_group_opt(text, ti, after_quant, must_end, captures, alternatives),
3209        _ => regex_match_group_exact(text, ti, rest, must_end, captures, alternatives),
3210    }
3211}
3212
3213/// Match a group with repetition quantifier (+ or *).
3214fn regex_match_group_rep(
3215    text: &[u8],
3216    ti: usize,
3217    after: &[u8],
3218    must_end: bool,
3219    captures: &mut Vec<(usize, usize)>,
3220    alternatives: &[Vec<u8>],
3221    min_reps: usize,
3222) -> Option<usize> {
3223    let save = captures.len();
3224    for end_pos in (ti..=text.len()).rev() {
3225        captures.truncate(save);
3226        if let Some(result) = regex_try_group_rep_at(
3227            text,
3228            ti,
3229            end_pos,
3230            after,
3231            must_end,
3232            captures,
3233            alternatives,
3234            min_reps,
3235            save,
3236        ) {
3237            return Some(result);
3238        }
3239    }
3240    captures.truncate(save);
3241    None
3242}
3243
3244#[allow(clippy::too_many_arguments)]
3245fn regex_try_group_rep_at(
3246    text: &[u8],
3247    ti: usize,
3248    end_pos: usize,
3249    after: &[u8],
3250    must_end: bool,
3251    captures: &mut Vec<(usize, usize)>,
3252    alternatives: &[Vec<u8>],
3253    min_reps: usize,
3254    save: usize,
3255) -> Option<usize> {
3256    if !regex_match_group_repeated(text, ti, end_pos, alternatives, min_reps) {
3257        return None;
3258    }
3259    let final_end = regex_match_capturing(text, end_pos, after, 0, must_end, captures)?;
3260    captures.insert(save, (ti, end_pos));
3261    Some(final_end)
3262}
3263
3264/// Match a group with `?` quantifier (zero or one).
3265fn regex_match_group_opt(
3266    text: &[u8],
3267    ti: usize,
3268    after: &[u8],
3269    must_end: bool,
3270    captures: &mut Vec<(usize, usize)>,
3271    alternatives: &[Vec<u8>],
3272) -> Option<usize> {
3273    let save = captures.len();
3274    // Try one
3275    if let Some(result) =
3276        regex_try_group_one_alt(text, ti, after, must_end, captures, alternatives, save)
3277    {
3278        return Some(result);
3279    }
3280    // Try zero
3281    captures.truncate(save);
3282    if let Some(final_end) = regex_match_capturing(text, ti, after, 0, must_end, captures) {
3283        captures.insert(save, (ti, ti));
3284        return Some(final_end);
3285    }
3286    captures.truncate(save);
3287    None
3288}
3289
3290fn regex_try_group_one_alt(
3291    text: &[u8],
3292    ti: usize,
3293    after: &[u8],
3294    must_end: bool,
3295    captures: &mut Vec<(usize, usize)>,
3296    alternatives: &[Vec<u8>],
3297    save: usize,
3298) -> Option<usize> {
3299    for alt in alternatives {
3300        captures.truncate(save);
3301        if let Some(result) =
3302            regex_try_alt_then_continue(text, ti, alt, after, must_end, captures, save)
3303        {
3304            return Some(result);
3305        }
3306        captures.truncate(save);
3307    }
3308    None
3309}
3310
3311fn regex_try_alt_then_continue(
3312    text: &[u8],
3313    ti: usize,
3314    alt: &[u8],
3315    after: &[u8],
3316    must_end: bool,
3317    captures: &mut Vec<(usize, usize)>,
3318    save: usize,
3319) -> Option<usize> {
3320    let end = regex_try_match_at(text, ti, alt)?;
3321    let final_end = regex_match_capturing(text, end, after, 0, must_end, captures)?;
3322    captures.insert(save, (ti, end));
3323    Some(final_end)
3324}
3325
3326/// Match a group exactly once (no quantifier).
3327fn regex_match_group_exact(
3328    text: &[u8],
3329    ti: usize,
3330    rest: &[u8],
3331    must_end: bool,
3332    captures: &mut Vec<(usize, usize)>,
3333    alternatives: &[Vec<u8>],
3334) -> Option<usize> {
3335    regex_try_group_one_alt(
3336        text,
3337        ti,
3338        rest,
3339        must_end,
3340        captures,
3341        alternatives,
3342        captures.len(),
3343    )
3344}
3345
3346/// Parse a quantifier after a regex element.
3347fn parse_quantifier(pat: &[u8], pos: usize) -> (u8, usize) {
3348    if pos < pat.len() {
3349        match pat[pos] {
3350            b'*' => (b'*', pos + 1),
3351            b'+' => (b'+', pos + 1),
3352            b'?' => (b'?', pos + 1),
3353            _ => (0, pos),
3354        }
3355    } else {
3356        (0, pos)
3357    }
3358}
3359
3360/// Match a single regex element (not a group) with optional quantifier.
3361fn regex_match_elem(
3362    text: &[u8],
3363    ti: usize,
3364    pat: &[u8],
3365    pi: usize,
3366    must_end: bool,
3367    captures: &mut Vec<(usize, usize)>,
3368) -> Option<usize> {
3369    let (elem_end, matches_fn) = parse_regex_elem(pat, pi);
3370    let (quant, after_quant) = parse_quantifier(pat, elem_end);
3371
3372    match quant {
3373        b'*' | b'+' => regex_match_repeated_elem(
3374            text,
3375            ti,
3376            pat,
3377            after_quant,
3378            quant,
3379            must_end,
3380            captures,
3381            &matches_fn,
3382        ),
3383        b'?' => {
3384            regex_match_optional_elem(text, ti, pat, after_quant, must_end, captures, &matches_fn)
3385        }
3386        _ => regex_match_single_elem(text, ti, pat, elem_end, must_end, captures, &matches_fn),
3387    }
3388}
3389
3390fn count_regex_matches(text: &[u8], ti: usize, matches_fn: &dyn Fn(u8) -> bool) -> usize {
3391    let mut count = 0;
3392    while ti + count < text.len() && matches_fn(text[ti + count]) {
3393        count += 1;
3394    }
3395    count
3396}
3397
3398fn regex_match_repeated_elem(
3399    text: &[u8],
3400    ti: usize,
3401    pat: &[u8],
3402    after_quant: usize,
3403    quant: u8,
3404    must_end: bool,
3405    captures: &mut Vec<(usize, usize)>,
3406    matches_fn: &dyn Fn(u8) -> bool,
3407) -> Option<usize> {
3408    let min = usize::from(quant == b'+');
3409    let count = count_regex_matches(text, ti, matches_fn);
3410    for c in (min..=count).rev() {
3411        if let Some(end) = regex_match_capturing(text, ti + c, pat, after_quant, must_end, captures)
3412        {
3413            return Some(end);
3414        }
3415    }
3416    None
3417}
3418
3419fn regex_match_optional_elem(
3420    text: &[u8],
3421    ti: usize,
3422    pat: &[u8],
3423    after_quant: usize,
3424    must_end: bool,
3425    captures: &mut Vec<(usize, usize)>,
3426    matches_fn: &dyn Fn(u8) -> bool,
3427) -> Option<usize> {
3428    if ti < text.len() && matches_fn(text[ti]) {
3429        if let Some(end) = regex_match_capturing(text, ti + 1, pat, after_quant, must_end, captures)
3430        {
3431            return Some(end);
3432        }
3433    }
3434    regex_match_capturing(text, ti, pat, after_quant, must_end, captures)
3435}
3436
3437fn regex_match_single_elem(
3438    text: &[u8],
3439    ti: usize,
3440    pat: &[u8],
3441    elem_end: usize,
3442    must_end: bool,
3443    captures: &mut Vec<(usize, usize)>,
3444    matches_fn: &dyn Fn(u8) -> bool,
3445) -> Option<usize> {
3446    if ti < text.len() && matches_fn(text[ti]) {
3447        regex_match_capturing(text, ti + 1, pat, elem_end, must_end, captures)
3448    } else {
3449        None
3450    }
3451}
3452
3453/// Try to match a simple pattern at a position, returning the end position if matched.
3454fn regex_try_match_at(text: &[u8], start: usize, pattern: &[u8]) -> Option<usize> {
3455    regex_try_match_inner(text, start, pattern, 0)
3456}
3457
3458/// Inner helper to find end position of a pattern match.
3459fn regex_try_match_inner(text: &[u8], ti: usize, pat: &[u8], pi: usize) -> Option<usize> {
3460    if pi >= pat.len() {
3461        return Some(ti);
3462    }
3463    if pat[pi] == b'(' {
3464        return regex_try_match_group(text, ti, pat, pi);
3465    }
3466    let (elem_end, matches_fn) = parse_regex_elem(pat, pi);
3467    let (quant, after_quant) = parse_quantifier(pat, elem_end);
3468    regex_try_apply_quant(text, ti, pat, elem_end, after_quant, quant, &matches_fn)
3469}
3470
3471/// Handle a group in `regex_try_match_inner`.
3472fn regex_try_match_group(text: &[u8], ti: usize, pat: &[u8], pi: usize) -> Option<usize> {
3473    let close = find_matching_paren_bytes(pat, pi + 1)?;
3474    let inner = &pat[pi + 1..close];
3475    let rest = &pat[close + 1..];
3476    let alternatives = split_alternatives_bytes(inner);
3477    for alt in &alternatives {
3478        if let Some(end) = regex_try_alt_and_rest(text, ti, alt, rest) {
3479            return Some(end);
3480        }
3481    }
3482    None
3483}
3484
3485fn regex_try_alt_and_rest(text: &[u8], ti: usize, alt: &[u8], rest: &[u8]) -> Option<usize> {
3486    let after_alt = regex_try_match_inner(text, ti, alt, 0)?;
3487    regex_try_match_inner(text, after_alt, rest, 0)
3488}
3489
3490/// Apply quantifier logic for `regex_try_match_inner`.
3491fn regex_try_apply_quant(
3492    text: &[u8],
3493    ti: usize,
3494    pat: &[u8],
3495    elem_end: usize,
3496    after_quant: usize,
3497    quant: u8,
3498    matches_fn: &dyn Fn(u8) -> bool,
3499) -> Option<usize> {
3500    match quant {
3501        b'*' | b'+' => regex_try_match_repeated_elem(text, ti, pat, after_quant, quant, matches_fn),
3502        b'?' => regex_try_match_optional_elem(text, ti, pat, after_quant, matches_fn),
3503        _ => regex_try_match_single_elem(text, ti, pat, elem_end, matches_fn),
3504    }
3505}
3506
3507fn regex_try_match_repeated_elem(
3508    text: &[u8],
3509    ti: usize,
3510    pat: &[u8],
3511    after_quant: usize,
3512    quant: u8,
3513    matches_fn: &dyn Fn(u8) -> bool,
3514) -> Option<usize> {
3515    let min = usize::from(quant == b'+');
3516    let count = count_regex_matches(text, ti, matches_fn);
3517    for c in (min..=count).rev() {
3518        if let Some(end) = regex_try_match_inner(text, ti + c, pat, after_quant) {
3519            return Some(end);
3520        }
3521    }
3522    None
3523}
3524
3525fn regex_try_match_optional_elem(
3526    text: &[u8],
3527    ti: usize,
3528    pat: &[u8],
3529    after_quant: usize,
3530    matches_fn: &dyn Fn(u8) -> bool,
3531) -> Option<usize> {
3532    if ti < text.len() && matches_fn(text[ti]) {
3533        if let Some(end) = regex_try_match_inner(text, ti + 1, pat, after_quant) {
3534            return Some(end);
3535        }
3536    }
3537    regex_try_match_inner(text, ti, pat, after_quant)
3538}
3539
3540fn regex_try_match_single_elem(
3541    text: &[u8],
3542    ti: usize,
3543    pat: &[u8],
3544    elem_end: usize,
3545    matches_fn: &dyn Fn(u8) -> bool,
3546) -> Option<usize> {
3547    if ti < text.len() && matches_fn(text[ti]) {
3548        regex_try_match_inner(text, ti + 1, pat, elem_end)
3549    } else {
3550        None
3551    }
3552}
3553
3554/// Check if alternatives can be matched repeatedly to fill text[start..end].
3555fn regex_match_group_repeated(
3556    text: &[u8],
3557    start: usize,
3558    end: usize,
3559    alternatives: &[Vec<u8>],
3560    min_reps: usize,
3561) -> bool {
3562    if start == end {
3563        return min_reps == 0;
3564    }
3565    if start > end {
3566        return false;
3567    }
3568    for alt in alternatives {
3569        if regex_group_repetition_matches(text, start, end, alternatives, min_reps, alt) {
3570            return true;
3571        }
3572    }
3573    false
3574}
3575
3576fn regex_group_repetition_matches(
3577    text: &[u8],
3578    start: usize,
3579    end: usize,
3580    alternatives: &[Vec<u8>],
3581    min_reps: usize,
3582    alt: &[u8],
3583) -> bool {
3584    let Some(after) = regex_try_match_inner(text, start, alt, 0) else {
3585        return false;
3586    };
3587    if after <= start || after > end {
3588        return false;
3589    }
3590    if after == end && min_reps <= 1 {
3591        return true;
3592    }
3593    regex_match_group_repeated(text, after, end, alternatives, min_reps.saturating_sub(1))
3594}
3595
3596/// Find matching `)` for a `(` in a byte pattern, handling nesting.
3597fn find_matching_paren_bytes(pat: &[u8], start: usize) -> Option<usize> {
3598    let mut depth = 1;
3599    let mut i = start;
3600    while i < pat.len() {
3601        if pat[i] == b'\\' {
3602            i += 2;
3603            continue;
3604        }
3605        if pat[i] == b'(' {
3606            depth += 1;
3607        } else if pat[i] == b')' {
3608            depth -= 1;
3609            if depth == 0 {
3610                return Some(i);
3611            }
3612        }
3613        i += 1;
3614    }
3615    None
3616}
3617
3618/// Split a byte pattern by `|` at the top level (not inside nested parens).
3619fn split_alternatives_bytes(pat: &[u8]) -> Vec<Vec<u8>> {
3620    let mut alternatives = Vec::new();
3621    let mut current = Vec::new();
3622    let mut depth = 0i32;
3623    let mut i = 0;
3624    while i < pat.len() {
3625        if pat[i] == b'\\' && i + 1 < pat.len() {
3626            current.push(pat[i]);
3627            current.push(pat[i + 1]);
3628            i += 2;
3629            continue;
3630        }
3631        split_alt_classify_byte(pat[i], &mut depth, &mut current, &mut alternatives);
3632        i += 1;
3633    }
3634    alternatives.push(current);
3635    alternatives
3636}
3637
3638fn split_alt_classify_byte(
3639    byte: u8,
3640    depth: &mut i32,
3641    current: &mut Vec<u8>,
3642    alternatives: &mut Vec<Vec<u8>>,
3643) {
3644    match byte {
3645        b'(' => {
3646            *depth += 1;
3647            current.push(byte);
3648        }
3649        b')' => {
3650            *depth -= 1;
3651            current.push(byte);
3652        }
3653        b'|' if *depth == 0 => {
3654            alternatives.push(std::mem::take(current));
3655        }
3656        _ => {
3657            current.push(byte);
3658        }
3659    }
3660}
3661
3662/// Simple regex-like matching for `=~`.
3663///
3664/// Supports: `^prefix`, `suffix$`, `^exact$`, and literal substring match.
3665/// This avoids pulling in a regex crate for wasm32.
3666#[allow(dead_code)]
3667fn simple_regex_match(text: &str, pattern: &str) -> bool {
3668    let (core, anchored_start, anchored_end) = regex_strip_anchors(pattern);
3669
3670    if has_regex_metachar(core) {
3671        return regex_like_match(text, pattern);
3672    }
3673
3674    // Pure literal matching with anchoring
3675    literal_match_range(text, core, anchored_start, anchored_end).is_some()
3676}
3677
3678/// A simple regex-like matcher supporting: `.` (any char), `*` (zero or more of previous),
3679/// `+` (one or more of previous), `?` (zero or one of previous), `^`, `$`,
3680/// `[abc]` character classes, `(a|b)` alternation, and literal chars.
3681/// This is intentionally limited but handles common bash `=~` patterns.
3682#[allow(dead_code)]
3683fn regex_like_match(text: &str, pattern: &str) -> bool {
3684    let (core, anchored_start, anchored_end) = regex_strip_anchors(pattern);
3685
3686    if anchored_start {
3687        regex_match_at(text, 0, core, anchored_end)
3688    } else {
3689        (0..=text.len()).any(|start| regex_match_at(text, start, core, anchored_end))
3690    }
3691}
3692
3693/// Try to match `core` pattern starting at byte position `start` in `text`.
3694/// If `must_end` is true, the match must consume through end of `text`.
3695#[allow(dead_code)]
3696fn regex_match_at(text: &str, start: usize, core: &str, must_end: bool) -> bool {
3697    let text_bytes = text.as_bytes();
3698    let core_bytes = core.as_bytes();
3699    regex_backtrack(text_bytes, start, core_bytes, 0, must_end)
3700}
3701
3702/// Recursive backtracking regex matcher.
3703#[allow(dead_code)]
3704fn regex_backtrack(text: &[u8], ti: usize, pat: &[u8], pi: usize, must_end: bool) -> bool {
3705    if pi >= pat.len() {
3706        return if must_end { ti >= text.len() } else { true };
3707    }
3708
3709    let (elem_end, matches_fn) = parse_regex_elem(pat, pi);
3710    let (quant, after_quant) = parse_quantifier(pat, elem_end);
3711
3712    match quant {
3713        b'*' => regex_backtrack_star(text, ti, pat, after_quant, must_end, &matches_fn),
3714        b'+' => regex_backtrack_plus(text, ti, pat, after_quant, must_end, &matches_fn),
3715        b'?' => regex_backtrack_optional(text, ti, pat, after_quant, must_end, &matches_fn),
3716        _ => regex_backtrack_single(text, ti, pat, elem_end, must_end, &matches_fn),
3717    }
3718}
3719
3720fn regex_backtrack_star(
3721    text: &[u8],
3722    ti: usize,
3723    pat: &[u8],
3724    after_quant: usize,
3725    must_end: bool,
3726    matches_fn: &dyn Fn(u8) -> bool,
3727) -> bool {
3728    let mut count = 0;
3729    loop {
3730        if regex_backtrack(text, ti + count, pat, after_quant, must_end) {
3731            return true;
3732        }
3733        if ti + count < text.len() && matches_fn(text[ti + count]) {
3734            count += 1;
3735        } else {
3736            return false;
3737        }
3738    }
3739}
3740
3741fn regex_backtrack_plus(
3742    text: &[u8],
3743    ti: usize,
3744    pat: &[u8],
3745    after_quant: usize,
3746    must_end: bool,
3747    matches_fn: &dyn Fn(u8) -> bool,
3748) -> bool {
3749    let count = count_regex_matches(text, ti, matches_fn);
3750    (1..=count).any(|matched| regex_backtrack(text, ti + matched, pat, after_quant, must_end))
3751}
3752
3753fn regex_backtrack_optional(
3754    text: &[u8],
3755    ti: usize,
3756    pat: &[u8],
3757    after_quant: usize,
3758    must_end: bool,
3759    matches_fn: &dyn Fn(u8) -> bool,
3760) -> bool {
3761    regex_backtrack(text, ti, pat, after_quant, must_end)
3762        || (ti < text.len()
3763            && matches_fn(text[ti])
3764            && regex_backtrack(text, ti + 1, pat, after_quant, must_end))
3765}
3766
3767fn regex_backtrack_single(
3768    text: &[u8],
3769    ti: usize,
3770    pat: &[u8],
3771    elem_end: usize,
3772    must_end: bool,
3773    matches_fn: &dyn Fn(u8) -> bool,
3774) -> bool {
3775    ti < text.len()
3776        && matches_fn(text[ti])
3777        && regex_backtrack(text, ti + 1, pat, elem_end, must_end)
3778}
3779
3780/// Parse one regex element at position `pi`, return (`end_pos`, `match_fn`).
3781/// An element is: `.`, `[class]`, `(alt)`, or a literal byte.
3782fn parse_regex_elem(pat: &[u8], pi: usize) -> (usize, Box<dyn Fn(u8) -> bool>) {
3783    match pat[pi] {
3784        b'.' => (pi + 1, Box::new(|_: u8| true)),
3785        b'[' => parse_regex_char_class(pat, pi),
3786        b'\\' if pi + 1 < pat.len() => {
3787            let escaped = pat[pi + 1];
3788            (pi + 2, Box::new(move |c: u8| c == escaped))
3789        }
3790        ch => (pi + 1, Box::new(move |c: u8| c == ch)),
3791    }
3792}
3793
3794fn parse_regex_char_class(pat: &[u8], pi: usize) -> (usize, Box<dyn Fn(u8) -> bool>) {
3795    let mut i = pi + 1;
3796    let negate = i < pat.len() && (pat[i] == b'^' || pat[i] == b'!');
3797    if negate {
3798        i += 1;
3799    }
3800    let mut chars = Vec::new();
3801    while i < pat.len() && pat[i] != b']' {
3802        if i + 2 < pat.len() && pat[i + 1] == b'-' {
3803            chars.extend(pat[i]..=pat[i + 2]);
3804            i += 3;
3805        } else {
3806            chars.push(pat[i]);
3807            i += 1;
3808        }
3809    }
3810    let end = if i < pat.len() { i + 1 } else { i };
3811    (
3812        end,
3813        Box::new(move |c: u8| regex_char_class_matches(&chars, negate, c)),
3814    )
3815}
3816
3817fn regex_char_class_matches(chars: &[u8], negate: bool, c: u8) -> bool {
3818    let found = chars.contains(&c);
3819    if negate {
3820        !found
3821    } else {
3822        found
3823    }
3824}
3825
3826/// Match a glob character class `[...]` at position `pi` (just past the `[`).
3827/// Returns `(new_pi, matched)` where `new_pi` is past the `]`.
3828fn glob_match_char_class(pattern: &[u8], mut pi: usize, ch: u8) -> (usize, bool) {
3829    let negate = pi < pattern.len() && (pattern[pi] == b'!' || pattern[pi] == b'^');
3830    if negate {
3831        pi += 1;
3832    }
3833    let mut matched = false;
3834    let mut first = true;
3835    while pi < pattern.len() && (first || pattern[pi] != b']') {
3836        first = false;
3837        let (next_pi, item_matched) = glob_match_char_class_item(pattern, pi, ch);
3838        matched |= item_matched;
3839        pi = next_pi;
3840    }
3841    if pi < pattern.len() && pattern[pi] == b']' {
3842        pi += 1;
3843    }
3844    (pi, matched != negate)
3845}
3846
3847fn glob_match_char_class_item(pattern: &[u8], pi: usize, ch: u8) -> (usize, bool) {
3848    if pi + 2 < pattern.len() && pattern[pi + 1] == b'-' {
3849        let lo = pattern[pi];
3850        let hi = pattern[pi + 2];
3851        return (pi + 3, ch >= lo && ch <= hi);
3852    }
3853    (pi + 1, pattern[pi] == ch)
3854}
3855
3856enum GlobPatternStep {
3857    Consume(usize),
3858    Star,
3859    Class(usize, bool),
3860    Mismatch,
3861}
3862
3863fn glob_step(pattern: &[u8], pi: usize, ch: u8) -> GlobPatternStep {
3864    if pi >= pattern.len() {
3865        return GlobPatternStep::Mismatch;
3866    }
3867
3868    match pattern[pi] {
3869        b'?' => GlobPatternStep::Consume(pi + 1),
3870        b'*' => GlobPatternStep::Star,
3871        b'[' => {
3872            let (new_pi, matched) = glob_match_char_class(pattern, pi + 1, ch);
3873            GlobPatternStep::Class(new_pi, matched)
3874        }
3875        literal if literal == ch => GlobPatternStep::Consume(pi + 1),
3876        _ => GlobPatternStep::Mismatch,
3877    }
3878}
3879
3880fn glob_backtrack(pi: &mut usize, ni: &mut usize, star_pi: usize, star_ni: &mut usize) -> bool {
3881    if star_pi == usize::MAX {
3882        return false;
3883    }
3884
3885    *pi = star_pi + 1;
3886    *star_ni += 1;
3887    *ni = *star_ni;
3888    true
3889}
3890
3891/// Core glob pattern matching (byte-level).
3892///
3893/// Supports `*` (any sequence), `?` (one char), and `[abc]` (character class).
3894fn glob_match_inner(pattern: &[u8], name: &[u8]) -> bool {
3895    let mut pi = 0;
3896    let mut ni = 0;
3897    let mut star_pi = usize::MAX;
3898    let mut star_ni = usize::MAX;
3899
3900    while ni < name.len() {
3901        match glob_step(pattern, pi, name[ni]) {
3902            GlobPatternStep::Star => {
3903                star_pi = pi;
3904                star_ni = ni;
3905                pi += 1;
3906            }
3907            GlobPatternStep::Consume(new_pi) | GlobPatternStep::Class(new_pi, true) => {
3908                pi = new_pi;
3909                ni += 1;
3910            }
3911            GlobPatternStep::Class(_, false) | GlobPatternStep::Mismatch => {
3912                if !glob_backtrack(&mut pi, &mut ni, star_pi, &mut star_ni) {
3913                    return false;
3914                }
3915            }
3916        }
3917    }
3918
3919    // Consume trailing stars
3920    while pi < pattern.len() && pattern[pi] == b'*' {
3921        pi += 1;
3922    }
3923
3924    pi == pattern.len()
3925}
3926
3927/// Extended glob matching with dotglob and extglob support.
3928fn glob_match_ext(pattern: &str, name: &str, dotglob: bool, extglob: bool) -> bool {
3929    // Don't match hidden files unless dotglob is enabled or pattern starts with '.'
3930    if name.starts_with('.') && !pattern.starts_with('.') && !dotglob {
3931        return false;
3932    }
3933    if extglob && has_extglob_pattern(pattern) {
3934        return extglob_match(pattern, name);
3935    }
3936    glob_match_inner(pattern.as_bytes(), name.as_bytes())
3937}
3938
3939/// Check if a pattern contains extglob operators: `?(`, `*(`, `+(`, `@(`, `!(`.
3940fn has_extglob_pattern(pattern: &str) -> bool {
3941    let bytes = pattern.as_bytes();
3942    for i in 0..bytes.len().saturating_sub(1) {
3943        if bytes[i + 1] == b'(' && matches!(bytes[i], b'?' | b'*' | b'+' | b'@' | b'!') {
3944            return true;
3945        }
3946    }
3947    false
3948}
3949
3950/// Match a name against an extglob pattern.
3951///
3952/// Supports: `?(pat|pat)`, `*(pat|pat)`, `+(pat|pat)`, `@(pat|pat)`, `!(pat|pat)`.
3953/// Non-extglob portions are handled by regular glob matching.
3954pub fn extglob_match(pattern: &str, name: &str) -> bool {
3955    extglob_match_recursive(pattern.as_bytes(), name.as_bytes())
3956}
3957
3958fn extglob_match_recursive(pattern: &[u8], name: &[u8]) -> bool {
3959    // Find the first extglob operator
3960    let Some((pi, op, close)) = find_extglob_operator(pattern) else {
3961        return glob_match_inner(pattern, name);
3962    };
3963
3964    let open = pi + 2;
3965    let alternatives = split_alternatives(&pattern[open..close]);
3966    let prefix = &pattern[..pi];
3967    let suffix = &pattern[close + 1..];
3968
3969    match op {
3970        b'@' | b'?' => extglob_match_at_or_opt(op, prefix, &alternatives, suffix, name),
3971        b'*' => extglob_star(prefix, &alternatives, suffix, name, 0),
3972        b'+' => extglob_plus(prefix, &alternatives, suffix, name, 0),
3973        b'!' => extglob_match_negate(prefix, &alternatives, suffix, name),
3974        _ => unreachable!(),
3975    }
3976}
3977
3978/// Find the first extglob operator in a pattern, returning (position, operator, `close_paren`).
3979fn find_extglob_operator(pattern: &[u8]) -> Option<(usize, u8, usize)> {
3980    let mut pi = 0;
3981    while pi < pattern.len() {
3982        if pi + 1 < pattern.len()
3983            && pattern[pi + 1] == b'('
3984            && matches!(pattern[pi], b'?' | b'*' | b'+' | b'@' | b'!')
3985        {
3986            if let Some(close) = find_matching_paren(pattern, pi + 2) {
3987                return Some((pi, pattern[pi], close));
3988            }
3989        }
3990        pi += 1;
3991    }
3992    None
3993}
3994
3995/// Build a combined pattern from prefix + alt + suffix.
3996fn build_combined(prefix: &[u8], mid: &[u8], suffix: &[u8]) -> Vec<u8> {
3997    let mut combined = Vec::with_capacity(prefix.len() + mid.len() + suffix.len());
3998    combined.extend_from_slice(prefix);
3999    combined.extend_from_slice(mid);
4000    combined.extend_from_slice(suffix);
4001    combined
4002}
4003
4004/// Handle `@(...)` (exactly one) and `?(...)` (zero or one) extglob patterns.
4005fn extglob_match_at_or_opt(
4006    op: u8,
4007    prefix: &[u8],
4008    alternatives: &[Vec<u8>],
4009    suffix: &[u8],
4010    name: &[u8],
4011) -> bool {
4012    // For `?`, try zero first
4013    if op == b'?' && extglob_match_recursive(&build_combined(prefix, &[], suffix), name) {
4014        return true;
4015    }
4016    // Try each alternative exactly once
4017    for alt in alternatives {
4018        if extglob_match_recursive(&build_combined(prefix, alt, suffix), name) {
4019            return true;
4020        }
4021    }
4022    false
4023}
4024
4025/// Handle `!(...)` extglob pattern: matches if no alternative matches.
4026fn extglob_match_negate(
4027    prefix: &[u8],
4028    alternatives: &[Vec<u8>],
4029    suffix: &[u8],
4030    name: &[u8],
4031) -> bool {
4032    for alt in alternatives {
4033        if extglob_match_recursive(&build_combined(prefix, alt, suffix), name) {
4034            return false;
4035        }
4036    }
4037    let wildcard = build_combined(prefix, b"*", suffix);
4038    glob_match_inner(&wildcard, name)
4039}
4040
4041/// Try zero or more repetitions of alternatives for `*(...)`.
4042fn extglob_star(
4043    prefix: &[u8],
4044    alternatives: &[Vec<u8>],
4045    suffix: &[u8],
4046    name: &[u8],
4047    depth: u32,
4048) -> bool {
4049    if depth > 20 {
4050        return false;
4051    }
4052    // Try zero repetitions
4053    if extglob_match_recursive(&build_combined(prefix, &[], suffix), name) {
4054        return true;
4055    }
4056    // Try one repetition followed by zero or more
4057    extglob_try_extend(prefix, alternatives, suffix, name, depth)
4058}
4059
4060fn extglob_try_extend(
4061    prefix: &[u8],
4062    alternatives: &[Vec<u8>],
4063    suffix: &[u8],
4064    name: &[u8],
4065    depth: u32,
4066) -> bool {
4067    let prefix_len = prefix.len();
4068    for alt in alternatives {
4069        let new_prefix = build_combined(prefix, alt, &[]);
4070        if new_prefix.len() > prefix_len
4071            && extglob_star(&new_prefix, alternatives, suffix, name, depth + 1)
4072        {
4073            return true;
4074        }
4075    }
4076    false
4077}
4078
4079/// Try one or more repetitions of alternatives for `+(...)`.
4080fn extglob_plus(
4081    prefix: &[u8],
4082    alternatives: &[Vec<u8>],
4083    suffix: &[u8],
4084    name: &[u8],
4085    depth: u32,
4086) -> bool {
4087    if depth > 20 {
4088        return false;
4089    }
4090    for alt in alternatives {
4091        let new_prefix = build_combined(prefix, alt, &[]);
4092        if extglob_star(&new_prefix, alternatives, suffix, name, depth + 1) {
4093            return true;
4094        }
4095    }
4096    false
4097}
4098
4099/// Find the matching `)` for a `(` at position `open` (character after `(`).
4100fn find_matching_paren(pattern: &[u8], open: usize) -> Option<usize> {
4101    let mut depth: u32 = 1;
4102    let mut i = open;
4103    while i < pattern.len() {
4104        if pattern[i] == b'(' {
4105            depth += 1;
4106        } else if pattern[i] == b')' {
4107            depth -= 1;
4108            if depth == 0 {
4109                return Some(i);
4110            }
4111        }
4112        i += 1;
4113    }
4114    None
4115}
4116
4117/// Split alternatives by `|` at the top level (not inside nested parens).
4118fn split_alternatives(pat: &[u8]) -> Vec<Vec<u8>> {
4119    let mut result = Vec::new();
4120    let mut current = Vec::new();
4121    let mut depth: u32 = 0;
4122    for &b in pat {
4123        if b == b'(' {
4124            depth += 1;
4125            current.push(b);
4126        } else if b == b')' {
4127            depth -= 1;
4128            current.push(b);
4129        } else if b == b'|' && depth == 0 {
4130            result.push(std::mem::take(&mut current));
4131        } else {
4132            current.push(b);
4133        }
4134    }
4135    result.push(current);
4136    result
4137}
4138
4139impl Default for WorkerRuntime {
4140    fn default() -> Self {
4141        Self::new()
4142    }
4143}