Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/command-subst.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) |
Expand Down
Loading