Skip to content

Commit a031674

Browse files
committed
fix(parser): handle quotes inside ${...} in double-quoted strings
The lexer now tracks ${...} brace depth inside double-quoted strings so that inner quotes (e.g. ${arr["key"]}) don't terminate the outer string. Also strips surrounding quotes from array indices during parsing. Closes #399 https://claude.ai/code/session_01WZjYqxm5xMPAEe7FSHJkDy
1 parent fc51ce0 commit a031674

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
@@ -9686,4 +9686,30 @@ bash /tmp/opts.sh -f xml -v
96869686
assert_eq!(lines[0], "FORMAT=json VERBOSE=1");
96879687
assert_eq!(lines[1], "FORMAT=xml VERBOSE=1");
96889688
}
9689+
9690+
#[tokio::test]
9691+
async fn test_assoc_array_in_double_quotes() {
9692+
// Issue #399: ${arr["key"]} inside double quotes misparsed
9693+
let mut bash = crate::Bash::new();
9694+
let result = bash
9695+
.exec(r#"declare -A arr; arr["foo"]="bar"; echo "value: ${arr["foo"]}""#)
9696+
.await
9697+
.unwrap();
9698+
assert_eq!(result.stdout.trim(), "value: bar");
9699+
}
9700+
9701+
#[tokio::test]
9702+
async fn test_assoc_array_keys_in_quotes() {
9703+
// Issue #399: ${!arr[@]} in string context
9704+
let mut bash = crate::Bash::new();
9705+
let result = bash
9706+
.exec(r#"declare -A arr; arr["a"]=1; arr["b"]=2; echo "keys: ${!arr[@]}""#)
9707+
.await
9708+
.unwrap();
9709+
let output = result.stdout.trim();
9710+
// Keys may be in any order
9711+
assert!(output.starts_with("keys: "), "got: {}", output);
9712+
assert!(output.contains("a"), "got: {}", output);
9713+
assert!(output.contains("b"), "got: {}", output);
9714+
}
96899715
}

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
@@ -2379,6 +2379,12 @@ impl<'a> Parser<'a> {
23792379
}
23802380
index.push(chars.next().unwrap());
23812381
}
2382+
// Strip surrounding quotes from index (e.g. "foo" -> foo)
2383+
if (index.starts_with('"') && index.ends_with('"'))
2384+
|| (index.starts_with('\'') && index.ends_with('\''))
2385+
{
2386+
index = index[1..index.len() - 1].to_string();
2387+
}
23822388
// After ], check for operators on array subscripts
23832389
if let Some(&next_c) = chars.peek() {
23842390
if next_c == ':' && (index == "@" || index == "*") {

0 commit comments

Comments
 (0)