Skip to content

Commit 2d5b74e

Browse files
chaliyclaude
andauthored
feat(bash): case ;& and ;;& fallthrough/continue-matching (#249)
## Summary - Add `DoubleSemicolon`, `SemiAmp`, and `DoubleSemiAmp` tokens to the lexer for proper case terminator parsing - Parse case terminators into `CaseTerminator` enum: `Break` (`;;`), `FallThrough` (`;&`), `Continue` (`;;&`) - `;&` falls through to next case body unconditionally (without pattern check) - `;;&` continues checking remaining case patterns Previously `;&` and `;;&` caused parser fuel exhaustion (infinite loop). ## Test plan - [x] 5 new spec tests: fallthrough, chain, continue-matching, continue-skip, and unconditional fallthrough - [x] All 1214 spec tests pass (1209 pass, 5 skip) - [x] `cargo clippy` and `cargo fmt` clean - [x] Existing case tests (`;;`) still pass Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21af1fb commit 2d5b74e

File tree

7 files changed

+152
-26
lines changed

7 files changed

+152
-26
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,20 +1327,59 @@ impl Interpreter {
13271327

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

1332-
// Try each case item in order
1333+
let mut stdout = String::new();
1334+
let mut stderr = String::new();
1335+
let mut exit_code = 0;
1336+
let mut fallthrough = false;
1337+
13331338
for case_item in &case_cmd.cases {
1334-
for pattern in &case_item.patterns {
1335-
let pattern_str = self.expand_word(pattern).await?;
1336-
if self.pattern_matches(&word_value, &pattern_str) {
1337-
return self.execute_command_sequence(&case_item.commands).await;
1339+
let matched = if fallthrough {
1340+
true
1341+
} else {
1342+
let mut m = false;
1343+
for pattern in &case_item.patterns {
1344+
let pattern_str = self.expand_word(pattern).await?;
1345+
if self.pattern_matches(&word_value, &pattern_str) {
1346+
m = true;
1347+
break;
1348+
}
1349+
}
1350+
m
1351+
};
1352+
1353+
if matched {
1354+
let r = self.execute_command_sequence(&case_item.commands).await?;
1355+
stdout.push_str(&r.stdout);
1356+
stderr.push_str(&r.stderr);
1357+
exit_code = r.exit_code;
1358+
match case_item.terminator {
1359+
CaseTerminator::Break => {
1360+
return Ok(ExecResult {
1361+
stdout,
1362+
stderr,
1363+
exit_code,
1364+
control_flow: r.control_flow,
1365+
});
1366+
}
1367+
CaseTerminator::FallThrough => {
1368+
fallthrough = true;
1369+
}
1370+
CaseTerminator::Continue => {
1371+
fallthrough = false;
1372+
}
13381373
}
13391374
}
13401375
}
13411376

1342-
// No pattern matched - return success with no output
1343-
Ok(ExecResult::ok(String::new()))
1377+
Ok(ExecResult {
1378+
stdout,
1379+
stderr,
1380+
exit_code,
1381+
control_flow: ControlFlow::None,
1382+
})
13441383
}
13451384

13461385
/// Execute a time command - measure wall-clock execution time

crates/bashkit/src/parser/ast.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,23 @@ pub struct CaseCommand {
190190
pub span: Span,
191191
}
192192

193+
/// Terminator for a case item.
194+
#[derive(Debug, Clone, PartialEq)]
195+
pub enum CaseTerminator {
196+
/// `;;` — stop matching
197+
Break,
198+
/// `;&` — fall through to next case body unconditionally
199+
FallThrough,
200+
/// `;;&` — continue checking remaining patterns
201+
Continue,
202+
}
203+
193204
/// A single case item.
194205
#[derive(Debug, Clone)]
195206
pub struct CaseItem {
196207
pub patterns: Vec<Word>,
197208
pub commands: Vec<Command>,
209+
pub terminator: CaseTerminator,
198210
}
199211

200212
/// Function definition.

crates/bashkit/src/parser/lexer.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,20 @@ impl<'a> Lexer<'a> {
7777
}
7878
';' => {
7979
self.advance();
80-
Some(Token::Semicolon)
80+
if self.peek_char() == Some(';') {
81+
self.advance();
82+
if self.peek_char() == Some('&') {
83+
self.advance();
84+
Some(Token::DoubleSemiAmp) // ;;&
85+
} else {
86+
Some(Token::DoubleSemicolon) // ;;
87+
}
88+
} else if self.peek_char() == Some('&') {
89+
self.advance();
90+
Some(Token::SemiAmp) // ;&
91+
} else {
92+
Some(Token::Semicolon)
93+
}
8194
}
8295
'|' => {
8396
self.advance();

crates/bashkit/src/parser/mod.rs

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -945,12 +945,12 @@ impl<'a> Parser<'a> {
945945
self.skip_newlines()?;
946946
}
947947

948-
cases.push(CaseItem { patterns, commands });
949-
950-
// Consume ;; if present
951-
if self.is_case_terminator() {
952-
self.advance_double_semicolon();
953-
}
948+
let terminator = self.parse_case_terminator();
949+
cases.push(CaseItem {
950+
patterns,
951+
commands,
952+
terminator,
953+
});
954954
self.skip_newlines()?;
955955
}
956956

@@ -1000,18 +1000,30 @@ impl<'a> Parser<'a> {
10001000

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

1008-
/// Advance past ;; (double semicolon)
1009-
fn advance_double_semicolon(&mut self) {
1010-
if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
1011-
self.advance();
1012-
if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
1011+
/// Parse case terminator: `;;` (break), `;&` (fallthrough), `;;&` (continue matching)
1012+
fn parse_case_terminator(&mut self) -> ast::CaseTerminator {
1013+
match self.current_token {
1014+
Some(tokens::Token::SemiAmp) => {
1015+
self.advance();
1016+
ast::CaseTerminator::FallThrough
1017+
}
1018+
Some(tokens::Token::DoubleSemiAmp) => {
1019+
self.advance();
1020+
ast::CaseTerminator::Continue
1021+
}
1022+
Some(tokens::Token::DoubleSemicolon) => {
10131023
self.advance();
1024+
ast::CaseTerminator::Break
10141025
}
1026+
_ => ast::CaseTerminator::Break,
10151027
}
10161028
}
10171029

crates/bashkit/src/parser/tokens.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ pub enum Token {
2323
/// Semicolon (;)
2424
Semicolon,
2525

26+
/// Double semicolon (;;) — case break
27+
DoubleSemicolon,
28+
29+
/// Case fallthrough (;&)
30+
SemiAmp,
31+
32+
/// Case continue-matching (;;&)
33+
DoubleSemiAmp,
34+
2635
/// Pipe (|)
2736
Pipe,
2837

crates/bashkit/tests/spec_cases/bash/control-flow.test.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,47 @@ case hello in hel*) echo prefix;; esac
173173
prefix
174174
### end
175175

176+
### case_fallthrough
177+
# Case ;& falls through to next body
178+
case a in a) echo first ;& b) echo second ;; esac
179+
### expect
180+
first
181+
second
182+
### end
183+
184+
### case_fallthrough_chain
185+
# Case ;& chains through multiple bodies
186+
case a in a) echo one ;& b) echo two ;& c) echo three ;; esac
187+
### expect
188+
one
189+
two
190+
three
191+
### end
192+
193+
### case_continue_matching
194+
# Case ;;& continues checking remaining patterns
195+
case "test" in t*) echo prefix ;;& *es*) echo middle ;; *z*) echo nope ;; esac
196+
### expect
197+
prefix
198+
middle
199+
### end
200+
201+
### case_continue_no_match
202+
# Case ;;& skips non-matching subsequent patterns
203+
case "hello" in h*) echo first ;;& *z*) echo nope ;; *lo) echo last ;; esac
204+
### expect
205+
first
206+
last
207+
### end
208+
209+
### case_fallthrough_no_match
210+
# Case ;& falls through even if next pattern wouldn't match
211+
case a in a) echo matched ;& z) echo fell_through ;; esac
212+
### expect
213+
matched
214+
fell_through
215+
### end
216+
176217
### and_list_success
177218
# AND list with success
178219
true && echo yes

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:** 1204 (1199 pass, 5 skip)
106+
**Total spec test cases:** 1214 (1209 pass, 5 skip)
107107

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

117117
### Bash Spec Tests Breakdown
118118

@@ -128,7 +128,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
128128
| command-not-found.test.sh | 17 | unknown command handling |
129129
| conditional.test.sh | 24 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH, glob `==`/`!=` |
130130
| command-subst.test.sh | 22 | includes backtick substitution, nested quotes in `$()` (1 skipped) |
131-
| control-flow.test.sh | 48 | if/elif/else, for, while, case, trap ERR, `[[ =~ ]]` BASH_REMATCH, compound input redirects |
131+
| control-flow.test.sh | 53 | 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) |
134134
| diff.test.sh | 4 | line diffs |

0 commit comments

Comments
 (0)