Skip to main content

rust_bash/interpreter/
expansion.rs

1//! Word expansion: parameter expansion, tilde expansion, special variables,
2//! IFS-based word splitting, and quoting correctness.
3
4use crate::error::RustBashError;
5use crate::interpreter::pattern;
6use crate::interpreter::walker::{clone_commands, execute_program};
7use crate::interpreter::{
8    ExecutionCounters, InterpreterState, next_random, parse, parser_options, set_variable,
9};
10
11use crate::vfs::GlobOptions;
12use brush_parser::ast;
13use brush_parser::word::{
14    Parameter, ParameterExpr, ParameterTestType, SpecialParameter, SubstringMatchKind, WordPiece,
15};
16use std::collections::HashMap;
17
18// ── Word expansion intermediate types ───────────────────────────────
19
20/// A segment of expanded text tracking quoting properties.
21#[derive(Debug, Clone)]
22struct Segment {
23    text: String,
24    /// If true, this segment came from a quoted context (single quotes, double
25    /// quotes, escape sequences, or literal text) and must not be IFS-split.
26    quoted: bool,
27    /// If true, glob metacharacters in this segment are protected from expansion.
28    /// True for single-quoted, double-quoted, and escape-sequence text.
29    /// False for unquoted literal text and unquoted parameter expansions.
30    glob_protected: bool,
31}
32
33/// A word being assembled from multiple segments during expansion.
34type WordInProgress = Vec<Segment>;
35
36// ── Public entry points ─────────────────────────────────────────────
37
38/// Expand a word into a list of strings (with IFS splitting on unquoted parts).
39///
40/// Most expansions produce a single word. `"$@"` in double-quotes produces
41/// one word per positional parameter. Unquoted `$VAR` where VAR contains
42/// IFS characters may produce multiple words. Unquoted glob metacharacters
43/// are expanded against the filesystem.
44pub fn expand_word(
45    word: &ast::Word,
46    state: &InterpreterState,
47) -> Result<Vec<String>, RustBashError> {
48    // Brace expansion first — operates on raw word text before parsing.
49    let brace_expanded =
50        crate::interpreter::brace::brace_expand(&word.value, state.limits.max_brace_expansion)?;
51
52    let mut all_results = Vec::new();
53    for raw in &brace_expanded {
54        let sub_word = ast::Word {
55            value: raw.clone(),
56            loc: word.loc.clone(),
57        };
58        let words = expand_word_segments(&sub_word, state)?;
59        let split = finalize_with_ifs_split(words, state);
60        let expanded = glob_expand_words(split, state)?;
61        all_results.extend(expanded);
62    }
63    Ok(all_results)
64}
65
66/// Mutable variant of expand_word for expansions that assign (e.g. `${VAR:=default}`).
67pub(crate) fn expand_word_mut(
68    word: &ast::Word,
69    state: &mut InterpreterState,
70) -> Result<Vec<String>, RustBashError> {
71    let brace_expanded =
72        crate::interpreter::brace::brace_expand(&word.value, state.limits.max_brace_expansion)?;
73
74    let mut all_results = Vec::new();
75    for raw in &brace_expanded {
76        let sub_word = ast::Word {
77            value: raw.clone(),
78            loc: word.loc.clone(),
79        };
80        let words = expand_word_segments_mut(&sub_word, state)?;
81        let split = finalize_with_ifs_split(words, state);
82        let expanded = glob_expand_words(split, state)?;
83        all_results.extend(expanded);
84    }
85    Ok(all_results)
86}
87
88/// Expand a word to a single string without IFS splitting
89/// (for assignments, redirections, case values, etc.).
90///
91/// Brace expansion is NOT applied here — assignments like `X={a,b}` keep
92/// literal braces, matching bash behavior.
93/// Expand a word to a single string (no word splitting or globbing).
94pub(crate) fn expand_word_to_string_mut(
95    word: &ast::Word,
96    state: &mut InterpreterState,
97) -> Result<String, RustBashError> {
98    let words = expand_word_segments_mut(word, state)?;
99    let result = finalize_no_split(words);
100    let joined = result.join(" ");
101    if joined.len() > state.limits.max_string_length {
102        return Err(RustBashError::LimitExceeded {
103            limit_name: "max_string_length",
104            limit_value: state.limits.max_string_length,
105            actual_value: joined.len(),
106        });
107    }
108    Ok(joined)
109}
110
111// ── Internal segment-based expansion ────────────────────────────────
112
113fn expand_word_segments(
114    word: &ast::Word,
115    state: &InterpreterState,
116) -> Result<Vec<WordInProgress>, RustBashError> {
117    let options = parser_options();
118    let pieces = brush_parser::word::parse(&word.value, &options)
119        .map_err(|e| RustBashError::Parse(e.to_string()))?;
120
121    let mut words: Vec<WordInProgress> = vec![Vec::new()];
122    for piece_ws in &pieces {
123        expand_word_piece(&piece_ws.piece, &mut words, state, false)?;
124    }
125    Ok(words)
126}
127
128fn expand_word_segments_mut(
129    word: &ast::Word,
130    state: &mut InterpreterState,
131) -> Result<Vec<WordInProgress>, RustBashError> {
132    let options = parser_options();
133    let pieces = brush_parser::word::parse(&word.value, &options)
134        .map_err(|e| RustBashError::Parse(e.to_string()))?;
135
136    let mut words: Vec<WordInProgress> = vec![Vec::new()];
137    for piece_ws in &pieces {
138        expand_word_piece_mut(&piece_ws.piece, &mut words, state, false)?;
139    }
140    Ok(words)
141}
142
143// ── Segment helpers ─────────────────────────────────────────────────
144
145/// Append text to the last word with the given quotedness.
146/// Merges with the previous segment when quotedness matches.
147/// Unquoted empty text is silently discarded; quoted empty text is preserved
148/// so that `""` and `"$EMPTY"` still produce one empty word.
149fn push_segment(words: &mut Vec<WordInProgress>, text: &str, quoted: bool, glob_protected: bool) {
150    if text.is_empty() && !quoted {
151        return;
152    }
153    if words.is_empty() {
154        words.push(Vec::new());
155    }
156    let word = words.last_mut().unwrap();
157    if let Some(last) = word.last_mut()
158        && last.quoted == quoted
159        && last.glob_protected == glob_protected
160    {
161        last.text.push_str(text);
162        return;
163    }
164    word.push(Segment {
165        text: text.to_string(),
166        quoted,
167        glob_protected,
168    });
169}
170
171/// Start a new (empty) word in the word list.
172fn start_new_word(words: &mut Vec<WordInProgress>) {
173    words.push(Vec::new());
174}
175
176/// Execute a command substitution: parse and run the command in a subshell,
177/// capture stdout, strip trailing newlines, and update `$?` in the parent.
178fn execute_command_substitution(
179    cmd_str: &str,
180    state: &mut InterpreterState,
181) -> Result<String, RustBashError> {
182    state.counters.substitution_depth += 1;
183    if state.counters.substitution_depth > state.limits.max_substitution_depth {
184        let actual = state.counters.substitution_depth;
185        state.counters.substitution_depth -= 1;
186        return Err(RustBashError::LimitExceeded {
187            limit_name: "max_substitution_depth",
188            limit_value: state.limits.max_substitution_depth,
189            actual_value: actual,
190        });
191    }
192
193    let program = match parse(cmd_str) {
194        Ok(p) => p,
195        Err(e) => {
196            state.counters.substitution_depth -= 1;
197            return Err(e);
198        }
199    };
200
201    // Create an isolated subshell state
202    let cloned_fs = state.fs.deep_clone();
203
204    let mut sub_state = InterpreterState {
205        fs: cloned_fs,
206        env: state.env.clone(),
207        cwd: state.cwd.clone(),
208        functions: state.functions.clone(),
209        last_exit_code: state.last_exit_code,
210        commands: clone_commands(&state.commands),
211        shell_opts: state.shell_opts.clone(),
212        shopt_opts: state.shopt_opts.clone(),
213        limits: state.limits.clone(),
214        counters: ExecutionCounters {
215            command_count: state.counters.command_count,
216            output_size: state.counters.output_size,
217            start_time: state.counters.start_time,
218            substitution_depth: state.counters.substitution_depth,
219            call_depth: 0,
220        },
221        network_policy: state.network_policy.clone(),
222        should_exit: false,
223        loop_depth: 0,
224        control_flow: None,
225        positional_params: state.positional_params.clone(),
226        shell_name: state.shell_name.clone(),
227        random_seed: state.random_seed,
228        local_scopes: Vec::new(),
229        in_function_depth: 0,
230        traps: HashMap::new(),
231        in_trap: false,
232        errexit_suppressed: 0,
233        stdin_offset: 0,
234        dir_stack: state.dir_stack.clone(),
235        command_hash: state.command_hash.clone(),
236        aliases: state.aliases.clone(),
237        current_lineno: state.current_lineno,
238        shell_start_time: state.shell_start_time,
239        last_argument: state.last_argument.clone(),
240        call_stack: state.call_stack.clone(),
241        machtype: state.machtype.clone(),
242        hosttype: state.hosttype.clone(),
243        persistent_fds: state.persistent_fds.clone(),
244        next_auto_fd: state.next_auto_fd,
245        proc_sub_counter: state.proc_sub_counter,
246        proc_sub_prealloc: HashMap::new(),
247        pipe_stdin_bytes: None,
248        pending_cmdsub_stderr: String::new(),
249    };
250
251    let result = execute_program(&program, &mut sub_state);
252
253    // Fold shared counters back into parent
254    state.counters.command_count = sub_state.counters.command_count;
255    state.counters.output_size = sub_state.counters.output_size;
256    state.counters.substitution_depth -= 1;
257
258    let result = result?;
259
260    // $? reflects the exit code of the substituted command
261    state.last_exit_code = result.exit_code;
262
263    // In bash, stderr from command substitution passes through to the parent.
264    // Accumulate it so the enclosing command can include it in its ExecResult.
265    if !result.stderr.is_empty() {
266        state.pending_cmdsub_stderr.push_str(&result.stderr);
267    }
268
269    // Strip trailing newlines from captured stdout
270    let mut output = result.stdout;
271    let trimmed_len = output.trim_end_matches('\n').len();
272    output.truncate(trimmed_len);
273
274    Ok(output)
275}
276
277// ── Piece expansion ─────────────────────────────────────────────────
278
279/// Expand a single word piece, appending segments to the last word.
280/// `in_dq` tracks whether we're inside double quotes.
281/// Returns `true` if the piece was a `"$@"` expansion with zero positional params.
282fn expand_word_piece(
283    piece: &WordPiece,
284    words: &mut Vec<WordInProgress>,
285    state: &InterpreterState,
286    in_dq: bool,
287) -> Result<bool, RustBashError> {
288    let mut at_empty = false;
289    match piece {
290        WordPiece::Text(s) => {
291            // Literal text from the source — IFS-protected but glob-eligible
292            // unless we are inside double quotes.
293            push_segment(words, s, true, in_dq);
294        }
295        WordPiece::SingleQuotedText(s) => {
296            push_segment(words, s, true, true);
297        }
298        WordPiece::AnsiCQuotedText(s) => {
299            let expanded = expand_escape_sequences(s);
300            push_segment(words, &expanded, true, true);
301        }
302        WordPiece::DoubleQuotedSequence(pieces)
303        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
304            let word_count_before = words.len();
305            let seg_count_before = words.last().map_or(0, Vec::len);
306            let mut saw_at_empty = false;
307            for inner in pieces {
308                if expand_word_piece(&inner.piece, words, state, true)? {
309                    saw_at_empty = true;
310                }
311            }
312            // If nothing was added, ensure the quoted context still produces an
313            // empty word (so `""` → one empty word, not zero words).
314            // Exception: `"$@"` with zero params must produce zero words.
315            if words.len() == word_count_before
316                && words.last().map_or(0, Vec::len) == seg_count_before
317                && !saw_at_empty
318            {
319                push_segment(words, "", true, true);
320            }
321        }
322        WordPiece::EscapeSequence(s) => {
323            if let Some(c) = s.strip_prefix('\\') {
324                // In double quotes, only \$, \`, \", \\, and \newline are special.
325                // Other \X sequences should preserve the backslash.
326                if in_dq {
327                    match c {
328                        "$" | "`" | "\"" | "\\" | "\n" => {
329                            push_segment(words, c, true, true);
330                        }
331                        _ => {
332                            push_segment(words, s, true, true);
333                        }
334                    }
335                } else {
336                    push_segment(words, c, true, true);
337                }
338            } else {
339                push_segment(words, s, true, true);
340            }
341        }
342        WordPiece::TildePrefix(user) => {
343            expand_tilde(user, words, state);
344        }
345        WordPiece::ParameterExpansion(expr) => {
346            at_empty = expand_parameter(expr, words, state, in_dq)?;
347        }
348        // Command substitution — future phases
349        WordPiece::CommandSubstitution(_) | WordPiece::BackquotedCommandSubstitution(_) => {}
350        WordPiece::ArithmeticExpression(_) => {
351            // Immutable path cannot evaluate arithmetic (needs mutable state).
352            // Arithmetic in non-mutable context is a no-op; real usage goes
353            // through expand_word_piece_mut.
354        }
355    }
356    Ok(at_empty)
357}
358
359/// Mutable variant for pieces that may need to assign variables.
360fn expand_word_piece_mut(
361    piece: &WordPiece,
362    words: &mut Vec<WordInProgress>,
363    state: &mut InterpreterState,
364    in_dq: bool,
365) -> Result<bool, RustBashError> {
366    match piece {
367        WordPiece::ParameterExpansion(expr) => {
368            let at_empty = expand_parameter_mut(expr, words, state, in_dq)?;
369            Ok(at_empty)
370        }
371        WordPiece::DoubleQuotedSequence(pieces)
372        | WordPiece::GettextDoubleQuotedSequence(pieces) => {
373            let word_count_before = words.len();
374            let seg_count_before = words.last().map_or(0, Vec::len);
375            let mut saw_at_empty = false;
376            for inner in pieces {
377                if expand_word_piece_mut(&inner.piece, words, state, true)? {
378                    saw_at_empty = true;
379                }
380            }
381            if words.len() == word_count_before
382                && words.last().map_or(0, Vec::len) == seg_count_before
383                && !saw_at_empty
384            {
385                push_segment(words, "", true, true);
386            }
387            Ok(false)
388        }
389        WordPiece::CommandSubstitution(cmd_str)
390        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {
391            let output = execute_command_substitution(cmd_str, state)?;
392            push_segment(words, &output, in_dq, in_dq);
393            Ok(false)
394        }
395        WordPiece::ArithmeticExpression(expr) => {
396            // Expand shell variables in the expression before arithmetic evaluation.
397            let expanded = expand_arith_expression(&expr.value, state)?;
398            let val = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
399            push_segment(words, &val.to_string(), in_dq, in_dq);
400            Ok(false)
401        }
402        // Non-mutating pieces delegate to immutable version
403        other => expand_word_piece(other, words, state, in_dq),
404    }
405}
406
407// ── Tilde expansion ─────────────────────────────────────────────────
408
409fn expand_tilde(user: &str, words: &mut Vec<WordInProgress>, state: &InterpreterState) {
410    if user.is_empty() {
411        // ~ → $HOME
412        let home = get_var(state, "HOME").unwrap_or_default();
413        push_segment(words, &home, true, true);
414    } else {
415        // ~user → not supported in sandbox, just output literally
416        push_segment(words, "~", true, true);
417        push_segment(words, user, true, true);
418    }
419}
420
421// ── Parameter expansion (immutable) ─────────────────────────────────
422
423/// Returns `true` if this was a `$@` expansion with zero positional params
424/// (used to prevent `""` preservation in enclosing double quotes).
425fn expand_parameter(
426    expr: &ParameterExpr,
427    words: &mut Vec<WordInProgress>,
428    state: &InterpreterState,
429    in_dq: bool,
430) -> Result<bool, RustBashError> {
431    validate_expr_parameter(expr)?;
432    let mut at_empty = false;
433    let ext = state.shopt_opts.extglob;
434    match expr {
435        ParameterExpr::Parameter {
436            parameter,
437            indirect,
438        } => {
439            check_nounset(parameter, state)?;
440            let val = resolve_parameter(parameter, state, *indirect);
441            at_empty = expand_param_value(&val, words, state, in_dq, parameter);
442        }
443        ParameterExpr::ParameterLength {
444            parameter,
445            indirect,
446        } => {
447            // ${#arr[@]} and ${#arr[*]} return element count
448            match parameter {
449                Parameter::Special(SpecialParameter::AllPositionalParameters {
450                    concatenate: _,
451                }) => {
452                    push_segment(
453                        words,
454                        &state.positional_params.len().to_string(),
455                        in_dq,
456                        in_dq,
457                    );
458                }
459                Parameter::NamedWithAllIndices { name, .. } => {
460                    let values = get_array_values(name, state);
461                    push_segment(words, &values.len().to_string(), in_dq, in_dq);
462                }
463                _ => {
464                    let val = resolve_parameter(parameter, state, *indirect);
465                    push_segment(words, &val.len().to_string(), in_dq, in_dq);
466                }
467            }
468        }
469        ParameterExpr::UseDefaultValues {
470            parameter,
471            indirect,
472            test_type,
473            default_value,
474        } => {
475            let val = resolve_parameter(parameter, state, *indirect);
476            let use_default = if *indirect {
477                // For indirect expansion, check the indirect target
478                let target_name = resolve_parameter(parameter, state, false);
479                should_use_indirect_default(&val, &target_name, test_type, state)
480            } else {
481                should_use_default(&val, test_type, state, parameter)
482            };
483            if use_default {
484                if let Some(dv) = default_value {
485                    let expanded = expand_raw_string_ctx(dv, state, in_dq)?;
486                    push_segment(words, &expanded, in_dq, in_dq);
487                }
488            } else {
489                push_segment(words, &val, in_dq, in_dq);
490            }
491        }
492        // AssignDefaultValues — needs mutation, handled by mut variant; here treat as UseDefault
493        ParameterExpr::AssignDefaultValues {
494            parameter,
495            indirect,
496            test_type,
497            default_value,
498        } => {
499            let val = resolve_parameter(parameter, state, *indirect);
500            let use_default = if *indirect {
501                let target_name = resolve_parameter(parameter, state, false);
502                should_use_indirect_default(&val, &target_name, test_type, state)
503            } else {
504                should_use_default(&val, test_type, state, parameter)
505            };
506            if use_default {
507                if let Some(dv) = default_value {
508                    let expanded = expand_raw_string_ctx(dv, state, in_dq)?;
509                    push_segment(words, &expanded, in_dq, in_dq);
510                }
511            } else {
512                push_segment(words, &val, in_dq, in_dq);
513            }
514        }
515        ParameterExpr::IndicateErrorIfNullOrUnset {
516            parameter,
517            indirect,
518            test_type,
519            error_message,
520        } => {
521            let val = resolve_parameter(parameter, state, *indirect);
522            let use_default = if *indirect {
523                let target_name = resolve_parameter(parameter, state, false);
524                should_use_indirect_default(&val, &target_name, test_type, state)
525            } else {
526                should_use_default(&val, test_type, state, parameter)
527            };
528            if use_default {
529                let param_name = parameter_name(parameter);
530                let msg = if let Some(raw) = error_message {
531                    expand_raw_string_ctx(raw, state, in_dq)?
532                } else {
533                    "parameter null or not set".to_string()
534                };
535                return Err(RustBashError::ExpansionError {
536                    message: format!("{param_name}: {msg}"),
537                    exit_code: 127,
538                    should_exit: true,
539                });
540            }
541            push_segment(words, &val, in_dq, in_dq);
542        }
543        ParameterExpr::UseAlternativeValue {
544            parameter,
545            indirect,
546            test_type,
547            alternative_value,
548        } => {
549            let val = resolve_parameter(parameter, state, *indirect);
550            let use_default = if *indirect {
551                let target_name = resolve_parameter(parameter, state, false);
552                should_use_indirect_default(&val, &target_name, test_type, state)
553            } else {
554                should_use_default(&val, test_type, state, parameter)
555            };
556            if !use_default && let Some(av) = alternative_value {
557                let expanded = expand_raw_string_ctx(av, state, in_dq)?;
558                push_segment(words, &expanded, in_dq, in_dq);
559            }
560            // If unset/null, expand to nothing
561        }
562        ParameterExpr::RemoveSmallestSuffixPattern {
563            parameter,
564            indirect,
565            pattern,
566        } => {
567            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
568            {
569                let results: Vec<String> = values
570                    .iter()
571                    .map(|v| {
572                        if let Some(pat) = pattern
573                            && let Some(idx) = pattern::shortest_suffix_match_ext(v, pat, ext)
574                        {
575                            v[..idx].to_string()
576                        } else {
577                            v.clone()
578                        }
579                    })
580                    .collect();
581                push_vectorized(results, concatenate, words, state, in_dq);
582            } else {
583                let val = resolve_parameter(parameter, state, *indirect);
584                let result = if let Some(pat) = pattern {
585                    if let Some(idx) = pattern::shortest_suffix_match_ext(&val, pat, ext) {
586                        val[..idx].to_string()
587                    } else {
588                        val
589                    }
590                } else {
591                    val
592                };
593                push_segment(words, &result, in_dq, in_dq);
594            }
595        }
596        ParameterExpr::RemoveLargestSuffixPattern {
597            parameter,
598            indirect,
599            pattern,
600        } => {
601            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
602            {
603                let results: Vec<String> = values
604                    .iter()
605                    .map(|v| {
606                        if let Some(pat) = pattern
607                            && let Some(idx) = pattern::longest_suffix_match_ext(v, pat, ext)
608                        {
609                            v[..idx].to_string()
610                        } else {
611                            v.clone()
612                        }
613                    })
614                    .collect();
615                push_vectorized(results, concatenate, words, state, in_dq);
616            } else {
617                let val = resolve_parameter(parameter, state, *indirect);
618                let result = if let Some(pat) = pattern {
619                    if let Some(idx) = pattern::longest_suffix_match_ext(&val, pat, ext) {
620                        val[..idx].to_string()
621                    } else {
622                        val
623                    }
624                } else {
625                    val
626                };
627                push_segment(words, &result, in_dq, in_dq);
628            }
629        }
630        ParameterExpr::RemoveSmallestPrefixPattern {
631            parameter,
632            indirect,
633            pattern,
634        } => {
635            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
636            {
637                let results: Vec<String> = values
638                    .iter()
639                    .map(|v| {
640                        if let Some(pat) = pattern
641                            && let Some(len) = pattern::shortest_prefix_match_ext(v, pat, ext)
642                        {
643                            v[len..].to_string()
644                        } else {
645                            v.clone()
646                        }
647                    })
648                    .collect();
649                push_vectorized(results, concatenate, words, state, in_dq);
650            } else {
651                let val = resolve_parameter(parameter, state, *indirect);
652                let result = if let Some(pat) = pattern {
653                    if let Some(len) = pattern::shortest_prefix_match_ext(&val, pat, ext) {
654                        val[len..].to_string()
655                    } else {
656                        val
657                    }
658                } else {
659                    val
660                };
661                push_segment(words, &result, in_dq, in_dq);
662            }
663        }
664        ParameterExpr::RemoveLargestPrefixPattern {
665            parameter,
666            indirect,
667            pattern,
668        } => {
669            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
670            {
671                let results: Vec<String> = values
672                    .iter()
673                    .map(|v| {
674                        if let Some(pat) = pattern
675                            && let Some(len) = pattern::longest_prefix_match_ext(v, pat, ext)
676                        {
677                            v[len..].to_string()
678                        } else {
679                            v.clone()
680                        }
681                    })
682                    .collect();
683                push_vectorized(results, concatenate, words, state, in_dq);
684            } else {
685                let val = resolve_parameter(parameter, state, *indirect);
686                let result = if let Some(pat) = pattern {
687                    if let Some(len) = pattern::longest_prefix_match_ext(&val, pat, ext) {
688                        val[len..].to_string()
689                    } else {
690                        val
691                    }
692                } else {
693                    val
694                };
695                push_segment(words, &result, in_dq, in_dq);
696            }
697        }
698        ParameterExpr::Substring {
699            parameter,
700            indirect,
701            offset,
702            length,
703        } => {
704            // Check if this is an array/positional parameter needing element-level slicing
705            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
706            {
707                let elem_count = values.len() as i64;
708                // For positional params $@/$*, offset 0 means $0 (shell name) in some contexts,
709                // but in practice ${@:n:m} uses 0-based offset on the values array
710                let is_positional = matches!(
711                    parameter,
712                    Parameter::Special(SpecialParameter::AllPositionalParameters { .. })
713                );
714                let off_raw = parse_arithmetic_value(&offset.value);
715                let off = if is_positional && off_raw > 0 {
716                    // For $@, offset 1 means "start from $1" which is values[0]
717                    (off_raw - 1) as usize
718                } else if off_raw < 0 {
719                    (elem_count + off_raw).max(0) as usize
720                } else {
721                    off_raw as usize
722                };
723                let sliced: Vec<String> = if let Some(len_expr) = length {
724                    let len = parse_arithmetic_value(&len_expr.value);
725                    let len = if len < 0 {
726                        (elem_count - off as i64 + len).max(0) as usize
727                    } else {
728                        len as usize
729                    };
730                    values.into_iter().skip(off).take(len).collect()
731                } else {
732                    values.into_iter().skip(off).collect()
733                };
734                push_vectorized(sliced, concatenate, words, state, in_dq);
735            } else {
736                let val = resolve_parameter(parameter, state, *indirect);
737                let char_count = val.chars().count();
738                let off = parse_arithmetic_value(&offset.value);
739                let off = if off < 0 {
740                    (char_count as i64 + off).max(0) as usize
741                } else {
742                    off as usize
743                };
744                let substr: String = if let Some(len_expr) = length {
745                    let len = parse_arithmetic_value(&len_expr.value);
746                    let len = if len < 0 {
747                        ((char_count as i64) - (off as i64) + len).max(0) as usize
748                    } else {
749                        len as usize
750                    };
751                    if off <= char_count {
752                        val.chars().skip(off).take(len).collect()
753                    } else {
754                        String::new()
755                    }
756                } else if off <= char_count {
757                    val.chars().skip(off).collect()
758                } else {
759                    String::new()
760                };
761                push_segment(words, &substr, in_dq, in_dq);
762            }
763        }
764        ParameterExpr::ReplaceSubstring {
765            parameter,
766            indirect,
767            pattern: pat,
768            replacement,
769            match_kind,
770        } => {
771            let repl = replacement.as_deref().unwrap_or("");
772            let do_replace = |val: &str| -> String {
773                match match_kind {
774                    SubstringMatchKind::FirstOccurrence => {
775                        if let Some((start, end)) = pattern::first_match_ext(val, pat, ext) {
776                            format!("{}{}{}", &val[..start], repl, &val[end..])
777                        } else {
778                            val.to_string()
779                        }
780                    }
781                    SubstringMatchKind::Anywhere => pattern::replace_all_ext(val, pat, repl, ext),
782                    SubstringMatchKind::Prefix => {
783                        if let Some(len) = pattern::longest_prefix_match_ext(val, pat, ext) {
784                            format!("{repl}{}", &val[len..])
785                        } else {
786                            val.to_string()
787                        }
788                    }
789                    SubstringMatchKind::Suffix => {
790                        if let Some(idx) = pattern::longest_suffix_match_ext(val, pat, ext) {
791                            format!("{}{repl}", &val[..idx])
792                        } else {
793                            val.to_string()
794                        }
795                    }
796                }
797            };
798            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
799            {
800                let results: Vec<String> = values.iter().map(|v| do_replace(v)).collect();
801                push_vectorized(results, concatenate, words, state, in_dq);
802            } else {
803                let val = resolve_parameter(parameter, state, *indirect);
804                let result = do_replace(&val);
805                push_segment(words, &result, in_dq, in_dq);
806            }
807        }
808        ParameterExpr::UppercaseFirstChar {
809            parameter,
810            indirect,
811            ..
812        } => {
813            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
814            {
815                let results: Vec<String> = values.iter().map(|v| uppercase_first(v)).collect();
816                push_vectorized(results, concatenate, words, state, in_dq);
817            } else {
818                let val = resolve_parameter(parameter, state, *indirect);
819                let result = uppercase_first(&val);
820                push_segment(words, &result, in_dq, in_dq);
821            }
822        }
823        ParameterExpr::UppercasePattern {
824            parameter,
825            indirect,
826            ..
827        } => {
828            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
829            {
830                let results: Vec<String> = values.iter().map(|v| v.to_uppercase()).collect();
831                push_vectorized(results, concatenate, words, state, in_dq);
832            } else {
833                let val = resolve_parameter(parameter, state, *indirect);
834                push_segment(words, &val.to_uppercase(), in_dq, in_dq);
835            }
836        }
837        ParameterExpr::LowercaseFirstChar {
838            parameter,
839            indirect,
840            ..
841        } => {
842            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
843            {
844                let results: Vec<String> = values.iter().map(|v| lowercase_first(v)).collect();
845                push_vectorized(results, concatenate, words, state, in_dq);
846            } else {
847                let val = resolve_parameter(parameter, state, *indirect);
848                let result = lowercase_first(&val);
849                push_segment(words, &result, in_dq, in_dq);
850            }
851        }
852        ParameterExpr::LowercasePattern {
853            parameter,
854            indirect,
855            ..
856        } => {
857            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
858            {
859                let results: Vec<String> = values.iter().map(|v| v.to_lowercase()).collect();
860                push_vectorized(results, concatenate, words, state, in_dq);
861            } else {
862                let val = resolve_parameter(parameter, state, *indirect);
863                push_segment(words, &val.to_lowercase(), in_dq, in_dq);
864            }
865        }
866        ParameterExpr::Transform {
867            parameter,
868            indirect,
869            op,
870        } => {
871            let var_name = parameter_name(parameter);
872            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)
873            {
874                let results: Vec<String> = values
875                    .iter()
876                    .map(|v| apply_transform(v, op, &var_name, state))
877                    .collect();
878                push_vectorized(results, concatenate, words, state, in_dq);
879            } else {
880                let val = resolve_parameter(parameter, state, *indirect);
881                let result = apply_transform(&val, op, &var_name, state);
882                push_segment(words, &result, in_dq, in_dq);
883            }
884        }
885        ParameterExpr::VariableNames { prefix, .. } => {
886            let mut names: Vec<String> = state
887                .env
888                .keys()
889                .filter(|k| k.starts_with(prefix.as_str()))
890                .cloned()
891                .collect();
892            names.sort();
893            push_segment(words, &names.join(" "), in_dq, in_dq);
894        }
895        ParameterExpr::MemberKeys {
896            variable_name,
897            concatenate,
898        } => {
899            let keys = get_array_keys(variable_name, state);
900            if *concatenate {
901                // ${!arr[*]} — join with IFS[0], single word
902                let sep = match get_var(state, "IFS") {
903                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
904                    None => " ".to_string(),
905                };
906                push_segment(words, &keys.join(&sep), in_dq, in_dq);
907            } else if keys.is_empty() {
908                at_empty = true;
909            } else {
910                // ${!arr[@]} — each key becomes a separate word
911                for (i, k) in keys.iter().enumerate() {
912                    if i > 0 {
913                        start_new_word(words);
914                    }
915                    push_segment(words, k, in_dq, in_dq);
916                }
917            }
918        }
919    }
920    Ok(at_empty)
921}
922
923/// Mutable variant that can assign defaults via `:=`.
924fn expand_parameter_mut(
925    expr: &ParameterExpr,
926    words: &mut Vec<WordInProgress>,
927    state: &mut InterpreterState,
928    in_dq: bool,
929) -> Result<bool, RustBashError> {
930    validate_expr_parameter(expr)?;
931    match expr {
932        ParameterExpr::AssignDefaultValues {
933            parameter,
934            indirect,
935            test_type,
936            default_value,
937        } => {
938            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;
939            let use_default = if *indirect {
940                let target_name = resolve_parameter_maybe_mut(parameter, state, false)?;
941                should_use_indirect_default(&val, &target_name, test_type, state)
942            } else {
943                should_use_default(&val, test_type, state, parameter)
944            };
945            if use_default {
946                let dv = if let Some(raw) = default_value {
947                    expand_raw_string_mut_ctx(raw, state, in_dq)?
948                } else {
949                    String::new()
950                };
951                if *indirect {
952                    // For ${!ref:=default}, assign to the indirect target
953                    if let Parameter::Named(_) = parameter {
954                        let target_name = resolve_parameter_maybe_mut(parameter, state, false)?;
955                        if !target_name.is_empty() {
956                            set_variable(state, &target_name, dv.clone())?;
957                        }
958                    }
959                } else if let Parameter::Named(name) = parameter {
960                    set_variable(state, name, dv.clone())?;
961                }
962                push_segment(words, &dv, in_dq, in_dq);
963            } else {
964                push_segment(words, &val, in_dq, in_dq);
965            }
966            Ok(false)
967        }
968        ParameterExpr::Parameter {
969            parameter,
970            indirect,
971        } => {
972            check_nounset(parameter, state)?;
973            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;
974            let at_empty = expand_param_value(&val, words, state, in_dq, parameter);
975            Ok(at_empty)
976        }
977        ParameterExpr::Substring {
978            parameter,
979            indirect,
980            offset,
981            length,
982        } => {
983            if let Some((_, concatenate)) = get_vectorized_values(parameter, state, *indirect) {
984                // Array/positional slicing with full arithmetic evaluation.
985                let is_positional = matches!(
986                    parameter,
987                    Parameter::Special(SpecialParameter::AllPositionalParameters { .. })
988                );
989
990                // Get key-value pairs for proper sparse-array handling.
991                let kv_pairs = get_array_kv_pairs(parameter, state);
992                let elem_count = kv_pairs.len() as i64;
993                let max_key = kv_pairs.last().map(|(k, _)| *k).unwrap_or(0) as i64;
994
995                // Evaluate offset as arithmetic.
996                let expanded_off = expand_arith_expression(&offset.value, state)?;
997                let off_raw =
998                    crate::interpreter::arithmetic::eval_arithmetic(&expanded_off, state)?;
999
1000                // Compute the key-based threshold for indexed arrays.
1001                // For negative offsets: threshold = max_key + 1 + offset.
1002                // For positional params, negative offsets use element count.
1003                let compute_threshold = |raw: i64| -> Option<usize> {
1004                    if is_positional {
1005                        if raw > 0 {
1006                            Some((raw - 1) as usize)
1007                        } else if raw < 0 {
1008                            let t = elem_count + raw;
1009                            if t < 0 { None } else { Some(t as usize) }
1010                        } else {
1011                            Some(0)
1012                        }
1013                    } else if raw < 0 {
1014                        let t = max_key.checked_add(1).and_then(|v| v.checked_add(raw));
1015                        match t {
1016                            Some(v) if v >= 0 => Some(v as usize),
1017                            _ => None,
1018                        }
1019                    } else {
1020                        Some(raw as usize)
1021                    }
1022                };
1023
1024                let sliced: Vec<String> = if let Some(len_expr) = length {
1025                    let expanded_len = expand_arith_expression(&len_expr.value, state)?;
1026                    let len_raw =
1027                        crate::interpreter::arithmetic::eval_arithmetic(&expanded_len, state)?;
1028                    if len_raw < 0 {
1029                        return Err(RustBashError::ExpansionError {
1030                            message: format!("{}: substring expression < 0", offset.value),
1031                            exit_code: 1,
1032                            should_exit: false,
1033                        });
1034                    }
1035                    let len = len_raw as usize;
1036                    match compute_threshold(off_raw) {
1037                        None => Vec::new(),
1038                        Some(threshold) if is_positional => kv_pairs
1039                            .into_iter()
1040                            .map(|(_, v)| v)
1041                            .skip(threshold)
1042                            .take(len)
1043                            .collect(),
1044                        Some(threshold) => kv_pairs
1045                            .into_iter()
1046                            .filter(|(k, _)| *k >= threshold)
1047                            .map(|(_, v)| v)
1048                            .take(len)
1049                            .collect(),
1050                    }
1051                } else {
1052                    // No length — take all from offset.
1053                    match compute_threshold(off_raw) {
1054                        None => Vec::new(),
1055                        Some(threshold) if is_positional => kv_pairs
1056                            .into_iter()
1057                            .map(|(_, v)| v)
1058                            .skip(threshold)
1059                            .collect(),
1060                        Some(threshold) => kv_pairs
1061                            .into_iter()
1062                            .filter(|(k, _)| *k >= threshold)
1063                            .map(|(_, v)| v)
1064                            .collect(),
1065                    }
1066                };
1067                push_vectorized(sliced, concatenate, words, state, in_dq);
1068            } else {
1069                // Scalar substring.
1070                let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;
1071                let char_count = val.chars().count();
1072                let expanded_off = expand_arith_expression(&offset.value, state)?;
1073                let off = crate::interpreter::arithmetic::eval_arithmetic(&expanded_off, state)?;
1074                let off = if off < 0 {
1075                    (char_count as i64 + off).max(0) as usize
1076                } else {
1077                    off as usize
1078                };
1079                let substr: String = if let Some(len_expr) = length {
1080                    let expanded_len = expand_arith_expression(&len_expr.value, state)?;
1081                    let len =
1082                        crate::interpreter::arithmetic::eval_arithmetic(&expanded_len, state)?;
1083                    let len = if len < 0 {
1084                        ((char_count as i64) - (off as i64) + len).max(0) as usize
1085                    } else {
1086                        len as usize
1087                    };
1088                    if off <= char_count {
1089                        val.chars().skip(off).take(len).collect()
1090                    } else {
1091                        String::new()
1092                    }
1093                } else if off <= char_count {
1094                    val.chars().skip(off).collect()
1095                } else {
1096                    String::new()
1097                };
1098                push_segment(words, &substr, in_dq, in_dq);
1099            }
1100            Ok(false)
1101        }
1102        // All other expressions delegate to immutable
1103        other => expand_parameter(other, words, state, in_dq),
1104    }
1105}
1106
1107/// Resolve a parameter with possible mutation (e.g. $RANDOM uses next_random).
1108/// Returns Result to propagate circular nameref errors.
1109fn resolve_parameter_maybe_mut(
1110    parameter: &Parameter,
1111    state: &mut InterpreterState,
1112    indirect: bool,
1113) -> Result<String, RustBashError> {
1114    // Check for circular namerefs on Named parameters.
1115    if let Parameter::Named(name) = parameter
1116        && let Err(_) = crate::interpreter::resolve_nameref(name, state)
1117    {
1118        // Circular nameref: set exit code 1, return empty
1119        // (bash prints a warning to stderr here — we silently fail to avoid
1120        // bypassing VFS with eprintln!)
1121        state.last_exit_code = 1;
1122        return Ok(String::new());
1123    }
1124    let val = match parameter {
1125        Parameter::Named(name) if name == "RANDOM" => next_random(state).to_string(),
1126        Parameter::NamedWithIndex { name, index } => resolve_array_element_mut(name, index, state)?,
1127        _ => resolve_parameter_direct(parameter, state),
1128    };
1129    if indirect {
1130        Ok(resolve_indirect_value(&val, state))
1131    } else {
1132        Ok(val)
1133    }
1134}
1135
1136// ── $@ / $* expansion ───────────────────────────────────────────────
1137
1138/// Expand a parameter value into word segments, handling $@ and $* split semantics.
1139/// Returns `true` if this was a `$@` expansion with zero positional params.
1140fn expand_param_value(
1141    val: &str,
1142    words: &mut Vec<WordInProgress>,
1143    state: &InterpreterState,
1144    in_dq: bool,
1145    parameter: &Parameter,
1146) -> bool {
1147    match parameter {
1148        Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) => {
1149            if *concatenate {
1150                // $* — join with first char of IFS.
1151                // IFS unset → default space; IFS="" → no separator.
1152                let ifs_val = get_var(state, "IFS");
1153                let ifs_empty = matches!(&ifs_val, Some(s) if s.is_empty());
1154                if !in_dq && ifs_empty {
1155                    // Unquoted $* with IFS='': each param is a separate word (like $@)
1156                    if state.positional_params.is_empty() {
1157                        return true;
1158                    }
1159                    for (i, param) in state.positional_params.iter().enumerate() {
1160                        if i > 0 {
1161                            start_new_word(words);
1162                        }
1163                        push_segment(words, param, false, false);
1164                    }
1165                    return false;
1166                }
1167                let sep = match ifs_val {
1168                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
1169                    None => " ".to_string(),
1170                };
1171                let joined = state.positional_params.join(&sep);
1172                push_segment(words, &joined, in_dq, in_dq);
1173                false
1174            } else if state.positional_params.is_empty() {
1175                // $@ with zero params — signal to DQ handler to not create empty word.
1176                true
1177            } else {
1178                // $@ — each positional parameter becomes a separate word.
1179                // In double quotes ("$@"): each param is a quoted word.
1180                // Outside quotes ($@): each param is an unquoted word (subject to IFS split).
1181                for (i, param) in state.positional_params.iter().enumerate() {
1182                    if i > 0 {
1183                        start_new_word(words);
1184                    }
1185                    push_segment(words, param, in_dq, in_dq);
1186                }
1187                false
1188            }
1189        }
1190        Parameter::NamedWithAllIndices { name, concatenate } => {
1191            let values = get_array_values(name, state);
1192            if *concatenate {
1193                // ${arr[*]} — join with first char of IFS
1194                let ifs_val = get_var(state, "IFS");
1195                let ifs_empty = matches!(&ifs_val, Some(s) if s.is_empty());
1196                if !in_dq && ifs_empty {
1197                    // Unquoted ${arr[*]} with IFS='': each element separate (like ${arr[@]})
1198                    if values.is_empty() {
1199                        return true;
1200                    }
1201                    for (i, v) in values.iter().enumerate() {
1202                        if i > 0 {
1203                            start_new_word(words);
1204                        }
1205                        push_segment(words, v, false, false);
1206                    }
1207                    return false;
1208                }
1209                let sep = match ifs_val {
1210                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
1211                    None => " ".to_string(),
1212                };
1213                let joined = values.join(&sep);
1214                push_segment(words, &joined, in_dq, in_dq);
1215                false
1216            } else if values.is_empty() {
1217                // ${arr[@]} with zero elements — signal empty like $@
1218                true
1219            } else {
1220                // ${arr[@]} — each element becomes a separate word (in dq)
1221                for (i, v) in values.iter().enumerate() {
1222                    if i > 0 {
1223                        start_new_word(words);
1224                    }
1225                    push_segment(words, v, in_dq, in_dq);
1226                }
1227                false
1228            }
1229        }
1230        _ => {
1231            push_segment(words, val, in_dq, in_dq);
1232            false
1233        }
1234    }
1235}
1236
1237// ── IFS word splitting ──────────────────────────────────────────────
1238
1239/// Get the IFS value from state, defaulting to space+tab+newline.
1240fn get_ifs(state: &InterpreterState) -> String {
1241    get_var(state, "IFS").unwrap_or_else(|| " \t\n".to_string())
1242}
1243
1244/// A word after IFS splitting, carrying glob eligibility metadata.
1245struct SplitWord {
1246    text: String,
1247    /// True if the word may contain unquoted glob metacharacters.
1248    may_glob: bool,
1249}
1250
1251/// Finalize expanded words by performing IFS splitting on unquoted segments.
1252fn finalize_with_ifs_split(words: Vec<WordInProgress>, state: &InterpreterState) -> Vec<SplitWord> {
1253    let ifs = get_ifs(state);
1254    let extglob = state.shopt_opts.extglob;
1255    let mut result = Vec::new();
1256    for word in words {
1257        ifs_split_word(&word, &ifs, &mut result);
1258    }
1259    // When extglob is enabled, mark words containing extglob syntax as glob-eligible
1260    if extglob {
1261        for w in &mut result {
1262            if !w.may_glob && has_extglob_pattern(&w.text) {
1263                w.may_glob = true;
1264            }
1265        }
1266    }
1267    result
1268}
1269
1270/// Finalize expanded words by concatenating segments without IFS splitting.
1271fn finalize_no_split(words: Vec<WordInProgress>) -> Vec<String> {
1272    words
1273        .into_iter()
1274        .map(|segments| segments.into_iter().map(|s| s.text).collect::<String>())
1275        .collect()
1276}
1277
1278/// Check whether a character is a glob metacharacter.
1279fn is_glob_meta(c: char) -> bool {
1280    matches!(c, '*' | '?' | '[')
1281}
1282
1283/// Check whether a string contains extglob syntax like `@(`, `+(`, `*(`, `?(`, `!(`.
1284fn has_extglob_pattern(s: &str) -> bool {
1285    let b = s.as_bytes();
1286    let mut i = 0;
1287    while i + 1 < b.len() {
1288        if b[i] == b'\\' {
1289            i += 2;
1290            continue;
1291        }
1292        if matches!(b[i], b'@' | b'+' | b'*' | b'?' | b'!') && b[i + 1] == b'(' {
1293            return true;
1294        }
1295        i += 1;
1296    }
1297    false
1298}
1299
1300/// IFS-split a single expanded word (represented as segments) into result words.
1301///
1302/// The algorithm flattens segments to character-level quotedness, then scans
1303/// through splitting only on unquoted IFS characters.
1304fn ifs_split_word(word: &[Segment], ifs: &str, result: &mut Vec<SplitWord>) {
1305    // Flatten segments to (char, quoted, glob_protected) triples.
1306    let chars: Vec<(char, bool, bool)> = word
1307        .iter()
1308        .flat_map(|s| s.text.chars().map(move |c| (c, s.quoted, s.glob_protected)))
1309        .collect();
1310
1311    if chars.is_empty() {
1312        // An empty word with at least one quoted segment → produce one empty word.
1313        if word.iter().any(|s| s.quoted) {
1314            result.push(SplitWord {
1315                text: String::new(),
1316                may_glob: false,
1317            });
1318        }
1319        return;
1320    }
1321
1322    // Fast path: entirely quoted → single word, no splitting.
1323    if chars.iter().all(|(_, q, _)| *q) {
1324        let s: String = chars.iter().map(|(c, _, _)| c).collect();
1325        let may_glob = chars.iter().any(|(c, _, gp)| !gp && is_glob_meta(*c));
1326        result.push(SplitWord { text: s, may_glob });
1327        return;
1328    }
1329
1330    // Classify IFS characters.
1331    let ifs_ws: Vec<char> = ifs
1332        .chars()
1333        .filter(|c| matches!(c, ' ' | '\t' | '\n'))
1334        .collect();
1335    let ifs_non_ws: Vec<char> = ifs
1336        .chars()
1337        .filter(|c| !matches!(c, ' ' | '\t' | '\n'))
1338        .collect();
1339
1340    let is_ifs_ws = |c: char| ifs_ws.contains(&c);
1341    let is_ifs_nw = |c: char| ifs_non_ws.contains(&c);
1342
1343    let len = chars.len();
1344    let mut current = String::new();
1345    let mut current_may_glob = false;
1346    let mut has_content = false;
1347    let mut i = 0;
1348
1349    // Skip leading unquoted IFS whitespace.
1350    while i < len {
1351        let (c, quoted, _) = chars[i];
1352        if !quoted && is_ifs_ws(c) {
1353            i += 1;
1354        } else {
1355            break;
1356        }
1357    }
1358
1359    while i < len {
1360        let (c, quoted, glob_protected) = chars[i];
1361        if quoted {
1362            current.push(c);
1363            if !glob_protected && is_glob_meta(c) {
1364                current_may_glob = true;
1365            }
1366            has_content = true;
1367            i += 1;
1368        } else if is_ifs_nw(c) {
1369            // Non-whitespace IFS delimiter: always produces a field boundary.
1370            result.push(SplitWord {
1371                text: std::mem::take(&mut current),
1372                may_glob: current_may_glob,
1373            });
1374            current_may_glob = false;
1375            has_content = false;
1376            i += 1;
1377            // Skip trailing IFS whitespace after delimiter.
1378            while i < len && !chars[i].1 && is_ifs_ws(chars[i].0) {
1379                i += 1;
1380            }
1381        } else if is_ifs_ws(c) {
1382            // Run of unquoted IFS whitespace.
1383            while i < len && !chars[i].1 && is_ifs_ws(chars[i].0) {
1384                i += 1;
1385            }
1386            // If followed by unquoted non-ws IFS char, this ws is absorbed into that delimiter.
1387            if i < len && !chars[i].1 && is_ifs_nw(chars[i].0) {
1388                continue;
1389            }
1390            // Standalone whitespace delimiter.
1391            if has_content || !current.is_empty() {
1392                result.push(SplitWord {
1393                    text: std::mem::take(&mut current),
1394                    may_glob: current_may_glob,
1395                });
1396                current_may_glob = false;
1397                has_content = false;
1398            }
1399        } else {
1400            // Regular character (not IFS).
1401            current.push(c);
1402            if !glob_protected && is_glob_meta(c) {
1403                current_may_glob = true;
1404            }
1405            has_content = true;
1406            i += 1;
1407        }
1408    }
1409
1410    // Push the last field if non-empty. Trailing non-whitespace IFS delimiters
1411    // do NOT produce a trailing empty field (bash behavior).
1412    if has_content || !current.is_empty() {
1413        result.push(SplitWord {
1414            text: current,
1415            may_glob: current_may_glob,
1416        });
1417    }
1418}
1419
1420// ── Glob expansion ──────────────────────────────────────────────────
1421
1422use std::path::PathBuf;
1423
1424/// Expand glob metacharacters in words against the filesystem.
1425///
1426/// For each word marked `may_glob`, attempt filesystem glob expansion.
1427/// Behavior depends on shopt options: nullglob, failglob, dotglob,
1428/// nocaseglob, and globstar. When `set -f` (noglob) is active, all
1429/// glob expansion is skipped and patterns pass through as literals.
1430fn glob_expand_words(
1431    words: Vec<SplitWord>,
1432    state: &InterpreterState,
1433) -> Result<Vec<String>, RustBashError> {
1434    // noglob: skip all filename expansion
1435    if state.shell_opts.noglob {
1436        return Ok(words.into_iter().map(|w| w.text).collect());
1437    }
1438
1439    let cwd = PathBuf::from(&state.cwd);
1440    let max = state.limits.max_glob_results;
1441    let opts = GlobOptions {
1442        dotglob: state.shopt_opts.dotglob,
1443        nocaseglob: state.shopt_opts.nocaseglob,
1444        globstar: state.shopt_opts.globstar,
1445        extglob: state.shopt_opts.extglob,
1446    };
1447
1448    // Parse GLOBIGNORE patterns (colon-separated list)
1449    let globignore_patterns: Vec<String> = get_var(state, "GLOBIGNORE")
1450        .filter(|s| !s.is_empty())
1451        .map(|s| s.split(':').map(String::from).collect())
1452        .unwrap_or_default();
1453    let has_globignore = !globignore_patterns.is_empty();
1454
1455    let mut result = Vec::new();
1456
1457    for w in words {
1458        if !w.may_glob {
1459            result.push(w.text);
1460            continue;
1461        }
1462
1463        match state.fs.glob_with_opts(&w.text, &cwd, &opts) {
1464            Ok(matches) if !matches.is_empty() => {
1465                if matches.len() > max {
1466                    return Err(RustBashError::LimitExceeded {
1467                        limit_name: "max_glob_results",
1468                        limit_value: max,
1469                        actual_value: matches.len(),
1470                    });
1471                }
1472                let before_len = result.len();
1473                for p in &matches {
1474                    let s = p.to_string_lossy().into_owned();
1475                    // Apply GLOBIGNORE filtering
1476                    if has_globignore {
1477                        let basename = s.rsplit('/').next().unwrap_or(&s);
1478                        // When GLOBIGNORE is set, . and .. are automatically excluded
1479                        if basename == "." || basename == ".." {
1480                            continue;
1481                        }
1482                        // Match GLOBIGNORE patterns against the full path
1483                        if globignore_patterns
1484                            .iter()
1485                            .any(|pat| pattern::glob_match_path(pat, &s))
1486                        {
1487                            continue;
1488                        }
1489                    }
1490                    result.push(s);
1491                }
1492                // When GLOBIGNORE filters ALL matches, treat as no-match
1493                if has_globignore && result.len() == before_len {
1494                    if state.shopt_opts.failglob {
1495                        return Err(RustBashError::FailGlob {
1496                            pattern: w.text.clone(),
1497                        });
1498                    }
1499                    if state.shopt_opts.nullglob {
1500                        continue;
1501                    }
1502                    result.push(w.text.clone());
1503                }
1504            }
1505            _ => {
1506                if state.shopt_opts.failglob {
1507                    return Err(RustBashError::FailGlob {
1508                        pattern: w.text.clone(),
1509                    });
1510                }
1511                if state.shopt_opts.nullglob {
1512                    // nullglob: pattern expands to nothing
1513                    continue;
1514                }
1515                // Default: keep pattern as literal
1516                result.push(w.text);
1517            }
1518        }
1519    }
1520
1521    Ok(result)
1522}
1523
1524// ── Transform / case helpers ────────────────────────────────────────
1525
1526use brush_parser::word::ParameterTransformOp;
1527
1528fn apply_transform(
1529    val: &str,
1530    op: &ParameterTransformOp,
1531    var_name: &str,
1532    state: &InterpreterState,
1533) -> String {
1534    match op {
1535        ParameterTransformOp::ToUpperCase => val.to_uppercase(),
1536        ParameterTransformOp::ToLowerCase => val.to_lowercase(),
1537        ParameterTransformOp::CapitalizeInitial => uppercase_first(val),
1538        ParameterTransformOp::Quoted => shell_quote(val),
1539        ParameterTransformOp::ExpandEscapeSequences => expand_escape_sequences(val),
1540        ParameterTransformOp::PromptExpand => expand_prompt_sequences(val, state),
1541        ParameterTransformOp::PossiblyQuoteWithArraysExpanded { .. } => shell_quote(val),
1542        ParameterTransformOp::ToAssignmentLogic => format_assignment(var_name, state),
1543        ParameterTransformOp::ToAttributeFlags => format_attribute_flags(var_name, state),
1544    }
1545}
1546
1547/// Shell-quote a value so it can be safely reused as input (@Q).
1548/// Empty strings → `''`. Strings without single quotes → `'val'`.
1549/// Strings with single quotes → `$'...'` with escaping.
1550fn shell_quote(val: &str) -> String {
1551    if val.is_empty() {
1552        return "''".to_string();
1553    }
1554    // Use $'...' if the string contains single quotes or non-printable chars
1555    let needs_dollar_quote = val.chars().any(|c| c == '\'' || c.is_ascii_control());
1556    if !needs_dollar_quote {
1557        return format!("'{val}'");
1558    }
1559    // Use $'...' notation for strings with single quotes
1560    let mut out = String::from("$'");
1561    for ch in val.chars() {
1562        match ch {
1563            '\'' => out.push_str("\\'"),
1564            '\\' => out.push_str("\\\\"),
1565            '\n' => out.push_str("\\n"),
1566            '\t' => out.push_str("\\t"),
1567            '\r' => out.push_str("\\r"),
1568            '\x07' => out.push_str("\\a"),
1569            '\x08' => out.push_str("\\b"),
1570            '\x0C' => out.push_str("\\f"),
1571            '\x0B' => out.push_str("\\v"),
1572            '\x1B' => out.push_str("\\E"),
1573            c if c.is_ascii_control() => {
1574                out.push_str(&format!("\\x{:02x}", c as u32));
1575            }
1576            c => out.push(c),
1577        }
1578    }
1579    out.push('\'');
1580    out
1581}
1582
1583/// Expand backslash escape sequences in a string (@E).
1584fn expand_escape_sequences(val: &str) -> String {
1585    let mut result = String::new();
1586    let chars: Vec<char> = val.chars().collect();
1587    let mut i = 0;
1588    while i < chars.len() {
1589        if chars[i] == '\\' && i + 1 < chars.len() {
1590            i += 1;
1591            match chars[i] {
1592                'n' => result.push('\n'),
1593                't' => result.push('\t'),
1594                'r' => result.push('\r'),
1595                'a' => result.push('\x07'),
1596                'b' => result.push('\x08'),
1597                'f' => result.push('\x0C'),
1598                'v' => result.push('\x0B'),
1599                'e' | 'E' => result.push('\x1B'),
1600                '\\' => result.push('\\'),
1601                '\'' => result.push('\''),
1602                '"' => result.push('"'),
1603                'x' => {
1604                    // \xHH — hex escape
1605                    let mut hex = String::new();
1606                    while hex.len() < 2 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
1607                        i += 1;
1608                        hex.push(chars[i]);
1609                    }
1610                    if hex.is_empty() {
1611                        // No hex digits followed — preserve as literal \x
1612                        result.push('\\');
1613                        result.push('x');
1614                    } else if let Ok(n) = u32::from_str_radix(&hex, 16)
1615                        && let Some(c) = char::from_u32(n)
1616                    {
1617                        result.push(c);
1618                    }
1619                    // Invalid codepoints (e.g. surrogates \uD800) silently produce nothing, matching bash.
1620                }
1621                'u' => {
1622                    // \uHHHH — unicode escape (up to 4 hex digits)
1623                    let mut hex = String::new();
1624                    while hex.len() < 4 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
1625                        i += 1;
1626                        hex.push(chars[i]);
1627                    }
1628                    if hex.is_empty() {
1629                        result.push('\\');
1630                        result.push('u');
1631                    } else if let Ok(n) = u32::from_str_radix(&hex, 16)
1632                        && let Some(c) = char::from_u32(n)
1633                    {
1634                        result.push(c);
1635                    }
1636                }
1637                'U' => {
1638                    // \UHHHHHHHH — unicode escape (up to 8 hex digits)
1639                    let mut hex = String::new();
1640                    while hex.len() < 8 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
1641                        i += 1;
1642                        hex.push(chars[i]);
1643                    }
1644                    if hex.is_empty() {
1645                        result.push('\\');
1646                        result.push('U');
1647                    } else if let Ok(n) = u32::from_str_radix(&hex, 16)
1648                        && let Some(c) = char::from_u32(n)
1649                    {
1650                        result.push(c);
1651                    }
1652                }
1653                '0'..='7' => {
1654                    // Octal escape: \0NNN (leading zero, up to 3 more digits)
1655                    // or \NNN (1-7, up to 2 more digits)
1656                    let first_digit = chars[i].to_digit(8).unwrap_or(0);
1657                    let max_extra = if chars[i] == '0' { 3 } else { 2 };
1658                    let mut val_octal = first_digit;
1659                    let mut count = 0;
1660                    while count < max_extra
1661                        && i + 1 < chars.len()
1662                        && chars[i + 1] >= '0'
1663                        && chars[i + 1] <= '7'
1664                    {
1665                        i += 1;
1666                        val_octal = val_octal * 8 + chars[i].to_digit(8).unwrap_or(0);
1667                        count += 1;
1668                    }
1669                    if let Some(c) = char::from_u32(val_octal) {
1670                        result.push(c);
1671                    }
1672                }
1673                other => {
1674                    result.push('\\');
1675                    result.push(other);
1676                }
1677            }
1678        } else {
1679            result.push(chars[i]);
1680        }
1681        i += 1;
1682    }
1683    result
1684}
1685
1686/// Expand prompt escape sequences (@P).
1687fn expand_prompt_sequences(val: &str, state: &InterpreterState) -> String {
1688    let mut result = String::new();
1689    let chars: Vec<char> = val.chars().collect();
1690    let mut i = 0;
1691    while i < chars.len() {
1692        if chars[i] == '\\' && i + 1 < chars.len() {
1693            i += 1;
1694            match chars[i] {
1695                'u' => {
1696                    result.push_str(&get_var(state, "USER").unwrap_or_else(|| "user".to_string()));
1697                }
1698                'h' => {
1699                    let hostname =
1700                        get_var(state, "HOSTNAME").unwrap_or_else(|| "localhost".to_string());
1701                    // \h is short hostname (up to first dot)
1702                    result.push_str(hostname.split('.').next().unwrap_or(&hostname));
1703                }
1704                'H' => {
1705                    result.push_str(
1706                        &get_var(state, "HOSTNAME").unwrap_or_else(|| "localhost".to_string()),
1707                    );
1708                }
1709                'w' => {
1710                    let cwd = &state.cwd;
1711                    let home = get_var(state, "HOME").unwrap_or_default();
1712                    if !home.is_empty() && cwd.starts_with(&home) {
1713                        result.push('~');
1714                        result.push_str(&cwd[home.len()..]);
1715                    } else {
1716                        result.push_str(cwd);
1717                    }
1718                }
1719                'W' => {
1720                    let cwd = &state.cwd;
1721                    if cwd == "/" {
1722                        result.push('/');
1723                    } else {
1724                        result.push_str(cwd.rsplit('/').next().unwrap_or(cwd));
1725                    }
1726                }
1727                'd' => {
1728                    // \d — "Weekday Month Day" in current locale
1729                    result.push_str("Mon Jan 01");
1730                }
1731                't' => {
1732                    // \t — HH:MM:SS (24-hour)
1733                    result.push_str("00:00:00");
1734                }
1735                'T' => {
1736                    // \T — HH:MM:SS (12-hour)
1737                    result.push_str("12:00:00");
1738                }
1739                '@' => {
1740                    // \@ — HH:MM AM/PM
1741                    result.push_str("12:00 AM");
1742                }
1743                'A' => {
1744                    // \A — HH:MM (24-hour)
1745                    result.push_str("00:00");
1746                }
1747                'n' => result.push('\n'),
1748                'r' => result.push('\r'),
1749                'a' => result.push('\x07'),
1750                'e' => result.push('\x1B'),
1751                's' => {
1752                    result.push_str(&state.shell_name);
1753                }
1754                'v' | 'V' => {
1755                    result.push_str("5.0");
1756                }
1757                '#' => {
1758                    result.push_str(&state.counters.command_count.to_string());
1759                }
1760                '$' => {
1761                    // \$ — '#' if uid is 0, else '$'
1762                    result.push('$');
1763                }
1764                '[' | ']' => {
1765                    // Non-printing character delimiters — empty in output
1766                }
1767                '\\' => result.push('\\'),
1768                other => {
1769                    result.push('\\');
1770                    result.push(other);
1771                }
1772            }
1773        } else {
1774            result.push(chars[i]);
1775        }
1776        i += 1;
1777    }
1778    result
1779}
1780
1781/// Format a variable as an assignment statement (@A).
1782fn format_assignment(name: &str, state: &InterpreterState) -> String {
1783    use crate::interpreter::{VariableAttrs, VariableValue};
1784    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
1785    let var = match state.env.get(&resolved) {
1786        Some(v) => v,
1787        None => return String::new(),
1788    };
1789
1790    let mut flags = String::from("declare ");
1791    let mut flag_chars = String::new();
1792    match &var.value {
1793        VariableValue::IndexedArray(_) => flag_chars.push('a'),
1794        VariableValue::AssociativeArray(_) => flag_chars.push('A'),
1795        VariableValue::Scalar(_) => {}
1796    }
1797    if var.attrs.contains(VariableAttrs::INTEGER) {
1798        flag_chars.push('i');
1799    }
1800    if var.attrs.contains(VariableAttrs::LOWERCASE) {
1801        flag_chars.push('l');
1802    }
1803    if var.attrs.contains(VariableAttrs::NAMEREF) {
1804        flag_chars.push('n');
1805    }
1806    if var.attrs.contains(VariableAttrs::READONLY) {
1807        flag_chars.push('r');
1808    }
1809    if var.attrs.contains(VariableAttrs::UPPERCASE) {
1810        flag_chars.push('u');
1811    }
1812    if var.attrs.contains(VariableAttrs::EXPORTED) {
1813        flag_chars.push('x');
1814    }
1815
1816    if flag_chars.is_empty() {
1817        flags.push_str("-- ");
1818    } else {
1819        flags.push('-');
1820        flags.push_str(&flag_chars);
1821        flags.push(' ');
1822    }
1823
1824    match &var.value {
1825        VariableValue::Scalar(s) => {
1826            format!("{flags}{resolved}='{s}'")
1827        }
1828        VariableValue::IndexedArray(map) => {
1829            let elements: Vec<String> = map.iter().map(|(k, v)| format!("[{k}]=\"{v}\"")).collect();
1830            format!("{flags}{resolved}=({})", elements.join(" "))
1831        }
1832        VariableValue::AssociativeArray(map) => {
1833            let mut keys: Vec<&String> = map.keys().collect();
1834            keys.sort();
1835            let elements: Vec<String> = keys
1836                .iter()
1837                .map(|k| format!("[{k}]=\"{}\"", map[*k]))
1838                .collect();
1839            format!("{flags}{resolved}=({})", elements.join(" "))
1840        }
1841    }
1842}
1843
1844/// Return attribute flags as a string (@a).
1845fn format_attribute_flags(name: &str, state: &InterpreterState) -> String {
1846    use crate::interpreter::{VariableAttrs, VariableValue};
1847    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
1848    let var = match state.env.get(&resolved) {
1849        Some(v) => v,
1850        None => return String::new(),
1851    };
1852    let mut flags = String::new();
1853    match &var.value {
1854        VariableValue::IndexedArray(_) => flags.push('a'),
1855        VariableValue::AssociativeArray(_) => flags.push('A'),
1856        VariableValue::Scalar(_) => {}
1857    }
1858    if var.attrs.contains(VariableAttrs::INTEGER) {
1859        flags.push('i');
1860    }
1861    if var.attrs.contains(VariableAttrs::LOWERCASE) {
1862        flags.push('l');
1863    }
1864    if var.attrs.contains(VariableAttrs::NAMEREF) {
1865        flags.push('n');
1866    }
1867    if var.attrs.contains(VariableAttrs::READONLY) {
1868        flags.push('r');
1869    }
1870    if var.attrs.contains(VariableAttrs::UPPERCASE) {
1871        flags.push('u');
1872    }
1873    if var.attrs.contains(VariableAttrs::EXPORTED) {
1874        flags.push('x');
1875    }
1876    flags
1877}
1878
1879fn uppercase_first(s: &str) -> String {
1880    let mut chars = s.chars();
1881    match chars.next() {
1882        None => String::new(),
1883        Some(c) => {
1884            let mut result = c.to_uppercase().to_string();
1885            result.extend(chars);
1886            result
1887        }
1888    }
1889}
1890
1891fn lowercase_first(s: &str) -> String {
1892    let mut chars = s.chars();
1893    match chars.next() {
1894        None => String::new(),
1895        Some(c) => {
1896            let mut result = c.to_lowercase().to_string();
1897            result.extend(chars);
1898            result
1899        }
1900    }
1901}
1902
1903// ── Parameter resolution ────────────────────────────────────────────
1904
1905/// Check if `set -u` (nounset) should produce an error for this parameter.
1906/// Returns an error if nounset is enabled and the parameter is unset.
1907/// Special parameters ($@, $*, $#, $?, etc.) are always exempt.
1908fn check_nounset(parameter: &Parameter, state: &InterpreterState) -> Result<(), RustBashError> {
1909    if !state.shell_opts.nounset {
1910        return Ok(());
1911    }
1912    // Special parameters are always OK
1913    if matches!(parameter, Parameter::Special(_)) {
1914        return Ok(());
1915    }
1916    if is_unset(state, parameter) {
1917        let name = parameter_name(parameter);
1918        return Err(RustBashError::Execution(format!(
1919            "{name}: unbound variable"
1920        )));
1921    }
1922    Ok(())
1923}
1924
1925/// Reject invalid parameter names like `${%}`.
1926/// Bash reports "bad substitution" for these.
1927fn validate_parameter_name(parameter: &Parameter) -> Result<(), RustBashError> {
1928    if let Parameter::Named(name) = parameter
1929        && (name.is_empty()
1930            || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1931            || name.starts_with(|c: char| c.is_ascii_digit()))
1932    {
1933        return Err(RustBashError::Execution(format!(
1934            "${{{name}}}: bad substitution"
1935        )));
1936    }
1937    Ok(())
1938}
1939
1940/// Extract the parameter from any ParameterExpr variant and validate it.
1941fn validate_expr_parameter(expr: &ParameterExpr) -> Result<(), RustBashError> {
1942    let param = match expr {
1943        ParameterExpr::Parameter { parameter, .. }
1944        | ParameterExpr::UseDefaultValues { parameter, .. }
1945        | ParameterExpr::AssignDefaultValues { parameter, .. }
1946        | ParameterExpr::IndicateErrorIfNullOrUnset { parameter, .. }
1947        | ParameterExpr::UseAlternativeValue { parameter, .. }
1948        | ParameterExpr::ParameterLength { parameter, .. }
1949        | ParameterExpr::RemoveSmallestSuffixPattern { parameter, .. }
1950        | ParameterExpr::RemoveLargestSuffixPattern { parameter, .. }
1951        | ParameterExpr::RemoveSmallestPrefixPattern { parameter, .. }
1952        | ParameterExpr::RemoveLargestPrefixPattern { parameter, .. }
1953        | ParameterExpr::Substring { parameter, .. }
1954        | ParameterExpr::UppercaseFirstChar { parameter, .. }
1955        | ParameterExpr::UppercasePattern { parameter, .. }
1956        | ParameterExpr::LowercaseFirstChar { parameter, .. }
1957        | ParameterExpr::LowercasePattern { parameter, .. }
1958        | ParameterExpr::ReplaceSubstring { parameter, .. }
1959        | ParameterExpr::Transform { parameter, .. } => parameter,
1960        ParameterExpr::VariableNames { .. } | ParameterExpr::MemberKeys { .. } => return Ok(()),
1961    };
1962    validate_parameter_name(param)
1963}
1964
1965fn resolve_parameter(parameter: &Parameter, state: &InterpreterState, indirect: bool) -> String {
1966    let val = resolve_parameter_direct(parameter, state);
1967    if indirect {
1968        resolve_indirect_value(&val, state)
1969    } else {
1970        val
1971    }
1972}
1973
1974/// Given a string that is the value of `${!ref}`, resolve it as a variable reference.
1975/// Handles: simple names, `arr[idx]`, positional params (`1`, `2`), and special (`@`, `*`).
1976fn resolve_indirect_value(target: &str, state: &InterpreterState) -> String {
1977    if target.is_empty() {
1978        return String::new();
1979    }
1980    // Check for array subscript: name[index]
1981    if let Some(bracket_pos) = target.find('[')
1982        && target.ends_with(']')
1983    {
1984        let name = &target[..bracket_pos];
1985        let index_raw = &target[bracket_pos + 1..target.len() - 1];
1986        if index_raw == "@" || index_raw == "*" {
1987            // ${!ref} where ref=arr[@] or ref=arr[*]
1988            let concatenate = index_raw == "*";
1989            return resolve_all_elements(name, concatenate, state);
1990        }
1991        // Expand simple $var references in the index.
1992        let index = expand_simple_dollar_vars(index_raw, state);
1993        return resolve_array_element(name, &index, state);
1994    }
1995    // Check for positional parameter (numeric string)
1996    if let Ok(n) = target.parse::<u32>() {
1997        if n == 0 {
1998            return state.shell_name.clone();
1999        }
2000        return state
2001            .positional_params
2002            .get(n as usize - 1)
2003            .cloned()
2004            .unwrap_or_default();
2005    }
2006    // Check for special parameters
2007    match target {
2008        "@" => state.positional_params.join(" "),
2009        "*" => {
2010            let sep = match get_var(state, "IFS") {
2011                Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
2012                None => " ".to_string(),
2013            };
2014            state.positional_params.join(&sep)
2015        }
2016        "#" => state.positional_params.len().to_string(),
2017        "?" => state.last_exit_code.to_string(),
2018        "-" => String::new(),
2019        "$" => "1".to_string(),
2020        "!" => String::new(),
2021        _ => get_var(state, target).unwrap_or_default(),
2022    }
2023}
2024
2025fn resolve_parameter_direct(parameter: &Parameter, state: &InterpreterState) -> String {
2026    match parameter {
2027        Parameter::Named(name) => resolve_named_var(name, state),
2028        Parameter::Positional(n) => {
2029            if *n == 0 {
2030                state.shell_name.clone()
2031            } else {
2032                state
2033                    .positional_params
2034                    .get(*n as usize - 1)
2035                    .cloned()
2036                    .unwrap_or_default()
2037            }
2038        }
2039        Parameter::Special(sp) => resolve_special(sp, state),
2040        Parameter::NamedWithIndex { name, index } => resolve_array_element(name, index, state),
2041        Parameter::NamedWithAllIndices { name, concatenate } => {
2042            // For resolve_parameter_direct, join all values into a single string.
2043            // The actual multi-word expansion for [@] is handled in expand_param_value.
2044            resolve_all_elements(name, *concatenate, state)
2045        }
2046    }
2047}
2048
2049/// Strip surrounding quotes (single or double) from a string.
2050/// Used for associative array key lookups where `A["key"]` and `A['key']` should use `key`.
2051fn strip_quotes(s: &str) -> String {
2052    let s = s.trim();
2053    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
2054        s[1..s.len() - 1].to_string()
2055    } else {
2056        s.to_string()
2057    }
2058}
2059
2060/// Resolve `${arr[index]}` — look up a specific element of an array variable.
2061fn resolve_array_element(name: &str, index: &str, state: &InterpreterState) -> String {
2062    // Handle call-stack pseudo-arrays before checking env.
2063    if let Some(val) = resolve_call_stack_element(name, index, state) {
2064        return val;
2065    }
2066    use crate::interpreter::VariableValue;
2067    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2068    let Some(var) = state.env.get(&resolved) else {
2069        return String::new();
2070    };
2071    match &var.value {
2072        VariableValue::IndexedArray(map) => {
2073            let idx = simple_arith_eval(index, state);
2074            let actual_idx = if idx < 0 {
2075                let max_key = map.keys().next_back().copied().unwrap_or(0);
2076                let resolved = max_key as i64 + 1 + idx;
2077                if resolved < 0 {
2078                    return String::new();
2079                }
2080                resolved as usize
2081            } else {
2082                idx as usize
2083            };
2084            map.get(&actual_idx).cloned().unwrap_or_default()
2085        }
2086        VariableValue::AssociativeArray(map) => {
2087            let key = strip_quotes(index);
2088            map.get(&key).cloned().unwrap_or_default()
2089        }
2090        VariableValue::Scalar(s) => {
2091            let idx = simple_arith_eval(index, state);
2092            if idx == 0 || idx == -1 {
2093                s.clone()
2094            } else {
2095                String::new()
2096            }
2097        }
2098    }
2099}
2100
2101/// Mutable variant of `resolve_array_element` that can expand `$`-references
2102/// and evaluate full arithmetic expressions in the index (e.g. `${a[$i]}`,
2103/// `${a[i-4]}`, `${a[$(echo 1)]}`).
2104fn resolve_array_element_mut(
2105    name: &str,
2106    index: &str,
2107    state: &mut InterpreterState,
2108) -> Result<String, RustBashError> {
2109    // Handle call-stack pseudo-arrays before checking env.
2110    if let Some(val) = resolve_call_stack_element(name, index, state) {
2111        return Ok(val);
2112    }
2113    use crate::interpreter::VariableValue;
2114    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2115
2116    // Check if associative array — use key as string, not arithmetic.
2117    let is_assoc = state
2118        .env
2119        .get(&resolved)
2120        .is_some_and(|v| matches!(&v.value, VariableValue::AssociativeArray(_)));
2121
2122    if is_assoc {
2123        // Expand $-references in the key string, then strip quotes.
2124        let expanded = expand_arith_expression(index, state)?;
2125        let key = strip_quotes(&expanded);
2126        let val = state
2127            .env
2128            .get(&resolved)
2129            .and_then(|v| {
2130                if let VariableValue::AssociativeArray(map) = &v.value {
2131                    map.get(&key).cloned()
2132                } else {
2133                    None
2134                }
2135            })
2136            .unwrap_or_default();
2137        return Ok(val);
2138    }
2139
2140    // Indexed array or scalar: expand $-references then evaluate as arithmetic.
2141    let expanded = expand_arith_expression(index, state)?;
2142    let idx = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;
2143
2144    let val = state
2145        .env
2146        .get(&resolved)
2147        .map(|var| match &var.value {
2148            VariableValue::IndexedArray(map) => {
2149                let actual_idx = if idx < 0 {
2150                    let max_key = map.keys().next_back().copied().unwrap_or(0);
2151                    let resolved_idx = max_key as i64 + 1 + idx;
2152                    if resolved_idx < 0 {
2153                        return String::new();
2154                    }
2155                    resolved_idx as usize
2156                } else {
2157                    idx as usize
2158                };
2159                map.get(&actual_idx).cloned().unwrap_or_default()
2160            }
2161            VariableValue::Scalar(s) => {
2162                if idx == 0 || idx == -1 {
2163                    s.clone()
2164                } else {
2165                    String::new()
2166                }
2167            }
2168            _ => String::new(),
2169        })
2170        .unwrap_or_default();
2171    Ok(val)
2172}
2173
2174/// Resolve `${FUNCNAME[i]}`, `${BASH_SOURCE[i]}`, `${BASH_LINENO[i]}` from the call stack.
2175/// Returns `None` if `name` is not a call-stack array, so the caller falls through to env.
2176fn resolve_call_stack_element(name: &str, index: &str, state: &InterpreterState) -> Option<String> {
2177    match name {
2178        "FUNCNAME" | "BASH_SOURCE" | "BASH_LINENO" => {}
2179        _ => return None,
2180    }
2181    let raw_idx = simple_arith_eval(index, state);
2182    // The call stack is ordered innermost-last; bash indexes 0 = current (innermost).
2183    // Build a reversed view: index 0 = top of stack, last = bottom ("main").
2184    let len = state.call_stack.len();
2185    let idx = if raw_idx < 0 {
2186        let resolved = len as i64 + raw_idx;
2187        if resolved < 0 {
2188            return Some(String::new());
2189        }
2190        resolved as usize
2191    } else {
2192        raw_idx as usize
2193    };
2194    if idx >= len {
2195        return Some(String::new());
2196    }
2197    let frame_idx = len - 1 - idx;
2198    let frame = &state.call_stack[frame_idx];
2199    Some(match name {
2200        "FUNCNAME" => frame.func_name.clone(),
2201        "BASH_SOURCE" => frame.source.clone(),
2202        "BASH_LINENO" => frame.lineno.to_string(),
2203        _ => String::new(),
2204    })
2205}
2206
2207/// Simple arithmetic evaluation for array indices in immutable contexts.
2208/// Handles integer literals, variable names, and simple expressions.
2209pub(crate) fn simple_arith_eval(expr: &str, state: &InterpreterState) -> i64 {
2210    let trimmed = expr.trim();
2211    // Try as integer literal
2212    if let Ok(n) = trimmed.parse::<i64>() {
2213        return n;
2214    }
2215    // Try as variable name
2216    if trimmed
2217        .chars()
2218        .all(|c| c.is_ascii_alphanumeric() || c == '_')
2219    {
2220        return read_var_immutable(state, trimmed);
2221    }
2222    // For complex expressions, return 0 — full arithmetic eval requires &mut
2223    0
2224}
2225
2226/// Read a variable as i64 (immutable — for use in expansion.rs contexts).
2227fn read_var_immutable(state: &InterpreterState, name: &str) -> i64 {
2228    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2229    state
2230        .env
2231        .get(&resolved)
2232        .map(|v| v.value.as_scalar().parse::<i64>().unwrap_or(0))
2233        .unwrap_or(0)
2234}
2235
2236/// Resolve all elements of an array, joined into a single string.
2237/// `concatenate=true` → `[*]` (join with IFS[0]), `concatenate=false` → `[@]` (join with space).
2238fn resolve_all_elements(name: &str, concatenate: bool, state: &InterpreterState) -> String {
2239    // Handle call-stack pseudo-arrays.
2240    if let Some(vals) = get_call_stack_values(name, state) {
2241        let sep = if concatenate {
2242            match get_var(state, "IFS") {
2243                Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
2244                None => " ".to_string(),
2245            }
2246        } else {
2247            " ".to_string()
2248        };
2249        return vals.join(&sep);
2250    }
2251    use crate::interpreter::VariableValue;
2252    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2253    let Some(var) = state.env.get(&resolved) else {
2254        return String::new();
2255    };
2256    let values: Vec<&str> = match &var.value {
2257        VariableValue::IndexedArray(map) => map.values().map(|s| s.as_str()).collect(),
2258        VariableValue::AssociativeArray(map) => map.values().map(|s| s.as_str()).collect(),
2259        VariableValue::Scalar(s) => {
2260            if s.is_empty() {
2261                vec![]
2262            } else {
2263                vec![s.as_str()]
2264            }
2265        }
2266    };
2267    if concatenate {
2268        let sep = match get_var(state, "IFS") {
2269            Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
2270            None => " ".to_string(),
2271        };
2272        values.join(&sep)
2273    } else {
2274        values.join(" ")
2275    }
2276}
2277
2278/// Get all values of call-stack pseudo-arrays as a Vec of owned Strings.
2279/// Returns `None` if `name` is not a call-stack array.
2280fn get_call_stack_values(name: &str, state: &InterpreterState) -> Option<Vec<String>> {
2281    match name {
2282        "FUNCNAME" => Some(
2283            state
2284                .call_stack
2285                .iter()
2286                .rev()
2287                .map(|f| f.func_name.clone())
2288                .collect(),
2289        ),
2290        "BASH_SOURCE" => Some(
2291            state
2292                .call_stack
2293                .iter()
2294                .rev()
2295                .map(|f| f.source.clone())
2296                .collect(),
2297        ),
2298        "BASH_LINENO" => Some(
2299            state
2300                .call_stack
2301                .iter()
2302                .rev()
2303                .map(|f| f.lineno.to_string())
2304                .collect(),
2305        ),
2306        _ => None,
2307    }
2308}
2309
2310/// Returns the individual element values for a parameter if it represents an
2311/// array expansion (`[@]` or `[*]` or `$@` / `$*`).  Returns `None` for scalar
2312/// parameters so the caller can fall through to the normal scalar path.  When
2313/// `Some` is returned, the bool indicates whether the values should be
2314/// concatenated (`[*]` / `$*`) or kept separate (`[@]` / `$@`).
2315fn get_vectorized_values(
2316    parameter: &Parameter,
2317    state: &InterpreterState,
2318    indirect: bool,
2319) -> Option<(Vec<String>, bool)> {
2320    let _ = indirect; // indirect not yet relevant for array expansion
2321    match parameter {
2322        Parameter::NamedWithAllIndices { name, concatenate } => {
2323            Some((get_array_values(name, state), *concatenate))
2324        }
2325        Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) => {
2326            Some((state.positional_params.clone(), *concatenate))
2327        }
2328        _ => None,
2329    }
2330}
2331
2332/// Push vectorized operation results into `words`, handling `[@]` vs `[*]`
2333/// semantics (separate words vs IFS-joined).
2334fn push_vectorized(
2335    results: Vec<String>,
2336    concatenate: bool,
2337    words: &mut Vec<WordInProgress>,
2338    state: &InterpreterState,
2339    in_dq: bool,
2340) {
2341    if concatenate {
2342        let sep = match get_var(state, "IFS") {
2343            Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
2344            None => " ".to_string(),
2345        };
2346        let joined = results.join(&sep);
2347        push_segment(words, &joined, in_dq, in_dq);
2348    } else {
2349        for (i, v) in results.iter().enumerate() {
2350            if i > 0 {
2351                start_new_word(words);
2352            }
2353            push_segment(words, v, in_dq, in_dq);
2354        }
2355    }
2356}
2357
2358/// Get all values of an array variable as a Vec.
2359fn get_array_values(name: &str, state: &InterpreterState) -> Vec<String> {
2360    // Handle call-stack pseudo-arrays first.
2361    if let Some(vals) = get_call_stack_values(name, state) {
2362        return vals;
2363    }
2364    use crate::interpreter::VariableValue;
2365    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2366    let Some(var) = state.env.get(&resolved) else {
2367        return Vec::new();
2368    };
2369    match &var.value {
2370        VariableValue::IndexedArray(map) => map.values().cloned().collect(),
2371        VariableValue::AssociativeArray(map) => map.values().cloned().collect(),
2372        VariableValue::Scalar(s) => {
2373            if s.is_empty() {
2374                vec![]
2375            } else {
2376                vec![s.clone()]
2377            }
2378        }
2379    }
2380}
2381
2382/// Get (key, value) pairs from an array or positional parameters.
2383/// Keys are numeric indices cast to `usize` for indexed arrays and positional params.
2384/// Used by Substring/slice expansion to support sparse-array key-based offsets.
2385fn get_array_kv_pairs(parameter: &Parameter, state: &InterpreterState) -> Vec<(usize, String)> {
2386    match parameter {
2387        Parameter::NamedWithAllIndices { name, .. } => {
2388            if let Some(vals) = get_call_stack_values(name, state) {
2389                return vals.into_iter().enumerate().collect();
2390            }
2391            use crate::interpreter::VariableValue;
2392            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2393            let Some(var) = state.env.get(&resolved) else {
2394                return Vec::new();
2395            };
2396            match &var.value {
2397                VariableValue::IndexedArray(map) => {
2398                    map.iter().map(|(&k, v)| (k, v.clone())).collect()
2399                }
2400                VariableValue::AssociativeArray(map) => {
2401                    // Assoc arrays don't have meaningful numeric keys for slicing,
2402                    // but bash allows it — just use enumeration order.
2403                    map.values()
2404                        .enumerate()
2405                        .map(|(i, v)| (i, v.clone()))
2406                        .collect()
2407                }
2408                VariableValue::Scalar(s) => {
2409                    if s.is_empty() {
2410                        vec![]
2411                    } else {
2412                        vec![(0, s.clone())]
2413                    }
2414                }
2415            }
2416        }
2417        Parameter::Special(SpecialParameter::AllPositionalParameters { .. }) => state
2418            .positional_params
2419            .iter()
2420            .enumerate()
2421            .map(|(i, v)| (i, v.clone()))
2422            .collect(),
2423        _ => Vec::new(),
2424    }
2425}
2426
2427/// Get keys/indices of an array variable.
2428fn get_array_keys(name: &str, state: &InterpreterState) -> Vec<String> {
2429    // Handle call-stack pseudo-arrays first.
2430    if let Some(vals) = get_call_stack_values(name, state) {
2431        return (0..vals.len()).map(|i| i.to_string()).collect();
2432    }
2433    use crate::interpreter::VariableValue;
2434    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2435    let Some(var) = state.env.get(&resolved) else {
2436        return Vec::new();
2437    };
2438    match &var.value {
2439        VariableValue::IndexedArray(map) => map.keys().map(|k| k.to_string()).collect(),
2440        VariableValue::AssociativeArray(map) => map.keys().cloned().collect(),
2441        VariableValue::Scalar(s) => {
2442            if s.is_empty() {
2443                vec![]
2444            } else {
2445                vec!["0".to_string()]
2446            }
2447        }
2448    }
2449}
2450
2451fn resolve_named_var(name: &str, state: &InterpreterState) -> String {
2452    // $RANDOM is handled exclusively via the mutable path
2453    // (resolve_parameter_maybe_mut → next_random) to use a single PRNG.
2454    match name {
2455        "LINENO" => return state.current_lineno.to_string(),
2456        "SECONDS" => return state.shell_start_time.elapsed().as_secs().to_string(),
2457        "_" => return state.last_argument.clone(),
2458        "PPID" => {
2459            return get_var(state, "PPID").unwrap_or_else(|| "1".to_string());
2460        }
2461        "UID" => {
2462            return get_var(state, "UID").unwrap_or_else(|| "1000".to_string());
2463        }
2464        "EUID" => {
2465            return get_var(state, "EUID").unwrap_or_else(|| "1000".to_string());
2466        }
2467        "BASHPID" => {
2468            return get_var(state, "BASHPID").unwrap_or_else(|| "1".to_string());
2469        }
2470        "SHELLOPTS" => return compute_shellopts(state),
2471        "BASHOPTS" => return compute_bashopts(state),
2472        "MACHTYPE" => return state.machtype.clone(),
2473        "HOSTTYPE" => return state.hosttype.clone(),
2474        "FUNCNAME" | "BASH_SOURCE" | "BASH_LINENO" => {
2475            return resolve_call_stack_scalar(name, state);
2476        }
2477        _ => {}
2478    }
2479    get_var(state, name).unwrap_or_default()
2480}
2481
2482/// Compute `SHELLOPTS` — colon-separated list of enabled `set -o` options.
2483fn compute_shellopts(state: &InterpreterState) -> String {
2484    let mut opts = Vec::new();
2485    if state.shell_opts.allexport {
2486        opts.push("allexport");
2487    }
2488    // braceexpand is always on
2489    opts.push("braceexpand");
2490    if state.shell_opts.emacs_mode {
2491        opts.push("emacs");
2492    }
2493    if state.shell_opts.errexit {
2494        opts.push("errexit");
2495    }
2496    // hashall is always on
2497    opts.push("hashall");
2498    if state.shell_opts.noclobber {
2499        opts.push("noclobber");
2500    }
2501    if state.shell_opts.noexec {
2502        opts.push("noexec");
2503    }
2504    if state.shell_opts.noglob {
2505        opts.push("noglob");
2506    }
2507    if state.shell_opts.nounset {
2508        opts.push("nounset");
2509    }
2510    if state.shell_opts.pipefail {
2511        opts.push("pipefail");
2512    }
2513    if state.shell_opts.posix {
2514        opts.push("posix");
2515    }
2516    if state.shell_opts.verbose {
2517        opts.push("verbose");
2518    }
2519    if state.shell_opts.vi_mode {
2520        opts.push("vi");
2521    }
2522    if state.shell_opts.xtrace {
2523        opts.push("xtrace");
2524    }
2525    // Already in alphabetical order due to how we construct it
2526    opts.join(":")
2527}
2528
2529/// Compute `BASHOPTS` — colon-separated list of enabled `shopt` options.
2530fn compute_bashopts(state: &InterpreterState) -> String {
2531    let o = &state.shopt_opts;
2532    let mut opts = Vec::new();
2533    // Must be alphabetical order (bash convention)
2534    if o.assoc_expand_once {
2535        opts.push("assoc_expand_once");
2536    }
2537    if o.autocd {
2538        opts.push("autocd");
2539    }
2540    if o.cdable_vars {
2541        opts.push("cdable_vars");
2542    }
2543    if o.cdspell {
2544        opts.push("cdspell");
2545    }
2546    if o.checkhash {
2547        opts.push("checkhash");
2548    }
2549    if o.checkjobs {
2550        opts.push("checkjobs");
2551    }
2552    if o.checkwinsize {
2553        opts.push("checkwinsize");
2554    }
2555    if o.cmdhist {
2556        opts.push("cmdhist");
2557    }
2558    if o.complete_fullquote {
2559        opts.push("complete_fullquote");
2560    }
2561    if o.direxpand {
2562        opts.push("direxpand");
2563    }
2564    if o.dirspell {
2565        opts.push("dirspell");
2566    }
2567    if o.dotglob {
2568        opts.push("dotglob");
2569    }
2570    if o.execfail {
2571        opts.push("execfail");
2572    }
2573    if o.expand_aliases {
2574        opts.push("expand_aliases");
2575    }
2576    if o.extdebug {
2577        opts.push("extdebug");
2578    }
2579    if o.extglob {
2580        opts.push("extglob");
2581    }
2582    if o.extquote {
2583        opts.push("extquote");
2584    }
2585    if o.failglob {
2586        opts.push("failglob");
2587    }
2588    if o.force_fignore {
2589        opts.push("force_fignore");
2590    }
2591    if o.globasciiranges {
2592        opts.push("globasciiranges");
2593    }
2594    if o.globskipdots {
2595        opts.push("globskipdots");
2596    }
2597    if o.globstar {
2598        opts.push("globstar");
2599    }
2600    if o.gnu_errfmt {
2601        opts.push("gnu_errfmt");
2602    }
2603    if o.histappend {
2604        opts.push("histappend");
2605    }
2606    if o.histreedit {
2607        opts.push("histreedit");
2608    }
2609    if o.histverify {
2610        opts.push("histverify");
2611    }
2612    if o.hostcomplete {
2613        opts.push("hostcomplete");
2614    }
2615    if o.huponexit {
2616        opts.push("huponexit");
2617    }
2618    if o.inherit_errexit {
2619        opts.push("inherit_errexit");
2620    }
2621    if o.interactive_comments {
2622        opts.push("interactive_comments");
2623    }
2624    if o.lastpipe {
2625        opts.push("lastpipe");
2626    }
2627    if o.lithist {
2628        opts.push("lithist");
2629    }
2630    if o.localvar_inherit {
2631        opts.push("localvar_inherit");
2632    }
2633    if o.localvar_unset {
2634        opts.push("localvar_unset");
2635    }
2636    if o.login_shell {
2637        opts.push("login_shell");
2638    }
2639    if o.mailwarn {
2640        opts.push("mailwarn");
2641    }
2642    if o.no_empty_cmd_completion {
2643        opts.push("no_empty_cmd_completion");
2644    }
2645    if o.nocaseglob {
2646        opts.push("nocaseglob");
2647    }
2648    if o.nocasematch {
2649        opts.push("nocasematch");
2650    }
2651    if o.nullglob {
2652        opts.push("nullglob");
2653    }
2654    if o.patsub_replacement {
2655        opts.push("patsub_replacement");
2656    }
2657    if o.progcomp {
2658        opts.push("progcomp");
2659    }
2660    if o.progcomp_alias {
2661        opts.push("progcomp_alias");
2662    }
2663    if o.promptvars {
2664        opts.push("promptvars");
2665    }
2666    if o.shift_verbose {
2667        opts.push("shift_verbose");
2668    }
2669    if o.sourcepath {
2670        opts.push("sourcepath");
2671    }
2672    if o.varredir_close {
2673        opts.push("varredir_close");
2674    }
2675    if o.xpg_echo {
2676        opts.push("xpg_echo");
2677    }
2678    opts.join(":")
2679}
2680
2681/// Resolve `FUNCNAME`, `BASH_SOURCE`, or `BASH_LINENO` as a scalar
2682/// (returns value at index 0, i.e. current/innermost frame).
2683fn resolve_call_stack_scalar(name: &str, state: &InterpreterState) -> String {
2684    if state.call_stack.is_empty() {
2685        return String::new();
2686    }
2687    let frame = &state.call_stack[state.call_stack.len() - 1];
2688    match name {
2689        "FUNCNAME" => frame.func_name.clone(),
2690        "BASH_SOURCE" => frame.source.clone(),
2691        "BASH_LINENO" => frame.lineno.to_string(),
2692        _ => String::new(),
2693    }
2694}
2695
2696fn resolve_special(sp: &SpecialParameter, state: &InterpreterState) -> String {
2697    match sp {
2698        SpecialParameter::LastExitStatus => state.last_exit_code.to_string(),
2699        SpecialParameter::PositionalParameterCount => state.positional_params.len().to_string(),
2700        SpecialParameter::AllPositionalParameters { concatenate } => {
2701            if *concatenate {
2702                // IFS unset → default space; IFS="" → no separator.
2703                let sep = match get_var(state, "IFS") {
2704                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),
2705                    None => " ".to_string(),
2706                };
2707                state.positional_params.join(&sep)
2708            } else {
2709                state.positional_params.join(" ")
2710            }
2711        }
2712        SpecialParameter::ProcessId => "1".to_string(),
2713        SpecialParameter::LastBackgroundProcessId => String::new(),
2714        SpecialParameter::ShellName => state.shell_name.clone(),
2715        SpecialParameter::CurrentOptionFlags => {
2716            // Bash emits flags in canonical order: a e f h n u v x B C
2717            let mut flags = String::new();
2718            if state.shell_opts.allexport {
2719                flags.push('a');
2720            }
2721            if state.shell_opts.errexit {
2722                flags.push('e');
2723            }
2724            if state.shell_opts.noglob {
2725                flags.push('f');
2726            }
2727            // hashall (h) is always on in bash by default
2728            flags.push('h');
2729            if state.shell_opts.noexec {
2730                flags.push('n');
2731            }
2732            if state.shell_opts.nounset {
2733                flags.push('u');
2734            }
2735            if state.shell_opts.verbose {
2736                flags.push('v');
2737            }
2738            if state.shell_opts.xtrace {
2739                flags.push('x');
2740            }
2741            // braceexpand (B) is always on by default
2742            flags.push('B');
2743            if state.shell_opts.noclobber {
2744                flags.push('C');
2745            }
2746            // 's' means read from stdin — always set for non-interactive shells
2747            flags.push('s');
2748            flags
2749        }
2750    }
2751}
2752
2753fn get_var(state: &InterpreterState, name: &str) -> Option<String> {
2754    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2755    // If the resolved name is an array subscript (e.g. from a nameref to "a[2]"),
2756    // handle it as an array element lookup.
2757    if let Some(bracket_pos) = resolved.find('[')
2758        && resolved.ends_with(']')
2759    {
2760        let arr_name = &resolved[..bracket_pos];
2761        let index_raw = &resolved[bracket_pos + 1..resolved.len() - 1];
2762        // Expand simple $var references in the index.
2763        let index = expand_simple_dollar_vars(index_raw, state);
2764        return Some(resolve_array_element(arr_name, &index, state));
2765    }
2766    state
2767        .env
2768        .get(&resolved)
2769        .map(|v| v.value.as_scalar().to_string())
2770}
2771
2772/// Expand simple `$name` variable references in a string.
2773/// Used for nameref targets like `A[$key]` where the index contains a variable.
2774fn expand_simple_dollar_vars(s: &str, state: &InterpreterState) -> String {
2775    if !s.contains('$') {
2776        return s.to_string();
2777    }
2778    let mut result = String::new();
2779    let chars: Vec<char> = s.chars().collect();
2780    let mut i = 0;
2781    while i < chars.len() {
2782        if chars[i] == '$' && i + 1 < chars.len() {
2783            i += 1;
2784            let mut var_name = String::new();
2785            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
2786                var_name.push(chars[i]);
2787                i += 1;
2788            }
2789            if !var_name.is_empty() {
2790                let resolved_var = crate::interpreter::resolve_nameref_or_self(&var_name, state);
2791                let val = state
2792                    .env
2793                    .get(&resolved_var)
2794                    .map(|v| v.value.as_scalar().to_string())
2795                    .unwrap_or_default();
2796                result.push_str(&val);
2797            } else {
2798                result.push('$');
2799            }
2800        } else {
2801            result.push(chars[i]);
2802            i += 1;
2803        }
2804    }
2805    result
2806}
2807
2808fn should_use_default(
2809    val: &str,
2810    test_type: &ParameterTestType,
2811    state: &InterpreterState,
2812    parameter: &Parameter,
2813) -> bool {
2814    match test_type {
2815        ParameterTestType::UnsetOrNull => val.is_empty() || is_unset(state, parameter),
2816        ParameterTestType::Unset => is_unset(state, parameter),
2817    }
2818}
2819
2820/// For indirect expansion (`${!ref-default}`), check whether the *indirect target*
2821/// is unset/null rather than the original parameter.
2822fn should_use_indirect_default(
2823    val: &str,
2824    target_name: &str,
2825    test_type: &ParameterTestType,
2826    state: &InterpreterState,
2827) -> bool {
2828    if target_name.is_empty() {
2829        // The reference variable itself is unset → target is unset
2830        return true;
2831    }
2832    let is_target_unset = is_unset(state, &Parameter::Named(target_name.to_string()));
2833    match test_type {
2834        ParameterTestType::UnsetOrNull => val.is_empty() || is_target_unset,
2835        ParameterTestType::Unset => is_target_unset,
2836    }
2837}
2838
2839/// Names that are always "set" because they are dynamically computed.
2840fn is_dynamic_special(name: &str) -> bool {
2841    matches!(
2842        name,
2843        "LINENO"
2844            | "SECONDS"
2845            | "_"
2846            | "PPID"
2847            | "UID"
2848            | "EUID"
2849            | "BASHPID"
2850            | "SHELLOPTS"
2851            | "BASHOPTS"
2852            | "MACHTYPE"
2853            | "HOSTTYPE"
2854            | "FUNCNAME"
2855            | "BASH_SOURCE"
2856            | "BASH_LINENO"
2857    )
2858}
2859
2860fn is_unset(state: &InterpreterState, parameter: &Parameter) -> bool {
2861    match parameter {
2862        Parameter::Named(name) => {
2863            if is_dynamic_special(name) {
2864                return false;
2865            }
2866            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2867            match state.env.get(&resolved) {
2868                None => true,
2869                Some(var) => {
2870                    // For indexed arrays, $name is equivalent to ${name[0]},
2871                    // so it's "unset" if index 0 is not present.
2872                    use crate::interpreter::VariableValue;
2873                    match &var.value {
2874                        VariableValue::IndexedArray(map) => !map.contains_key(&0),
2875                        _ => false,
2876                    }
2877                }
2878            }
2879        }
2880        Parameter::Positional(n) => {
2881            if *n == 0 {
2882                false
2883            } else {
2884                state.positional_params.get(*n as usize - 1).is_none()
2885            }
2886        }
2887        Parameter::Special(_) => false,
2888        Parameter::NamedWithIndex { name, index } => {
2889            if is_dynamic_special(name) {
2890                return false;
2891            }
2892            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2893            match state.env.get(&resolved) {
2894                None => true,
2895                Some(var) => {
2896                    use crate::interpreter::VariableValue;
2897                    match &var.value {
2898                        VariableValue::IndexedArray(map) => {
2899                            let idx = simple_arith_eval(index, state);
2900                            let actual_idx = if idx < 0 {
2901                                let max_key = map.keys().next_back().copied().unwrap_or(0);
2902                                let resolved_idx = max_key as i64 + 1 + idx;
2903                                if resolved_idx < 0 {
2904                                    return true;
2905                                }
2906                                resolved_idx as usize
2907                            } else {
2908                                idx as usize
2909                            };
2910                            !map.contains_key(&actual_idx)
2911                        }
2912                        VariableValue::AssociativeArray(map) => !map.contains_key(index.as_str()),
2913                        VariableValue::Scalar(_) => {
2914                            let idx = simple_arith_eval(index, state);
2915                            idx != 0 && idx != -1
2916                        }
2917                    }
2918                }
2919            }
2920        }
2921        Parameter::NamedWithAllIndices { name, .. } => {
2922            if is_dynamic_special(name) {
2923                return false;
2924            }
2925            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);
2926            !state.env.contains_key(&resolved)
2927        }
2928    }
2929}
2930
2931fn parameter_name(parameter: &Parameter) -> String {
2932    match parameter {
2933        Parameter::Named(name) => name.clone(),
2934        Parameter::Positional(n) => n.to_string(),
2935        Parameter::Special(sp) => match sp {
2936            SpecialParameter::LastExitStatus => "?".to_string(),
2937            SpecialParameter::PositionalParameterCount => "#".to_string(),
2938            SpecialParameter::AllPositionalParameters { concatenate } => {
2939                if *concatenate {
2940                    "*".to_string()
2941                } else {
2942                    "@".to_string()
2943                }
2944            }
2945            SpecialParameter::ProcessId => "$".to_string(),
2946            SpecialParameter::LastBackgroundProcessId => "!".to_string(),
2947            SpecialParameter::ShellName => "0".to_string(),
2948            SpecialParameter::CurrentOptionFlags => "-".to_string(),
2949        },
2950        Parameter::NamedWithIndex { name, index } => format!("{name}[{index}]"),
2951        Parameter::NamedWithAllIndices { name, .. } => name.clone(),
2952    }
2953}
2954
2955/// Parse a simple integer from an arithmetic expression string.
2956fn parse_arithmetic_value(expr: &str) -> i64 {
2957    let trimmed = expr.trim();
2958    trimmed.parse::<i64>().unwrap_or(0)
2959}
2960
2961// ── Raw string expansion (for default/alternative values) ───────────
2962
2963fn expand_raw_string_ctx(
2964    raw: &str,
2965    state: &InterpreterState,
2966    in_dq: bool,
2967) -> Result<String, RustBashError> {
2968    let options = parser_options();
2969    let pieces = brush_parser::word::parse(raw, &options)
2970        .map_err(|e| RustBashError::Parse(e.to_string()))?;
2971
2972    let mut words: Vec<WordInProgress> = vec![Vec::new()];
2973    for piece_ws in &pieces {
2974        expand_raw_piece(&piece_ws.piece, &mut words, state, in_dq)?;
2975    }
2976    let result = finalize_no_split(words);
2977    Ok(result.join(" "))
2978}
2979
2980fn expand_raw_string_mut_ctx(
2981    raw: &str,
2982    state: &mut InterpreterState,
2983    in_dq: bool,
2984) -> Result<String, RustBashError> {
2985    let options = parser_options();
2986    let pieces = brush_parser::word::parse(raw, &options)
2987        .map_err(|e| RustBashError::Parse(e.to_string()))?;
2988
2989    let mut words: Vec<WordInProgress> = vec![Vec::new()];
2990    for piece_ws in &pieces {
2991        expand_raw_piece_mut(&piece_ws.piece, &mut words, state, in_dq)?;
2992    }
2993    let result = finalize_no_split(words);
2994    Ok(result.join(" "))
2995}
2996
2997/// Expand a word piece from a parameter expansion operand.
2998/// When `in_dq` is true, single quotes are literal characters (not quote
2999/// delimiters), matching bash behavior for e.g. `"${var:-'hello'}"`.
3000fn expand_raw_piece(
3001    piece: &WordPiece,
3002    words: &mut Vec<WordInProgress>,
3003    state: &InterpreterState,
3004    in_dq: bool,
3005) -> Result<bool, RustBashError> {
3006    if in_dq && let WordPiece::SingleQuotedText(s) = piece {
3007        // Inside DQ context, single quotes are literal characters.
3008        push_segment(words, &format!("'{s}'"), true, true);
3009        return Ok(false);
3010    }
3011    expand_word_piece(piece, words, state, in_dq)
3012}
3013
3014/// Mutable variant of `expand_raw_piece`.
3015fn expand_raw_piece_mut(
3016    piece: &WordPiece,
3017    words: &mut Vec<WordInProgress>,
3018    state: &mut InterpreterState,
3019    in_dq: bool,
3020) -> Result<bool, RustBashError> {
3021    if in_dq && let WordPiece::SingleQuotedText(s) = piece {
3022        push_segment(words, &format!("'{s}'"), true, true);
3023        return Ok(false);
3024    }
3025    expand_word_piece_mut(piece, words, state, in_dq)
3026}
3027
3028/// Expand shell variables inside an arithmetic expression before evaluation.
3029/// This handles cases like `$((${zero}11))` where `zero=0` should yield `011`.
3030pub(crate) fn expand_arith_expression(
3031    expr: &str,
3032    state: &mut InterpreterState,
3033) -> Result<String, RustBashError> {
3034    // If the expression contains no shell expansion markers or quotes, return as-is.
3035    if !expr.contains('$') && !expr.contains('`') && !expr.contains('\'') && !expr.contains('"') {
3036        return Ok(expr.to_string());
3037    }
3038    // Parse the expression as a shell word and expand it.
3039    let word = ast::Word {
3040        value: expr.to_string(),
3041        loc: None,
3042    };
3043    expand_word_to_string_mut(&word, state)
3044}