diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 487938e..b810314 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -45,7 +45,7 @@ for sandbox security reasons. See the compliance spec for details. | Builtin | Flags/Features | Notes | |---------|----------------|-------| | `echo` | `-n`, `-e`, `-E` | Basic escape sequences | -| `printf` | `%s`, `%d`, `%x`, `%o`, `%f` | Format specifiers | +| `printf` | `%s`, `%d`, `%x`, `%o`, `%f` | Format specifiers, repeats format for multiple args | | `cat` | (none) | Concatenate files/stdin | | `true` | - | Exit 0 | | `false` | - | Exit 1 | @@ -270,7 +270,8 @@ for sandbox security reasons. See the compliance spec for details. |---------|--------|---------| | Declaration | ✅ | `arr=(a b c)` | | Index access | ✅ | `${arr[0]}` | -| All elements | ✅ | `${arr[@]}` | +| All elements `@` | ✅ | `${arr[@]}` (separate args) | +| All elements `*` | ✅ | `${arr[*]}` (single arg when quoted) | | Array length | ✅ | `${#arr[@]}` | | Element length | ✅ | `${#arr[0]}` | | Append | ✅ | `arr+=(d e)` | diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 378c51d..5fdee8c 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1907,29 +1907,33 @@ impl Interpreter { // Expand arguments with brace and glob expansion let mut args: Vec = Vec::new(); for word in &command.args { - let expanded = self.expand_word(word).await?; + // Use field expansion so "${arr[@]}" produces multiple args + let fields = self.expand_word_to_fields(word).await?; // Skip brace and glob expansion for quoted words if word.quoted { - args.push(expanded); + args.extend(fields); continue; } - // Step 1: Brace expansion (produces multiple strings) - let brace_expanded = self.expand_braces(&expanded); - - // Step 2: For each brace-expanded item, do glob expansion - for item in brace_expanded { - if self.contains_glob_chars(&item) { - let glob_matches = self.expand_glob(&item).await?; - if glob_matches.is_empty() { - // No matches - keep original pattern (bash behavior) - args.push(item); + // For each field, apply brace and glob expansion + for expanded in fields { + // Step 1: Brace expansion (produces multiple strings) + let brace_expanded = self.expand_braces(&expanded); + + // Step 2: For each brace-expanded item, do glob expansion + for item in brace_expanded { + if self.contains_glob_chars(&item) { + let glob_matches = self.expand_glob(&item).await?; + if glob_matches.is_empty() { + // No matches - keep original pattern (bash behavior) + args.push(item); + } else { + args.extend(glob_matches); + } } else { - args.extend(glob_matches); + args.push(item); } - } else { - args.push(item); } } } @@ -2660,18 +2664,24 @@ impl Interpreter { Ok(result) } - /// Expand a word to multiple fields (for array iteration in for loops) - /// Returns Vec where array expansions like "${arr[@]}" produce multiple fields + /// Expand a word to multiple fields (for array iteration and command args) + /// Returns Vec where array expansions like "${arr[@]}" produce multiple fields. + /// "${arr[*]}" in quoted context joins elements into a single field (bash behavior). async fn expand_word_to_fields(&mut self, word: &Word) -> Result> { // Check if the word contains only an array expansion if word.parts.len() == 1 { if let WordPart::ArrayAccess { name, index } = &word.parts[0] { if index == "@" || index == "*" { - // ${arr[@]} or ${arr[*]} - return each element as separate field if let Some(arr) = self.arrays.get(name) { let mut indices: Vec<_> = arr.keys().collect(); indices.sort(); - return Ok(indices.iter().filter_map(|i| arr.get(i).cloned()).collect()); + let values: Vec = + indices.iter().filter_map(|i| arr.get(i).cloned()).collect(); + // "${arr[*]}" joins into single field; "${arr[@]}" keeps separate + if word.quoted && index == "*" { + return Ok(vec![values.join(" ")]); + } + return Ok(values); } return Ok(Vec::new()); } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 3f9cbc0..eca1e0c 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -554,7 +554,13 @@ impl<'a> Parser<'a> { match &self.current_token { Some(tokens::Token::Word(w)) if w == "do" => break, Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => { - words.push(self.parse_word(w.clone())); + let is_quoted = + matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))); + let mut word = self.parse_word(w.clone()); + if is_quoted { + word.quoted = true; + } + words.push(word); self.advance(); } Some(tokens::Token::LiteralWord(w)) => { @@ -1295,6 +1301,8 @@ impl<'a> Parser<'a> { | Some(tokens::Token::QuotedWord(w)) => { let is_literal = matches!(&self.current_token, Some(tokens::Token::LiteralWord(_))); + let is_quoted = + matches!(&self.current_token, Some(tokens::Token::QuotedWord(_))); // Stop if this word cannot start a command (like 'then', 'fi', etc.) // This check is only for command position - reserved words in argument @@ -1414,7 +1422,11 @@ impl<'a> Parser<'a> { quoted: true, } } else { - self.parse_word(w.clone()) + let mut word = self.parse_word(w.clone()); + if is_quoted { + word.quoted = true; + } + word }; words.push(word); self.advance(); diff --git a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh index 3aa9124..419c080 100644 --- a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh @@ -114,3 +114,31 @@ arr=(a b c d e); echo ${arr[@]:0:2} ### expect a b ### end + +### array_at_expansion_as_args +# "${arr[@]}" expands to separate arguments for commands +arr=(one two three) +printf "%s\n" "${arr[@]}" +### expect +one +two +three +### end + +### array_star_expansion_quoted +# "${arr[*]}" joins into single argument when quoted +arr=(one two three) +printf "[%s]\n" "${arr[*]}" +### expect +[one two three] +### end + +### array_at_expansion_unquoted +# ${arr[@]} unquoted also produces separate args +arr=(x y z) +printf "(%s)\n" ${arr[@]} +### expect +(x) +(y) +(z) +### end diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index e477bb7..fdb3478 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -124,3 +124,62 @@ printf "%06d\n" -42 ### expect -00042 ### end + +### printf_array_expansion +# Printf with array expansion - format string repeats per element +colors=(Black Red Green Yellow Blue Magenta Cyan White) +printf "%s\n" "${colors[@]}" +### expect +Black +Red +Green +Yellow +Blue +Magenta +Cyan +White +### end + +### printf_array_at_format_reuse +# Printf reuses format for each array element via ${arr[@]} +nums=(1 2 3) +printf "%d\n" "${nums[@]}" +### expect +1 +2 +3 +### end + +### printf_array_star_quoted +# "${arr[*]}" joins elements into single arg +arr=(a b c) +printf "[%s]\n" "${arr[*]}" +### expect +[a b c] +### end + +### printf_array_at_with_format +# Printf with multi-specifier format and array args +items=(Alice 30 Bob 25) +printf "%s is %d\n" "${items[@]}" +### expect +Alice is 30 +Bob is 25 +### end + +### printf_empty_array +# Printf with empty array - format runs once with empty %s +arr=() +result=$(printf "%s\n" "${arr[@]}") +echo "[$result]" +### expect +[] +### end + +### printf_single_element_array +# Printf with single-element array +arr=(only) +printf "(%s)\n" "${arr[@]}" +### expect +(only) +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 3632678..cc41376 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -124,7 +124,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | File | Cases | Notes | |------|-------|-------| | arithmetic.test.sh | 29 | includes logical operators | -| arrays.test.sh | 16 | includes indices | +| arrays.test.sh | 19 | includes indices, `${arr[@]}` / `${arr[*]}` expansion | | background.test.sh | 2 | | | bash-command.test.sh | 25 | bash/sh re-invocation | | brace-expansion.test.sh | 11 | {a,b,c}, {1..5} | @@ -149,7 +149,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | paste.test.sh | 4 | line merging (2 skipped) | | path.test.sh | 14 | | | pipes-redirects.test.sh | 19 | includes stderr redirects | -| printf.test.sh | 18 | format specifiers | +| printf.test.sh | 24 | format specifiers, array expansion | | procsub.test.sh | 6 | | | sleep.test.sh | 6 | | | sortuniq.test.sh | 28 | sort and uniq (13 skipped) | @@ -185,7 +185,7 @@ Features that may be added in the future (not intentionally excluded): | `local` | Declaration | Proper scoping in nested functions | | `return` | Basic usage | Return value propagation | | Heredocs | Basic | Variable expansion inside | -| Arrays | Indexing, `[@]`, `${!arr[@]}`, `+=` | Slice `${arr[@]:1:2}` | +| Arrays | Indexing, `[@]`/`[*]` as separate args, `${!arr[@]}`, `+=` | Slice `${arr[@]:1:2}` | | `echo -n` | Flag parsed | Trailing newline handling | | `time` | Wall-clock timing | User/sys CPU time (always 0) | | `timeout` | Basic usage | `-k` kill timeout |