From 5353905a164b2e62293b237c08833506d0c7ea91 Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Sat, 14 Mar 2026 23:25:25 +0100 Subject: [PATCH 1/3] 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 3ff71847c..7048fcec7 100644 --- a/brush-parser/src/word.rs +++ b/brush-parser/src/word.rs @@ -1074,9 +1074,45 @@ 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 0319b9b2be1f2b5b7b3eb3603e602b35a31a04dc Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Sun, 15 Mar 2026 00:17:24 +0100 Subject: [PATCH 2/3] 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 b9f75c96a..61be8de30 100644 --- a/brush-parser/src/tokenizer.rs +++ b/brush-parser/src/tokenizer.rs @@ -525,6 +525,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 { @@ -607,6 +615,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 { @@ -636,12 +648,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 { @@ -650,9 +692,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()); } From 19aabdcdb5712ba5cd84fd7c36b7d3d4a430aab8 Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Sun, 15 Mar 2026 21:24:05 +0100 Subject: [PATCH 3/3] wip: Try to see what's the content --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5bed3dfed..73e417852 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -250,8 +250,8 @@ jobs: env: VARIANT: ${{ matrix.variant }} run: | + sed -n '665,680p' /usr/lib/git-core/git-sh-prompt set -euxo pipefail - # Run tests with coverage collection result=0 cargo xtask test integration --coverage --coverage-output ./codecov-${VARIANT}.xml || result=$?