From b5528aeacf9c78b4ee9a80436328b535633e6813 Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Sat, 14 Mar 2026 23:25:25 +0100 Subject: [PATCH 1/2] fix(peg): handle case statements in command substitution word parsing Add case_statement rule to PEG word parser to correctly parse case statements inside command substitutions. This is a temporary fix until the PEG parser is replaced by winnow for word expansion. Adds test case for case statement inside command substitution. --- brush-parser/src/word.rs | 36 +++++++++++++++++++ .../cases/compat/compound_cmds/case.yaml | 4 +++ 2 files changed, 40 insertions(+) diff --git a/brush-parser/src/word.rs b/brush-parser/src/word.rs index 6413cb4a4..7b20bafc5 100644 --- a/brush-parser/src/word.rs +++ b/brush-parser/src/word.rs @@ -1074,10 +1074,46 @@ peg::parser! { $(command_piece()*) pub(crate) rule command_piece() -> () = + case_statement() / word_piece(<[')']>, true /*in_command*/) {} / ([' ' | '\t'])+ {} / ['\'' | '`'] {} + rule case_statement() -> () = + "case" [' ' | '\t']+ [^' ' | '\t' | '\n']+ [' ' | '\t']* "in" + case_body() "esac" {} + + rule case_body() -> () = + // Match everything until 'esac', handling nested parens and quotes + (!"esac" case_body_piece())* {} + + rule case_body_piece() -> () = + // Match quoted strings + "'" [^'\'']* "'" / + "\"" [^'\"']* "\"" / + // Match nested command substitutions + "$(" case_body() ")" / + // Match nested subshells + "(" (!")" case_body_piece())* ")" / + // Match any other character + [_] {} + + rule case_item() -> () = + [' ' | '\t']* case_pattern() ")" [' ' | '\t' | '\n']* + case_item_body()* + case_terminator()? {} + + rule case_pattern() -> () = + word_piece(<['|']>, false) ("|" word_piece(<['|']>, false))* {} + + rule case_item_body() -> () = + word_piece(<[')']>, true) {} / + ([' ' | '\t' | '\n'])+ {} / + "#" [^'\n']* {} + + rule case_terminator() -> () = + ";;&" / ";;" / ";&" {} + rule backquoted_command() -> String = chars:(backquoted_char()*) { chars.into_iter().collect() } diff --git a/brush-shell/tests/cases/compat/compound_cmds/case.yaml b/brush-shell/tests/cases/compat/compound_cmds/case.yaml index 0857e4c1f..4a45768a8 100644 --- a/brush-shell/tests/cases/compat/compound_cmds/case.yaml +++ b/brush-shell/tests/cases/compat/compound_cmds/case.yaml @@ -183,3 +183,7 @@ cases: } f echo "Exit code: $?" + + - name: "Case inside command substitution" + stdin: | + echo $(case a in a) echo ok ;; esac) From f4c42bf3a6f71a231889588d308e687de0ac01b3 Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Sun, 15 Mar 2026 00:17:24 +0100 Subject: [PATCH 2/2] fix(tokenizer): handle case statements inside command substitution Track case statement state while tokenizing nested constructs to prevent incorrectly treating ')' in case patterns as the end of command substitution. Fixes parsing of constructs like: echo $(case a in a) echo ok ;; esac) --- brush-parser/src/tokenizer.rs | 55 ++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/brush-parser/src/tokenizer.rs b/brush-parser/src/tokenizer.rs index ceee9e364..b62d47c65 100644 --- a/brush-parser/src/tokenizer.rs +++ b/brush-parser/src/tokenizer.rs @@ -532,6 +532,14 @@ pub fn uncached_tokenize_str( Ok(tokens) } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CaseState { + NotInCase, + AfterCase, + AfterIn, + InBody, +} + impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self { Tokenizer { @@ -615,6 +623,10 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { let mut pending_here_doc_tokens = vec![]; let mut drain_here_doc_tokens = false; + // Track case statement state for handling ) in case patterns + let mut case_state = CaseState::NotInCase; + let mut case_depth: u32 = 0; + loop { let cur_token = if drain_here_doc_tokens && !pending_here_doc_tokens.is_empty() { if pending_here_doc_tokens.len() == 1 { @@ -644,12 +656,42 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { continue; } - if let Some(cur_token_value) = cur_token.token { + if let Some(cur_token_value) = &cur_token.token { state.append_str(cur_token_value.to_str()); if matches!(cur_token_value, Token::Operator(o, _) if o == nesting_open) { nesting_count += 1; } + + // Track case statement state + if let Token::Word(word, _) = cur_token_value { + match word.trim() { + "case" if case_state == CaseState::NotInCase => { + case_state = CaseState::AfterCase; + case_depth += 1; + } + "in" if case_state == CaseState::AfterCase => { + case_state = CaseState::AfterIn; + } + "esac" if case_depth > 0 => { + case_depth = case_depth.saturating_sub(1); + if case_depth == 0 { + case_state = CaseState::NotInCase; + } else { + case_state = CaseState::AfterIn; + } + } + _ => {} + } + } + + // Handle case terminators (;;, ;&, ;;&) + if let Token::Operator(op, _) = cur_token_value { + if matches!(op.as_str(), ";;" | ";&" | ";;&") && case_state == CaseState::InBody + { + case_state = CaseState::AfterIn; + } + } } match cur_token.reason { @@ -658,9 +700,14 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { } TokenEndReason::NonNewLineBlank => state.append_char(' '), TokenEndReason::SpecifiedTerminatingChar => { - nesting_count -= 1; - if nesting_count == 0 { - break; + // If we're inside a case pattern (AfterIn), the ')' is part of case syntax + if matches!(case_state, CaseState::AfterIn) { + case_state = CaseState::InBody; + } else { + nesting_count -= 1; + if nesting_count == 0 { + break; + } } state.append_char(self.next_char()?.unwrap()); }