Skip to main content

rust_bash/interpreter/
builtins.rs

1//! Shell builtins that modify interpreter state.
2
3use crate::commands::CommandMeta;
4use crate::error::RustBashError;
5use crate::interpreter::walker::execute_program;
6use crate::interpreter::{
7    ControlFlow, ExecResult, InterpreterState, Variable, VariableAttrs, VariableValue, parse,
8    set_array_element, set_variable,
9};
10use crate::vfs::NodeType;
11use std::path::Path;
12
13/// Dispatch a shell builtin by name.
14/// Returns `Ok(Some(result))` if the name is a recognised builtin,
15/// `Ok(None)` if not.
16pub(crate) fn execute_builtin(
17    name: &str,
18    args: &[String],
19    state: &mut InterpreterState,
20    stdin: &str,
21) -> Result<Option<ExecResult>, RustBashError> {
22    match name {
23        "exit" => builtin_exit(args, state).map(Some),
24        "cd" => builtin_cd(args, state).map(Some),
25        "export" => builtin_export(args, state).map(Some),
26        "unset" => builtin_unset(args, state).map(Some),
27        "set" => builtin_set(args, state).map(Some),
28        "shift" => builtin_shift(args, state).map(Some),
29        "readonly" => builtin_readonly(args, state).map(Some),
30        "declare" | "typeset" => builtin_declare(args, state).map(Some),
31        "read" => builtin_read(args, state, stdin).map(Some),
32        "eval" => builtin_eval(args, state).map(Some),
33        "source" | "." => builtin_source(args, state).map(Some),
34        "break" => builtin_break(args, state).map(Some),
35        "continue" => builtin_continue(args, state).map(Some),
36        ":" | "colon" => Ok(Some(ExecResult::default())),
37        "let" => builtin_let(args, state).map(Some),
38        "local" => builtin_local(args, state).map(Some),
39        "return" => builtin_return(args, state).map(Some),
40        "trap" => builtin_trap(args, state).map(Some),
41        "shopt" => builtin_shopt(args, state).map(Some),
42        "type" => builtin_type(args, state).map(Some),
43        "command" => builtin_command(args, state, stdin).map(Some),
44        "builtin" => builtin_builtin(args, state, stdin).map(Some),
45        "getopts" => builtin_getopts(args, state).map(Some),
46        "mapfile" | "readarray" => builtin_mapfile(args, state, stdin).map(Some),
47        "pushd" => builtin_pushd(args, state).map(Some),
48        "popd" => builtin_popd(args, state).map(Some),
49        "dirs" => builtin_dirs(args, state).map(Some),
50        "hash" => builtin_hash(args, state).map(Some),
51        "wait" => Ok(Some(ExecResult::default())),
52        "alias" => builtin_alias(args, state).map(Some),
53        "unalias" => builtin_unalias(args, state).map(Some),
54        "printf" => builtin_printf(args, state).map(Some),
55        "sh" | "bash" => builtin_sh(args, state, stdin).map(Some),
56        "help" => builtin_help(args, state).map(Some),
57        "history" => Ok(Some(ExecResult::default())),
58        _ => Ok(None),
59    }
60}
61
62/// Check if a name is a known shell builtin.
63/// Derives from `builtin_names()` to keep a single source of truth.
64pub(crate) fn is_builtin(name: &str) -> bool {
65    builtin_names().contains(&name)
66}
67
68/// Check if `name` is a valid shell variable name (alphanumerics and underscores, no leading digit).
69fn is_valid_var_name(name: &str) -> bool {
70    !name.is_empty()
71        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
72        && !name.starts_with(|c: char| c.is_ascii_digit())
73}
74
75/// Extract the base variable name from an argument like `name=val`, `name+=val`,
76/// or `name[idx]=val` and validate it.  Returns `Ok(())` if valid, or an error
77/// message string suitable for stderr if invalid.
78fn validate_var_arg(arg: &str, builtin: &str) -> Result<(), String> {
79    let var_name = if let Some((n, _)) = arg.split_once("+=") {
80        n
81    } else if let Some((n, _)) = arg.split_once('=') {
82        n
83    } else {
84        arg
85    };
86    // Strip array subscript for validation: name[idx] → name
87    let base_name = var_name.split('[').next().unwrap_or(var_name);
88    if is_valid_var_name(base_name) {
89        Ok(())
90    } else {
91        Err(format!(
92            "rust-bash: {builtin}: `{arg}': not a valid identifier\n"
93        ))
94    }
95}
96
97/// Shared --help check for `command` and `builtin` wrappers.
98fn check_help(name: &str, state: &InterpreterState) -> Option<ExecResult> {
99    if let Some(meta) = builtin_meta(name)
100        && meta.supports_help_flag
101    {
102        return Some(ExecResult {
103            stdout: crate::commands::format_help(meta),
104            ..ExecResult::default()
105        });
106    }
107    if let Some(cmd) = state.commands.get(name)
108        && let Some(meta) = cmd.meta()
109        && meta.supports_help_flag
110    {
111        return Some(ExecResult {
112            stdout: crate::commands::format_help(meta),
113            ..ExecResult::default()
114        });
115    }
116    None
117}
118
119/// Return the list of known shell builtin names.
120pub fn builtin_names() -> &'static [&'static str] {
121    &[
122        "exit",
123        "cd",
124        "export",
125        "unset",
126        "set",
127        "shift",
128        "readonly",
129        "declare",
130        "typeset",
131        "read",
132        "eval",
133        "source",
134        ".",
135        "break",
136        "continue",
137        ":",
138        "colon",
139        "let",
140        "local",
141        "return",
142        "trap",
143        "shopt",
144        "type",
145        "command",
146        "builtin",
147        "getopts",
148        "mapfile",
149        "readarray",
150        "pushd",
151        "popd",
152        "dirs",
153        "hash",
154        "wait",
155        "alias",
156        "unalias",
157        "printf",
158        "exec",
159        "sh",
160        "bash",
161        "help",
162        "history",
163    ]
164}
165
166// ── Builtin command metadata for --help ──────────────────────────────
167
168static CD_META: CommandMeta = CommandMeta {
169    name: "cd",
170    synopsis: "cd [dir]",
171    description: "Change the shell working directory.",
172    options: &[],
173    supports_help_flag: true,
174    flags: &[],
175};
176
177static EXIT_META: CommandMeta = CommandMeta {
178    name: "exit",
179    synopsis: "exit [n]",
180    description: "Exit the shell.",
181    options: &[],
182    supports_help_flag: true,
183    flags: &[],
184};
185
186static EXPORT_META: CommandMeta = CommandMeta {
187    name: "export",
188    synopsis: "export [-n] [name[=value] ...]",
189    description: "Set export attribute for shell variables.",
190    options: &[("-n", "remove the export property from each name")],
191    supports_help_flag: true,
192    flags: &[],
193};
194
195static UNSET_META: CommandMeta = CommandMeta {
196    name: "unset",
197    synopsis: "unset [-fv] [name ...]",
198    description: "Unset values and attributes of shell variables and functions.",
199    options: &[
200        ("-f", "treat each name as a shell function"),
201        ("-v", "treat each name as a shell variable"),
202    ],
203    supports_help_flag: true,
204    flags: &[],
205};
206
207static SET_META: CommandMeta = CommandMeta {
208    name: "set",
209    synopsis: "set [-euxvnCaf] [-o option-name] [--] [arg ...]",
210    description: "Set or unset values of shell options and positional parameters.",
211    options: &[
212        (
213            "-e",
214            "exit immediately if a command exits with non-zero status",
215        ),
216        ("-u", "treat unset variables as an error"),
217        (
218            "-x",
219            "print commands and their arguments as they are executed",
220        ),
221        ("-v", "print shell input lines as they are read"),
222        ("-n", "read commands but do not execute them"),
223        ("-C", "do not allow output redirection to overwrite files"),
224        ("-a", "mark variables for export"),
225        ("-f", "disable file name generation (globbing)"),
226        (
227            "-o OPTION",
228            "set option by name (errexit, nounset, pipefail, ...)",
229        ),
230    ],
231    supports_help_flag: true,
232    flags: &[],
233};
234
235static SHIFT_META: CommandMeta = CommandMeta {
236    name: "shift",
237    synopsis: "shift [n]",
238    description: "Shift positional parameters.",
239    options: &[],
240    supports_help_flag: true,
241    flags: &[],
242};
243
244static READONLY_META: CommandMeta = CommandMeta {
245    name: "readonly",
246    synopsis: "readonly [name[=value] ...]",
247    description: "Mark shell variables as unchangeable.",
248    options: &[],
249    supports_help_flag: true,
250    flags: &[],
251};
252
253static DECLARE_META: CommandMeta = CommandMeta {
254    name: "declare",
255    synopsis: "declare [-aAilnprux] [name[=value] ...]",
256    description: "Set variable values and attributes.",
257    options: &[
258        ("-a", "indexed array"),
259        ("-A", "associative array"),
260        ("-i", "integer attribute"),
261        ("-l", "convert to lower case on assignment"),
262        ("-u", "convert to upper case on assignment"),
263        ("-n", "nameref attribute"),
264        ("-r", "readonly attribute"),
265        ("-x", "export attribute"),
266        ("-p", "display attributes and values"),
267    ],
268    supports_help_flag: true,
269    flags: &[],
270};
271
272static READ_META: CommandMeta = CommandMeta {
273    name: "read",
274    synopsis: "read [-r] [-a array] [-d delim] [-n count] [-N count] [-p prompt] [name ...]",
275    description: "Read a line from standard input and split it into fields.",
276    options: &[
277        ("-r", "do not allow backslashes to escape characters"),
278        ("-a ARRAY", "assign words to indices of ARRAY"),
279        ("-d DELIM", "read until DELIM instead of newline"),
280        ("-n COUNT", "read at most COUNT characters"),
281        ("-N COUNT", "read exactly COUNT characters"),
282        ("-p PROMPT", "output PROMPT before reading"),
283    ],
284    supports_help_flag: true,
285    flags: &[],
286};
287
288static EVAL_META: CommandMeta = CommandMeta {
289    name: "eval",
290    synopsis: "eval [arg ...]",
291    description: "Execute arguments as a shell command.",
292    options: &[],
293    supports_help_flag: true,
294    flags: &[],
295};
296
297static SOURCE_META: CommandMeta = CommandMeta {
298    name: "source",
299    synopsis: "source filename [arguments]",
300    description: "Execute commands from a file in the current shell.",
301    options: &[],
302    supports_help_flag: true,
303    flags: &[],
304};
305
306static BREAK_META: CommandMeta = CommandMeta {
307    name: "break",
308    synopsis: "break [n]",
309    description: "Exit for, while, or until loops.",
310    options: &[],
311    supports_help_flag: true,
312    flags: &[],
313};
314
315static CONTINUE_META: CommandMeta = CommandMeta {
316    name: "continue",
317    synopsis: "continue [n]",
318    description: "Resume the next iteration of the enclosing loop.",
319    options: &[],
320    supports_help_flag: true,
321    flags: &[],
322};
323
324static COLON_META: CommandMeta = CommandMeta {
325    name: ":",
326    synopsis: ": [arguments]",
327    description: "No effect; the command does nothing.",
328    options: &[],
329    supports_help_flag: true,
330    flags: &[],
331};
332
333static LET_META: CommandMeta = CommandMeta {
334    name: "let",
335    synopsis: "let arg [arg ...]",
336    description: "Evaluate arithmetic expressions.",
337    options: &[],
338    supports_help_flag: true,
339    flags: &[],
340};
341
342static LOCAL_META: CommandMeta = CommandMeta {
343    name: "local",
344    synopsis: "local [name[=value] ...]",
345    description: "Define local variables.",
346    options: &[],
347    supports_help_flag: true,
348    flags: &[],
349};
350
351static RETURN_META: CommandMeta = CommandMeta {
352    name: "return",
353    synopsis: "return [n]",
354    description: "Return from a shell function.",
355    options: &[],
356    supports_help_flag: true,
357    flags: &[],
358};
359
360static TRAP_META: CommandMeta = CommandMeta {
361    name: "trap",
362    synopsis: "trap [-lp] [action signal ...]",
363    description: "Trap signals and other events.",
364    options: &[
365        ("-l", "list signal names"),
366        ("-p", "display trap commands for each signal"),
367    ],
368    supports_help_flag: true,
369    flags: &[],
370};
371
372static SHOPT_META: CommandMeta = CommandMeta {
373    name: "shopt",
374    synopsis: "shopt [-pqsu] [optname ...]",
375    description: "Set and unset shell options.",
376    options: &[
377        ("-s", "enable (set) each optname"),
378        ("-u", "disable (unset) each optname"),
379        (
380            "-q",
381            "suppresses normal output; exit status indicates match",
382        ),
383        ("-p", "display in a form that may be reused as input"),
384    ],
385    supports_help_flag: true,
386    flags: &[],
387};
388
389static TYPE_META: CommandMeta = CommandMeta {
390    name: "type",
391    synopsis: "type [-tap] name [name ...]",
392    description: "Display information about command type.",
393    options: &[
394        ("-t", "print a single word describing the type"),
395        ("-a", "display all locations containing an executable"),
396        ("-p", "print the file name of the disk file"),
397    ],
398    supports_help_flag: true,
399    flags: &[],
400};
401
402static COMMAND_META: CommandMeta = CommandMeta {
403    name: "command",
404    synopsis: "command [-vVp] command [arg ...]",
405    description: "Execute a simple command or display information about commands.",
406    options: &[
407        ("-v", "display a description of COMMAND similar to type"),
408        ("-V", "display a more verbose description"),
409        ("-p", "use a default value for PATH"),
410    ],
411    supports_help_flag: true,
412    flags: &[],
413};
414
415static BUILTIN_CMD_META: CommandMeta = CommandMeta {
416    name: "builtin",
417    synopsis: "builtin shell-builtin [arguments]",
418    description: "Execute shell builtins.",
419    options: &[],
420    supports_help_flag: true,
421    flags: &[],
422};
423
424static GETOPTS_META: CommandMeta = CommandMeta {
425    name: "getopts",
426    synopsis: "getopts optstring name [arg ...]",
427    description: "Parse option arguments.",
428    options: &[],
429    supports_help_flag: true,
430    flags: &[],
431};
432
433static MAPFILE_META: CommandMeta = CommandMeta {
434    name: "mapfile",
435    synopsis: "mapfile [-t] [-d delim] [-n count] [-s count] [array]",
436    description: "Read lines from standard input into an indexed array variable.",
437    options: &[
438        ("-t", "remove a trailing delimiter from each line"),
439        (
440            "-d DELIM",
441            "use DELIM to terminate lines instead of newline",
442        ),
443        ("-n COUNT", "copy at most COUNT lines"),
444        ("-s COUNT", "discard the first COUNT lines"),
445    ],
446    supports_help_flag: true,
447    flags: &[],
448};
449
450static PUSHD_META: CommandMeta = CommandMeta {
451    name: "pushd",
452    synopsis: "pushd [+N | -N | dir]",
453    description: "Add directories to stack.",
454    options: &[],
455    supports_help_flag: true,
456    flags: &[],
457};
458
459static POPD_META: CommandMeta = CommandMeta {
460    name: "popd",
461    synopsis: "popd [+N | -N]",
462    description: "Remove directories from stack.",
463    options: &[],
464    supports_help_flag: true,
465    flags: &[],
466};
467
468static DIRS_META: CommandMeta = CommandMeta {
469    name: "dirs",
470    synopsis: "dirs [-cpvl]",
471    description: "Display directory stack.",
472    options: &[
473        ("-c", "clear the directory stack"),
474        ("-p", "print one entry per line"),
475        ("-v", "print one entry per line, with index"),
476        ("-l", "use full pathnames"),
477    ],
478    supports_help_flag: true,
479    flags: &[],
480};
481
482static HASH_META: CommandMeta = CommandMeta {
483    name: "hash",
484    synopsis: "hash [-r] [name ...]",
485    description: "Remember or display program locations.",
486    options: &[("-r", "forget all remembered locations")],
487    supports_help_flag: true,
488    flags: &[],
489};
490
491static WAIT_META: CommandMeta = CommandMeta {
492    name: "wait",
493    synopsis: "wait [pid ...]",
494    description: "Wait for job completion and return exit status.",
495    options: &[],
496    supports_help_flag: true,
497    flags: &[],
498};
499
500static ALIAS_META: CommandMeta = CommandMeta {
501    name: "alias",
502    synopsis: "alias [-p] [name[=value] ...]",
503    description: "Define or display aliases.",
504    options: &[("-p", "print all defined aliases in a reusable format")],
505    supports_help_flag: true,
506    flags: &[],
507};
508
509static UNALIAS_META: CommandMeta = CommandMeta {
510    name: "unalias",
511    synopsis: "unalias [-a] name [name ...]",
512    description: "Remove alias definitions.",
513    options: &[("-a", "remove all alias definitions")],
514    supports_help_flag: true,
515    flags: &[],
516};
517
518static PRINTF_META: CommandMeta = CommandMeta {
519    name: "printf",
520    synopsis: "printf [-v var] format [arguments]",
521    description: "Format and print data.",
522    options: &[("-v VAR", "assign the output to shell variable VAR")],
523    supports_help_flag: true,
524    flags: &[],
525};
526
527static EXEC_META: CommandMeta = CommandMeta {
528    name: "exec",
529    synopsis: "exec [-a name] [command [arguments]]",
530    description: "Replace the shell with the given command.",
531    options: &[],
532    supports_help_flag: true,
533    flags: &[],
534};
535
536static SH_META: CommandMeta = CommandMeta {
537    name: "sh",
538    synopsis: "sh [-c command_string] [file]",
539    description: "Execute commands from a string, file, or standard input.",
540    options: &[("-c", "read commands from the command_string operand")],
541    supports_help_flag: true,
542    flags: &[],
543};
544
545static HELP_META: CommandMeta = CommandMeta {
546    name: "help",
547    synopsis: "help [pattern]",
548    description: "Display information about builtin commands.",
549    options: &[],
550    supports_help_flag: true,
551    flags: &[],
552};
553
554static HISTORY_META: CommandMeta = CommandMeta {
555    name: "history",
556    synopsis: "history [n]",
557    description: "Display the command history list.",
558    options: &[],
559    supports_help_flag: true,
560    flags: &[],
561};
562
563/// Return the `CommandMeta` for a shell builtin, if one exists.
564pub(crate) fn builtin_meta(name: &str) -> Option<&'static CommandMeta> {
565    match name {
566        "cd" => Some(&CD_META),
567        "exit" => Some(&EXIT_META),
568        "export" => Some(&EXPORT_META),
569        "unset" => Some(&UNSET_META),
570        "set" => Some(&SET_META),
571        "shift" => Some(&SHIFT_META),
572        "readonly" => Some(&READONLY_META),
573        "declare" | "typeset" => Some(&DECLARE_META),
574        "read" => Some(&READ_META),
575        "eval" => Some(&EVAL_META),
576        "source" | "." => Some(&SOURCE_META),
577        "break" => Some(&BREAK_META),
578        "continue" => Some(&CONTINUE_META),
579        ":" | "colon" => Some(&COLON_META),
580        "let" => Some(&LET_META),
581        "local" => Some(&LOCAL_META),
582        "return" => Some(&RETURN_META),
583        "trap" => Some(&TRAP_META),
584        "shopt" => Some(&SHOPT_META),
585        "type" => Some(&TYPE_META),
586        "command" => Some(&COMMAND_META),
587        "builtin" => Some(&BUILTIN_CMD_META),
588        "getopts" => Some(&GETOPTS_META),
589        "mapfile" | "readarray" => Some(&MAPFILE_META),
590        "pushd" => Some(&PUSHD_META),
591        "popd" => Some(&POPD_META),
592        "dirs" => Some(&DIRS_META),
593        "hash" => Some(&HASH_META),
594        "wait" => Some(&WAIT_META),
595        "alias" => Some(&ALIAS_META),
596        "unalias" => Some(&UNALIAS_META),
597        "printf" => Some(&PRINTF_META),
598        "exec" => Some(&EXEC_META),
599        "sh" | "bash" => Some(&SH_META),
600        "help" => Some(&HELP_META),
601        "history" => Some(&HISTORY_META),
602        _ => None,
603    }
604}
605
606fn builtin_exit(
607    args: &[String],
608    state: &mut InterpreterState,
609) -> Result<ExecResult, RustBashError> {
610    state.should_exit = true;
611    let code = if let Some(arg) = args.first() {
612        match arg.parse::<i32>() {
613            Ok(n) => n,
614            Err(_) => {
615                return Ok(ExecResult {
616                    stderr: format!("exit: {arg}: numeric argument required\n"),
617                    exit_code: 2,
618                    ..ExecResult::default()
619                });
620            }
621        }
622    } else {
623        state.last_exit_code
624    };
625    Ok(ExecResult {
626        exit_code: code & 0xFF,
627        ..ExecResult::default()
628    })
629}
630
631// ── break ────────────────────────────────────────────────────────────
632
633fn builtin_break(
634    args: &[String],
635    state: &mut InterpreterState,
636) -> Result<ExecResult, RustBashError> {
637    let n = parse_loop_level("break", args)?;
638    let n = match n {
639        Ok(level) => level,
640        Err(result) => return Ok(result),
641    };
642    if state.loop_depth == 0 {
643        return Ok(ExecResult {
644            stderr: "break: only meaningful in a `for', `while', or `until' loop\n".to_string(),
645            exit_code: 1,
646            ..ExecResult::default()
647        });
648    }
649    state.control_flow = Some(ControlFlow::Break(n.min(state.loop_depth)));
650    Ok(ExecResult::default())
651}
652
653// ── continue ─────────────────────────────────────────────────────────
654
655fn builtin_continue(
656    args: &[String],
657    state: &mut InterpreterState,
658) -> Result<ExecResult, RustBashError> {
659    let n = parse_loop_level("continue", args)?;
660    let n = match n {
661        Ok(level) => level,
662        Err(result) => return Ok(result),
663    };
664    if state.loop_depth == 0 {
665        return Ok(ExecResult {
666            stderr: "continue: only meaningful in a `for', `while', or `until' loop\n".to_string(),
667            exit_code: 1,
668            ..ExecResult::default()
669        });
670    }
671    state.control_flow = Some(ControlFlow::Continue(n.min(state.loop_depth)));
672    Ok(ExecResult::default())
673}
674
675/// Parse the optional numeric level argument for break/continue.
676/// Returns `Ok(Ok(n))` on success, `Ok(Err(result))` for user-facing errors.
677fn parse_loop_level(
678    name: &str,
679    args: &[String],
680) -> Result<Result<usize, ExecResult>, RustBashError> {
681    if let Some(arg) = args.first() {
682        match arg.parse::<isize>() {
683            Ok(n) if n <= 0 => Ok(Err(ExecResult {
684                stderr: format!("{name}: {arg}: loop count out of range\n"),
685                exit_code: 1,
686                ..ExecResult::default()
687            })),
688            Ok(n) => Ok(Ok(n as usize)),
689            Err(_) => Ok(Err(ExecResult {
690                stderr: format!("{name}: {arg}: numeric argument required\n"),
691                exit_code: 128,
692                ..ExecResult::default()
693            })),
694        }
695    } else {
696        Ok(Ok(1))
697    }
698}
699
700// ── cd ──────────────────────────────────────────────────────────────
701
702fn builtin_cd(args: &[String], state: &mut InterpreterState) -> Result<ExecResult, RustBashError> {
703    // Skip -- if present
704    let effective_args: &[String] = if args.first().is_some_and(|a| a == "--") {
705        &args[1..]
706    } else {
707        args
708    };
709
710    // bash rejects cd with 2+ positional arguments
711    if effective_args.len() > 1 {
712        return Ok(ExecResult {
713            stderr: "cd: too many arguments\n".to_string(),
714            exit_code: 1,
715            ..ExecResult::default()
716        });
717    }
718
719    let target = if effective_args.is_empty() {
720        // cd with no args → $HOME
721        match state.env.get("HOME") {
722            Some(v) if !v.value.as_scalar().is_empty() => v.value.as_scalar().to_string(),
723            _ => {
724                return Ok(ExecResult {
725                    stderr: "cd: HOME not set\n".to_string(),
726                    exit_code: 1,
727                    ..ExecResult::default()
728                });
729            }
730        }
731    } else if effective_args[0] == "-" {
732        // cd - → $OLDPWD
733        match state.env.get("OLDPWD") {
734            Some(v) if !v.value.as_scalar().is_empty() => v.value.as_scalar().to_string(),
735            _ => {
736                return Ok(ExecResult {
737                    stderr: "cd: OLDPWD not set\n".to_string(),
738                    exit_code: 1,
739                    ..ExecResult::default()
740                });
741            }
742        }
743    } else {
744        effective_args[0].clone()
745    };
746
747    // CDPATH support: if target is relative and not starting with ./,
748    // try CDPATH directories first.
749    let mut cd_printed_path = String::new();
750    let resolved = if !target.starts_with('/')
751        && !target.starts_with("./")
752        && !target.starts_with("../")
753        && target != "."
754        && target != ".."
755    {
756        if let Some(cdpath_var) = state.env.get("CDPATH") {
757            let cdpath = cdpath_var.value.as_scalar().to_string();
758            let mut found = None;
759            for dir in cdpath.split(':') {
760                let base = if dir.is_empty() { "." } else { dir };
761                let candidate = resolve_path(
762                    &state.cwd,
763                    &format!("{}/{}", base.trim_end_matches('/'), &target),
764                );
765                let path = Path::new(&candidate);
766                if state.fs.exists(path)
767                    && state
768                        .fs
769                        .stat(path)
770                        .is_ok_and(|m| m.node_type == NodeType::Directory)
771                {
772                    cd_printed_path = candidate.clone();
773                    found = Some(candidate);
774                    break;
775                }
776            }
777            found.unwrap_or_else(|| resolve_path(&state.cwd, &target))
778        } else {
779            resolve_path(&state.cwd, &target)
780        }
781    } else {
782        resolve_path(&state.cwd, &target)
783    };
784
785    // Validate intermediate path components (cd BAD/.. should fail)
786    if target.contains('/') && !target.starts_with('/') {
787        let components: Vec<&str> = target.split('/').collect();
788        let mut check_path = state.cwd.clone();
789        for (i, comp) in components.iter().enumerate() {
790            if *comp == "." || comp.is_empty() {
791                continue;
792            }
793            if *comp == ".." {
794                // .. is valid if we have a parent
795                continue;
796            }
797            check_path = resolve_path(&check_path, comp);
798            // Only check intermediate components, not the final target
799            if i < components.len() - 1 && !state.fs.exists(Path::new(&check_path)) {
800                return Ok(ExecResult {
801                    stderr: format!("cd: {target}: No such file or directory\n"),
802                    exit_code: 1,
803                    ..ExecResult::default()
804                });
805            }
806        }
807    }
808
809    // Validate the final path exists and is a directory
810    let path = Path::new(&resolved);
811    if !state.fs.exists(path) {
812        return Ok(ExecResult {
813            stderr: format!("cd: {target}: No such file or directory\n"),
814            exit_code: 1,
815            ..ExecResult::default()
816        });
817    }
818
819    match state.fs.stat(path) {
820        Ok(meta) if meta.node_type == NodeType::Directory => {}
821        _ => {
822            return Ok(ExecResult {
823                stderr: format!("cd: {target}: Not a directory\n"),
824                exit_code: 1,
825                ..ExecResult::default()
826            });
827        }
828    }
829
830    let old_cwd = state.cwd.clone();
831    state.cwd = resolved;
832
833    // Set OLDPWD — use set_variable to respect readonly
834    let _ = set_variable(state, "OLDPWD", old_cwd);
835    if let Some(var) = state.env.get_mut("OLDPWD") {
836        var.attrs.insert(VariableAttrs::EXPORTED);
837    }
838    let new_cwd = state.cwd.clone();
839    let _ = set_variable(state, "PWD", new_cwd);
840    if let Some(var) = state.env.get_mut("PWD") {
841        var.attrs.insert(VariableAttrs::EXPORTED);
842    }
843
844    // Print directory if cd - or CDPATH match
845    let stdout = if (!effective_args.is_empty() && effective_args[0] == "-")
846        || !cd_printed_path.is_empty()
847    {
848        format!("{}\n", state.cwd)
849    } else {
850        String::new()
851    };
852
853    Ok(ExecResult {
854        stdout,
855        ..ExecResult::default()
856    })
857}
858
859/// Resolve a potentially relative path against a base directory.
860pub(crate) fn resolve_path(cwd: &str, path: &str) -> String {
861    if path.starts_with('/') {
862        normalize_path(path)
863    } else {
864        let combined = if cwd.ends_with('/') {
865            format!("{cwd}{path}")
866        } else {
867            format!("{cwd}/{path}")
868        };
869        normalize_path(&combined)
870    }
871}
872
873fn normalize_path(path: &str) -> String {
874    let mut parts: Vec<&str> = Vec::new();
875    for component in path.split('/') {
876        match component {
877            "" | "." => {}
878            ".." => {
879                parts.pop();
880            }
881            other => parts.push(other),
882        }
883    }
884    if parts.is_empty() {
885        "/".to_string()
886    } else {
887        format!("/{}", parts.join("/"))
888    }
889}
890
891// ── export ──────────────────────────────────────────────────────────
892
893fn builtin_export(
894    args: &[String],
895    state: &mut InterpreterState,
896) -> Result<ExecResult, RustBashError> {
897    if args.is_empty() || args == ["-p"] {
898        // List all exported variables
899        let mut lines: Vec<String> = state
900            .env
901            .iter()
902            .filter(|(_, v)| v.exported())
903            .map(|(k, v)| format_declare_line(k, v))
904            .collect();
905        lines.sort();
906        return Ok(ExecResult {
907            stdout: lines.join(""),
908            ..ExecResult::default()
909        });
910    }
911
912    let mut unexport = false;
913    let mut exit_code = 0;
914    let mut stderr = String::new();
915    for arg in args {
916        if arg == "-n" {
917            unexport = true;
918            continue;
919        }
920        if arg.starts_with('-') && !arg.contains('=') {
921            continue; // skip other flags
922        }
923
924        if let Err(msg) = validate_var_arg(arg, "export") {
925            stderr.push_str(&msg);
926            exit_code = 1;
927            continue;
928        }
929
930        if let Some((name, value)) = arg.split_once("+=") {
931            // export name+=value — append
932            let current = state
933                .env
934                .get(name)
935                .map(|v| v.value.as_scalar().to_string())
936                .unwrap_or_default();
937            let new_val = format!("{current}{value}");
938            set_variable(state, name, new_val)?;
939            if let Some(var) = state.env.get_mut(name) {
940                var.attrs.insert(VariableAttrs::EXPORTED);
941            }
942        } else if let Some((name, value)) = arg.split_once('=') {
943            set_variable(state, name, value.to_string())?;
944            if let Some(var) = state.env.get_mut(name) {
945                if unexport {
946                    var.attrs.remove(VariableAttrs::EXPORTED);
947                } else {
948                    var.attrs.insert(VariableAttrs::EXPORTED);
949                }
950            }
951        } else if unexport {
952            // export -n VAR — remove export flag
953            if let Some(var) = state.env.get_mut(arg.as_str()) {
954                var.attrs.remove(VariableAttrs::EXPORTED);
955            }
956        } else {
957            // Just mark existing variable as exported
958            if let Some(var) = state.env.get_mut(arg.as_str()) {
959                var.attrs.insert(VariableAttrs::EXPORTED);
960            } else {
961                // Create empty exported variable
962                state.env.insert(
963                    arg.clone(),
964                    Variable {
965                        value: VariableValue::Scalar(String::new()),
966                        attrs: VariableAttrs::EXPORTED,
967                    },
968                );
969            }
970        }
971    }
972
973    Ok(ExecResult {
974        exit_code,
975        stderr,
976        ..ExecResult::default()
977    })
978}
979
980// ── unset ───────────────────────────────────────────────────────────
981
982fn builtin_unset(
983    args: &[String],
984    state: &mut InterpreterState,
985) -> Result<ExecResult, RustBashError> {
986    let mut unset_func = false;
987    let mut names_start = 0;
988    for (i, arg) in args.iter().enumerate() {
989        if arg == "-f" {
990            unset_func = true;
991            names_start = i + 1;
992        } else if arg == "-v" {
993            unset_func = false;
994            names_start = i + 1;
995        } else if arg.starts_with('-') {
996            names_start = i + 1;
997        } else {
998            break;
999        }
1000    }
1001    for arg in &args[names_start..] {
1002        if unset_func {
1003            state.functions.remove(arg.as_str());
1004            continue;
1005        }
1006        // Check for array element unset: name[index]
1007        if let Some(bracket_pos) = arg.find('[')
1008            && arg.ends_with(']')
1009        {
1010            let name = &arg[..bracket_pos];
1011            let index_str = &arg[bracket_pos + 1..arg.len() - 1];
1012            if let Some(var) = state.env.get(name)
1013                && var.readonly()
1014            {
1015                return Ok(ExecResult {
1016                    stderr: format!("unset: {name}: cannot unset: readonly variable\n"),
1017                    exit_code: 1,
1018                    ..ExecResult::default()
1019                });
1020            }
1021            // Evaluate index before borrowing env mutably
1022            let is_indexed = state
1023                .env
1024                .get(name)
1025                .is_some_and(|v| matches!(v.value, VariableValue::IndexedArray(_)));
1026            let is_assoc = state
1027                .env
1028                .get(name)
1029                .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));
1030            let is_scalar = state
1031                .env
1032                .get(name)
1033                .is_some_and(|v| matches!(v.value, VariableValue::Scalar(_)));
1034
1035            if is_indexed {
1036                if let Ok(idx) = crate::interpreter::arithmetic::eval_arithmetic(index_str, state) {
1037                    let actual_idx = if idx < 0 {
1038                        // Resolve negative index relative to max key.
1039                        let max_key = state.env.get(name).and_then(|v| {
1040                            if let VariableValue::IndexedArray(map) = &v.value {
1041                                map.keys().next_back().copied()
1042                            } else {
1043                                None
1044                            }
1045                        });
1046                        if let Some(mk) = max_key {
1047                            let resolved = mk as i64 + 1 + idx;
1048                            if resolved < 0 {
1049                                return Ok(ExecResult {
1050                                    stderr: format!(
1051                                        "unset: {name}[{index_str}]: bad array subscript\n"
1052                                    ),
1053                                    exit_code: 1,
1054                                    ..ExecResult::default()
1055                                });
1056                            }
1057                            Some(resolved as usize)
1058                        } else {
1059                            None
1060                        }
1061                    } else {
1062                        Some(idx as usize)
1063                    };
1064                    if let Some(actual) = actual_idx
1065                        && let Some(var) = state.env.get_mut(name)
1066                        && let VariableValue::IndexedArray(map) = &mut var.value
1067                    {
1068                        map.remove(&actual);
1069                    }
1070                }
1071            } else if is_assoc {
1072                // Expand variables and strip quotes in the key.
1073                let word = brush_parser::ast::Word {
1074                    value: index_str.to_string(),
1075                    loc: None,
1076                };
1077                let expanded_key =
1078                    crate::interpreter::expansion::expand_word_to_string_mut(&word, state)?;
1079                if let Some(var) = state.env.get_mut(name)
1080                    && let VariableValue::AssociativeArray(map) = &mut var.value
1081                {
1082                    map.remove(&expanded_key);
1083                }
1084            } else if is_scalar
1085                && index_str == "0"
1086                && let Some(var) = state.env.get_mut(name)
1087            {
1088                var.value = VariableValue::Scalar(String::new());
1089            }
1090            continue;
1091        }
1092        if let Some(var) = state.env.get(arg.as_str())
1093            && var.readonly()
1094        {
1095            return Ok(ExecResult {
1096                stderr: format!("unset: {arg}: cannot unset: readonly variable\n"),
1097                exit_code: 1,
1098                ..ExecResult::default()
1099            });
1100        }
1101        // Resolve nameref: unset the target, not the ref itself.
1102        let is_nameref = state
1103            .env
1104            .get(arg.as_str())
1105            .is_some_and(|v| v.attrs.contains(VariableAttrs::NAMEREF));
1106        if is_nameref {
1107            let target = crate::interpreter::resolve_nameref_or_self(arg, state);
1108            if target != *arg {
1109                // Check if target is readonly.
1110                if let Some(var) = state.env.get(target.as_str())
1111                    && var.readonly()
1112                {
1113                    return Ok(ExecResult {
1114                        stderr: format!("unset: {target}: cannot unset: readonly variable\n"),
1115                        exit_code: 1,
1116                        ..ExecResult::default()
1117                    });
1118                }
1119                state.env.remove(target.as_str());
1120                continue;
1121            }
1122        }
1123        state.env.remove(arg.as_str());
1124    }
1125    Ok(ExecResult::default())
1126}
1127
1128// ── set ─────────────────────────────────────────────────────────────
1129
1130fn builtin_set(args: &[String], state: &mut InterpreterState) -> Result<ExecResult, RustBashError> {
1131    if args.is_empty() {
1132        // List all variables in bash format (no quotes for scalars, array syntax for arrays)
1133        let mut lines: Vec<String> = state
1134            .env
1135            .iter()
1136            .map(|(k, v)| match &v.value {
1137                VariableValue::IndexedArray(map) => {
1138                    let elements: Vec<String> = map
1139                        .iter()
1140                        .map(|(idx, val)| format!("[{idx}]=\"{val}\""))
1141                        .collect();
1142                    format!("{k}=({})\n", elements.join(" "))
1143                }
1144                VariableValue::AssociativeArray(map) => {
1145                    let mut entries: Vec<(&String, &String)> = map.iter().collect();
1146                    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1147                    let elements: Vec<String> = entries
1148                        .iter()
1149                        .map(|(key, val)| {
1150                            if key.contains(' ') || key.contains('"') {
1151                                format!("[\"{key}\"]=\"{val}\"")
1152                            } else {
1153                                format!("[{key}]=\"{val}\"")
1154                            }
1155                        })
1156                        .collect();
1157                    if elements.is_empty() {
1158                        format!("{k}=()\n")
1159                    } else {
1160                        // Bash outputs a trailing space before the closing paren
1161                        format!("{k}=({} )\n", elements.join(" "))
1162                    }
1163                }
1164                VariableValue::Scalar(s) => format!("{k}={s}\n"),
1165            })
1166            .collect();
1167        lines.sort();
1168        return Ok(ExecResult {
1169            stdout: lines.join(""),
1170            ..ExecResult::default()
1171        });
1172    }
1173
1174    let mut i = 0;
1175    while i < args.len() {
1176        let arg = &args[i];
1177        if arg == "--" {
1178            // Everything after -- becomes positional parameters
1179            state.positional_params = args[i + 1..].to_vec();
1180            return Ok(ExecResult::default());
1181        } else if arg.starts_with('+') || arg.starts_with('-') {
1182            let enable = arg.starts_with('-');
1183            if arg == "-o" || arg == "+o" {
1184                i += 1;
1185                if i < args.len() {
1186                    apply_option_name(&args[i], enable, state);
1187                } else if !enable {
1188                    // set +o with no arg: list options in re-parseable format
1189                    let mut out = String::new();
1190                    for name in SET_O_OPTIONS {
1191                        let val = get_set_option(name, state).unwrap_or(false);
1192                        let flag = if val { "-o" } else { "+o" };
1193                        out.push_str(&format!("set {flag} {name}\n"));
1194                    }
1195                    return Ok(ExecResult {
1196                        stdout: out,
1197                        ..ExecResult::default()
1198                    });
1199                } else {
1200                    // set -o with no arg: list options in tabular format
1201                    return Ok(ExecResult {
1202                        stdout: format_options(state),
1203                        ..ExecResult::default()
1204                    });
1205                }
1206            } else {
1207                let chars: Vec<char> = arg[1..].chars().collect();
1208                let mut saw_o = false;
1209                for c in &chars {
1210                    if *c == 'o' {
1211                        saw_o = true;
1212                    } else {
1213                        apply_option_char(*c, enable, state);
1214                    }
1215                }
1216                if saw_o {
1217                    // 'o' in a flag group (e.g., -eo) consumes next arg as option name
1218                    i += 1;
1219                    if i < args.len() {
1220                        apply_option_name(&args[i], enable, state);
1221                    }
1222                }
1223            }
1224        } else {
1225            // Positional parameters
1226            state.positional_params = args[i..].to_vec();
1227            return Ok(ExecResult::default());
1228        }
1229        i += 1;
1230    }
1231
1232    Ok(ExecResult::default())
1233}
1234
1235fn apply_option_char(c: char, enable: bool, state: &mut InterpreterState) {
1236    match c {
1237        'e' => state.shell_opts.errexit = enable,
1238        'u' => state.shell_opts.nounset = enable,
1239        'x' => state.shell_opts.xtrace = enable,
1240        'v' => state.shell_opts.verbose = enable,
1241        'n' => state.shell_opts.noexec = enable,
1242        'C' => state.shell_opts.noclobber = enable,
1243        'a' => state.shell_opts.allexport = enable,
1244        'f' => state.shell_opts.noglob = enable,
1245        _ => {}
1246    }
1247}
1248
1249fn apply_option_name(name: &str, enable: bool, state: &mut InterpreterState) {
1250    match name {
1251        "errexit" => state.shell_opts.errexit = enable,
1252        "nounset" => state.shell_opts.nounset = enable,
1253        "pipefail" => state.shell_opts.pipefail = enable,
1254        "xtrace" => state.shell_opts.xtrace = enable,
1255        "verbose" => state.shell_opts.verbose = enable,
1256        "noexec" => state.shell_opts.noexec = enable,
1257        "noclobber" => state.shell_opts.noclobber = enable,
1258        "allexport" => state.shell_opts.allexport = enable,
1259        "noglob" => state.shell_opts.noglob = enable,
1260        "posix" => state.shell_opts.posix = enable,
1261        "vi" => {
1262            state.shell_opts.vi_mode = enable;
1263            if enable {
1264                state.shell_opts.emacs_mode = false;
1265            }
1266        }
1267        "emacs" => {
1268            state.shell_opts.emacs_mode = enable;
1269            if enable {
1270                state.shell_opts.vi_mode = false;
1271            }
1272        }
1273        _ => {}
1274    }
1275}
1276
1277fn format_options(state: &InterpreterState) -> String {
1278    let mut out = String::new();
1279    for name in SET_O_OPTIONS {
1280        let val = get_set_option(name, state).unwrap_or(false);
1281        let status = if val { "on" } else { "off" };
1282        out.push_str(&format!("{name:<23}\t{status}\n"));
1283    }
1284    out
1285}
1286
1287// ── shift ───────────────────────────────────────────────────────────
1288
1289fn builtin_shift(
1290    args: &[String],
1291    state: &mut InterpreterState,
1292) -> Result<ExecResult, RustBashError> {
1293    let n = if let Some(arg) = args.first() {
1294        match arg.parse::<usize>() {
1295            Ok(n) => n,
1296            Err(_) => {
1297                return Ok(ExecResult {
1298                    stderr: format!("shift: {arg}: numeric argument required\n"),
1299                    exit_code: 1,
1300                    ..ExecResult::default()
1301                });
1302            }
1303        }
1304    } else {
1305        1
1306    };
1307
1308    if n > state.positional_params.len() {
1309        return Ok(ExecResult {
1310            stderr: format!("shift: {n}: shift count out of range\n"),
1311            exit_code: 1,
1312            ..ExecResult::default()
1313        });
1314    }
1315
1316    state.positional_params = state.positional_params[n..].to_vec();
1317    Ok(ExecResult::default())
1318}
1319
1320// ── readonly ────────────────────────────────────────────────────────
1321
1322fn builtin_readonly(
1323    args: &[String],
1324    state: &mut InterpreterState,
1325) -> Result<ExecResult, RustBashError> {
1326    if args.is_empty() || args == ["-p"] {
1327        let mut lines: Vec<String> = state
1328            .env
1329            .iter()
1330            .filter(|(_, v)| v.readonly())
1331            .map(|(k, v)| format_declare_line(k, v))
1332            .collect();
1333        lines.sort();
1334        return Ok(ExecResult {
1335            stdout: lines.join(""),
1336            ..ExecResult::default()
1337        });
1338    }
1339
1340    let mut exit_code = 0;
1341    let mut stderr = String::new();
1342    for arg in args {
1343        if arg.starts_with('-') {
1344            continue; // skip flags
1345        }
1346        if let Err(msg) = validate_var_arg(arg, "readonly") {
1347            stderr.push_str(&msg);
1348            exit_code = 1;
1349            continue;
1350        }
1351        if let Some((name, value)) = arg.split_once("+=") {
1352            let current = state
1353                .env
1354                .get(name)
1355                .map(|v| v.value.as_scalar().to_string())
1356                .unwrap_or_default();
1357            let new_val = format!("{current}{value}");
1358            set_variable(state, name, new_val)?;
1359            if let Some(var) = state.env.get_mut(name) {
1360                var.attrs.insert(VariableAttrs::READONLY);
1361            }
1362        } else if let Some((name, value)) = arg.split_once('=') {
1363            set_variable(state, name, value.to_string())?;
1364            if let Some(var) = state.env.get_mut(name) {
1365                var.attrs.insert(VariableAttrs::READONLY);
1366            }
1367        } else {
1368            // Mark existing variable as readonly
1369            if let Some(var) = state.env.get_mut(arg.as_str()) {
1370                var.attrs.insert(VariableAttrs::READONLY);
1371            } else {
1372                state.env.insert(
1373                    arg.clone(),
1374                    Variable {
1375                        value: VariableValue::Scalar(String::new()),
1376                        attrs: VariableAttrs::READONLY,
1377                    },
1378                );
1379            }
1380        }
1381    }
1382
1383    Ok(ExecResult {
1384        exit_code,
1385        stderr,
1386        ..ExecResult::default()
1387    })
1388}
1389
1390// ── declare ─────────────────────────────────────────────────────────
1391
1392fn builtin_declare(
1393    args: &[String],
1394    state: &mut InterpreterState,
1395) -> Result<ExecResult, RustBashError> {
1396    let mut make_readonly = false;
1397    let mut make_exported = false;
1398    let mut make_indexed_array = false;
1399    let mut make_assoc_array = false;
1400    let mut make_integer = false;
1401    let mut make_lowercase = false;
1402    let mut make_uppercase = false;
1403    let mut make_nameref = false;
1404    let mut print_mode = false;
1405    let mut func_mode = false; // -f: functions
1406    let mut func_names_mode = false; // -F: function names only
1407    let mut global_mode = false; // -g
1408    let mut remove_exported = false; // +x
1409    let mut remove_readonly = false; // +r
1410    let mut var_args: Vec<&String> = Vec::new();
1411
1412    for arg in args {
1413        if let Some(flags) = arg.strip_prefix('-') {
1414            if flags.is_empty() {
1415                var_args.push(arg);
1416                continue;
1417            }
1418            for c in flags.chars() {
1419                match c {
1420                    'r' => make_readonly = true,
1421                    'x' => make_exported = true,
1422                    'a' => make_indexed_array = true,
1423                    'A' => make_assoc_array = true,
1424                    'i' => make_integer = true,
1425                    'l' => make_lowercase = true,
1426                    'u' => make_uppercase = true,
1427                    'n' => make_nameref = true,
1428                    'p' => print_mode = true,
1429                    'f' => func_mode = true,
1430                    'F' => func_names_mode = true,
1431                    'g' => global_mode = true,
1432                    _ => {}
1433                }
1434            }
1435        } else if let Some(flags) = arg.strip_prefix('+') {
1436            for c in flags.chars() {
1437                match c {
1438                    'x' => remove_exported = true,
1439                    'r' => remove_readonly = true,
1440                    _ => {}
1441                }
1442            }
1443        } else {
1444            var_args.push(arg);
1445        }
1446    }
1447
1448    // declare -f / declare -F: function listing/checking
1449    if func_mode || func_names_mode {
1450        return declare_functions(state, &var_args, func_names_mode);
1451    }
1452
1453    // declare -p [varname...] — print variable declarations
1454    if print_mode {
1455        return declare_print(
1456            state,
1457            &var_args,
1458            make_readonly,
1459            make_exported,
1460            make_nameref,
1461            make_indexed_array,
1462            make_assoc_array,
1463        );
1464    }
1465
1466    // typeset +x / +r — remove attributes
1467    // Note: +r is accepted but ignored per bash behavior (readonly cannot
1468    // be removed once set).
1469    if remove_exported || remove_readonly {
1470        for arg in &var_args {
1471            let (name, opt_value) = if let Some((n, v)) = arg.split_once('=') {
1472                (n, Some(v))
1473            } else {
1474                (arg.as_str(), None)
1475            };
1476            if remove_exported && let Some(var) = state.env.get_mut(name) {
1477                var.attrs.remove(VariableAttrs::EXPORTED);
1478            }
1479            // +r: only assign value if the variable is not readonly
1480            if let Some(value) = opt_value {
1481                let is_ro = state.env.get(name).is_some_and(|v| v.readonly());
1482                if !is_ro {
1483                    set_variable(state, name, value.to_string())?;
1484                }
1485            }
1486        }
1487        return Ok(ExecResult::default());
1488    }
1489
1490    let _ = global_mode; // accepted but not yet meaningful (no dynamic scoping)
1491
1492    let has_any_flag = make_readonly
1493        || make_exported
1494        || make_indexed_array
1495        || make_assoc_array
1496        || make_integer
1497        || make_lowercase
1498        || make_uppercase
1499        || make_nameref;
1500
1501    if var_args.is_empty() && !has_any_flag {
1502        // declare with no args — list all variables
1503        return declare_list_all(state);
1504    }
1505
1506    // Build the attribute bitmask from flags.
1507    let mut flag_attrs = VariableAttrs::empty();
1508    if make_readonly {
1509        flag_attrs.insert(VariableAttrs::READONLY);
1510    }
1511    if make_exported {
1512        flag_attrs.insert(VariableAttrs::EXPORTED);
1513    }
1514    if make_integer {
1515        flag_attrs.insert(VariableAttrs::INTEGER);
1516    }
1517    if make_lowercase {
1518        flag_attrs.insert(VariableAttrs::LOWERCASE);
1519    }
1520    if make_uppercase {
1521        flag_attrs.insert(VariableAttrs::UPPERCASE);
1522    }
1523    if make_nameref {
1524        flag_attrs.insert(VariableAttrs::NAMEREF);
1525    }
1526
1527    let mut exit_code = 0;
1528    let mut result_stderr = String::new();
1529    for arg in var_args {
1530        if let Err(msg) = validate_var_arg(arg, "declare") {
1531            result_stderr.push_str(&msg);
1532            exit_code = 1;
1533            continue;
1534        }
1535
1536        // Check for += (append) before = (assign)
1537        if let Some((name, value)) = arg.split_once("+=") {
1538            declare_append_value(
1539                state,
1540                name,
1541                value,
1542                flag_attrs,
1543                make_assoc_array,
1544                make_indexed_array,
1545            )?;
1546        } else if let Some((name, value)) = arg.split_once('=') {
1547            declare_with_value(
1548                state,
1549                name,
1550                value,
1551                flag_attrs,
1552                make_assoc_array,
1553                make_indexed_array,
1554                make_nameref,
1555            )?;
1556        } else {
1557            declare_without_value(state, arg, flag_attrs, make_assoc_array, make_indexed_array)?;
1558        }
1559    }
1560
1561    Ok(ExecResult {
1562        exit_code,
1563        stderr: result_stderr,
1564        ..ExecResult::default()
1565    })
1566}
1567
1568/// Handle `declare -f` (list function bodies) and `declare -F` (list function names).
1569fn declare_functions(
1570    state: &InterpreterState,
1571    var_args: &[&String],
1572    names_only: bool,
1573) -> Result<ExecResult, RustBashError> {
1574    if var_args.is_empty() {
1575        // List all functions
1576        let mut lines: Vec<String> = Vec::new();
1577        for name in state.functions.keys() {
1578            if names_only {
1579                lines.push(format!("declare -f {name}\n"));
1580            } else {
1581                lines.push(format!("{name} () {{ :; }}\n")); // simplified body
1582            }
1583        }
1584        lines.sort();
1585        return Ok(ExecResult {
1586            stdout: lines.join(""),
1587            ..ExecResult::default()
1588        });
1589    }
1590    // Check specific function existence
1591    let mut exit_code = 0;
1592    let mut stdout = String::new();
1593    for name in var_args {
1594        if state.functions.contains_key(name.as_str()) {
1595            if names_only {
1596                stdout.push_str(&format!("declare -f {name}\n"));
1597            }
1598        } else {
1599            exit_code = 1;
1600        }
1601    }
1602    Ok(ExecResult {
1603        stdout,
1604        exit_code,
1605        ..ExecResult::default()
1606    })
1607}
1608
1609/// Print variable declarations with `declare -p`.
1610fn declare_print(
1611    state: &InterpreterState,
1612    var_args: &[&String],
1613    filter_readonly: bool,
1614    filter_exported: bool,
1615    filter_nameref: bool,
1616    filter_indexed: bool,
1617    filter_assoc: bool,
1618) -> Result<ExecResult, RustBashError> {
1619    let has_filter =
1620        filter_readonly || filter_exported || filter_nameref || filter_indexed || filter_assoc;
1621
1622    if var_args.is_empty() {
1623        if has_filter {
1624            // Filter by attribute, sort by name
1625            let mut entries: Vec<(&String, &Variable)> = state
1626                .env
1627                .iter()
1628                .filter(|(_, v)| {
1629                    if filter_readonly && v.attrs.contains(VariableAttrs::READONLY) {
1630                        return true;
1631                    }
1632                    if filter_exported && v.attrs.contains(VariableAttrs::EXPORTED) {
1633                        return true;
1634                    }
1635                    if filter_nameref && v.attrs.contains(VariableAttrs::NAMEREF) {
1636                        return true;
1637                    }
1638                    if filter_indexed && matches!(v.value, VariableValue::IndexedArray(_)) {
1639                        return true;
1640                    }
1641                    if filter_assoc && matches!(v.value, VariableValue::AssociativeArray(_)) {
1642                        return true;
1643                    }
1644                    false
1645                })
1646                .collect();
1647            entries.sort_by_key(|(name, _)| name.as_str());
1648            let stdout: String = entries
1649                .iter()
1650                .map(|(k, v)| format_declare_line(k, v))
1651                .collect();
1652            return Ok(ExecResult {
1653                stdout,
1654                ..ExecResult::default()
1655            });
1656        }
1657        // `declare -p` with no args — list all with declare prefix, sort by name
1658        let mut entries: Vec<(&String, &Variable)> = state.env.iter().collect();
1659        entries.sort_by_key(|(name, _)| name.as_str());
1660        let stdout: String = entries
1661            .iter()
1662            .map(|(k, v)| format_declare_line(k, v))
1663            .collect();
1664        return Ok(ExecResult {
1665            stdout,
1666            ..ExecResult::default()
1667        });
1668    }
1669    let mut stdout = String::new();
1670    let mut stderr = String::new();
1671    let mut exit_code = 0;
1672    for name in var_args {
1673        if let Some(var) = state.env.get(name.as_str()) {
1674            stdout.push_str(&format_declare_line(name, var));
1675        } else {
1676            stderr.push_str(&format!("declare: {name}: not found\n"));
1677            exit_code = 1;
1678        }
1679    }
1680    Ok(ExecResult {
1681        stdout,
1682        stderr,
1683        exit_code,
1684        stdout_bytes: None,
1685    })
1686}
1687
1688/// List all variables with their declarations.
1689fn declare_list_all(state: &InterpreterState) -> Result<ExecResult, RustBashError> {
1690    // Bare `declare` uses simple `name=value` format (no `declare` prefix).
1691    let mut entries: Vec<(&String, &Variable)> = state.env.iter().collect();
1692    entries.sort_by_key(|(name, _)| name.as_str());
1693    let stdout: String = entries
1694        .iter()
1695        .map(|(name, var)| format_simple_line(name, var))
1696        .collect();
1697    Ok(ExecResult {
1698        stdout,
1699        ..ExecResult::default()
1700    })
1701}
1702
1703/// Format `name=value` (used by bare `declare` and `local`).
1704fn format_simple_line(name: &str, var: &Variable) -> String {
1705    match &var.value {
1706        VariableValue::Scalar(s) => format!("{name}={s}\n"),
1707        VariableValue::IndexedArray(map) => {
1708            let elems: Vec<String> = map.iter().map(|(k, v)| format!("[{k}]=\"{v}\"")).collect();
1709            format!("{name}=({})\n", elems.join(" "))
1710        }
1711        VariableValue::AssociativeArray(map) => {
1712            let mut entries: Vec<(&String, &String)> = map.iter().collect();
1713            entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1714            let elems: Vec<String> = entries
1715                .iter()
1716                .map(|(k, v)| format!("[{k}]=\"{v}\""))
1717                .collect();
1718            if elems.is_empty() {
1719                format!("{name}=()\n")
1720            } else {
1721                format!("{name}=({} )\n", elems.join(" "))
1722            }
1723        }
1724    }
1725}
1726
1727/// Format a single `declare -<flags> name="value"` line.
1728fn format_declare_line(name: &str, var: &Variable) -> String {
1729    let mut flags = String::new();
1730    // Flag order: a, A, i, l, n, r, u, x (alphabetical)
1731    if matches!(var.value, VariableValue::IndexedArray(_)) {
1732        flags.push('a');
1733    }
1734    if matches!(var.value, VariableValue::AssociativeArray(_)) {
1735        flags.push('A');
1736    }
1737    if var.attrs.contains(VariableAttrs::INTEGER) {
1738        flags.push('i');
1739    }
1740    if var.attrs.contains(VariableAttrs::LOWERCASE) {
1741        flags.push('l');
1742    }
1743    if var.attrs.contains(VariableAttrs::NAMEREF) {
1744        flags.push('n');
1745    }
1746    if var.attrs.contains(VariableAttrs::READONLY) {
1747        flags.push('r');
1748    }
1749    if var.attrs.contains(VariableAttrs::UPPERCASE) {
1750        flags.push('u');
1751    }
1752    if var.attrs.contains(VariableAttrs::EXPORTED) {
1753        flags.push('x');
1754    }
1755
1756    let flag_str = if flags.is_empty() {
1757        "-- ".to_string()
1758    } else {
1759        format!("-{flags} ")
1760    };
1761
1762    match &var.value {
1763        VariableValue::Scalar(s) => format!("declare {flag_str}{name}=\"{s}\"\n"),
1764        VariableValue::IndexedArray(map) => {
1765            let elems: Vec<String> = map.iter().map(|(k, v)| format!("[{k}]=\"{v}\"")).collect();
1766            format!("declare {flag_str}{name}=({})\n", elems.join(" "))
1767        }
1768        VariableValue::AssociativeArray(map) => {
1769            let mut entries: Vec<(&String, &String)> = map.iter().collect();
1770            entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1771            let elems: Vec<String> = entries
1772                .iter()
1773                .map(|(k, v)| format!("[{k}]=\"{v}\""))
1774                .collect();
1775            if elems.is_empty() {
1776                format!("declare {flag_str}{name}=()\n")
1777            } else {
1778                // Bash outputs a trailing space before closing paren
1779                format!("declare {flag_str}{name}=({} )\n", elems.join(" "))
1780            }
1781        }
1782    }
1783}
1784
1785/// Handle `declare [-flags] name+=value` — append to existing variable.
1786fn declare_append_value(
1787    state: &mut InterpreterState,
1788    name: &str,
1789    value: &str,
1790    flag_attrs: VariableAttrs,
1791    make_assoc_array: bool,
1792    _make_indexed_array: bool,
1793) -> Result<(), RustBashError> {
1794    // Handle array append: name+=(val1 val2 ...)
1795    if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1796        // Check if the target is an assoc array
1797        let is_assoc = make_assoc_array
1798            || state
1799                .env
1800                .get(name)
1801                .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));
1802
1803        if is_assoc {
1804            // Create assoc array if it doesn't exist
1805            if !state.env.contains_key(name) {
1806                state.env.insert(
1807                    name.to_string(),
1808                    Variable {
1809                        value: VariableValue::AssociativeArray(std::collections::BTreeMap::new()),
1810                        attrs: flag_attrs,
1811                    },
1812                );
1813            }
1814            parse_and_set_assoc_array_append(state, name, inner)?;
1815        } else {
1816            // Find current max index + 1
1817            let start_idx = match state.env.get(name) {
1818                Some(var) => match &var.value {
1819                    VariableValue::IndexedArray(map) => {
1820                        map.keys().next_back().map(|k| k + 1).unwrap_or(0)
1821                    }
1822                    VariableValue::Scalar(s) if s.is_empty() => 0,
1823                    VariableValue::Scalar(_) => 1,
1824                    VariableValue::AssociativeArray(_) => 0,
1825                },
1826                None => 0,
1827            };
1828
1829            // Create array if it doesn't exist
1830            if !state.env.contains_key(name) {
1831                state.env.insert(
1832                    name.to_string(),
1833                    Variable {
1834                        value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
1835                        attrs: flag_attrs,
1836                    },
1837                );
1838            }
1839
1840            // Convert scalar to array if needed
1841            if let Some(var) = state.env.get_mut(name)
1842                && let VariableValue::Scalar(s) = &var.value
1843            {
1844                let mut map = std::collections::BTreeMap::new();
1845                if !s.is_empty() {
1846                    map.insert(0, s.clone());
1847                }
1848                var.value = VariableValue::IndexedArray(map);
1849            }
1850
1851            let words = shell_split_array_body(inner);
1852            let mut idx = start_idx;
1853            for word in &words {
1854                let val = unquote_simple(word);
1855                crate::interpreter::set_array_element(state, name, idx, val)?;
1856                idx += 1;
1857            }
1858
1859            if let Some(var) = state.env.get_mut(name) {
1860                var.attrs.insert(flag_attrs);
1861            }
1862        }
1863    } else {
1864        // Scalar append
1865        let current = state
1866            .env
1867            .get(name)
1868            .map(|v| v.value.as_scalar().to_string())
1869            .unwrap_or_default();
1870        let new_val = format!("{current}{value}");
1871        set_variable(state, name, new_val)?;
1872        if let Some(var) = state.env.get_mut(name) {
1873            var.attrs.insert(flag_attrs);
1874        }
1875    }
1876    Ok(())
1877}
1878
1879/// Handle `declare [-flags] name=value`.
1880fn declare_with_value(
1881    state: &mut InterpreterState,
1882    name: &str,
1883    value: &str,
1884    flag_attrs: VariableAttrs,
1885    make_assoc_array: bool,
1886    make_indexed_array: bool,
1887    make_nameref: bool,
1888) -> Result<(), RustBashError> {
1889    if make_nameref {
1890        // Nameref: set the variable directly (don't follow existing nameref).
1891        let var = state
1892            .env
1893            .entry(name.to_string())
1894            .or_insert_with(|| Variable {
1895                value: VariableValue::Scalar(String::new()),
1896                attrs: VariableAttrs::empty(),
1897            });
1898        var.value = VariableValue::Scalar(value.to_string());
1899        var.attrs.insert(flag_attrs);
1900        return Ok(());
1901    }
1902
1903    if make_assoc_array {
1904        let var = state
1905            .env
1906            .entry(name.to_string())
1907            .or_insert_with(|| Variable {
1908                value: VariableValue::AssociativeArray(std::collections::BTreeMap::new()),
1909                attrs: VariableAttrs::empty(),
1910            });
1911        var.attrs.insert(flag_attrs);
1912        if !matches!(var.value, VariableValue::AssociativeArray(_)) {
1913            var.value = VariableValue::AssociativeArray(std::collections::BTreeMap::new());
1914        }
1915        // Parse assoc array literal: ([key1]=val1 [key2]=val2 ...)
1916        if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1917            parse_and_set_assoc_array(state, name, inner)?;
1918        }
1919    } else if make_indexed_array {
1920        let var = state
1921            .env
1922            .entry(name.to_string())
1923            .or_insert_with(|| Variable {
1924                value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
1925                attrs: VariableAttrs::empty(),
1926            });
1927        var.attrs.insert(flag_attrs);
1928        if !matches!(var.value, VariableValue::IndexedArray(_)) {
1929            var.value = VariableValue::IndexedArray(std::collections::BTreeMap::new());
1930        }
1931        // Parse array literal (x y z) or set element [0].
1932        if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1933            parse_and_set_indexed_array(state, name, inner)?;
1934        } else if !value.is_empty() {
1935            crate::interpreter::set_array_element(state, name, 0, value.to_string())?;
1936        }
1937    } else if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
1938        // `declare name=(x y z)` without -a flag — auto-create indexed array.
1939        let var = state
1940            .env
1941            .entry(name.to_string())
1942            .or_insert_with(|| Variable {
1943                value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
1944                attrs: VariableAttrs::empty(),
1945            });
1946        var.attrs.insert(flag_attrs);
1947        if !matches!(var.value, VariableValue::IndexedArray(_)) {
1948            var.value = VariableValue::IndexedArray(std::collections::BTreeMap::new());
1949        }
1950        parse_and_set_indexed_array(state, name, inner)?;
1951    } else {
1952        let non_readonly_attrs = flag_attrs - VariableAttrs::READONLY;
1953        let var = state
1954            .env
1955            .entry(name.to_string())
1956            .or_insert_with(|| Variable {
1957                value: VariableValue::Scalar(String::new()),
1958                attrs: VariableAttrs::empty(),
1959            });
1960        var.attrs.insert(non_readonly_attrs);
1961        // Now set value through set_variable to apply INTEGER/LOWERCASE/UPPERCASE.
1962        set_variable(state, name, value.to_string())?;
1963        // Apply READONLY after the value is set.
1964        if flag_attrs.contains(VariableAttrs::READONLY)
1965            && let Some(var) = state.env.get_mut(name)
1966        {
1967            var.attrs.insert(VariableAttrs::READONLY);
1968        }
1969    }
1970    Ok(())
1971}
1972
1973/// Handle `declare [-flags] name` (no value).
1974fn declare_without_value(
1975    state: &mut InterpreterState,
1976    name: &str,
1977    flag_attrs: VariableAttrs,
1978    make_assoc_array: bool,
1979    make_indexed_array: bool,
1980) -> Result<(), RustBashError> {
1981    if let Some(var) = state.env.get_mut(name) {
1982        var.attrs.insert(flag_attrs);
1983        if make_assoc_array && !matches!(var.value, VariableValue::AssociativeArray(_)) {
1984            var.value = VariableValue::AssociativeArray(std::collections::BTreeMap::new());
1985        }
1986        if make_indexed_array && !matches!(var.value, VariableValue::IndexedArray(_)) {
1987            var.value = VariableValue::IndexedArray(std::collections::BTreeMap::new());
1988        }
1989    } else {
1990        let value = if make_assoc_array {
1991            VariableValue::AssociativeArray(std::collections::BTreeMap::new())
1992        } else if make_indexed_array {
1993            VariableValue::IndexedArray(std::collections::BTreeMap::new())
1994        } else {
1995            VariableValue::Scalar(String::new())
1996        };
1997        state.env.insert(
1998            name.to_string(),
1999            Variable {
2000                value,
2001                attrs: flag_attrs,
2002            },
2003        );
2004    }
2005    Ok(())
2006}
2007
2008/// Parse an array literal body like `x y z` or `[0]="x" [1]="y"` and populate
2009/// the named variable as an indexed array.
2010fn parse_and_set_indexed_array(
2011    state: &mut InterpreterState,
2012    name: &str,
2013    body: &str,
2014) -> Result<(), RustBashError> {
2015    // Split into shell-like words respecting double/single quotes.
2016    let words = shell_split_array_body(body);
2017    // Reset the array to empty.
2018    if let Some(var) = state.env.get_mut(name) {
2019        var.value = VariableValue::IndexedArray(std::collections::BTreeMap::new());
2020    }
2021    let mut idx: usize = 0;
2022    for word in &words {
2023        if let Some(rest) = word.strip_prefix('[') {
2024            // [index]="value" form
2025            if let Some(eq_pos) = rest.find("]=") {
2026                let index_str = &rest[..eq_pos];
2027                let value_part = &rest[eq_pos + 2..];
2028                let value = unquote_simple(value_part);
2029                if let Ok(i) = index_str.parse::<usize>() {
2030                    crate::interpreter::set_array_element(state, name, i, value)?;
2031                    idx = i + 1;
2032                }
2033            }
2034        } else {
2035            let value = unquote_simple(word);
2036            crate::interpreter::set_array_element(state, name, idx, value)?;
2037            idx += 1;
2038        }
2039    }
2040    Ok(())
2041}
2042
2043/// Parse and set associative array from literal body: `[key1]=val1 [key2]=val2 ...`
2044fn parse_and_set_assoc_array(
2045    state: &mut InterpreterState,
2046    name: &str,
2047    body: &str,
2048) -> Result<(), RustBashError> {
2049    let words = shell_split_array_body(body);
2050    // Reset the array to empty.
2051    if let Some(var) = state.env.get_mut(name) {
2052        var.value = VariableValue::AssociativeArray(std::collections::BTreeMap::new());
2053    }
2054    for word in &words {
2055        if let Some(rest) = word.strip_prefix('[') {
2056            // [key]=value form
2057            if let Some(eq_pos) = rest.find("]=") {
2058                let key = unquote_simple(&rest[..eq_pos]);
2059                let value = unquote_simple(&rest[eq_pos + 2..]);
2060                crate::interpreter::set_assoc_element(state, name, key, value)?;
2061            } else if let Some(key_str) = rest.strip_suffix(']') {
2062                // [key]= with empty value (no = sign) — just a key with empty value
2063                let key = unquote_simple(key_str);
2064                crate::interpreter::set_assoc_element(state, name, key, String::new())?;
2065            }
2066        }
2067        // Non-[key]=val entries are ignored for assoc arrays
2068    }
2069    Ok(())
2070}
2071
2072/// Parse and append to an associative array from body text (no reset).
2073fn parse_and_set_assoc_array_append(
2074    state: &mut InterpreterState,
2075    name: &str,
2076    body: &str,
2077) -> Result<(), RustBashError> {
2078    let words = shell_split_array_body(body);
2079    for word in &words {
2080        if let Some(rest) = word.strip_prefix('[') {
2081            if let Some(eq_pos) = rest.find("]=") {
2082                let key = unquote_simple(&rest[..eq_pos]);
2083                let value = unquote_simple(&rest[eq_pos + 2..]);
2084                crate::interpreter::set_assoc_element(state, name, key, value)?;
2085            } else if let Some(key_str) = rest.strip_suffix(']') {
2086                let key = unquote_simple(key_str);
2087                crate::interpreter::set_assoc_element(state, name, key, String::new())?;
2088            }
2089        }
2090    }
2091    Ok(())
2092}
2093
2094/// Simple shell word splitting for array bodies, respecting double/single quotes.
2095fn shell_split_array_body(s: &str) -> Vec<String> {
2096    let mut words = Vec::new();
2097    let mut current = String::new();
2098    let mut chars = s.chars().peekable();
2099    while let Some(&c) = chars.peek() {
2100        match c {
2101            ' ' | '\t' | '\n' => {
2102                if !current.is_empty() {
2103                    words.push(std::mem::take(&mut current));
2104                }
2105                chars.next();
2106            }
2107            '"' => {
2108                chars.next();
2109                current.push('"');
2110                while let Some(&ch) = chars.peek() {
2111                    if ch == '"' {
2112                        current.push('"');
2113                        chars.next();
2114                        break;
2115                    }
2116                    if ch == '\\' {
2117                        chars.next();
2118                        current.push('\\');
2119                        if let Some(&esc) = chars.peek() {
2120                            current.push(esc);
2121                            chars.next();
2122                        }
2123                    } else {
2124                        current.push(ch);
2125                        chars.next();
2126                    }
2127                }
2128            }
2129            '\'' => {
2130                chars.next();
2131                current.push('\'');
2132                while let Some(&ch) = chars.peek() {
2133                    if ch == '\'' {
2134                        current.push('\'');
2135                        chars.next();
2136                        break;
2137                    }
2138                    current.push(ch);
2139                    chars.next();
2140                }
2141            }
2142            _ => {
2143                current.push(c);
2144                chars.next();
2145            }
2146        }
2147    }
2148    if !current.is_empty() {
2149        words.push(current);
2150    }
2151    words
2152}
2153
2154/// Remove outer quotes from a simple value like `"foo"` or `'bar'`.
2155fn unquote_simple(s: &str) -> String {
2156    if s.len() >= 2
2157        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
2158    {
2159        return s[1..s.len() - 1].to_string();
2160    }
2161    s.to_string()
2162}
2163
2164// ── read ────────────────────────────────────────────────────────────
2165
2166fn builtin_read(
2167    args: &[String],
2168    state: &mut InterpreterState,
2169    stdin: &str,
2170) -> Result<ExecResult, RustBashError> {
2171    let mut raw_mode = false;
2172    let mut array_name: Option<String> = None;
2173    let mut delimiter: Option<char> = None; // None = newline (default)
2174    let mut read_until_eof = false; // -d '' means read until EOF
2175    let mut n_count: Option<usize> = None; // -n count
2176    let mut big_n_count: Option<usize> = None; // -N count
2177    let mut var_names: Vec<&str> = Vec::new();
2178    let mut i = 0;
2179
2180    // Parse arguments — support combined short flags like `-ra`
2181    while i < args.len() {
2182        let arg = &args[i];
2183        if arg == "--" {
2184            // Everything after -- is a variable name
2185            for a in &args[i + 1..] {
2186                var_names.push(a);
2187            }
2188            break;
2189        } else if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
2190            let flag_chars: Vec<char> = arg[1..].chars().collect();
2191            let mut j = 0;
2192            while j < flag_chars.len() {
2193                match flag_chars[j] {
2194                    'r' => raw_mode = true,
2195                    's' => { /* silent mode — no-op in sandbox */ }
2196                    'a' => {
2197                        // -a arrayname: rest of this flag group is the name, or next arg
2198                        let rest: String = flag_chars[j + 1..].iter().collect();
2199                        if rest.is_empty() {
2200                            i += 1;
2201                            if i < args.len() {
2202                                array_name = Some(args[i].clone());
2203                            }
2204                        } else {
2205                            array_name = Some(rest);
2206                        }
2207                        j = flag_chars.len(); // consumed rest of flag group
2208                        continue;
2209                    }
2210                    'd' => {
2211                        // -d delim: rest of flag group is the delimiter, or next arg
2212                        let rest: String = flag_chars[j + 1..].iter().collect();
2213                        let delim_str = if rest.is_empty() {
2214                            i += 1;
2215                            if i < args.len() { args[i].as_str() } else { "" }
2216                        } else {
2217                            rest.as_str()
2218                        };
2219                        if delim_str.is_empty() {
2220                            read_until_eof = true;
2221                        } else {
2222                            delimiter = Some(delim_str.chars().next().unwrap());
2223                        }
2224                        j = flag_chars.len();
2225                        continue;
2226                    }
2227                    'n' => {
2228                        // -n count
2229                        let rest: String = flag_chars[j + 1..].iter().collect();
2230                        let count_str = if rest.is_empty() {
2231                            i += 1;
2232                            if i < args.len() {
2233                                args[i].as_str()
2234                            } else {
2235                                "0"
2236                            }
2237                        } else {
2238                            rest.as_str()
2239                        };
2240                        n_count = count_str.parse().ok();
2241                        j = flag_chars.len();
2242                        continue;
2243                    }
2244                    'N' => {
2245                        // -N count
2246                        let rest: String = flag_chars[j + 1..].iter().collect();
2247                        let count_str = if rest.is_empty() {
2248                            i += 1;
2249                            if i < args.len() {
2250                                args[i].as_str()
2251                            } else {
2252                                "0"
2253                            }
2254                        } else {
2255                            rest.as_str()
2256                        };
2257                        big_n_count = count_str.parse().ok();
2258                        j = flag_chars.len();
2259                        continue;
2260                    }
2261                    'p' => {
2262                        // -p prompt: skip the prompt value (no-op in sandbox)
2263                        let rest: String = flag_chars[j + 1..].iter().collect();
2264                        if rest.is_empty() {
2265                            i += 1; // skip the next arg (the prompt string)
2266                        }
2267                        j = flag_chars.len();
2268                        continue;
2269                    }
2270                    't' => {
2271                        // -t timeout: skip the timeout value (stub)
2272                        let rest: String = flag_chars[j + 1..].iter().collect();
2273                        if rest.is_empty() {
2274                            i += 1;
2275                        }
2276                        j = flag_chars.len();
2277                        continue;
2278                    }
2279                    _ => { /* unknown flag — ignore */ }
2280                }
2281                j += 1;
2282            }
2283        } else {
2284            var_names.push(arg);
2285        }
2286        i += 1;
2287    }
2288
2289    // Defaults
2290    if array_name.is_none() && var_names.is_empty() {
2291        var_names.push("REPLY");
2292    }
2293
2294    // Get the remaining stdin
2295    let effective_stdin = if state.stdin_offset < stdin.len() {
2296        &stdin[state.stdin_offset..]
2297    } else {
2298        ""
2299    };
2300
2301    // -t timeout stub: return 1 if stdin exhausted
2302    // (The actual timeout behavior is a no-op since stdin is always available in sandbox)
2303
2304    if effective_stdin.is_empty() {
2305        return Ok(ExecResult {
2306            exit_code: 1,
2307            ..ExecResult::default()
2308        });
2309    }
2310
2311    // Read input based on mode, tracking whether we hit EOF without the expected terminator
2312    let mut hit_eof = false;
2313
2314    let line = if let Some(count) = big_n_count {
2315        // -N count: read exactly N characters, including newlines
2316        let chars: String = effective_stdin.chars().take(count).collect();
2317        state.stdin_offset += chars.len();
2318        if chars.chars().count() < count {
2319            hit_eof = true;
2320        }
2321        chars
2322    } else if let Some(count) = n_count {
2323        // -n count: read at most N characters, stop at newline
2324        let mut result = String::new();
2325        let mut found_newline = false;
2326        for ch in effective_stdin.chars().take(count) {
2327            if ch == '\n' {
2328                state.stdin_offset += 1; // consume the newline
2329                found_newline = true;
2330                break;
2331            }
2332            result.push(ch);
2333        }
2334        state.stdin_offset += result.len();
2335        if !found_newline && state.stdin_offset >= stdin.len() {
2336            hit_eof = true;
2337        }
2338        result
2339    } else if read_until_eof {
2340        // -d '' : read until EOF (NUL never found in text, so always returns 1)
2341        hit_eof = true;
2342        let data = effective_stdin.to_string();
2343        state.stdin_offset += data.len();
2344        data
2345    } else if let Some(delim) = delimiter {
2346        // -d delim: read until delimiter character
2347        let mut result = String::new();
2348        let mut found_delim = false;
2349        for ch in effective_stdin.chars() {
2350            if ch == delim {
2351                state.stdin_offset += ch.len_utf8(); // consume the delimiter
2352                found_delim = true;
2353                break;
2354            }
2355            result.push(ch);
2356        }
2357        state.stdin_offset += result.len();
2358        if !found_delim {
2359            hit_eof = true;
2360        }
2361        result
2362    } else {
2363        // Default: read until newline
2364        match effective_stdin.lines().next() {
2365            Some(l) => {
2366                state.stdin_offset += l.len();
2367                if state.stdin_offset < stdin.len()
2368                    && stdin.as_bytes().get(state.stdin_offset) == Some(&b'\n')
2369                {
2370                    state.stdin_offset += 1;
2371                } else {
2372                    hit_eof = true;
2373                }
2374                l.to_string()
2375            }
2376            None => {
2377                return Ok(ExecResult {
2378                    exit_code: 1,
2379                    ..ExecResult::default()
2380                });
2381            }
2382        }
2383    };
2384
2385    // Check input line length before processing
2386    if line.len() > state.limits.max_string_length {
2387        return Err(RustBashError::LimitExceeded {
2388            limit_name: "max_string_length",
2389            limit_value: state.limits.max_string_length,
2390            actual_value: line.len(),
2391        });
2392    }
2393
2394    // Handle -r flag: process backslash escapes if not raw mode.
2395    // -N also suppresses backslash processing (bash behavior).
2396    let line = if raw_mode || big_n_count.is_some() {
2397        line
2398    } else {
2399        let mut result = String::new();
2400        let mut chars = line.chars().peekable();
2401        while let Some(c) = chars.next() {
2402            if c == '\\' {
2403                if let Some(&next) = chars.peek() {
2404                    if next == '\n' {
2405                        chars.next(); // skip newline (line continuation)
2406                    } else {
2407                        result.push(next);
2408                        chars.next();
2409                    }
2410                }
2411            } else {
2412                result.push(c);
2413            }
2414        }
2415        result
2416    };
2417
2418    // Get IFS for splitting
2419    let ifs = state
2420        .env
2421        .get("IFS")
2422        .map(|v| v.value.as_scalar().to_string())
2423        .unwrap_or_else(|| " \t\n".to_string());
2424
2425    if let Some(ref arr_name) = array_name {
2426        // -a mode: split into indexed array
2427        let fields: Vec<&str> = if line.is_empty() {
2428            // Empty input always produces an empty array
2429            vec![]
2430        } else if ifs.is_empty() {
2431            vec![line.as_str()]
2432        } else {
2433            split_by_ifs(&line, &ifs)
2434        };
2435
2436        // Clear or create the array
2437        state.env.insert(
2438            arr_name.to_string(),
2439            Variable {
2440                value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
2441                attrs: VariableAttrs::empty(),
2442            },
2443        );
2444
2445        for (idx, field) in fields.iter().enumerate() {
2446            set_array_element(state, arr_name, idx, field.to_string())?;
2447        }
2448    } else if big_n_count.is_some() {
2449        // -N mode: no IFS splitting, assign raw content directly
2450        let var_name = var_names.first().copied().unwrap_or("REPLY");
2451        set_variable(state, var_name, line)?;
2452        // Clear remaining variables
2453        for extra_var in var_names.iter().skip(1) {
2454            set_variable(state, extra_var, String::new())?;
2455        }
2456    } else {
2457        // Normal mode: assign to named variables, preserving original text for the last var
2458        assign_fields_to_vars(state, &line, &ifs, &var_names)?;
2459    }
2460
2461    Ok(ExecResult {
2462        exit_code: i32::from(hit_eof),
2463        ..ExecResult::default()
2464    })
2465}
2466
2467/// Assign IFS-split fields to variables, preserving original text for the last variable.
2468/// In bash, the last variable receives the remainder of the line (not a split-and-rejoin).
2469fn assign_fields_to_vars(
2470    state: &mut InterpreterState,
2471    line: &str,
2472    ifs: &str,
2473    var_names: &[&str],
2474) -> Result<(), RustBashError> {
2475    if ifs.is_empty() || var_names.len() <= 1 {
2476        // Single variable: assign whole line
2477        // For REPLY (no named vars), don't trim leading/trailing whitespace
2478        // For a single named var, only trim IFS whitespace from edges
2479        let value = if var_names.first().copied() == Some("REPLY") && var_names.len() == 1 {
2480            // REPLY: strip trailing newline but preserve other whitespace
2481            line.to_string()
2482        } else if ifs.is_empty() {
2483            line.to_string()
2484        } else {
2485            let ifs_ws = |c: char| (c == ' ' || c == '\t' || c == '\n') && ifs.contains(c);
2486            line.trim_matches(ifs_ws).to_string()
2487        };
2488        let var_name = var_names.first().copied().unwrap_or("REPLY");
2489        return set_variable(state, var_name, value);
2490    }
2491
2492    // Multiple variables: extract fields one at a time, preserving original text for the last
2493    let ifs_is_ws = |c: char| (c == ' ' || c == '\t' || c == '\n') && ifs.contains(c);
2494    let ifs_is_delim = |c: char| ifs.contains(c);
2495    let has_ws = ifs.contains(' ') || ifs.contains('\t') || ifs.contains('\n');
2496
2497    let mut pos = 0;
2498    // Skip leading IFS whitespace
2499    if has_ws {
2500        while pos < line.len() {
2501            let ch = line[pos..].chars().next().unwrap();
2502            if ifs_is_ws(ch) {
2503                pos += ch.len_utf8();
2504            } else {
2505                break;
2506            }
2507        }
2508    }
2509
2510    for (i, var_name) in var_names.iter().enumerate() {
2511        if i == var_names.len() - 1 {
2512            // Last variable: take the rest of the line, trim trailing IFS whitespace
2513            let rest = &line[pos..];
2514            let trimmed = if has_ws {
2515                rest.trim_end_matches(ifs_is_ws)
2516            } else {
2517                rest
2518            };
2519            set_variable(state, var_name, trimmed.to_string())?;
2520        } else {
2521            // Extract one field
2522            let field_start = pos;
2523            while pos < line.len() {
2524                let ch = line[pos..].chars().next().unwrap();
2525                if ifs_is_delim(ch) {
2526                    break;
2527                }
2528                pos += ch.len_utf8();
2529            }
2530            let field = &line[field_start..pos];
2531            set_variable(state, var_name, field.to_string())?;
2532
2533            // Skip separators after the field
2534            if has_ws {
2535                while pos < line.len() {
2536                    let ch = line[pos..].chars().next().unwrap();
2537                    if ifs_is_ws(ch) {
2538                        pos += ch.len_utf8();
2539                    } else {
2540                        break;
2541                    }
2542                }
2543            }
2544            // Skip exactly one non-whitespace IFS delimiter if present
2545            if pos < line.len() {
2546                let ch = line[pos..].chars().next().unwrap();
2547                if ifs_is_delim(ch) && !ifs_is_ws(ch) {
2548                    pos += ch.len_utf8();
2549                    // Skip trailing IFS whitespace after non-ws delimiter
2550                    if has_ws {
2551                        while pos < line.len() {
2552                            let ch2 = line[pos..].chars().next().unwrap();
2553                            if ifs_is_ws(ch2) {
2554                                pos += ch2.len_utf8();
2555                            } else {
2556                                break;
2557                            }
2558                        }
2559                    }
2560                }
2561            }
2562        }
2563    }
2564    Ok(())
2565}
2566
2567fn split_by_ifs<'a>(s: &'a str, ifs: &str) -> Vec<&'a str> {
2568    let has_whitespace = ifs.contains(' ') || ifs.contains('\t') || ifs.contains('\n');
2569
2570    if has_whitespace {
2571        // IFS whitespace splitting: leading/trailing whitespace is trimmed,
2572        // consecutive whitespace chars are treated as one delimiter
2573        s.split(|c: char| ifs.contains(c))
2574            .filter(|s| !s.is_empty())
2575            .collect()
2576    } else {
2577        // Non-whitespace IFS: split on each char, preserve empty fields
2578        s.split(|c: char| ifs.contains(c)).collect()
2579    }
2580}
2581
2582// ── eval ─────────────────────────────────────────────────────────────
2583
2584fn builtin_eval(
2585    args: &[String],
2586    state: &mut InterpreterState,
2587) -> Result<ExecResult, RustBashError> {
2588    if args.is_empty() {
2589        return Ok(ExecResult::default());
2590    }
2591
2592    // eval accepts and ignores `--`
2593    let args = if args.first().map(|a| a.as_str()) == Some("--") {
2594        &args[1..]
2595    } else {
2596        args
2597    };
2598
2599    if args.is_empty() {
2600        return Ok(ExecResult::default());
2601    }
2602
2603    let input = args.join(" ");
2604    if input.is_empty() {
2605        return Ok(ExecResult::default());
2606    }
2607
2608    state.counters.call_depth += 1;
2609    if state.counters.call_depth > state.limits.max_call_depth {
2610        let actual = state.counters.call_depth;
2611        state.counters.call_depth -= 1;
2612        return Err(RustBashError::LimitExceeded {
2613            limit_name: "max_call_depth",
2614            limit_value: state.limits.max_call_depth,
2615            actual_value: actual,
2616        });
2617    }
2618
2619    let program = match parse(&input) {
2620        Ok(p) => p,
2621        Err(e) => {
2622            state.counters.call_depth -= 1;
2623            let msg = format!("{e}");
2624            return Ok(ExecResult {
2625                stderr: if msg.is_empty() {
2626                    String::new()
2627                } else {
2628                    format!("eval: {msg}\n")
2629                },
2630                exit_code: 1,
2631                ..ExecResult::default()
2632            });
2633        }
2634    };
2635    let result = execute_program(&program, state);
2636    state.counters.call_depth -= 1;
2637    result
2638}
2639
2640/// Common signal names for `trap -l`.
2641const SIGNAL_NAMES: &[&str] = &[
2642    "EXIT", "HUP", "INT", "QUIT", "ILL", "TRAP", "ABRT", "BUS", "FPE", "KILL", "USR1", "SEGV",
2643    "USR2", "PIPE", "ALRM", "TERM", "STKFLT", "CHLD", "CONT", "STOP", "TSTP", "TTIN", "TTOU",
2644    "URG", "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "IO", "PWR", "SYS", "ERR", "DEBUG", "RETURN",
2645];
2646
2647/// Normalize signal name: strip leading "SIG" prefix and uppercase.
2648fn normalize_signal(name: &str) -> String {
2649    let upper = name.to_uppercase();
2650    upper.strip_prefix("SIG").unwrap_or(&upper).to_string()
2651}
2652
2653fn builtin_trap(
2654    args: &[String],
2655    state: &mut InterpreterState,
2656) -> Result<ExecResult, RustBashError> {
2657    // `trap` with no args — list current traps
2658    if args.is_empty() {
2659        let mut out = String::new();
2660        let mut names: Vec<&String> = state.traps.keys().collect();
2661        names.sort();
2662        for name in names {
2663            let cmd = &state.traps[name];
2664            out.push_str(&format!(
2665                "trap -- '{}' {}\n",
2666                cmd.replace('\'', "'\\''"),
2667                name
2668            ));
2669        }
2670        return Ok(ExecResult {
2671            stdout: out,
2672            ..ExecResult::default()
2673        });
2674    }
2675
2676    // `trap -l` — list signal names
2677    if args.len() == 1 && args[0] == "-l" {
2678        let out: String = SIGNAL_NAMES
2679            .iter()
2680            .enumerate()
2681            .map(|(i, s)| {
2682                if i > 0 && i % 8 == 0 {
2683                    format!("\n{:2}) SIG{}", i, s)
2684                } else {
2685                    format!("{:>3}) SIG{}", i, s)
2686                }
2687            })
2688            .collect::<Vec<_>>()
2689            .join(" ");
2690        return Ok(ExecResult {
2691            stdout: format!("{out}\n"),
2692            ..ExecResult::default()
2693        });
2694    }
2695
2696    // `trap - SIGNAL ...` — reset signals to default (remove handler)
2697    if args.first().map(|s| s.as_str()) == Some("-") {
2698        for sig in &args[1..] {
2699            state.traps.remove(&normalize_signal(sig));
2700        }
2701        return Ok(ExecResult::default());
2702    }
2703
2704    // `trap 'command' SIGNAL [SIGNAL ...]`
2705    if args.len() < 2 {
2706        return Ok(ExecResult {
2707            stderr: "trap: usage: trap [-lp] [[arg] signal_spec ...]\n".to_string(),
2708            exit_code: 2,
2709            ..ExecResult::default()
2710        });
2711    }
2712
2713    let command = &args[0];
2714    for sig in &args[1..] {
2715        let name = normalize_signal(sig);
2716        if command.is_empty() {
2717            // `trap '' SIGNAL` — register empty handler (ignore signal)
2718            state.traps.insert(name, String::new());
2719        } else {
2720            state.traps.insert(name, command.clone());
2721        }
2722    }
2723
2724    Ok(ExecResult::default())
2725}
2726
2727// ── shopt ───────────────────────────────────────────────────────────
2728
2729/// Ordered list of all shopt option names (for consistent listing).
2730const SHOPT_OPTIONS: &[&str] = &[
2731    "assoc_expand_once",
2732    "autocd",
2733    "cdable_vars",
2734    "cdspell",
2735    "checkhash",
2736    "checkjobs",
2737    "checkwinsize",
2738    "cmdhist",
2739    "complete_fullquote",
2740    "direxpand",
2741    "dirspell",
2742    "dotglob",
2743    "execfail",
2744    "expand_aliases",
2745    "extdebug",
2746    "extglob",
2747    "extquote",
2748    "failglob",
2749    "force_fignore",
2750    "globasciiranges",
2751    "globskipdots",
2752    "globstar",
2753    "gnu_errfmt",
2754    "histappend",
2755    "histreedit",
2756    "histverify",
2757    "hostcomplete",
2758    "huponexit",
2759    "inherit_errexit",
2760    "interactive_comments",
2761    "lastpipe",
2762    "lithist",
2763    "localvar_inherit",
2764    "localvar_unset",
2765    "login_shell",
2766    "mailwarn",
2767    "no_empty_cmd_completion",
2768    "nocaseglob",
2769    "nocasematch",
2770    "nullglob",
2771    "patsub_replacement",
2772    "progcomp",
2773    "progcomp_alias",
2774    "promptvars",
2775    "shift_verbose",
2776    "sourcepath",
2777    "varredir_close",
2778    "xpg_echo",
2779];
2780
2781fn get_shopt(state: &InterpreterState, name: &str) -> Option<bool> {
2782    let o = &state.shopt_opts;
2783    match name {
2784        "assoc_expand_once" => Some(o.assoc_expand_once),
2785        "autocd" => Some(o.autocd),
2786        "cdable_vars" => Some(o.cdable_vars),
2787        "cdspell" => Some(o.cdspell),
2788        "checkhash" => Some(o.checkhash),
2789        "checkjobs" => Some(o.checkjobs),
2790        "checkwinsize" => Some(o.checkwinsize),
2791        "cmdhist" => Some(o.cmdhist),
2792        "complete_fullquote" => Some(o.complete_fullquote),
2793        "direxpand" => Some(o.direxpand),
2794        "dirspell" => Some(o.dirspell),
2795        "dotglob" => Some(o.dotglob),
2796        "execfail" => Some(o.execfail),
2797        "expand_aliases" => Some(o.expand_aliases),
2798        "extdebug" => Some(o.extdebug),
2799        "extglob" => Some(o.extglob),
2800        "extquote" => Some(o.extquote),
2801        "failglob" => Some(o.failglob),
2802        "force_fignore" => Some(o.force_fignore),
2803        "globasciiranges" => Some(o.globasciiranges),
2804        "globskipdots" => Some(o.globskipdots),
2805        "globstar" => Some(o.globstar),
2806        "gnu_errfmt" => Some(o.gnu_errfmt),
2807        "histappend" => Some(o.histappend),
2808        "histreedit" => Some(o.histreedit),
2809        "histverify" => Some(o.histverify),
2810        "hostcomplete" => Some(o.hostcomplete),
2811        "huponexit" => Some(o.huponexit),
2812        "inherit_errexit" => Some(o.inherit_errexit),
2813        "interactive_comments" => Some(o.interactive_comments),
2814        "lastpipe" => Some(o.lastpipe),
2815        "lithist" => Some(o.lithist),
2816        "localvar_inherit" => Some(o.localvar_inherit),
2817        "localvar_unset" => Some(o.localvar_unset),
2818        "login_shell" => Some(o.login_shell),
2819        "mailwarn" => Some(o.mailwarn),
2820        "no_empty_cmd_completion" => Some(o.no_empty_cmd_completion),
2821        "nocaseglob" => Some(o.nocaseglob),
2822        "nocasematch" => Some(o.nocasematch),
2823        "nullglob" => Some(o.nullglob),
2824        "patsub_replacement" => Some(o.patsub_replacement),
2825        "progcomp" => Some(o.progcomp),
2826        "progcomp_alias" => Some(o.progcomp_alias),
2827        "promptvars" => Some(o.promptvars),
2828        "shift_verbose" => Some(o.shift_verbose),
2829        "sourcepath" => Some(o.sourcepath),
2830        "varredir_close" => Some(o.varredir_close),
2831        "xpg_echo" => Some(o.xpg_echo),
2832        _ => None,
2833    }
2834}
2835
2836fn set_shopt(state: &mut InterpreterState, name: &str, value: bool) -> bool {
2837    let o = &mut state.shopt_opts;
2838    match name {
2839        "assoc_expand_once" => o.assoc_expand_once = value,
2840        "autocd" => o.autocd = value,
2841        "cdable_vars" => o.cdable_vars = value,
2842        "cdspell" => o.cdspell = value,
2843        "checkhash" => o.checkhash = value,
2844        "checkjobs" => o.checkjobs = value,
2845        "checkwinsize" => o.checkwinsize = value,
2846        "cmdhist" => o.cmdhist = value,
2847        "complete_fullquote" => o.complete_fullquote = value,
2848        "direxpand" => o.direxpand = value,
2849        "dirspell" => o.dirspell = value,
2850        "dotglob" => o.dotglob = value,
2851        "execfail" => o.execfail = value,
2852        "expand_aliases" => o.expand_aliases = value,
2853        "extdebug" => o.extdebug = value,
2854        "extglob" => o.extglob = value,
2855        "extquote" => o.extquote = value,
2856        "failglob" => o.failglob = value,
2857        "force_fignore" => o.force_fignore = value,
2858        "globasciiranges" => o.globasciiranges = value,
2859        "globskipdots" => o.globskipdots = value,
2860        "globstar" => o.globstar = value,
2861        "gnu_errfmt" => o.gnu_errfmt = value,
2862        "histappend" => o.histappend = value,
2863        "histreedit" => o.histreedit = value,
2864        "histverify" => o.histverify = value,
2865        "hostcomplete" => o.hostcomplete = value,
2866        "huponexit" => o.huponexit = value,
2867        "inherit_errexit" => o.inherit_errexit = value,
2868        "interactive_comments" => o.interactive_comments = value,
2869        "lastpipe" => o.lastpipe = value,
2870        "lithist" => o.lithist = value,
2871        "localvar_inherit" => o.localvar_inherit = value,
2872        "localvar_unset" => o.localvar_unset = value,
2873        "login_shell" => o.login_shell = value,
2874        "mailwarn" => o.mailwarn = value,
2875        "no_empty_cmd_completion" => o.no_empty_cmd_completion = value,
2876        "nocaseglob" => o.nocaseglob = value,
2877        "nocasematch" => o.nocasematch = value,
2878        "nullglob" => o.nullglob = value,
2879        "patsub_replacement" => o.patsub_replacement = value,
2880        "progcomp" => o.progcomp = value,
2881        "progcomp_alias" => o.progcomp_alias = value,
2882        "promptvars" => o.promptvars = value,
2883        "shift_verbose" => o.shift_verbose = value,
2884        "sourcepath" => o.sourcepath = value,
2885        "varredir_close" => o.varredir_close = value,
2886        "xpg_echo" => o.xpg_echo = value,
2887        _ => return false,
2888    }
2889    true
2890}
2891
2892fn builtin_shopt(
2893    args: &[String],
2894    state: &mut InterpreterState,
2895) -> Result<ExecResult, RustBashError> {
2896    // Parse flags
2897    let mut set_flag = false; // -s
2898    let mut unset_flag = false; // -u
2899    let mut query_flag = false; // -q
2900    let mut print_flag = false; // -p
2901    let mut o_flag = false; // -o (use set -o options instead of shopt options)
2902    let mut opt_names: Vec<&str> = Vec::new();
2903
2904    let mut i = 0;
2905    while i < args.len() {
2906        let arg = &args[i];
2907        if arg.starts_with('-') && arg.len() > 1 && opt_names.is_empty() {
2908            for c in arg[1..].chars() {
2909                match c {
2910                    's' => set_flag = true,
2911                    'u' => unset_flag = true,
2912                    'q' => query_flag = true,
2913                    'p' => print_flag = true,
2914                    'o' => o_flag = true,
2915                    _ => {
2916                        return Ok(ExecResult {
2917                            stderr: format!("shopt: -{c}: invalid option\n"),
2918                            exit_code: 2,
2919                            ..ExecResult::default()
2920                        });
2921                    }
2922                }
2923            }
2924        } else {
2925            opt_names.push(arg);
2926        }
2927        i += 1;
2928    }
2929
2930    // If -o flag is set, operate on set -o options instead of shopt options
2931    if o_flag {
2932        return shopt_o_mode(
2933            set_flag, unset_flag, query_flag, print_flag, &opt_names, state,
2934        );
2935    }
2936
2937    // shopt -s opt ... — enable; or shopt -s with no args — list enabled
2938    if set_flag {
2939        if opt_names.is_empty() {
2940            let mut out = String::new();
2941            for name in SHOPT_OPTIONS {
2942                if get_shopt(state, name) == Some(true) {
2943                    out.push_str(&format!("{name:<20}on\n"));
2944                }
2945            }
2946            return Ok(ExecResult {
2947                stdout: out,
2948                ..ExecResult::default()
2949            });
2950        }
2951        for name in &opt_names {
2952            if !set_shopt(state, name, true) {
2953                return Ok(ExecResult {
2954                    stderr: format!("shopt: {name}: invalid shell option name\n"),
2955                    exit_code: 1,
2956                    ..ExecResult::default()
2957                });
2958            }
2959        }
2960        return Ok(ExecResult::default());
2961    }
2962
2963    // shopt -u opt ... — disable; or shopt -u with no args — list disabled
2964    if unset_flag {
2965        if opt_names.is_empty() {
2966            let mut out = String::new();
2967            for name in SHOPT_OPTIONS {
2968                if get_shopt(state, name) == Some(false) {
2969                    out.push_str(&format!("{name:<20}off\n"));
2970                }
2971            }
2972            return Ok(ExecResult {
2973                stdout: out,
2974                ..ExecResult::default()
2975            });
2976        }
2977        let exit_code = 0;
2978        for name in &opt_names {
2979            if !set_shopt(state, name, false) {
2980                return Ok(ExecResult {
2981                    stderr: format!("shopt: {name}: invalid shell option name\n"),
2982                    exit_code: 1,
2983                    ..ExecResult::default()
2984                });
2985            }
2986        }
2987        return Ok(ExecResult {
2988            exit_code,
2989            ..ExecResult::default()
2990        });
2991    }
2992
2993    // shopt -q opt ... — query
2994    if query_flag {
2995        for name in &opt_names {
2996            match get_shopt(state, name) {
2997                Some(true) => {}
2998                Some(false) => {
2999                    return Ok(ExecResult {
3000                        exit_code: 1,
3001                        ..ExecResult::default()
3002                    });
3003                }
3004                None => {
3005                    // bash returns 1 (not 2) for invalid option with -q
3006                    return Ok(ExecResult {
3007                        stderr: format!("shopt: {name}: invalid shell option name\n"),
3008                        exit_code: 1,
3009                        ..ExecResult::default()
3010                    });
3011                }
3012            }
3013        }
3014        return Ok(ExecResult::default());
3015    }
3016
3017    // shopt -p [opt ...] or shopt with no flags — listing mode
3018    if print_flag || (!set_flag && !unset_flag && !query_flag) {
3019        let no_args = opt_names.is_empty();
3020        let names: Vec<&str> = if no_args {
3021            SHOPT_OPTIONS.to_vec()
3022        } else {
3023            opt_names
3024        };
3025
3026        // No-flags, no-args listing: show name on/off format
3027        if !print_flag && no_args {
3028            let mut out = String::new();
3029            for name in SHOPT_OPTIONS {
3030                let val = get_shopt(state, name).unwrap_or(false);
3031                let status = if val { "on" } else { "off" };
3032                out.push_str(&format!("{name:<20}{status}\n"));
3033            }
3034            return Ok(ExecResult {
3035                stdout: out,
3036                ..ExecResult::default()
3037            });
3038        }
3039
3040        // -p format or named queries without flags
3041        let mut out = String::new();
3042        let mut stderr = String::new();
3043        let mut any_invalid = false;
3044        let mut any_unset = false;
3045        for name in &names {
3046            match get_shopt(state, name) {
3047                Some(val) => {
3048                    if !val {
3049                        any_unset = true;
3050                    }
3051                    if print_flag {
3052                        let flag = if val { "-s" } else { "-u" };
3053                        out.push_str(&format!("shopt {flag} {name}\n"));
3054                    } else {
3055                        let status = if val { "on" } else { "off" };
3056                        out.push_str(&format!("{name:<24}{status}\n"));
3057                    }
3058                }
3059                None => {
3060                    stderr.push_str(&format!("shopt: {name}: invalid shell option name\n"));
3061                    any_invalid = true;
3062                }
3063            }
3064        }
3065        // Exit code reflects option state for named options (both -p and no-flag modes)
3066        let exit_code = if any_invalid || (!no_args && any_unset) {
3067            1
3068        } else {
3069            0
3070        };
3071        return Ok(ExecResult {
3072            stdout: out,
3073            stderr,
3074            exit_code,
3075            ..ExecResult::default()
3076        });
3077    }
3078
3079    Ok(ExecResult::default())
3080}
3081
3082// ── shopt -o helper ─────────────────────────────────────────────────
3083
3084const SET_O_OPTIONS: &[&str] = &[
3085    "allexport",
3086    "braceexpand",
3087    "emacs",
3088    "errexit",
3089    "hashall",
3090    "histexpand",
3091    "history",
3092    "interactive-comments",
3093    "monitor",
3094    "noclobber",
3095    "noexec",
3096    "noglob",
3097    "nounset",
3098    "pipefail",
3099    "posix",
3100    "verbose",
3101    "vi",
3102    "xtrace",
3103];
3104
3105fn get_set_option(name: &str, state: &InterpreterState) -> Option<bool> {
3106    match name {
3107        "allexport" => Some(state.shell_opts.allexport),
3108        "braceexpand" => Some(true), // always on
3109        "emacs" => Some(state.shell_opts.emacs_mode),
3110        "errexit" => Some(state.shell_opts.errexit),
3111        "hashall" => Some(true), // always on
3112        "histexpand" => Some(false),
3113        "history" => Some(false),
3114        "interactive-comments" => Some(true),
3115        "monitor" => Some(false),
3116        "noclobber" => Some(state.shell_opts.noclobber),
3117        "noexec" => Some(state.shell_opts.noexec),
3118        "noglob" => Some(state.shell_opts.noglob),
3119        "nounset" => Some(state.shell_opts.nounset),
3120        "pipefail" => Some(state.shell_opts.pipefail),
3121        "posix" => Some(state.shell_opts.posix),
3122        "verbose" => Some(state.shell_opts.verbose),
3123        "vi" => Some(state.shell_opts.vi_mode),
3124        "xtrace" => Some(state.shell_opts.xtrace),
3125        _ => None,
3126    }
3127}
3128
3129fn shopt_o_mode(
3130    set_flag: bool,
3131    unset_flag: bool,
3132    query_flag: bool,
3133    print_flag: bool,
3134    opt_names: &[&str],
3135    state: &mut InterpreterState,
3136) -> Result<ExecResult, RustBashError> {
3137    // shopt -o -s opt ... — enable set option
3138    if set_flag {
3139        if opt_names.is_empty() {
3140            let mut out = String::new();
3141            for name in SET_O_OPTIONS {
3142                if get_set_option(name, state) == Some(true) {
3143                    out.push_str(&format!("{name:<20}on\n"));
3144                }
3145            }
3146            return Ok(ExecResult {
3147                stdout: out,
3148                ..ExecResult::default()
3149            });
3150        }
3151        for name in opt_names {
3152            if get_set_option(name, state).is_none() {
3153                return Ok(ExecResult {
3154                    stderr: format!("shopt: {name}: invalid shell option name\n"),
3155                    exit_code: 1,
3156                    ..ExecResult::default()
3157                });
3158            }
3159            apply_option_name(name, true, state);
3160        }
3161        return Ok(ExecResult::default());
3162    }
3163
3164    // shopt -o -u opt ... — disable set option
3165    if unset_flag {
3166        if opt_names.is_empty() {
3167            let mut out = String::new();
3168            for name in SET_O_OPTIONS {
3169                if get_set_option(name, state) == Some(false) {
3170                    out.push_str(&format!("{name:<20}off\n"));
3171                }
3172            }
3173            return Ok(ExecResult {
3174                stdout: out,
3175                ..ExecResult::default()
3176            });
3177        }
3178        for name in opt_names {
3179            if get_set_option(name, state).is_none() {
3180                return Ok(ExecResult {
3181                    stderr: format!("shopt: {name}: invalid shell option name\n"),
3182                    exit_code: 1,
3183                    ..ExecResult::default()
3184                });
3185            }
3186            apply_option_name(name, false, state);
3187        }
3188        return Ok(ExecResult::default());
3189    }
3190
3191    // shopt -o -q opt ... — query
3192    if query_flag {
3193        for name in opt_names {
3194            match get_set_option(name, state) {
3195                Some(true) => {}
3196                Some(false) => {
3197                    return Ok(ExecResult {
3198                        exit_code: 1,
3199                        ..ExecResult::default()
3200                    });
3201                }
3202                None => {
3203                    return Ok(ExecResult {
3204                        stderr: format!("shopt: {name}: invalid shell option name\n"),
3205                        exit_code: 2,
3206                        ..ExecResult::default()
3207                    });
3208                }
3209            }
3210        }
3211        return Ok(ExecResult::default());
3212    }
3213
3214    // shopt -p -o / shopt -o (listing)
3215    let no_args = opt_names.is_empty();
3216    let names: Vec<&str> = if no_args {
3217        SET_O_OPTIONS.to_vec()
3218    } else {
3219        opt_names.to_vec()
3220    };
3221
3222    if !print_flag && no_args {
3223        let mut out = String::new();
3224        for name in SET_O_OPTIONS {
3225            let val = get_set_option(name, state).unwrap_or(false);
3226            let status = if val { "on" } else { "off" };
3227            out.push_str(&format!("{name:<20}{status}\n"));
3228        }
3229        return Ok(ExecResult {
3230            stdout: out,
3231            ..ExecResult::default()
3232        });
3233    }
3234
3235    let mut out = String::new();
3236    let mut stderr = String::new();
3237    let mut any_invalid = false;
3238    let mut any_unset = false;
3239    for name in &names {
3240        match get_set_option(name, state) {
3241            Some(val) => {
3242                if !val {
3243                    any_unset = true;
3244                }
3245                let flag = if val { "-o" } else { "+o" };
3246                out.push_str(&format!("set {flag} {name}\n"));
3247            }
3248            None => {
3249                stderr.push_str(&format!("shopt: {name}: invalid shell option name\n"));
3250                any_invalid = true;
3251            }
3252        }
3253    }
3254    let exit_code = if any_invalid || (!no_args && any_unset) {
3255        1
3256    } else {
3257        0
3258    };
3259    Ok(ExecResult {
3260        stdout: out,
3261        stderr,
3262        exit_code,
3263        ..ExecResult::default()
3264    })
3265}
3266
3267// ── source / . ──────────────────────────────────────────────────────
3268
3269fn builtin_source(
3270    args: &[String],
3271    state: &mut InterpreterState,
3272) -> Result<ExecResult, RustBashError> {
3273    // source accepts and ignores `--`
3274    let args = if args.first().map(|a| a.as_str()) == Some("--") {
3275        &args[1..]
3276    } else {
3277        args
3278    };
3279
3280    let path_arg = match args.first() {
3281        Some(p) => p,
3282        None => {
3283            return Ok(ExecResult {
3284                stderr: "source: filename argument required\n".to_string(),
3285                exit_code: 2,
3286                ..ExecResult::default()
3287            });
3288        }
3289    };
3290
3291    let resolved = resolve_path(&state.cwd, path_arg);
3292    let content = match state.fs.read_file(Path::new(&resolved)) {
3293        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
3294        Err(_) => {
3295            return Ok(ExecResult {
3296                stderr: format!("source: {path_arg}: No such file or directory\n"),
3297                exit_code: 1,
3298                ..ExecResult::default()
3299            });
3300        }
3301    };
3302
3303    state.counters.call_depth += 1;
3304    if state.counters.call_depth > state.limits.max_call_depth {
3305        let actual = state.counters.call_depth;
3306        state.counters.call_depth -= 1;
3307        return Err(RustBashError::LimitExceeded {
3308            limit_name: "max_call_depth",
3309            limit_value: state.limits.max_call_depth,
3310            actual_value: actual,
3311        });
3312    }
3313
3314    let program = match parse(&content) {
3315        Ok(p) => p,
3316        Err(e) => {
3317            state.counters.call_depth -= 1;
3318            let msg = format!("{e}");
3319            return Ok(ExecResult {
3320                stderr: if msg.is_empty() {
3321                    String::new()
3322                } else {
3323                    format!("{path_arg}: {msg}\n")
3324                },
3325                exit_code: 1,
3326                ..ExecResult::default()
3327            });
3328        }
3329    };
3330    let result = execute_program(&program, state);
3331    state.counters.call_depth -= 1;
3332    result
3333}
3334
3335// ── local ────────────────────────────────────────────────────────────
3336
3337fn builtin_local(
3338    args: &[String],
3339    state: &mut InterpreterState,
3340) -> Result<ExecResult, RustBashError> {
3341    // Parse flags similar to declare
3342    let mut make_indexed_array = false;
3343    let mut make_assoc_array = false;
3344    let mut make_readonly = false;
3345    let mut make_exported = false;
3346    let mut make_integer = false;
3347    let mut make_nameref = false;
3348    let mut var_args: Vec<&String> = Vec::new();
3349
3350    for arg in args {
3351        if let Some(flags) = arg.strip_prefix('-') {
3352            if flags.is_empty() {
3353                var_args.push(arg);
3354                continue;
3355            }
3356            for c in flags.chars() {
3357                match c {
3358                    'a' => make_indexed_array = true,
3359                    'A' => make_assoc_array = true,
3360                    'r' => make_readonly = true,
3361                    'x' => make_exported = true,
3362                    'i' => make_integer = true,
3363                    'n' => make_nameref = true,
3364                    _ => {}
3365                }
3366            }
3367        } else {
3368            var_args.push(arg);
3369        }
3370    }
3371
3372    // bare `local` with no args — list local variables in simple name=value format
3373    if var_args.is_empty() {
3374        let mut stdout = String::new();
3375        if let Some(scope) = state.local_scopes.last() {
3376            let mut names: Vec<&String> = scope.keys().collect();
3377            names.sort();
3378            for name in names {
3379                if let Some(var) = state.env.get(name.as_str()) {
3380                    stdout.push_str(&format_simple_line(name, var));
3381                }
3382            }
3383        }
3384        return Ok(ExecResult {
3385            stdout,
3386            ..ExecResult::default()
3387        });
3388    }
3389
3390    let mut exit_code = 0;
3391    let mut result_stderr = String::new();
3392    for arg in &var_args {
3393        if let Err(msg) = validate_var_arg(arg, "local") {
3394            result_stderr.push_str(&msg);
3395            exit_code = 1;
3396            continue;
3397        }
3398
3399        if let Some((raw_name, value)) = arg.split_once("+=") {
3400            // local name+=value — append
3401            let name = raw_name;
3402            if let Some(scope) = state.local_scopes.last_mut() {
3403                scope
3404                    .entry(name.to_string())
3405                    .or_insert_with(|| state.env.get(name).cloned());
3406            }
3407            if value.starts_with('(') && value.ends_with(')') {
3408                // Array append
3409                let inner = &value[1..value.len() - 1];
3410                let start_idx = match state.env.get(name) {
3411                    Some(var) => match &var.value {
3412                        VariableValue::IndexedArray(map) => {
3413                            map.keys().next_back().map(|k| k + 1).unwrap_or(0)
3414                        }
3415                        VariableValue::Scalar(s) if s.is_empty() => 0,
3416                        VariableValue::Scalar(_) => 1,
3417                        _ => 0,
3418                    },
3419                    None => 0,
3420                };
3421                if !state.env.contains_key(name) {
3422                    state.env.insert(
3423                        name.to_string(),
3424                        Variable {
3425                            value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
3426                            attrs: VariableAttrs::empty(),
3427                        },
3428                    );
3429                }
3430                let words = shell_split_array_body(inner);
3431                let mut idx = start_idx;
3432                for word in &words {
3433                    let val = unquote_simple(word);
3434                    crate::interpreter::set_array_element(state, name, idx, val)?;
3435                    idx += 1;
3436                }
3437            } else {
3438                let current = state
3439                    .env
3440                    .get(name)
3441                    .map(|v| v.value.as_scalar().to_string())
3442                    .unwrap_or_default();
3443                let new_val = format!("{current}{value}");
3444                set_variable(state, name, new_val)?;
3445            }
3446        } else if let Some((name, value)) = arg.split_once('=') {
3447            // Save current value in the top local scope (if inside a function)
3448            if let Some(scope) = state.local_scopes.last_mut() {
3449                scope
3450                    .entry(name.to_string())
3451                    .or_insert_with(|| state.env.get(name).cloned());
3452            }
3453
3454            if make_assoc_array {
3455                if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
3456                    state.env.insert(
3457                        name.to_string(),
3458                        Variable {
3459                            value: VariableValue::AssociativeArray(
3460                                std::collections::BTreeMap::new(),
3461                            ),
3462                            attrs: VariableAttrs::empty(),
3463                        },
3464                    );
3465                    // Parse associative array body
3466                    let words = shell_split_array_body(inner);
3467                    for word in &words {
3468                        if let Some(rest) = word.strip_prefix('[')
3469                            && let Some(eq_pos) = rest.find("]=")
3470                        {
3471                            let key = &rest[..eq_pos];
3472                            let val = unquote_simple(&rest[eq_pos + 2..]);
3473                            if let Some(var) = state.env.get_mut(name)
3474                                && let VariableValue::AssociativeArray(map) = &mut var.value
3475                            {
3476                                map.insert(key.to_string(), val);
3477                            }
3478                        }
3479                    }
3480                } else {
3481                    set_variable(state, name, value.to_string())?;
3482                }
3483            } else if make_indexed_array || value.starts_with('(') && value.ends_with(')') {
3484                if let Some(inner) = value.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
3485                    state.env.insert(
3486                        name.to_string(),
3487                        Variable {
3488                            value: VariableValue::IndexedArray(std::collections::BTreeMap::new()),
3489                            attrs: VariableAttrs::empty(),
3490                        },
3491                    );
3492                    parse_and_set_indexed_array(state, name, inner)?;
3493                } else {
3494                    set_variable(state, name, value.to_string())?;
3495                }
3496            } else {
3497                set_variable(state, name, value.to_string())?;
3498            }
3499
3500            // Apply attribute flags
3501            if let Some(var) = state.env.get_mut(name) {
3502                if make_readonly {
3503                    var.attrs.insert(VariableAttrs::READONLY);
3504                }
3505                if make_exported {
3506                    var.attrs.insert(VariableAttrs::EXPORTED);
3507                }
3508                if make_integer {
3509                    var.attrs.insert(VariableAttrs::INTEGER);
3510                }
3511                if make_nameref {
3512                    var.attrs.insert(VariableAttrs::NAMEREF);
3513                }
3514            }
3515        } else {
3516            // `local VAR` with no value — declare it as local with empty value
3517            if let Some(scope) = state.local_scopes.last_mut() {
3518                scope
3519                    .entry(arg.to_string())
3520                    .or_insert_with(|| state.env.get(arg.as_str()).cloned());
3521            }
3522            // Inside a function: always set to empty. Outside: only if undefined.
3523            if state.in_function_depth > 0 || !state.env.contains_key(arg.as_str()) {
3524                let value = if make_indexed_array {
3525                    VariableValue::IndexedArray(std::collections::BTreeMap::new())
3526                } else if make_assoc_array {
3527                    VariableValue::AssociativeArray(std::collections::BTreeMap::new())
3528                } else {
3529                    VariableValue::Scalar(String::new())
3530                };
3531                state.env.insert(
3532                    arg.to_string(),
3533                    Variable {
3534                        value,
3535                        attrs: VariableAttrs::empty(),
3536                    },
3537                );
3538            }
3539        }
3540    }
3541    Ok(ExecResult {
3542        exit_code,
3543        stderr: result_stderr,
3544        ..ExecResult::default()
3545    })
3546}
3547
3548// ── return ───────────────────────────────────────────────────────────
3549
3550fn builtin_return(
3551    args: &[String],
3552    state: &mut InterpreterState,
3553) -> Result<ExecResult, RustBashError> {
3554    if state.in_function_depth == 0 {
3555        return Ok(ExecResult {
3556            stderr: "return: can only `return' from a function or sourced script\n".to_string(),
3557            exit_code: 1,
3558            ..ExecResult::default()
3559        });
3560    }
3561
3562    let code = if let Some(arg) = args.first() {
3563        match arg.parse::<i32>() {
3564            Ok(n) => n & 0xFF,
3565            Err(_) => {
3566                return Ok(ExecResult {
3567                    stderr: format!("return: {arg}: numeric argument required\n"),
3568                    exit_code: 2,
3569                    ..ExecResult::default()
3570                });
3571            }
3572        }
3573    } else {
3574        state.last_exit_code
3575    };
3576
3577    state.control_flow = Some(ControlFlow::Return(code));
3578    Ok(ExecResult {
3579        exit_code: code,
3580        ..ExecResult::default()
3581    })
3582}
3583
3584// ── let ─────────────────────────────────────────────────────────────
3585
3586fn builtin_let(args: &[String], state: &mut InterpreterState) -> Result<ExecResult, RustBashError> {
3587    if args.is_empty() {
3588        return Err(RustBashError::Execution(
3589            "let: usage: let arg [arg ...]".into(),
3590        ));
3591    }
3592    let mut last_val: i64 = 0;
3593    for arg in args {
3594        last_val = crate::interpreter::arithmetic::eval_arithmetic(arg, state)?;
3595    }
3596    // Exit code 0 if last result non-zero, 1 if zero
3597    Ok(ExecResult {
3598        exit_code: if last_val != 0 { 0 } else { 1 },
3599        ..ExecResult::default()
3600    })
3601}
3602
3603// ── PATH resolution helper ─────────────────────────────────────────
3604
3605/// Search `$PATH` directories in the VFS for an executable file.
3606fn search_path(cmd: &str, state: &InterpreterState) -> Option<String> {
3607    let path_var = state
3608        .env
3609        .get("PATH")
3610        .map(|v| v.value.as_scalar().to_string())
3611        .unwrap_or_else(|| "/usr/bin:/bin".to_string());
3612
3613    for dir in path_var.split(':') {
3614        let candidate = if dir.is_empty() {
3615            format!("./{cmd}")
3616        } else {
3617            format!("{dir}/{cmd}")
3618        };
3619        let p = Path::new(&candidate);
3620        if state.fs.exists(p)
3621            && let Ok(meta) = state.fs.stat(p)
3622            && matches!(meta.node_type, NodeType::File)
3623        {
3624            return Some(candidate);
3625        }
3626    }
3627    None
3628}
3629
3630// ── type ────────────────────────────────────────────────────────────
3631
3632fn builtin_type(
3633    args: &[String],
3634    state: &mut InterpreterState,
3635) -> Result<ExecResult, RustBashError> {
3636    let mut t_flag = false;
3637    let mut a_flag = false;
3638    let mut p_flag = false;
3639    let mut big_p_flag = false;
3640    let mut f_flag = false;
3641    let mut names: Vec<&str> = Vec::new();
3642
3643    for arg in args {
3644        if arg.starts_with('-') && names.is_empty() {
3645            for c in arg[1..].chars() {
3646                match c {
3647                    't' => t_flag = true,
3648                    'a' => a_flag = true,
3649                    'p' => p_flag = true,
3650                    'P' => big_p_flag = true,
3651                    'f' => f_flag = true,
3652                    _ => {
3653                        return Ok(ExecResult {
3654                            stderr: format!("type: -{c}: invalid option\n"),
3655                            exit_code: 2,
3656                            ..ExecResult::default()
3657                        });
3658                    }
3659                }
3660            }
3661        } else {
3662            names.push(arg);
3663        }
3664    }
3665
3666    if names.is_empty() {
3667        return Ok(ExecResult::default());
3668    }
3669
3670    let mut stdout = String::new();
3671    let mut stderr = String::new();
3672    let mut exit_code = 0;
3673
3674    for name in &names {
3675        let mut found = false;
3676
3677        // -P: only search PATH (skip builtins, functions, aliases, keywords)
3678        if big_p_flag {
3679            let paths = search_path_all(name, state);
3680            if paths.is_empty() {
3681                exit_code = 1;
3682            } else {
3683                for path in &paths {
3684                    stdout.push_str(&format!("{path}\n"));
3685                    found = true;
3686                    if !a_flag {
3687                        break;
3688                    }
3689                }
3690            }
3691            if !found {
3692                exit_code = 1;
3693            }
3694            continue;
3695        }
3696
3697        // Check alias (-f only suppresses function lookup, not aliases)
3698        if let Some(expansion) = state.aliases.get(*name) {
3699            if t_flag {
3700                stdout.push_str("alias\n");
3701            } else if !p_flag {
3702                stdout.push_str(&format!("{name} is aliased to `{expansion}'\n"));
3703            }
3704            found = true;
3705            if !a_flag {
3706                continue;
3707            }
3708        }
3709
3710        // Check keyword
3711        if is_shell_keyword(name) {
3712            if t_flag {
3713                stdout.push_str("keyword\n");
3714            } else if !p_flag {
3715                stdout.push_str(&format!("{name} is a shell keyword\n"));
3716            }
3717            found = true;
3718            if !a_flag {
3719                continue;
3720            }
3721        }
3722
3723        // Check function (skip if -f flag)
3724        if !f_flag && let Some(func) = state.functions.get(*name) {
3725            if t_flag {
3726                stdout.push_str("function\n");
3727            } else if !p_flag {
3728                stdout.push_str(&format!("{name} is a function\n"));
3729                // Print function body
3730                let body_str = format_function_body(name, &func.body);
3731                stdout.push_str(&body_str);
3732                stdout.push('\n');
3733            }
3734            found = true;
3735            if !a_flag {
3736                continue;
3737            }
3738        }
3739
3740        // Check builtin
3741        if is_builtin(name) {
3742            if t_flag {
3743                stdout.push_str("builtin\n");
3744            } else if !p_flag {
3745                stdout.push_str(&format!("{name} is a shell builtin\n"));
3746            }
3747            found = true;
3748            if !a_flag {
3749                continue;
3750            }
3751        }
3752
3753        // Check registered commands (treated as builtins) - skip if already a builtin
3754        if !is_builtin(name) && state.commands.contains_key(*name) {
3755            if t_flag {
3756                stdout.push_str("builtin\n");
3757            } else if !p_flag {
3758                stdout.push_str(&format!("{name} is a shell builtin\n"));
3759            }
3760            found = true;
3761            if !a_flag {
3762                continue;
3763            }
3764        }
3765
3766        // Check PATH — with -a, list all matches
3767        let paths = search_path_all(name, state);
3768        for path in &paths {
3769            if t_flag {
3770                stdout.push_str("file\n");
3771            } else if p_flag {
3772                stdout.push_str(&format!("{path}\n"));
3773            } else {
3774                stdout.push_str(&format!("{name} is {path}\n"));
3775            }
3776            found = true;
3777            if !a_flag {
3778                break;
3779            }
3780        }
3781
3782        if !found {
3783            // -t: no stderr for not-found (just set exit code)
3784            if !t_flag {
3785                stderr.push_str(&format!("type: {name}: not found\n"));
3786            }
3787            exit_code = 1;
3788        }
3789    }
3790
3791    Ok(ExecResult {
3792        stdout,
3793        stderr,
3794        exit_code,
3795        stdout_bytes: None,
3796    })
3797}
3798
3799/// Format a function body for `type` output, mimicking bash's format.
3800fn format_function_body(name: &str, _body: &brush_parser::ast::FunctionBody) -> String {
3801    // Minimal formatting: just show the structure
3802    format!("{name} () \n{{ \n    ...\n}}")
3803}
3804
3805/// Search all PATH entries for a command and return all matches.
3806fn search_path_all(cmd: &str, state: &InterpreterState) -> Vec<String> {
3807    let path_var = state
3808        .env
3809        .get("PATH")
3810        .map(|v| v.value.as_scalar().to_string())
3811        .unwrap_or_else(|| "/usr/bin:/bin".to_string());
3812    let mut results = Vec::new();
3813    for dir in path_var.split(':') {
3814        let candidate = if dir.is_empty() {
3815            format!("./{cmd}")
3816        } else {
3817            format!("{dir}/{cmd}")
3818        };
3819        let p = Path::new(&candidate);
3820        if state.fs.exists(p)
3821            && let Ok(meta) = state.fs.stat(p)
3822            && matches!(meta.node_type, NodeType::File | NodeType::Symlink)
3823        {
3824            results.push(candidate);
3825        }
3826    }
3827    results
3828}
3829
3830// ── command ─────────────────────────────────────────────────────────
3831
3832fn builtin_command(
3833    args: &[String],
3834    state: &mut InterpreterState,
3835    stdin: &str,
3836) -> Result<ExecResult, RustBashError> {
3837    let mut v_flag = false;
3838    let mut big_v_flag = false;
3839    let mut cmd_start = 0;
3840
3841    // Parse flags
3842    for (i, arg) in args.iter().enumerate() {
3843        if arg.starts_with('-') && cmd_start == i {
3844            let mut consumed = true;
3845            for c in arg[1..].chars() {
3846                match c {
3847                    'v' => v_flag = true,
3848                    'V' => big_v_flag = true,
3849                    'p' => { /* use default PATH — we ignore this in sandbox */ }
3850                    _ => {
3851                        consumed = false;
3852                        break;
3853                    }
3854                }
3855            }
3856            if consumed {
3857                cmd_start = i + 1;
3858            } else {
3859                break;
3860            }
3861        } else {
3862            break;
3863        }
3864    }
3865
3866    let remaining = &args[cmd_start..];
3867    if remaining.is_empty() {
3868        return Ok(ExecResult::default());
3869    }
3870
3871    let name = &remaining[0];
3872
3873    // command -v: print how name would be resolved
3874    if v_flag {
3875        return command_v(name, state);
3876    }
3877
3878    // command -V: verbose description
3879    if big_v_flag {
3880        return command_big_v(name, state);
3881    }
3882
3883    // command name [args]: run bypassing functions — only builtins and commands
3884    let cmd_args = &remaining[1..];
3885    let cmd_args_owned: Vec<String> = cmd_args.to_vec();
3886
3887    // --help interception (consistent with dispatch_command)
3888    if cmd_args_owned.first().map(|a| a.as_str()) == Some("--help")
3889        && let Some(help) = check_help(name, state)
3890    {
3891        return Ok(help);
3892    }
3893
3894    // Try builtin first
3895    if let Some(result) = execute_builtin(name, &cmd_args_owned, state, stdin)? {
3896        return Ok(result);
3897    }
3898
3899    // Try registered commands (skip functions)
3900    if state.commands.contains_key(name.as_str()) {
3901        // Re-dispatch through the normal path but the caller should skip functions.
3902        // We replicate the command execution logic here.
3903        let env: std::collections::HashMap<String, String> = state
3904            .env
3905            .iter()
3906            .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
3907            .collect();
3908        let fs = std::sync::Arc::clone(&state.fs);
3909        let cwd = state.cwd.clone();
3910        let limits = state.limits.clone();
3911        let network_policy = state.network_policy.clone();
3912
3913        let ctx = crate::commands::CommandContext {
3914            fs: &*fs,
3915            cwd: &cwd,
3916            env: &env,
3917            variables: None,
3918            stdin,
3919            stdin_bytes: None,
3920            limits: &limits,
3921            network_policy: &network_policy,
3922            exec: None,
3923            shell_opts: None,
3924        };
3925
3926        let cmd = state.commands.get(name.as_str()).unwrap();
3927        let cmd_result = cmd.execute(&cmd_args_owned, &ctx);
3928        return Ok(ExecResult {
3929            stdout: cmd_result.stdout,
3930            stderr: cmd_result.stderr,
3931            exit_code: cmd_result.exit_code,
3932            stdout_bytes: cmd_result.stdout_bytes,
3933        });
3934    }
3935
3936    // Not found
3937    Ok(ExecResult {
3938        stderr: format!("{name}: command not found\n"),
3939        exit_code: 127,
3940        ..ExecResult::default()
3941    })
3942}
3943
3944/// Shell keywords recognized by type/command -v
3945const SHELL_KEYWORDS: &[&str] = &[
3946    "if", "then", "else", "elif", "fi", "case", "esac", "for", "select", "while", "until", "do",
3947    "done", "in", "function", "time", "{", "}", "!", "[[", "]]", "coproc",
3948];
3949
3950fn is_shell_keyword(name: &str) -> bool {
3951    SHELL_KEYWORDS.contains(&name)
3952}
3953
3954fn command_v(name: &str, state: &InterpreterState) -> Result<ExecResult, RustBashError> {
3955    // Keyword
3956    if is_shell_keyword(name) {
3957        return Ok(ExecResult {
3958            stdout: format!("{name}\n"),
3959            ..ExecResult::default()
3960        });
3961    }
3962
3963    // Alias
3964    if let Some(expansion) = state.aliases.get(name) {
3965        return Ok(ExecResult {
3966            stdout: format!("alias {name}='{expansion}'\n"),
3967            ..ExecResult::default()
3968        });
3969    }
3970
3971    // Function
3972    if state.functions.contains_key(name) {
3973        return Ok(ExecResult {
3974            stdout: format!("{name}\n"),
3975            ..ExecResult::default()
3976        });
3977    }
3978
3979    // Builtin or registered command
3980    if is_builtin(name) || state.commands.contains_key(name) {
3981        return Ok(ExecResult {
3982            stdout: format!("{name}\n"),
3983            ..ExecResult::default()
3984        });
3985    }
3986
3987    // PATH search
3988    if let Some(path) = search_path(name, state) {
3989        return Ok(ExecResult {
3990            stdout: format!("{path}\n"),
3991            ..ExecResult::default()
3992        });
3993    }
3994
3995    Ok(ExecResult {
3996        exit_code: 1,
3997        ..ExecResult::default()
3998    })
3999}
4000
4001fn command_big_v(name: &str, state: &InterpreterState) -> Result<ExecResult, RustBashError> {
4002    if is_shell_keyword(name) {
4003        return Ok(ExecResult {
4004            stdout: format!("{name} is a shell keyword\n"),
4005            ..ExecResult::default()
4006        });
4007    }
4008
4009    if let Some(expansion) = state.aliases.get(name) {
4010        return Ok(ExecResult {
4011            stdout: format!("{name} is aliased to `{expansion}'\n"),
4012            ..ExecResult::default()
4013        });
4014    }
4015
4016    if state.functions.contains_key(name) {
4017        return Ok(ExecResult {
4018            stdout: format!("{name} is a function\n"),
4019            ..ExecResult::default()
4020        });
4021    }
4022
4023    if is_builtin(name) || state.commands.contains_key(name) {
4024        return Ok(ExecResult {
4025            stdout: format!("{name} is a shell builtin\n"),
4026            ..ExecResult::default()
4027        });
4028    }
4029
4030    if let Some(path) = search_path(name, state) {
4031        return Ok(ExecResult {
4032            stdout: format!("{name} is {path}\n"),
4033            ..ExecResult::default()
4034        });
4035    }
4036
4037    Ok(ExecResult {
4038        stderr: format!("command: {name}: not found\n"),
4039        exit_code: 1,
4040        ..ExecResult::default()
4041    })
4042}
4043
4044// ── builtin (the keyword) ──────────────────────────────────────────
4045
4046fn builtin_builtin(
4047    args: &[String],
4048    state: &mut InterpreterState,
4049    stdin: &str,
4050) -> Result<ExecResult, RustBashError> {
4051    if args.is_empty() {
4052        return Ok(ExecResult::default());
4053    }
4054
4055    let name = &args[0];
4056    let sub_args: Vec<String> = args[1..].to_vec();
4057
4058    // --help interception (consistent with dispatch_command)
4059    if sub_args.first().map(|a| a.as_str()) == Some("--help")
4060        && let Some(help) = check_help(name, state)
4061    {
4062        return Ok(help);
4063    }
4064
4065    // Try shell builtins first
4066    if let Some(result) = execute_builtin(name, &sub_args, state, stdin)? {
4067        return Ok(result);
4068    }
4069
4070    // Also try registered commands (echo, printf, etc. are implemented as commands)
4071    if let Some(cmd) = state.commands.get(name.as_str()) {
4072        let env: std::collections::HashMap<String, String> = state
4073            .env
4074            .iter()
4075            .map(|(k, v)| (k.clone(), v.value.as_scalar().to_string()))
4076            .collect();
4077        let fs = std::sync::Arc::clone(&state.fs);
4078        let cwd = state.cwd.clone();
4079        let limits = state.limits.clone();
4080        let network_policy = state.network_policy.clone();
4081
4082        let ctx = crate::commands::CommandContext {
4083            fs: &*fs,
4084            cwd: &cwd,
4085            env: &env,
4086            variables: None,
4087            stdin,
4088            stdin_bytes: None,
4089            limits: &limits,
4090            network_policy: &network_policy,
4091            exec: None,
4092            shell_opts: None,
4093        };
4094
4095        let cmd_result = cmd.execute(&sub_args, &ctx);
4096        return Ok(ExecResult {
4097            stdout: cmd_result.stdout,
4098            stderr: cmd_result.stderr,
4099            exit_code: cmd_result.exit_code,
4100            stdout_bytes: cmd_result.stdout_bytes,
4101        });
4102    }
4103
4104    Ok(ExecResult {
4105        stderr: format!("builtin: {name}: not a shell builtin\n"),
4106        exit_code: 1,
4107        ..ExecResult::default()
4108    })
4109}
4110
4111// ── getopts ─────────────────────────────────────────────────────────
4112
4113fn builtin_getopts(
4114    args: &[String],
4115    state: &mut InterpreterState,
4116) -> Result<ExecResult, RustBashError> {
4117    if args.len() < 2 {
4118        return Ok(ExecResult {
4119            stderr: "getopts: usage: getopts optstring name [arg ...]\n".to_string(),
4120            exit_code: 2,
4121            ..ExecResult::default()
4122        });
4123    }
4124
4125    let optstring = &args[0];
4126    let var_name = &args[1];
4127
4128    // If extra args provided, use them; otherwise use positional params
4129    let option_args: Vec<String> = if args.len() > 2 {
4130        args[2..].to_vec()
4131    } else {
4132        state.positional_params.clone()
4133    };
4134
4135    // Loop instead of recursion: advance to the next argument when the
4136    // sub-position within bundled flags has been exhausted.
4137    loop {
4138        let optind: usize = state
4139            .env
4140            .get("OPTIND")
4141            .and_then(|v| v.value.as_scalar().parse().ok())
4142            .unwrap_or(1);
4143
4144        let idx = optind.saturating_sub(1);
4145
4146        if idx >= option_args.len() {
4147            set_variable(state, var_name, "?".to_string())?;
4148            return Ok(ExecResult {
4149                exit_code: 1,
4150                ..ExecResult::default()
4151            });
4152        }
4153
4154        let current_arg = &option_args[idx];
4155
4156        if !current_arg.starts_with('-') || current_arg == "-" || current_arg == "--" {
4157            set_variable(state, var_name, "?".to_string())?;
4158            if current_arg == "--" {
4159                set_variable(state, "OPTIND", (optind + 1).to_string())?;
4160            }
4161            return Ok(ExecResult {
4162                exit_code: 1,
4163                ..ExecResult::default()
4164            });
4165        }
4166
4167        let opt_chars: Vec<char> = current_arg[1..].chars().collect();
4168
4169        let sub_pos: usize = state
4170            .env
4171            .get("__GETOPTS_SUBPOS")
4172            .and_then(|v| v.value.as_scalar().parse().ok())
4173            .unwrap_or(0);
4174
4175        if sub_pos >= opt_chars.len() {
4176            // Advance to next argument and retry (loop, not recurse).
4177            set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4178            set_variable(state, "OPTIND", (optind + 1).to_string())?;
4179            continue;
4180        }
4181
4182        let opt_char = opt_chars[sub_pos];
4183        let silent = optstring.starts_with(':');
4184        let optstring_chars: &str = if silent { &optstring[1..] } else { optstring };
4185        let opt_pos = optstring_chars.find(opt_char);
4186
4187        if let Some(pos) = opt_pos {
4188            let takes_arg = optstring_chars.chars().nth(pos + 1) == Some(':');
4189
4190            if takes_arg {
4191                let rest: String = opt_chars[sub_pos + 1..].iter().collect();
4192                if !rest.is_empty() {
4193                    set_variable(state, "OPTARG", rest)?;
4194                    set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4195                    set_variable(state, "OPTIND", (optind + 1).to_string())?;
4196                } else if idx + 1 < option_args.len() {
4197                    set_variable(state, "OPTARG", option_args[idx + 1].clone())?;
4198                    set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4199                    set_variable(state, "OPTIND", (optind + 2).to_string())?;
4200                } else {
4201                    // Missing argument
4202                    set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4203                    set_variable(state, "OPTIND", (optind + 1).to_string())?;
4204                    if silent {
4205                        set_variable(state, var_name, ":".to_string())?;
4206                        set_variable(state, "OPTARG", opt_char.to_string())?;
4207                        return Ok(ExecResult::default());
4208                    }
4209                    set_variable(state, var_name, "?".to_string())?;
4210                    return Ok(ExecResult {
4211                        stderr: format!("getopts: option requires an argument -- '{opt_char}'\n"),
4212                        ..ExecResult::default()
4213                    });
4214                }
4215            } else {
4216                state.env.remove("OPTARG");
4217                if sub_pos + 1 < opt_chars.len() {
4218                    set_variable(state, "__GETOPTS_SUBPOS", (sub_pos + 1).to_string())?;
4219                } else {
4220                    set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4221                    set_variable(state, "OPTIND", (optind + 1).to_string())?;
4222                }
4223            }
4224            set_variable(state, var_name, opt_char.to_string())?;
4225            return Ok(ExecResult::default());
4226        }
4227
4228        // Invalid option
4229        if silent {
4230            set_variable(state, var_name, "?".to_string())?;
4231            set_variable(state, "OPTARG", opt_char.to_string())?;
4232        } else {
4233            set_variable(state, var_name, "?".to_string())?;
4234        }
4235        if sub_pos + 1 < opt_chars.len() {
4236            set_variable(state, "__GETOPTS_SUBPOS", (sub_pos + 1).to_string())?;
4237        } else {
4238            set_variable(state, "__GETOPTS_SUBPOS", "0".to_string())?;
4239            set_variable(state, "OPTIND", (optind + 1).to_string())?;
4240        }
4241        let stderr = if silent {
4242            String::new()
4243        } else {
4244            format!("getopts: illegal option -- '{opt_char}'\n")
4245        };
4246        return Ok(ExecResult {
4247            stderr,
4248            ..ExecResult::default()
4249        });
4250    }
4251}
4252
4253// ── mapfile / readarray ─────────────────────────────────────────────
4254
4255fn builtin_mapfile(
4256    args: &[String],
4257    state: &mut InterpreterState,
4258    stdin: &str,
4259) -> Result<ExecResult, RustBashError> {
4260    let mut strip_newline = false;
4261    let mut delimiter = '\n';
4262    let mut max_count: Option<usize> = None;
4263    let mut skip_count: usize = 0;
4264    let mut array_name = "MAPFILE".to_string();
4265    let mut i = 0;
4266
4267    while i < args.len() {
4268        let arg = &args[i];
4269        if arg.starts_with('-') && arg.len() > 1 {
4270            let mut chars = arg[1..].chars();
4271            while let Some(c) = chars.next() {
4272                match c {
4273                    't' => strip_newline = true,
4274                    'd' => {
4275                        let rest: String = chars.collect();
4276                        let delim_str = if rest.is_empty() {
4277                            i += 1;
4278                            if i < args.len() { args[i].as_str() } else { "" }
4279                        } else {
4280                            &rest
4281                        };
4282                        delimiter = delim_str.chars().next().unwrap_or('\0');
4283                        break;
4284                    }
4285                    'n' => {
4286                        let rest: String = chars.collect();
4287                        let count_str = if rest.is_empty() {
4288                            i += 1;
4289                            if i < args.len() {
4290                                args[i].as_str()
4291                            } else {
4292                                "0"
4293                            }
4294                        } else {
4295                            &rest
4296                        };
4297                        max_count = count_str.parse().ok();
4298                        break;
4299                    }
4300                    's' => {
4301                        let rest: String = chars.collect();
4302                        let count_str = if rest.is_empty() {
4303                            i += 1;
4304                            if i < args.len() {
4305                                args[i].as_str()
4306                            } else {
4307                                "0"
4308                            }
4309                        } else {
4310                            &rest
4311                        };
4312                        skip_count = count_str.parse().unwrap_or(0);
4313                        break;
4314                    }
4315                    'C' | 'c' | 'O' | 'u' => {
4316                        // -C callback, -c quantum, -O origin, -u fd — skip values
4317                        let rest: String = chars.collect();
4318                        if rest.is_empty() {
4319                            i += 1; // skip the argument value
4320                        }
4321                        break;
4322                    }
4323                    _ => {
4324                        return Ok(ExecResult {
4325                            stderr: format!("mapfile: -{c}: invalid option\n"),
4326                            exit_code: 2,
4327                            ..ExecResult::default()
4328                        });
4329                    }
4330                }
4331            }
4332        } else {
4333            array_name = arg.clone();
4334        }
4335        i += 1;
4336    }
4337
4338    // Split stdin by delimiter
4339    let lines: Vec<&str> = if delimiter == '\0' {
4340        // NUL delimiter: split on NUL
4341        stdin.split('\0').collect()
4342    } else {
4343        split_keeping_delimiter(stdin, delimiter)
4344    };
4345
4346    // Build the array
4347    let mut map = std::collections::BTreeMap::new();
4348    let mut count = 0;
4349
4350    for (line_idx, line) in lines.iter().enumerate() {
4351        if line_idx < skip_count {
4352            continue;
4353        }
4354        if let Some(max) = max_count
4355            && count >= max
4356        {
4357            break;
4358        }
4359
4360        let value = if strip_newline {
4361            line.trim_end_matches(delimiter).to_string()
4362        } else {
4363            (*line).to_string()
4364        };
4365
4366        if map.len() >= state.limits.max_array_elements {
4367            return Err(RustBashError::LimitExceeded {
4368                limit_name: "max_array_elements",
4369                limit_value: state.limits.max_array_elements,
4370                actual_value: map.len() + 1,
4371            });
4372        }
4373        map.insert(count, value);
4374        count += 1;
4375    }
4376
4377    state.env.insert(
4378        array_name,
4379        Variable {
4380            value: VariableValue::IndexedArray(map),
4381            attrs: VariableAttrs::empty(),
4382        },
4383    );
4384
4385    Ok(ExecResult::default())
4386}
4387
4388/// Split a string by delimiter, keeping the delimiter at the end of each segment
4389/// (like bash mapfile behavior — each line includes its trailing newline).
4390fn split_keeping_delimiter(s: &str, delim: char) -> Vec<&str> {
4391    let mut result = Vec::new();
4392    let mut start = 0;
4393    for (i, c) in s.char_indices() {
4394        if c == delim {
4395            let end = i + c.len_utf8();
4396            result.push(&s[start..end]);
4397            start = end;
4398        }
4399    }
4400    // Don't add empty trailing segment
4401    if start < s.len() {
4402        result.push(&s[start..]);
4403    }
4404    result
4405}
4406
4407// ── pushd ───────────────────────────────────────────────────────────
4408
4409fn builtin_pushd(
4410    args: &[String],
4411    state: &mut InterpreterState,
4412) -> Result<ExecResult, RustBashError> {
4413    if args.is_empty() {
4414        // pushd with no args: swap top two
4415        if state.dir_stack.is_empty() {
4416            return Ok(ExecResult {
4417                stderr: "pushd: no other directory\n".to_string(),
4418                exit_code: 1,
4419                ..ExecResult::default()
4420            });
4421        }
4422        let top = state.dir_stack.remove(0);
4423        let old_cwd = state.cwd.clone();
4424        // cd to top
4425        let result = builtin_cd(std::slice::from_ref(&top), state)?;
4426        if result.exit_code != 0 {
4427            state.dir_stack.insert(0, top);
4428            return Ok(result);
4429        }
4430        state.dir_stack.insert(0, old_cwd);
4431        return Ok(dirs_output(state));
4432    }
4433
4434    // Reject multiple arguments
4435    let mut positional = Vec::new();
4436    let mut saw_dashdash = false;
4437    for arg in args {
4438        if saw_dashdash {
4439            positional.push(arg);
4440        } else if arg == "--" {
4441            saw_dashdash = true;
4442        } else if arg == "-" {
4443            positional.push(arg);
4444        } else if arg.starts_with('-')
4445            && !arg[1..].chars().next().is_some_and(|c| c.is_ascii_digit())
4446            && !arg.starts_with('+')
4447        {
4448            // Invalid flag like -z
4449            return Ok(ExecResult {
4450                stderr: format!("pushd: {arg}: invalid option\n"),
4451                exit_code: 2,
4452                ..ExecResult::default()
4453            });
4454        } else {
4455            positional.push(arg);
4456        }
4457    }
4458
4459    if positional.len() > 1 {
4460        return Ok(ExecResult {
4461            stderr: "pushd: too many arguments\n".to_string(),
4462            exit_code: 1,
4463            ..ExecResult::default()
4464        });
4465    }
4466
4467    let arg = positional.first().copied().unwrap_or(&args[0]);
4468
4469    // pushd +N / -N: rotate stack
4470    if (arg.starts_with('+') || arg.starts_with('-'))
4471        && let Ok(n) = arg[1..].parse::<usize>()
4472    {
4473        let stack_size = state.dir_stack.len() + 1; // +1 for cwd
4474        if n >= stack_size {
4475            return Ok(ExecResult {
4476                stderr: format!("pushd: {arg}: directory stack index out of range\n"),
4477                exit_code: 1,
4478                ..ExecResult::default()
4479            });
4480        }
4481
4482        // Build full stack: cwd + dir_stack
4483        let mut full_stack = vec![state.cwd.clone()];
4484        full_stack.extend(state.dir_stack.iter().cloned());
4485
4486        let rotate_n = if arg.starts_with('+') {
4487            n
4488        } else {
4489            stack_size - n
4490        };
4491        full_stack.rotate_left(rotate_n);
4492
4493        state.cwd = full_stack.remove(0);
4494        state.dir_stack = full_stack;
4495
4496        let cwd = state.cwd.clone();
4497        let _ = set_variable(state, "PWD", cwd);
4498        return Ok(dirs_output(state));
4499    }
4500
4501    // pushd dir: push current dir, cd to dir
4502    let old_cwd = state.cwd.clone();
4503    let result = builtin_cd(std::slice::from_ref(arg), state)?;
4504    if result.exit_code != 0 {
4505        return Ok(result);
4506    }
4507    state.dir_stack.insert(0, old_cwd);
4508
4509    Ok(dirs_output(state))
4510}
4511
4512// ── popd ────────────────────────────────────────────────────────────
4513
4514fn builtin_popd(
4515    args: &[String],
4516    state: &mut InterpreterState,
4517) -> Result<ExecResult, RustBashError> {
4518    if state.dir_stack.is_empty() {
4519        return Ok(ExecResult {
4520            stderr: "popd: directory stack empty\n".to_string(),
4521            exit_code: 1,
4522            ..ExecResult::default()
4523        });
4524    }
4525
4526    if !args.is_empty() {
4527        let arg = &args[0];
4528
4529        // `--` terminates options
4530        if arg == "--" {
4531            // popd -- is just popd (default behavior)
4532            let top = state.dir_stack.remove(0);
4533            let result = builtin_cd(std::slice::from_ref(&top), state)?;
4534            if result.exit_code != 0 {
4535                state.dir_stack.insert(0, top);
4536                return Ok(result);
4537            }
4538            return Ok(dirs_output(state));
4539        }
4540
4541        // popd +N / -N: remove Nth entry
4542        if (arg.starts_with('+') || arg.starts_with('-'))
4543            && let Ok(n) = arg[1..].parse::<usize>()
4544        {
4545            let stack_size = state.dir_stack.len() + 1;
4546            if n >= stack_size {
4547                return Ok(ExecResult {
4548                    stderr: format!("popd: {arg}: directory stack index out of range\n"),
4549                    exit_code: 1,
4550                    ..ExecResult::default()
4551                });
4552            }
4553            let idx = if arg.starts_with('+') {
4554                n
4555            } else {
4556                stack_size - 1 - n
4557            };
4558            if idx == 0 {
4559                // Remove cwd, set cwd to next
4560                let new_cwd = state.dir_stack.remove(0);
4561                state.cwd = new_cwd;
4562                let cwd = state.cwd.clone();
4563                let _ = set_variable(state, "PWD", cwd);
4564            } else {
4565                state.dir_stack.remove(idx - 1);
4566            }
4567            return Ok(dirs_output(state));
4568        }
4569
4570        // Invalid argument (not +N or -N)
4571        return Ok(ExecResult {
4572            stderr: format!("popd: {arg}: invalid argument\n"),
4573            exit_code: 2,
4574            ..ExecResult::default()
4575        });
4576    }
4577
4578    // Default: pop top and cd there
4579    let top = state.dir_stack.remove(0);
4580    let result = builtin_cd(std::slice::from_ref(&top), state)?;
4581    if result.exit_code != 0 {
4582        state.dir_stack.insert(0, top);
4583        return Ok(result);
4584    }
4585
4586    Ok(dirs_output(state))
4587}
4588
4589// ── dirs ────────────────────────────────────────────────────────────
4590
4591fn builtin_dirs(
4592    args: &[String],
4593    state: &mut InterpreterState,
4594) -> Result<ExecResult, RustBashError> {
4595    let mut clear = false;
4596    let mut per_line = false;
4597    let mut with_index = false;
4598    let mut long_format = false;
4599
4600    for arg in args {
4601        if let Some(flags) = arg.strip_prefix('-') {
4602            if flags.is_empty() {
4603                // bare "-" is not a valid argument
4604                return Ok(ExecResult {
4605                    stderr: "dirs: -: invalid option\n".to_string(),
4606                    exit_code: 1,
4607                    ..ExecResult::default()
4608                });
4609            }
4610            for c in flags.chars() {
4611                match c {
4612                    'c' => clear = true,
4613                    'p' => per_line = true,
4614                    'v' => {
4615                        with_index = true;
4616                        per_line = true;
4617                    }
4618                    'l' => long_format = true,
4619                    _ => {
4620                        return Ok(ExecResult {
4621                            stderr: format!("dirs: -{c}: invalid option\n"),
4622                            exit_code: 2,
4623                            ..ExecResult::default()
4624                        });
4625                    }
4626                }
4627            }
4628        } else if arg.starts_with('+') {
4629            // +N is ok (not yet handled but valid syntax)
4630            continue;
4631        } else {
4632            // Non-flag arguments are not accepted
4633            return Ok(ExecResult {
4634                stderr: format!("dirs: {arg}: invalid argument\n"),
4635                exit_code: 1,
4636                ..ExecResult::default()
4637            });
4638        }
4639    }
4640
4641    if clear {
4642        state.dir_stack.clear();
4643        return Ok(ExecResult::default());
4644    }
4645
4646    let home = state
4647        .env
4648        .get("HOME")
4649        .map(|v| v.value.as_scalar().to_string())
4650        .unwrap_or_default();
4651
4652    // Build stack: cwd at position 0, then dir_stack entries
4653    let mut entries = vec![state.cwd.clone()];
4654    entries.extend(state.dir_stack.iter().cloned());
4655
4656    let mut stdout = String::new();
4657    if with_index {
4658        for (i, entry) in entries.iter().enumerate() {
4659            let display = if !long_format
4660                && !home.is_empty()
4661                && (*entry == home || entry.starts_with(&format!("{home}/")))
4662            {
4663                format!("~{}", &entry[home.len()..])
4664            } else {
4665                entry.clone()
4666            };
4667            stdout.push_str(&format!(" {i}  {display}\n"));
4668        }
4669    } else if per_line {
4670        for entry in &entries {
4671            let display = if !long_format
4672                && !home.is_empty()
4673                && (*entry == home || entry.starts_with(&format!("{home}/")))
4674            {
4675                format!("~{}", &entry[home.len()..])
4676            } else {
4677                entry.clone()
4678            };
4679            stdout.push_str(&format!("{display}\n"));
4680        }
4681    } else {
4682        let display_entries: Vec<String> = entries
4683            .iter()
4684            .map(|e| {
4685                if !long_format
4686                    && !home.is_empty()
4687                    && (*e == home || e.starts_with(&format!("{home}/")))
4688                {
4689                    format!("~{}", &e[home.len()..])
4690                } else {
4691                    e.clone()
4692                }
4693            })
4694            .collect();
4695        stdout = display_entries.join(" ");
4696        stdout.push('\n');
4697    }
4698
4699    Ok(ExecResult {
4700        stdout,
4701        ..ExecResult::default()
4702    })
4703}
4704
4705/// Helper to produce `dirs`-style output for pushd/popd.
4706fn dirs_output(state: &InterpreterState) -> ExecResult {
4707    let mut entries = vec![state.cwd.clone()];
4708    entries.extend(state.dir_stack.iter().cloned());
4709
4710    let home = state
4711        .env
4712        .get("HOME")
4713        .map(|v| v.value.as_scalar().to_string())
4714        .unwrap_or_default();
4715
4716    let display_entries: Vec<String> = entries
4717        .iter()
4718        .map(|e| {
4719            if !home.is_empty() && (*e == home || e.starts_with(&format!("{home}/"))) {
4720                format!("~{}", &e[home.len()..])
4721            } else {
4722                e.clone()
4723            }
4724        })
4725        .collect();
4726
4727    ExecResult {
4728        stdout: format!("{}\n", display_entries.join(" ")),
4729        ..ExecResult::default()
4730    }
4731}
4732
4733// ── hash ────────────────────────────────────────────────────────────
4734
4735fn builtin_hash(
4736    args: &[String],
4737    state: &mut InterpreterState,
4738) -> Result<ExecResult, RustBashError> {
4739    if args.is_empty() {
4740        // List all hashed commands
4741        if state.command_hash.is_empty() {
4742            return Ok(ExecResult {
4743                stderr: "hash: hash table empty\n".to_string(),
4744                ..ExecResult::default()
4745            });
4746        }
4747        let mut stdout = String::new();
4748        let mut entries: Vec<(&String, &String)> = state.command_hash.iter().collect();
4749        entries.sort_by_key(|(k, _)| k.as_str());
4750        for (name, path) in entries {
4751            stdout.push_str(&format!("{name}={path}\n"));
4752        }
4753        return Ok(ExecResult {
4754            stdout,
4755            ..ExecResult::default()
4756        });
4757    }
4758
4759    let mut reset = false;
4760    let mut names: Vec<&str> = Vec::new();
4761
4762    for arg in args {
4763        if arg == "-r" {
4764            reset = true;
4765        } else if arg.starts_with('-') {
4766            // Other flags like -d, -l, -t: ignore for now
4767        } else {
4768            names.push(arg);
4769        }
4770    }
4771
4772    if reset {
4773        state.command_hash.clear();
4774    }
4775
4776    for name in &names {
4777        if let Some(path) = search_path(name, state) {
4778            state.command_hash.insert(name.to_string(), path);
4779        } else {
4780            return Ok(ExecResult {
4781                stderr: format!("hash: {name}: not found\n"),
4782                exit_code: 1,
4783                ..ExecResult::default()
4784            });
4785        }
4786    }
4787
4788    Ok(ExecResult::default())
4789}
4790
4791// ── alias / unalias ─────────────────────────────────────────────────
4792
4793fn builtin_alias(
4794    args: &[String],
4795    state: &mut InterpreterState,
4796) -> Result<ExecResult, RustBashError> {
4797    if args.is_empty() {
4798        // List all aliases
4799        let mut entries: Vec<(&String, &String)> = state.aliases.iter().collect();
4800        entries.sort_by_key(|(k, _)| k.as_str());
4801        let mut stdout = String::new();
4802        for (name, value) in entries {
4803            stdout.push_str(&format!("alias {name}='{value}'\n"));
4804        }
4805        return Ok(ExecResult {
4806            stdout,
4807            ..ExecResult::default()
4808        });
4809    }
4810
4811    let mut exit_code = 0;
4812    let mut stdout = String::new();
4813    let mut stderr = String::new();
4814
4815    for arg in args {
4816        if arg.starts_with('-') {
4817            // -p flag: print all aliases (same as no args)
4818            if arg == "-p" {
4819                let mut entries: Vec<(&String, &String)> = state.aliases.iter().collect();
4820                entries.sort_by_key(|(k, _)| k.as_str());
4821                for (name, value) in &entries {
4822                    stdout.push_str(&format!("alias {name}='{value}'\n"));
4823                }
4824            }
4825            continue;
4826        }
4827
4828        if let Some(eq_pos) = arg.find('=') {
4829            // alias name=value
4830            let name = &arg[..eq_pos];
4831            let value = &arg[eq_pos + 1..];
4832            state.aliases.insert(name.to_string(), value.to_string());
4833        } else {
4834            // alias name — print this alias
4835            if let Some(value) = state.aliases.get(arg.as_str()) {
4836                stdout.push_str(&format!("alias {arg}='{value}'\n"));
4837            } else {
4838                stderr.push_str(&format!("alias: {arg}: not found\n"));
4839                exit_code = 1;
4840            }
4841        }
4842    }
4843
4844    Ok(ExecResult {
4845        stdout,
4846        stderr,
4847        exit_code,
4848        stdout_bytes: None,
4849    })
4850}
4851
4852fn builtin_unalias(
4853    args: &[String],
4854    state: &mut InterpreterState,
4855) -> Result<ExecResult, RustBashError> {
4856    if args.is_empty() {
4857        return Ok(ExecResult {
4858            stderr: "unalias: usage: unalias [-a] name [name ...]\n".to_string(),
4859            exit_code: 2,
4860            ..ExecResult::default()
4861        });
4862    }
4863
4864    let mut exit_code = 0;
4865    let mut stderr = String::new();
4866
4867    for arg in args {
4868        if arg == "-a" {
4869            state.aliases.clear();
4870            continue;
4871        }
4872        if state.aliases.remove(arg.as_str()).is_none() {
4873            stderr.push_str(&format!("unalias: {arg}: not found\n"));
4874            exit_code = 1;
4875        }
4876    }
4877
4878    Ok(ExecResult {
4879        stderr,
4880        exit_code,
4881        ..ExecResult::default()
4882    })
4883}
4884
4885// ── printf ───────────────────────────────────────────────────────────
4886
4887fn builtin_printf(
4888    args: &[String],
4889    state: &mut InterpreterState,
4890) -> Result<ExecResult, RustBashError> {
4891    if args.is_empty() {
4892        return Ok(ExecResult {
4893            stderr: "printf: usage: printf [-v var] format [arguments]\n".into(),
4894            exit_code: 2,
4895            ..ExecResult::default()
4896        });
4897    }
4898
4899    let mut var_name: Option<String> = None;
4900    let mut remaining_args = args;
4901
4902    // Parse -v varname
4903    if remaining_args.len() >= 2 && remaining_args[0] == "-v" {
4904        let vname = &remaining_args[1];
4905        // Validate variable name: must be identifier or identifier[subscript]
4906        let base_name = vname.split('[').next().unwrap_or(vname);
4907        let valid_base = !base_name.is_empty()
4908            && base_name
4909                .chars()
4910                .all(|c| c.is_ascii_alphanumeric() || c == '_')
4911            && !base_name.starts_with(|c: char| c.is_ascii_digit());
4912        let valid_subscript = if let Some(bracket_pos) = vname.find('[') {
4913            vname.ends_with(']') && bracket_pos + 1 < vname.len() - 1
4914        } else {
4915            true
4916        };
4917        if !valid_base || !valid_subscript {
4918            return Ok(ExecResult {
4919                stderr: format!("printf: `{vname}': not a valid identifier\n"),
4920                exit_code: 2,
4921                ..ExecResult::default()
4922            });
4923        }
4924        var_name = Some(vname.clone());
4925        remaining_args = &remaining_args[2..];
4926    }
4927
4928    // Skip -- end-of-options marker
4929    if !remaining_args.is_empty() && remaining_args[0] == "--" {
4930        remaining_args = &remaining_args[1..];
4931    }
4932
4933    if remaining_args.is_empty() {
4934        return Ok(ExecResult {
4935            stderr: "printf: usage: printf [-v var] format [arguments]\n".into(),
4936            exit_code: 2,
4937            ..ExecResult::default()
4938        });
4939    }
4940
4941    let format_str = &remaining_args[0];
4942    let arguments = &remaining_args[1..];
4943    let result = crate::commands::text::run_printf_format(format_str, arguments);
4944
4945    let exit_code = if result.had_error { 1 } else { 0 };
4946
4947    if let Some(name) = var_name {
4948        set_variable(state, &name, result.stdout)?;
4949        Ok(ExecResult {
4950            stderr: result.stderr,
4951            exit_code,
4952            ..ExecResult::default()
4953        })
4954    } else {
4955        Ok(ExecResult {
4956            stdout: result.stdout,
4957            stderr: result.stderr,
4958            exit_code,
4959            ..ExecResult::default()
4960        })
4961    }
4962}
4963
4964// ── sh / bash builtin ───────────────────────────────────────────────
4965
4966fn builtin_sh(
4967    args: &[String],
4968    state: &mut InterpreterState,
4969    stdin: &str,
4970) -> Result<ExecResult, RustBashError> {
4971    if args.is_empty() {
4972        if stdin.is_empty() {
4973            return Ok(ExecResult::default());
4974        }
4975        let program = parse(stdin)?;
4976        return run_in_subshell(state, &program, &[], None);
4977    }
4978
4979    let mut i = 0;
4980    while i < args.len() {
4981        let arg = &args[i];
4982        if arg == "-c" {
4983            i += 1;
4984            if i < args.len() {
4985                let cmd = &args[i];
4986                let extra = &args[i + 1..];
4987                // In bash: sh -c cmd arg0 arg1 → $0=arg0, $1=arg1
4988                let shell_name_override = extra.first().map(|s| s.as_str());
4989                let positional: Vec<String> = if extra.len() > 1 {
4990                    extra[1..].iter().map(|s| s.to_string()).collect()
4991                } else {
4992                    Vec::new()
4993                };
4994                let program = parse(cmd)?;
4995                return run_in_subshell(state, &program, &positional, shell_name_override);
4996            } else {
4997                return Ok(ExecResult {
4998                    stderr: "sh: -c: option requires an argument\n".into(),
4999                    exit_code: 2,
5000                    ..ExecResult::default()
5001                });
5002            }
5003        } else if arg.starts_with('-') && arg.len() > 1 {
5004            i += 1;
5005            continue;
5006        } else {
5007            let path = crate::interpreter::builtins::resolve_path(&state.cwd, arg);
5008            let path_buf = std::path::PathBuf::from(&path);
5009            match state.fs.read_file(&path_buf) {
5010                Ok(bytes) => {
5011                    let script = String::from_utf8_lossy(&bytes).to_string();
5012                    let positional = args[i + 1..]
5013                        .iter()
5014                        .map(|s| s.to_string())
5015                        .collect::<Vec<_>>();
5016                    let program = parse(&script)?;
5017                    return run_in_subshell(state, &program, &positional, None);
5018                }
5019                Err(e) => {
5020                    return Ok(ExecResult {
5021                        stderr: format!("sh: {}: {}\n", arg, e),
5022                        exit_code: 127,
5023                        ..ExecResult::default()
5024                    });
5025                }
5026            }
5027        }
5028    }
5029
5030    Ok(ExecResult::default())
5031}
5032
5033/// Execute a parsed program in an isolated subshell, returning only
5034/// stdout/stderr/exit_code. Mirrors `execute_subshell` in walker.rs.
5035fn run_in_subshell(
5036    state: &mut InterpreterState,
5037    program: &brush_parser::ast::Program,
5038    positional: &[String],
5039    shell_name_override: Option<&str>,
5040) -> Result<ExecResult, RustBashError> {
5041    use std::collections::HashMap;
5042    let cloned_fs = state.fs.deep_clone();
5043    let mut sub_state = InterpreterState {
5044        fs: cloned_fs,
5045        env: state.env.clone(),
5046        cwd: state.cwd.clone(),
5047        functions: state.functions.clone(),
5048        last_exit_code: state.last_exit_code,
5049        commands: crate::interpreter::walker::clone_commands(&state.commands),
5050        shell_opts: state.shell_opts.clone(),
5051        shopt_opts: state.shopt_opts.clone(),
5052        limits: state.limits.clone(),
5053        counters: crate::interpreter::ExecutionCounters {
5054            command_count: state.counters.command_count,
5055            output_size: state.counters.output_size,
5056            start_time: state.counters.start_time,
5057            substitution_depth: state.counters.substitution_depth,
5058            call_depth: 0,
5059        },
5060        network_policy: state.network_policy.clone(),
5061        should_exit: false,
5062        loop_depth: 0,
5063        control_flow: None,
5064        positional_params: if positional.is_empty() {
5065            state.positional_params.clone()
5066        } else {
5067            positional.to_vec()
5068        },
5069        shell_name: shell_name_override
5070            .map(|s| s.to_string())
5071            .unwrap_or_else(|| state.shell_name.clone()),
5072        random_seed: state.random_seed,
5073        local_scopes: Vec::new(),
5074        in_function_depth: 0,
5075        traps: state.traps.clone(),
5076        in_trap: false,
5077        errexit_suppressed: 0,
5078        stdin_offset: 0,
5079        dir_stack: state.dir_stack.clone(),
5080        command_hash: state.command_hash.clone(),
5081        aliases: state.aliases.clone(),
5082        current_lineno: state.current_lineno,
5083        shell_start_time: state.shell_start_time,
5084        last_argument: state.last_argument.clone(),
5085        call_stack: state.call_stack.clone(),
5086        machtype: state.machtype.clone(),
5087        hosttype: state.hosttype.clone(),
5088        persistent_fds: state.persistent_fds.clone(),
5089        next_auto_fd: state.next_auto_fd,
5090        proc_sub_counter: state.proc_sub_counter,
5091        proc_sub_prealloc: HashMap::new(),
5092        pipe_stdin_bytes: None,
5093        pending_cmdsub_stderr: String::new(),
5094    };
5095
5096    let result = execute_program(program, &mut sub_state);
5097
5098    // Fold shared counters back into parent
5099    state.counters.command_count = sub_state.counters.command_count;
5100    state.counters.output_size = sub_state.counters.output_size;
5101
5102    result
5103}
5104
5105// ── help builtin ────────────────────────────────────────────────────
5106
5107fn builtin_help(args: &[String], state: &InterpreterState) -> Result<ExecResult, RustBashError> {
5108    if args.is_empty() {
5109        // List all builtins with one-line descriptions
5110        let mut stdout = String::from("Shell builtin commands:\n\n");
5111        let mut names: Vec<&str> = builtin_names().to_vec();
5112        names.sort();
5113        for name in &names {
5114            if let Some(meta) = builtin_meta(name) {
5115                stdout.push_str(&format!("  {:<16} {}\n", name, meta.description));
5116            } else {
5117                stdout.push_str(&format!("  {}\n", name));
5118            }
5119        }
5120        return Ok(ExecResult {
5121            stdout,
5122            ..ExecResult::default()
5123        });
5124    }
5125
5126    let name = &args[0];
5127
5128    // Check builtins first
5129    if let Some(meta) = builtin_meta(name) {
5130        return Ok(ExecResult {
5131            stdout: crate::commands::format_help(meta),
5132            ..ExecResult::default()
5133        });
5134    }
5135
5136    // Check registered commands
5137    if let Some(cmd) = state.commands.get(name.as_str())
5138        && let Some(meta) = cmd.meta()
5139    {
5140        return Ok(ExecResult {
5141            stdout: crate::commands::format_help(meta),
5142            ..ExecResult::default()
5143        });
5144    }
5145
5146    Ok(ExecResult {
5147        stderr: format!("help: no help topics match '{}'\n", name),
5148        exit_code: 1,
5149        ..ExecResult::default()
5150    })
5151}
5152
5153#[cfg(test)]
5154mod tests {
5155    use super::*;
5156    use crate::interpreter::{ExecutionCounters, ExecutionLimits, ShellOpts, ShoptOpts};
5157    use crate::network::NetworkPolicy;
5158    use crate::platform::Instant;
5159    use crate::vfs::{InMemoryFs, VirtualFs};
5160    use std::collections::HashMap;
5161    use std::sync::Arc;
5162
5163    fn make_state() -> InterpreterState {
5164        let fs = Arc::new(InMemoryFs::new());
5165        fs.mkdir_p(Path::new("/home/user")).unwrap();
5166
5167        InterpreterState {
5168            fs,
5169            env: HashMap::new(),
5170            cwd: "/".to_string(),
5171            functions: HashMap::new(),
5172            last_exit_code: 0,
5173            commands: HashMap::new(),
5174            shell_opts: ShellOpts::default(),
5175            shopt_opts: ShoptOpts::default(),
5176            limits: ExecutionLimits::default(),
5177            counters: ExecutionCounters::default(),
5178            network_policy: NetworkPolicy::default(),
5179            should_exit: false,
5180            loop_depth: 0,
5181            control_flow: None,
5182            positional_params: Vec::new(),
5183            shell_name: "rust-bash".to_string(),
5184            random_seed: 42,
5185            local_scopes: Vec::new(),
5186            in_function_depth: 0,
5187            traps: HashMap::new(),
5188            in_trap: false,
5189            errexit_suppressed: 0,
5190            stdin_offset: 0,
5191            dir_stack: Vec::new(),
5192            command_hash: HashMap::new(),
5193            aliases: HashMap::new(),
5194            current_lineno: 0,
5195            shell_start_time: Instant::now(),
5196            last_argument: String::new(),
5197            call_stack: Vec::new(),
5198            machtype: "x86_64-pc-linux-gnu".to_string(),
5199            hosttype: "x86_64".to_string(),
5200            persistent_fds: HashMap::new(),
5201            next_auto_fd: 10,
5202            proc_sub_counter: 0,
5203            proc_sub_prealloc: HashMap::new(),
5204            pipe_stdin_bytes: None,
5205            pending_cmdsub_stderr: String::new(),
5206        }
5207    }
5208
5209    #[test]
5210    fn cd_to_directory() {
5211        let mut state = make_state();
5212        let result = builtin_cd(&["/home/user".to_string()], &mut state).unwrap();
5213        assert_eq!(result.exit_code, 0);
5214        assert_eq!(state.cwd, "/home/user");
5215    }
5216
5217    #[test]
5218    fn cd_nonexistent() {
5219        let mut state = make_state();
5220        let result = builtin_cd(&["/nonexistent".to_string()], &mut state).unwrap();
5221        assert_eq!(result.exit_code, 1);
5222        assert!(result.stderr.contains("No such file or directory"));
5223    }
5224
5225    #[test]
5226    fn cd_home() {
5227        let mut state = make_state();
5228        state.env.insert(
5229            "HOME".to_string(),
5230            Variable {
5231                value: VariableValue::Scalar("/home/user".to_string()),
5232                attrs: VariableAttrs::EXPORTED,
5233            },
5234        );
5235        let result = builtin_cd(&[], &mut state).unwrap();
5236        assert_eq!(result.exit_code, 0);
5237        assert_eq!(state.cwd, "/home/user");
5238    }
5239
5240    #[test]
5241    fn cd_dash() {
5242        let mut state = make_state();
5243        state.env.insert(
5244            "OLDPWD".to_string(),
5245            Variable {
5246                value: VariableValue::Scalar("/home/user".to_string()),
5247                attrs: VariableAttrs::EXPORTED,
5248            },
5249        );
5250        let result = builtin_cd(&["-".to_string()], &mut state).unwrap();
5251        assert_eq!(result.exit_code, 0);
5252        assert_eq!(state.cwd, "/home/user");
5253        assert!(result.stdout.contains("/home/user"));
5254    }
5255
5256    #[test]
5257    fn export_and_list() {
5258        let mut state = make_state();
5259        builtin_export(&["FOO=bar".to_string()], &mut state).unwrap();
5260        assert!(state.env.get("FOO").unwrap().exported());
5261        assert_eq!(state.env.get("FOO").unwrap().value.as_scalar(), "bar");
5262    }
5263
5264    #[test]
5265    fn unset_variable() {
5266        let mut state = make_state();
5267        set_variable(&mut state, "FOO", "bar".to_string()).unwrap();
5268        builtin_unset(&["FOO".to_string()], &mut state).unwrap();
5269        assert!(!state.env.contains_key("FOO"));
5270    }
5271
5272    #[test]
5273    fn unset_readonly_fails() {
5274        let mut state = make_state();
5275        state.env.insert(
5276            "FOO".to_string(),
5277            Variable {
5278                value: VariableValue::Scalar("bar".to_string()),
5279                attrs: VariableAttrs::READONLY,
5280            },
5281        );
5282        let result = builtin_unset(&["FOO".to_string()], &mut state).unwrap();
5283        assert_eq!(result.exit_code, 1);
5284        assert!(state.env.contains_key("FOO"));
5285    }
5286
5287    #[test]
5288    fn set_positional_params() {
5289        let mut state = make_state();
5290        builtin_set(
5291            &[
5292                "--".to_string(),
5293                "a".to_string(),
5294                "b".to_string(),
5295                "c".to_string(),
5296            ],
5297            &mut state,
5298        )
5299        .unwrap();
5300        assert_eq!(state.positional_params, vec!["a", "b", "c"]);
5301    }
5302
5303    #[test]
5304    fn set_errexit() {
5305        let mut state = make_state();
5306        builtin_set(&["-e".to_string()], &mut state).unwrap();
5307        assert!(state.shell_opts.errexit);
5308        builtin_set(&["+e".to_string()], &mut state).unwrap();
5309        assert!(!state.shell_opts.errexit);
5310    }
5311
5312    #[test]
5313    fn shift_params() {
5314        let mut state = make_state();
5315        state.positional_params = vec!["a".to_string(), "b".to_string(), "c".to_string()];
5316        builtin_shift(&[], &mut state).unwrap();
5317        assert_eq!(state.positional_params, vec!["b", "c"]);
5318    }
5319
5320    #[test]
5321    fn shift_too_many() {
5322        let mut state = make_state();
5323        state.positional_params = vec!["a".to_string()];
5324        let result = builtin_shift(&["5".to_string()], &mut state).unwrap();
5325        assert_eq!(result.exit_code, 1);
5326    }
5327
5328    #[test]
5329    fn readonly_variable() {
5330        let mut state = make_state();
5331        builtin_readonly(&["FOO=bar".to_string()], &mut state).unwrap();
5332        assert!(state.env.get("FOO").unwrap().readonly());
5333        assert_eq!(state.env.get("FOO").unwrap().value.as_scalar(), "bar");
5334    }
5335
5336    #[test]
5337    fn declare_readonly() {
5338        let mut state = make_state();
5339        builtin_declare(&["-r".to_string(), "X=42".to_string()], &mut state).unwrap();
5340        assert!(state.env.get("X").unwrap().readonly());
5341    }
5342
5343    #[test]
5344    fn read_single_var() {
5345        let mut state = make_state();
5346        builtin_read(&["NAME".to_string()], &mut state, "hello world\n").unwrap();
5347        assert_eq!(
5348            state.env.get("NAME").unwrap().value.as_scalar(),
5349            "hello world"
5350        );
5351    }
5352
5353    #[test]
5354    fn read_multiple_vars() {
5355        let mut state = make_state();
5356        builtin_read(
5357            &["A".to_string(), "B".to_string()],
5358            &mut state,
5359            "one two three\n",
5360        )
5361        .unwrap();
5362        assert_eq!(state.env.get("A").unwrap().value.as_scalar(), "one");
5363        assert_eq!(state.env.get("B").unwrap().value.as_scalar(), "two three");
5364    }
5365
5366    #[test]
5367    fn read_reply_default() {
5368        let mut state = make_state();
5369        builtin_read(&[], &mut state, "test input\n").unwrap();
5370        assert_eq!(
5371            state.env.get("REPLY").unwrap().value.as_scalar(),
5372            "test input"
5373        );
5374    }
5375
5376    #[test]
5377    fn read_eof_returns_1() {
5378        let mut state = make_state();
5379        let result = builtin_read(&["VAR".to_string()], &mut state, "").unwrap();
5380        assert_eq!(result.exit_code, 1);
5381    }
5382
5383    #[test]
5384    fn read_into_array() {
5385        let mut state = make_state();
5386        builtin_read(
5387            &["-r".to_string(), "-a".to_string(), "arr".to_string()],
5388            &mut state,
5389            "a b c\n",
5390        )
5391        .unwrap();
5392        let var = state.env.get("arr").unwrap();
5393        match &var.value {
5394            VariableValue::IndexedArray(map) => {
5395                assert_eq!(map.get(&0).unwrap(), "a");
5396                assert_eq!(map.get(&1).unwrap(), "b");
5397                assert_eq!(map.get(&2).unwrap(), "c");
5398                assert_eq!(map.len(), 3);
5399            }
5400            _ => panic!("expected indexed array"),
5401        }
5402    }
5403
5404    #[test]
5405    fn read_delimiter() {
5406        let mut state = make_state();
5407        builtin_read(
5408            &["-d".to_string(), ":".to_string(), "x".to_string()],
5409            &mut state,
5410            "a:b:c",
5411        )
5412        .unwrap();
5413        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "a");
5414    }
5415
5416    #[test]
5417    fn read_delimiter_empty_reads_until_eof() {
5418        let mut state = make_state();
5419        builtin_read(
5420            &["-d".to_string(), "".to_string(), "x".to_string()],
5421            &mut state,
5422            "hello\nworld",
5423        )
5424        .unwrap();
5425        assert_eq!(
5426            state.env.get("x").unwrap().value.as_scalar(),
5427            "hello\nworld"
5428        );
5429    }
5430
5431    #[test]
5432    fn read_n_count() {
5433        let mut state = make_state();
5434        builtin_read(
5435            &["-n".to_string(), "3".to_string(), "x".to_string()],
5436            &mut state,
5437            "hello\n",
5438        )
5439        .unwrap();
5440        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "hel");
5441    }
5442
5443    #[test]
5444    fn read_n_stops_at_newline() {
5445        let mut state = make_state();
5446        builtin_read(
5447            &["-n".to_string(), "10".to_string(), "x".to_string()],
5448            &mut state,
5449            "hi\nthere\n",
5450        )
5451        .unwrap();
5452        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "hi");
5453    }
5454
5455    #[test]
5456    fn read_big_n_includes_newlines() {
5457        let mut state = make_state();
5458        builtin_read(
5459            &["-N".to_string(), "4".to_string(), "x".to_string()],
5460            &mut state,
5461            "ab\ncd",
5462        )
5463        .unwrap();
5464        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "ab\nc");
5465    }
5466
5467    #[test]
5468    fn read_silent_flag_accepted() {
5469        let mut state = make_state();
5470        let result = builtin_read(
5471            &["-s".to_string(), "VAR".to_string()],
5472            &mut state,
5473            "secret\n",
5474        )
5475        .unwrap();
5476        assert_eq!(result.exit_code, 0);
5477        assert_eq!(state.env.get("VAR").unwrap().value.as_scalar(), "secret");
5478    }
5479
5480    #[test]
5481    fn read_timeout_stub_with_data() {
5482        let mut state = make_state();
5483        let result = builtin_read(
5484            &["-t".to_string(), "1".to_string(), "VAR".to_string()],
5485            &mut state,
5486            "data\n",
5487        )
5488        .unwrap();
5489        assert_eq!(result.exit_code, 0);
5490        assert_eq!(state.env.get("VAR").unwrap().value.as_scalar(), "data");
5491    }
5492
5493    #[test]
5494    fn read_timeout_stub_no_data() {
5495        let mut state = make_state();
5496        let result = builtin_read(
5497            &["-t".to_string(), "1".to_string(), "VAR".to_string()],
5498            &mut state,
5499            "",
5500        )
5501        .unwrap();
5502        assert_eq!(result.exit_code, 1);
5503    }
5504
5505    #[test]
5506    fn read_combined_ra_flags() {
5507        let mut state = make_state();
5508        builtin_read(
5509            &["-ra".to_string(), "arr".to_string()],
5510            &mut state,
5511            "x y z\n",
5512        )
5513        .unwrap();
5514        let var = state.env.get("arr").unwrap();
5515        match &var.value {
5516            VariableValue::IndexedArray(map) => {
5517                assert_eq!(map.len(), 3);
5518                assert_eq!(map.get(&0).unwrap(), "x");
5519                assert_eq!(map.get(&1).unwrap(), "y");
5520                assert_eq!(map.get(&2).unwrap(), "z");
5521            }
5522            _ => panic!("expected indexed array"),
5523        }
5524    }
5525
5526    #[test]
5527    fn read_delimiter_not_found_returns_1() {
5528        let mut state = make_state();
5529        let result = builtin_read(
5530            &["-d".to_string(), ":".to_string(), "x".to_string()],
5531            &mut state,
5532            "abc",
5533        )
5534        .unwrap();
5535        assert_eq!(result.exit_code, 1);
5536        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "abc");
5537    }
5538
5539    #[test]
5540    fn read_delimiter_empty_returns_1() {
5541        let mut state = make_state();
5542        let result = builtin_read(
5543            &["-d".to_string(), "".to_string(), "x".to_string()],
5544            &mut state,
5545            "hello\nworld",
5546        )
5547        .unwrap();
5548        assert_eq!(result.exit_code, 1);
5549    }
5550
5551    #[test]
5552    fn read_big_n_short_read_returns_1() {
5553        let mut state = make_state();
5554        let result = builtin_read(
5555            &["-N".to_string(), "10".to_string(), "x".to_string()],
5556            &mut state,
5557            "ab",
5558        )
5559        .unwrap();
5560        assert_eq!(result.exit_code, 1);
5561        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "ab");
5562    }
5563
5564    #[test]
5565    fn read_big_n_preserves_backslash() {
5566        let mut state = make_state();
5567        builtin_read(
5568            &["-N".to_string(), "4".to_string(), "x".to_string()],
5569            &mut state,
5570            "a\\bc",
5571        )
5572        .unwrap();
5573        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "a\\bc");
5574    }
5575
5576    #[test]
5577    fn read_n_zero_assigns_empty() {
5578        let mut state = make_state();
5579        let result = builtin_read(
5580            &["-n".to_string(), "0".to_string(), "x".to_string()],
5581            &mut state,
5582            "hello\n",
5583        )
5584        .unwrap();
5585        assert_eq!(result.exit_code, 0);
5586        assert_eq!(state.env.get("x").unwrap().value.as_scalar(), "");
5587    }
5588
5589    #[test]
5590    fn read_big_n_clears_extra_vars() {
5591        let mut state = make_state();
5592        builtin_read(
5593            &[
5594                "-N".to_string(),
5595                "4".to_string(),
5596                "a".to_string(),
5597                "b".to_string(),
5598            ],
5599            &mut state,
5600            "abcd",
5601        )
5602        .unwrap();
5603        assert_eq!(state.env.get("a").unwrap().value.as_scalar(), "abcd");
5604        assert_eq!(state.env.get("b").unwrap().value.as_scalar(), "");
5605    }
5606
5607    #[test]
5608    fn resolve_relative_path() {
5609        assert_eq!(resolve_path("/home/user", "docs"), "/home/user/docs");
5610        assert_eq!(resolve_path("/home/user", ".."), "/home");
5611        assert_eq!(resolve_path("/home/user", "/tmp"), "/tmp");
5612    }
5613
5614    #[test]
5615    fn builtin_names_is_nonempty() {
5616        assert!(
5617            !builtin_names().is_empty(),
5618            "builtin_names() should list at least one builtin"
5619        );
5620        // is_builtin() derives from builtin_names(), so consistency is guaranteed.
5621        for &name in builtin_names() {
5622            assert!(is_builtin(name));
5623        }
5624    }
5625
5626    #[test]
5627    fn all_builtins_have_meta() {
5628        let missing: Vec<&str> = builtin_names()
5629            .iter()
5630            .filter(|&&name| builtin_meta(name).is_none())
5631            .copied()
5632            .collect();
5633        assert!(missing.is_empty(), "Builtins missing meta: {:?}", missing);
5634    }
5635}