diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f36b208f..8eb4642e 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -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); + } } diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 793554e1..0c92d81b 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -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); } } '`' => { @@ -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) diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 3270f0d6..48cd36ad 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -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 == "*") {