Skip to content

Commit 1d80c0f

Browse files
chaliyclaude
andauthored
fix(parser): handle quotes inside ${...} in double-quoted strings (#483)
## Summary - Fixed `${arr["key"]}` inside double-quoted strings being misparsed - Root cause: lexer's `read_double_quoted_string` didn't track `${...}` brace depth, so inner `"` terminated the outer string - Added `read_param_expansion_into()` to track brace depth inside parameter expansions - Also strips surrounding quotes from array indices during parsing Closes #399 ## Test plan - [x] test_assoc_array_in_double_quotes - [x] test_assoc_array_keys_in_quotes - [x] vop_backslash_in_default spec test still passes - [x] All 1809 tests pass Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e63506 commit 1d80c0f

File tree

3 files changed

+114
-0
lines changed

3 files changed

+114
-0
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9749,4 +9749,30 @@ echo "count=$COUNT"
97499749
.unwrap();
97509750
assert_eq!(result.stdout.trim(), "match");
97519751
}
9752+
9753+
#[tokio::test]
9754+
async fn test_assoc_array_in_double_quotes() {
9755+
// Issue #399: ${arr["key"]} inside double quotes misparsed
9756+
let mut bash = crate::Bash::new();
9757+
let result = bash
9758+
.exec(r#"declare -A arr; arr["foo"]="bar"; echo "value: ${arr["foo"]}""#)
9759+
.await
9760+
.unwrap();
9761+
assert_eq!(result.stdout.trim(), "value: bar");
9762+
}
9763+
9764+
#[tokio::test]
9765+
async fn test_assoc_array_keys_in_quotes() {
9766+
// Issue #399: ${!arr[@]} in string context
9767+
let mut bash = crate::Bash::new();
9768+
let result = bash
9769+
.exec(r#"declare -A arr; arr["a"]=1; arr["b"]=2; echo "keys: ${!arr[@]}""#)
9770+
.await
9771+
.unwrap();
9772+
let output = result.stdout.trim();
9773+
// Keys may be in any order
9774+
assert!(output.starts_with("keys: "), "got: {}", output);
9775+
assert!(output.contains("a"), "got: {}", output);
9776+
assert!(output.contains("b"), "got: {}", output);
9777+
}
97529778
}

crates/bashkit/src/parser/lexer.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,12 @@ impl<'a> Lexer<'a> {
10431043
content.push('(');
10441044
self.advance();
10451045
self.read_command_subst_into(&mut content);
1046+
} else if self.peek_char() == Some('{') {
1047+
// ${...} parameter expansion — track brace depth so
1048+
// inner quotes (e.g. ${arr["key"]}) don't end the string
1049+
content.push('{');
1050+
self.advance();
1051+
self.read_param_expansion_into(&mut content);
10461052
}
10471053
}
10481054
'`' => {
@@ -1170,6 +1176,82 @@ impl<'a> Lexer<'a> {
11701176
}
11711177
}
11721178

1179+
/// Read parameter expansion content after `${`, handling nested braces and quotes.
1180+
/// In bash, quotes inside `${...}` (e.g. `${arr["key"]}`) don't terminate the
1181+
/// outer double-quoted string. Appends chars including closing `}` to `content`.
1182+
fn read_param_expansion_into(&mut self, content: &mut String) {
1183+
let mut depth = 1;
1184+
while let Some(c) = self.peek_char() {
1185+
match c {
1186+
'{' => {
1187+
depth += 1;
1188+
content.push(c);
1189+
self.advance();
1190+
}
1191+
'}' => {
1192+
depth -= 1;
1193+
self.advance();
1194+
content.push('}');
1195+
if depth == 0 {
1196+
break;
1197+
}
1198+
}
1199+
'"' => {
1200+
// Quotes inside ${...} are part of the expansion, not string delimiters
1201+
content.push('"');
1202+
self.advance();
1203+
}
1204+
'\'' => {
1205+
content.push('\'');
1206+
self.advance();
1207+
}
1208+
'\\' => {
1209+
// Inside ${...} within double quotes, same escape rules apply:
1210+
// \", \\, \$, \` produce the escaped char; others keep backslash
1211+
self.advance();
1212+
if let Some(esc) = self.peek_char() {
1213+
match esc {
1214+
'"' | '\\' | '$' | '`' => {
1215+
content.push(esc);
1216+
self.advance();
1217+
}
1218+
'}' => {
1219+
// \} should be a literal } without closing the expansion
1220+
content.push('\\');
1221+
content.push('}');
1222+
self.advance();
1223+
}
1224+
_ => {
1225+
content.push('\\');
1226+
content.push(esc);
1227+
self.advance();
1228+
}
1229+
}
1230+
} else {
1231+
content.push('\\');
1232+
}
1233+
}
1234+
'$' => {
1235+
content.push('$');
1236+
self.advance();
1237+
if self.peek_char() == Some('(') {
1238+
content.push('(');
1239+
self.advance();
1240+
self.read_command_subst_into(content);
1241+
} else if self.peek_char() == Some('{') {
1242+
content.push('{');
1243+
self.advance();
1244+
self.read_param_expansion_into(content);
1245+
}
1246+
}
1247+
_ => {
1248+
content.push(c);
1249+
self.advance();
1250+
}
1251+
}
1252+
}
1253+
}
1254+
11731255
/// Check if the content starting with { looks like a brace expansion
11741256
/// Brace expansion: {a,b,c} or {1..5} (contains , or ..)
11751257
/// Brace group: { cmd; } (contains spaces, semicolons, newlines)

crates/bashkit/src/parser/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2389,6 +2389,12 @@ impl<'a> Parser<'a> {
23892389
}
23902390
index.push(chars.next().unwrap());
23912391
}
2392+
// Strip surrounding quotes from index (e.g. "foo" -> foo)
2393+
if (index.starts_with('"') && index.ends_with('"'))
2394+
|| (index.starts_with('\'') && index.ends_with('\''))
2395+
{
2396+
index = index[1..index.len() - 1].to_string();
2397+
}
23922398
// After ], check for operators on array subscripts
23932399
if let Some(&next_c) = chars.peek() {
23942400
if next_c == ':' && (index == "@" || index == "*") {

0 commit comments

Comments
 (0)