diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index a9342b7a..f3ae3fec 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -590,6 +590,16 @@ impl<'a> Lexer<'a> { } } } + '$' => { + content.push('$'); + self.advance(); + if self.peek_char() == Some('(') { + // $(...) command substitution — track paren depth + content.push('('); + self.advance(); + self.read_command_subst_into(&mut content); + } + } '`' => { // Backtick command substitution inside double quotes self.advance(); // consume opening ` @@ -628,6 +638,89 @@ impl<'a> Lexer<'a> { Some(Token::QuotedWord(content)) } + /// Read command substitution content after `$(`, handling nested parens and quotes. + /// Appends chars to `content` and adds the closing `)`. + fn read_command_subst_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(); + if depth == 0 { + content.push(')'); + break; + } + content.push(c); + } + '"' => { + // Nested double-quoted string inside $() + content.push('"'); + self.advance(); + while let Some(qc) = self.peek_char() { + match qc { + '"' => { + content.push('"'); + self.advance(); + break; + } + '\\' => { + content.push('\\'); + self.advance(); + if let Some(esc) = self.peek_char() { + content.push(esc); + self.advance(); + } + } + '$' => { + content.push('$'); + self.advance(); + if self.peek_char() == Some('(') { + content.push('('); + self.advance(); + self.read_command_subst_into(content); + } + } + _ => { + content.push(qc); + self.advance(); + } + } + } + } + '\'' => { + // Single-quoted string inside $() + content.push('\''); + self.advance(); + while let Some(qc) = self.peek_char() { + content.push(qc); + self.advance(); + if qc == '\'' { + break; + } + } + } + '\\' => { + content.push('\\'); + self.advance(); + if let Some(esc) = self.peek_char() { + content.push(esc); + self.advance(); + } + } + _ => { + 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/tests/spec_cases/bash/command-subst.test.sh b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh index 4c3417f8..5d35cca3 100644 --- a/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/command-subst.test.sh @@ -96,3 +96,59 @@ VAR=$(printf 'hello\n\n\n'); echo "x${VAR}y" ### expect xhelloy ### end + +### subst_nested_quotes +# Nested double quotes inside $() inside double quotes +echo "$(echo "hello world")" +### expect +hello world +### end + +### subst_nested_quotes_var +# Variable expansion in nested quoted $() +x="John"; echo "Hello, $(echo "$x")!" +### expect +Hello, John! +### end + +### subst_deeply_nested_quotes +# Deeply nested $() with quotes +echo "nested: $(echo "$(echo "deep")")" +### expect +nested: deep +### end + +### subst_nested_single_quotes +# Single quotes inside $() inside double quotes +echo "$(echo 'single quoted')" +### expect +single quoted +### end + +### subst_nested_quotes_no_expand +# Nested quotes without variable (literal string) +echo "result=$(echo "done")" +### expect +result=done +### end + +### subst_nested_quotes_empty +# Nested quotes with empty inner string +echo "x$(echo "")y" +### expect +xy +### end + +### subst_nested_quotes_multiple +# Multiple nested $() in same double-quoted string +echo "$(echo "a") and $(echo "b")" +### expect +a and b +### end + +### subst_nested_quotes_escape +# Escaped characters inside nested $() +echo "$(echo "hello\"world")" +### expect +hello"world +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index ee409f1e..977a7635 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,16 +103,16 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1186 (1181 pass, 5 skip) +**Total spec test cases:** 1194 (1189 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 825 | Yes | 820 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 833 | Yes | 828 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | | JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | -| **Total** | **1186** | **Yes** | **1181** | **5** | | +| **Total** | **1194** | **Yes** | **1189** | **5** | | ### Bash Spec Tests Breakdown @@ -127,7 +127,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | command.test.sh | 9 | `command -v`, `-V`, function bypass | | command-not-found.test.sh | 17 | unknown command handling | | conditional.test.sh | 24 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH, glob `==`/`!=` | -| command-subst.test.sh | 14 | includes backtick substitution (1 skipped) | +| command-subst.test.sh | 22 | includes backtick substitution, nested quotes in `$()` (1 skipped) | | control-flow.test.sh | 48 | if/elif/else, for, while, case, trap ERR, `[[ =~ ]]` BASH_REMATCH, compound input redirects | | cuttr.test.sh | 32 | cut and tr commands, `-z` zero-terminated | | date.test.sh | 38 | format specifiers, `-d` relative/compound/epoch, `-R`, `-I`, `%N` (2 skipped) |