Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)` |
Expand Down
48 changes: 29 additions & 19 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1907,29 +1907,33 @@ impl Interpreter {
// Expand arguments with brace and glob expansion
let mut args: Vec<String> = 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?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve empty arg for quoted unset ${arr[*]}

Switching argument expansion to expand_word_to_fields here changes "${arr[*]}" when arr is unset from one empty argument to zero arguments, because the helper returns an empty vector for missing arrays. In bash, quoted * expansion still yields one field (e.g. f(){ echo $#; }; unset arr; f "${arr[*]}" prints 1), so this is a compatibility regression for functions/builtins that depend on argument count.

Useful? React with 👍 / 👎.


// 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);
}
}
}
Expand Down Expand Up @@ -2660,18 +2664,24 @@ impl Interpreter {
Ok(result)
}

/// Expand a word to multiple fields (for array iteration in for loops)
/// Returns Vec<String> where array expansions like "${arr[@]}" produce multiple fields
/// Expand a word to multiple fields (for array iteration and command args)
/// Returns Vec<String> 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<Vec<String>> {
// 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<String> =
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());
}
Expand Down
16 changes: 14 additions & 2 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/arrays.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/printf.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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} |
Expand All @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
Loading