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