Skip to content

Commit 30377ee

Browse files
chaliyclaude
andauthored
fix(bash): nested double quotes inside $() in double-quoted strings (#246)
## Summary - Fix lexer to handle nested double quotes inside `$()` command substitutions within double-quoted strings - `echo "$(echo "$x")"` now correctly expands instead of prematurely closing the outer quotes - Add `read_command_subst_into()` method that tracks paren depth and handles nested quotes recursively ## Test plan - [x] 8 new spec tests covering nested quotes, variable expansion, deeply nested `$()`, single quotes, empty strings, multiple substitutions, and escaped characters - [x] All 1194 spec tests pass (1189 pass, 5 skip) - [x] `cargo clippy` and `cargo fmt` clean Co-authored-by: Claude <noreply@anthropic.com>
1 parent 335cb4f commit 30377ee

3 files changed

Lines changed: 153 additions & 4 deletions

File tree

crates/bashkit/src/parser/lexer.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,16 @@ impl<'a> Lexer<'a> {
590590
}
591591
}
592592
}
593+
'$' => {
594+
content.push('$');
595+
self.advance();
596+
if self.peek_char() == Some('(') {
597+
// $(...) command substitution — track paren depth
598+
content.push('(');
599+
self.advance();
600+
self.read_command_subst_into(&mut content);
601+
}
602+
}
593603
'`' => {
594604
// Backtick command substitution inside double quotes
595605
self.advance(); // consume opening `
@@ -628,6 +638,89 @@ impl<'a> Lexer<'a> {
628638
Some(Token::QuotedWord(content))
629639
}
630640

641+
/// Read command substitution content after `$(`, handling nested parens and quotes.
642+
/// Appends chars to `content` and adds the closing `)`.
643+
fn read_command_subst_into(&mut self, content: &mut String) {
644+
let mut depth = 1;
645+
while let Some(c) = self.peek_char() {
646+
match c {
647+
'(' => {
648+
depth += 1;
649+
content.push(c);
650+
self.advance();
651+
}
652+
')' => {
653+
depth -= 1;
654+
self.advance();
655+
if depth == 0 {
656+
content.push(')');
657+
break;
658+
}
659+
content.push(c);
660+
}
661+
'"' => {
662+
// Nested double-quoted string inside $()
663+
content.push('"');
664+
self.advance();
665+
while let Some(qc) = self.peek_char() {
666+
match qc {
667+
'"' => {
668+
content.push('"');
669+
self.advance();
670+
break;
671+
}
672+
'\\' => {
673+
content.push('\\');
674+
self.advance();
675+
if let Some(esc) = self.peek_char() {
676+
content.push(esc);
677+
self.advance();
678+
}
679+
}
680+
'$' => {
681+
content.push('$');
682+
self.advance();
683+
if self.peek_char() == Some('(') {
684+
content.push('(');
685+
self.advance();
686+
self.read_command_subst_into(content);
687+
}
688+
}
689+
_ => {
690+
content.push(qc);
691+
self.advance();
692+
}
693+
}
694+
}
695+
}
696+
'\'' => {
697+
// Single-quoted string inside $()
698+
content.push('\'');
699+
self.advance();
700+
while let Some(qc) = self.peek_char() {
701+
content.push(qc);
702+
self.advance();
703+
if qc == '\'' {
704+
break;
705+
}
706+
}
707+
}
708+
'\\' => {
709+
content.push('\\');
710+
self.advance();
711+
if let Some(esc) = self.peek_char() {
712+
content.push(esc);
713+
self.advance();
714+
}
715+
}
716+
_ => {
717+
content.push(c);
718+
self.advance();
719+
}
720+
}
721+
}
722+
}
723+
631724
/// Check if the content starting with { looks like a brace expansion
632725
/// Brace expansion: {a,b,c} or {1..5} (contains , or ..)
633726
/// Brace group: { cmd; } (contains spaces, semicolons, newlines)

crates/bashkit/tests/spec_cases/bash/command-subst.test.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,59 @@ VAR=$(printf 'hello\n\n\n'); echo "x${VAR}y"
9696
### expect
9797
xhelloy
9898
### end
99+
100+
### subst_nested_quotes
101+
# Nested double quotes inside $() inside double quotes
102+
echo "$(echo "hello world")"
103+
### expect
104+
hello world
105+
### end
106+
107+
### subst_nested_quotes_var
108+
# Variable expansion in nested quoted $()
109+
x="John"; echo "Hello, $(echo "$x")!"
110+
### expect
111+
Hello, John!
112+
### end
113+
114+
### subst_deeply_nested_quotes
115+
# Deeply nested $() with quotes
116+
echo "nested: $(echo "$(echo "deep")")"
117+
### expect
118+
nested: deep
119+
### end
120+
121+
### subst_nested_single_quotes
122+
# Single quotes inside $() inside double quotes
123+
echo "$(echo 'single quoted')"
124+
### expect
125+
single quoted
126+
### end
127+
128+
### subst_nested_quotes_no_expand
129+
# Nested quotes without variable (literal string)
130+
echo "result=$(echo "done")"
131+
### expect
132+
result=done
133+
### end
134+
135+
### subst_nested_quotes_empty
136+
# Nested quotes with empty inner string
137+
echo "x$(echo "")y"
138+
### expect
139+
xy
140+
### end
141+
142+
### subst_nested_quotes_multiple
143+
# Multiple nested $() in same double-quoted string
144+
echo "$(echo "a") and $(echo "b")"
145+
### expect
146+
a and b
147+
### end
148+
149+
### subst_nested_quotes_escape
150+
# Escaped characters inside nested $()
151+
echo "$(echo "hello\"world")"
152+
### expect
153+
hello"world
154+
### end

specs/009-implementation-status.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,16 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
103103

104104
## Spec Test Coverage
105105

106-
**Total spec test cases:** 1186 (1181 pass, 5 skip)
106+
**Total spec test cases:** 1194 (1189 pass, 5 skip)
107107

108108
| Category | Cases | In CI | Pass | Skip | Notes |
109109
|----------|-------|-------|------|------|-------|
110-
| Bash (core) | 825 | Yes | 820 | 5 | `bash_spec_tests` in CI |
110+
| Bash (core) | 833 | Yes | 828 | 5 | `bash_spec_tests` in CI |
111111
| AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g |
112112
| Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect |
113113
| Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E |
114114
| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env |
115-
| **Total** | **1186** | **Yes** | **1181** | **5** | |
115+
| **Total** | **1194** | **Yes** | **1189** | **5** | |
116116

117117
### Bash Spec Tests Breakdown
118118

@@ -127,7 +127,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
127127
| command.test.sh | 9 | `command -v`, `-V`, function bypass |
128128
| command-not-found.test.sh | 17 | unknown command handling |
129129
| conditional.test.sh | 24 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH, glob `==`/`!=` |
130-
| command-subst.test.sh | 14 | includes backtick substitution (1 skipped) |
130+
| command-subst.test.sh | 22 | includes backtick substitution, nested quotes in `$()` (1 skipped) |
131131
| control-flow.test.sh | 48 | if/elif/else, for, while, case, trap ERR, `[[ =~ ]]` BASH_REMATCH, compound input redirects |
132132
| cuttr.test.sh | 32 | cut and tr commands, `-z` zero-terminated |
133133
| date.test.sh | 38 | format specifiers, `-d` relative/compound/epoch, `-R`, `-I`, `%N` (2 skipped) |

0 commit comments

Comments
 (0)