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
53 changes: 46 additions & 7 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1327,20 +1327,59 @@ impl Interpreter {

/// Execute a case statement
async fn execute_case(&mut self, case_cmd: &CaseCommand) -> Result<ExecResult> {
use crate::parser::CaseTerminator;
let word_value = self.expand_word(&case_cmd.word).await?;

// Try each case item in order
let mut stdout = String::new();
let mut stderr = String::new();
let mut exit_code = 0;
let mut fallthrough = false;

for case_item in &case_cmd.cases {
for pattern in &case_item.patterns {
let pattern_str = self.expand_word(pattern).await?;
if self.pattern_matches(&word_value, &pattern_str) {
return self.execute_command_sequence(&case_item.commands).await;
let matched = if fallthrough {
true
} else {
let mut m = false;
for pattern in &case_item.patterns {
let pattern_str = self.expand_word(pattern).await?;
if self.pattern_matches(&word_value, &pattern_str) {
m = true;
break;
}
}
m
};

if matched {
let r = self.execute_command_sequence(&case_item.commands).await?;
stdout.push_str(&r.stdout);
stderr.push_str(&r.stderr);
exit_code = r.exit_code;
match case_item.terminator {
CaseTerminator::Break => {
return Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: r.control_flow,
});
}
CaseTerminator::FallThrough => {
fallthrough = true;
}
CaseTerminator::Continue => {
fallthrough = false;
}
}
}
}

// No pattern matched - return success with no output
Ok(ExecResult::ok(String::new()))
Ok(ExecResult {
stdout,
stderr,
exit_code,
control_flow: ControlFlow::None,
})
}

/// Execute a time command - measure wall-clock execution time
Expand Down
12 changes: 12 additions & 0 deletions crates/bashkit/src/parser/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,23 @@ pub struct CaseCommand {
pub span: Span,
}

/// Terminator for a case item.
#[derive(Debug, Clone, PartialEq)]
pub enum CaseTerminator {
/// `;;` — stop matching
Break,
/// `;&` — fall through to next case body unconditionally
FallThrough,
/// `;;&` — continue checking remaining patterns
Continue,
}

/// A single case item.
#[derive(Debug, Clone)]
pub struct CaseItem {
pub patterns: Vec<Word>,
pub commands: Vec<Command>,
pub terminator: CaseTerminator,
}

/// Function definition.
Expand Down
15 changes: 14 additions & 1 deletion crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,20 @@ impl<'a> Lexer<'a> {
}
';' => {
self.advance();
Some(Token::Semicolon)
if self.peek_char() == Some(';') {
self.advance();
if self.peek_char() == Some('&') {
self.advance();
Some(Token::DoubleSemiAmp) // ;;&
} else {
Some(Token::DoubleSemicolon) // ;;
}
} else if self.peek_char() == Some('&') {
self.advance();
Some(Token::SemiAmp) // ;&
} else {
Some(Token::Semicolon)
}
}
'|' => {
self.advance();
Expand Down
40 changes: 26 additions & 14 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,12 +945,12 @@ impl<'a> Parser<'a> {
self.skip_newlines()?;
}

cases.push(CaseItem { patterns, commands });

// Consume ;; if present
if self.is_case_terminator() {
self.advance_double_semicolon();
}
let terminator = self.parse_case_terminator();
cases.push(CaseItem {
patterns,
commands,
terminator,
});
self.skip_newlines()?;
}

Expand Down Expand Up @@ -1000,18 +1000,30 @@ impl<'a> Parser<'a> {

/// Check if current token is ;; (case terminator)
fn is_case_terminator(&self) -> bool {
// The lexer returns Semicolon for ; but we need ;;
// For now, check for two semicolons
matches!(self.current_token, Some(tokens::Token::Semicolon))
matches!(
self.current_token,
Some(tokens::Token::DoubleSemicolon)
| Some(tokens::Token::SemiAmp)
| Some(tokens::Token::DoubleSemiAmp)
)
}

/// Advance past ;; (double semicolon)
fn advance_double_semicolon(&mut self) {
if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
self.advance();
if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
/// Parse case terminator: `;;` (break), `;&` (fallthrough), `;;&` (continue matching)
fn parse_case_terminator(&mut self) -> ast::CaseTerminator {
match self.current_token {
Some(tokens::Token::SemiAmp) => {
self.advance();
ast::CaseTerminator::FallThrough
}
Some(tokens::Token::DoubleSemiAmp) => {
self.advance();
ast::CaseTerminator::Continue
}
Some(tokens::Token::DoubleSemicolon) => {
self.advance();
ast::CaseTerminator::Break
}
_ => ast::CaseTerminator::Break,
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/bashkit/src/parser/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ pub enum Token {
/// Semicolon (;)
Semicolon,

/// Double semicolon (;;) — case break
DoubleSemicolon,

/// Case fallthrough (;&)
SemiAmp,

/// Case continue-matching (;;&)
DoubleSemiAmp,

/// Pipe (|)
Pipe,

Expand Down
41 changes: 41 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/control-flow.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,47 @@ case hello in hel*) echo prefix;; esac
prefix
### end

### case_fallthrough
# Case ;& falls through to next body
case a in a) echo first ;& b) echo second ;; esac
### expect
first
second
### end

### case_fallthrough_chain
# Case ;& chains through multiple bodies
case a in a) echo one ;& b) echo two ;& c) echo three ;; esac
### expect
one
two
three
### end

### case_continue_matching
# Case ;;& continues checking remaining patterns
case "test" in t*) echo prefix ;;& *es*) echo middle ;; *z*) echo nope ;; esac
### expect
prefix
middle
### end

### case_continue_no_match
# Case ;;& skips non-matching subsequent patterns
case "hello" in h*) echo first ;;& *z*) echo nope ;; *lo) echo last ;; esac
### expect
first
last
### end

### case_fallthrough_no_match
# Case ;& falls through even if next pattern wouldn't match
case a in a) echo matched ;& z) echo fell_through ;; esac
### expect
matched
fell_through
### end

### and_list_success
# AND list with success
true && echo yes
Expand Down
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:** 1204 (1199 pass, 5 skip)
**Total spec test cases:** 1214 (1209 pass, 5 skip)

| Category | Cases | In CI | Pass | Skip | Notes |
|----------|-------|-------|------|------|-------|
| Bash (core) | 843 | Yes | 838 | 5 | `bash_spec_tests` in CI |
| Bash (core) | 853 | Yes | 848 | 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** | **1204** | **Yes** | **1199** | **5** | |
| **Total** | **1214** | **Yes** | **1209** | **5** | |

### Bash Spec Tests Breakdown

Expand All @@ -128,7 +128,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
| command-not-found.test.sh | 17 | unknown command handling |
| conditional.test.sh | 24 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH, glob `==`/`!=` |
| 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 |
| control-flow.test.sh | 53 | 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) |
| diff.test.sh | 4 | line diffs |
Expand Down
Loading