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
26 changes: 26 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9749,4 +9749,30 @@ echo "count=$COUNT"
.unwrap();
assert_eq!(result.stdout.trim(), "match");
}

#[tokio::test]
async fn test_assoc_array_in_double_quotes() {
// Issue #399: ${arr["key"]} inside double quotes misparsed
let mut bash = crate::Bash::new();
let result = bash
.exec(r#"declare -A arr; arr["foo"]="bar"; echo "value: ${arr["foo"]}""#)
.await
.unwrap();
assert_eq!(result.stdout.trim(), "value: bar");
}

#[tokio::test]
async fn test_assoc_array_keys_in_quotes() {
// Issue #399: ${!arr[@]} in string context
let mut bash = crate::Bash::new();
let result = bash
.exec(r#"declare -A arr; arr["a"]=1; arr["b"]=2; echo "keys: ${!arr[@]}""#)
.await
.unwrap();
let output = result.stdout.trim();
// Keys may be in any order
assert!(output.starts_with("keys: "), "got: {}", output);
assert!(output.contains("a"), "got: {}", output);
assert!(output.contains("b"), "got: {}", output);
}
}
82 changes: 82 additions & 0 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,12 @@ impl<'a> Lexer<'a> {
content.push('(');
self.advance();
self.read_command_subst_into(&mut content);
} else if self.peek_char() == Some('{') {
// ${...} parameter expansion — track brace depth so
// inner quotes (e.g. ${arr["key"]}) don't end the string
content.push('{');
self.advance();
self.read_param_expansion_into(&mut content);
}
}
'`' => {
Expand Down Expand Up @@ -1170,6 +1176,82 @@ impl<'a> Lexer<'a> {
}
}

/// Read parameter expansion content after `${`, handling nested braces and quotes.
/// In bash, quotes inside `${...}` (e.g. `${arr["key"]}`) don't terminate the
/// outer double-quoted string. Appends chars including closing `}` to `content`.
fn read_param_expansion_into(&mut self, content: &mut String) {
let mut depth = 1;
while let Some(c) = self.peek_char() {
match c {
'{' => {
depth += 1;
content.push(c);
self.advance();
}
'}' => {
depth -= 1;
self.advance();
content.push('}');
if depth == 0 {
break;
}
}
'"' => {
// Quotes inside ${...} are part of the expansion, not string delimiters
content.push('"');
self.advance();
}
'\'' => {
content.push('\'');
self.advance();
}
'\\' => {
// Inside ${...} within double quotes, same escape rules apply:
// \", \\, \$, \` produce the escaped char; others keep backslash
self.advance();
if let Some(esc) = self.peek_char() {
match esc {
'"' | '\\' | '$' | '`' => {
content.push(esc);
self.advance();
}
'}' => {
// \} should be a literal } without closing the expansion
content.push('\\');
content.push('}');
self.advance();
}
_ => {
content.push('\\');
content.push(esc);
self.advance();
}
}
} else {
content.push('\\');
}
}
'$' => {
content.push('$');
self.advance();
if self.peek_char() == Some('(') {
content.push('(');
self.advance();
self.read_command_subst_into(content);
} else if self.peek_char() == Some('{') {
content.push('{');
self.advance();
self.read_param_expansion_into(content);
}
}
_ => {
content.push(c);
self.advance();
}
}
}
}

/// Check if the content starting with { looks like a brace expansion
/// Brace expansion: {a,b,c} or {1..5} (contains , or ..)
/// Brace group: { cmd; } (contains spaces, semicolons, newlines)
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2389,6 +2389,12 @@ impl<'a> Parser<'a> {
}
index.push(chars.next().unwrap());
}
// Strip surrounding quotes from index (e.g. "foo" -> foo)
if (index.starts_with('"') && index.ends_with('"'))
|| (index.starts_with('\'') && index.ends_with('\''))
{
index = index[1..index.len() - 1].to_string();
}
// After ], check for operators on array subscripts
if let Some(&next_c) = chars.peek() {
if next_c == ':' && (index == "@" || index == "*") {
Expand Down
Loading