Repository: reubeno/brush
Pull request #207 (2024-10-16 15:45:21)

fix: matching newline chars in glob patterns
* Enables glob patterns to match against newline chars using regex `(?ms)` options.
* Adopts builder pattern for `Pattern` struct, primarily for configuring options (e.g., multiline match support, extglob support).
* Cleans up pattern handling code to remove duplicate logic.
* Adds test case ensuring `case` patterns work correctly against a multiline string.

github-actions[bot]: ## Test Results
  2 files    9 suites   1m 4s :stopwatch:
469 tests 469 :white_check_mark: 0 :zzz: 0 :x:
929 runs  929 :white_check_mark: 0 :zzz: 0 :x:

Results for commit 4c177704.

[test-results]:data:application/gzip;base64,H4sIAHzhD2cC/13NTQ7CIBCG4as0rF0ATkG8jIGBJsS2GH5WjXcXSavo8nkn+WYjk59dIteBnwaSis8NqsKWqLMPa6WA6nrJ7xsIdeiWCuJ/uvtHTfQTJu3nn+BiDHEvsaztH1c7jsm+fBebu8Hmfg/DsvhcQQCZlJICpyDO1l4Y40YJMwLTVgKiEdSNFIE8X+dLG3EEAQAA

github-actions[bot]: # Performance Benchmark Report
| Benchmark name                         | Baseline (μs) |  Test/PR (μs) |    Delta (μs) | Delta %         |
| -------------------------------------- | ------------- | ------------- | ------------- | --------------- |
| `expand_one_string                   ` | `    3.40 μs` | `    3.41 μs` | `    0.00 μs` | `⚪  Unchanged` |
| `instantiate_shell                   ` | `   60.71 μs` | `   59.63 μs` | `   -1.08 μs` | `⚪  Unchanged` |
| `instantiate_shell_with_init_scripts ` | `28131.86 μs` | `28066.37 μs` | `  -65.50 μs` | `⚪  Unchanged` |
| `parse_bash_completion               ` | ` 2734.40 μs` | ` 2711.16 μs` | `  -23.24 μs` | `🟢 -0.85%    ` |
| `parse_sample_script                 ` | `    3.99 μs` | `    4.00 μs` | `    0.01 μs` | `⚪  Unchanged` |
| `run_echo_builtin_command            ` | `   90.19 μs` | `   91.31 μs` | `    1.11 μs` | `⚪  Unchanged` |
| `run_one_builtin_command             ` | `  107.34 μs` | `  109.90 μs` | `    2.56 μs` | `⚪  Unchanged` |
| `run_one_external_command            ` | ` 1909.58 μs` | ` 1975.98 μs` | `   66.40 μs` | `🟠 +3.48%    ` |
| `run_one_external_command_directly   ` | ` 1004.68 μs` | ` 1012.80 μs` | `    8.11 μs` | `⚪  Unchanged` |

# Code Coverage Report: Only Changed Files listed

| Package | Base Coverage | New Coverage | Difference |
| ------- | ------------- | ------------ | ---------- |
| brush-core/src/completion.rs | 🔴 19.22% | 🔴 19.07% | 🔴 -0.15% |
| brush-core/src/expansion.rs | 🟢 95.99% | 🟢 95.94% | 🔴 -0.05% |
| brush-core/src/extendedtests.rs | 🟠 69.86% | 🟠 69.15% | 🔴 -0.71% |
| brush-core/src/interp.rs | 🟢 90.19% | 🟢 90.18% | 🔴 -0.01% |
| brush-core/src/patterns.rs | 🟢 97.85% | 🟢 97.77% | 🔴 -0.08% |
| brush-core/src/shell.rs | 🟢 77.75% | 🟢 76.97% | 🔴 -0.78% |
| brush-shell/src/main.rs | 🟢 90.26% | 🟢 89.61% | 🔴 -0.65% |
| **Overall Coverage** | **🟢 72.66%** | **🟢 72.54%** | **🔴 -0.12%** |

_Minimum allowed coverage is_ `70%`_, this run produced_ `72.54%`
<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/brush-core/src/builtins/help.rs b/brush-core/src/builtins/help.rs
index 32d0fde..3fee6af 100644
--- a/brush-core/src/builtins/help.rs
+++ b/brush-core/src/builtins/help.rs
@@ -80,7 +80,7 @@ impl HelpCommand {
 
         let mut found_count = 0;
         for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) {
-            if pattern.exactly_matches(builtin_name.as_str(), false)? {
+            if pattern.exactly_matches(builtin_name.as_str())? {
                 self.display_help_for_builtin(
                     context,
                     builtin_name.as_str(),
diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs
index d90cca1..5795f39 100644
--- a/brush-core/src/completion.rs
+++ b/brush-core/src/completion.rs
@@ -256,10 +256,11 @@ impl Spec {
         }
 
         if let Some(glob_pattern) = &self.glob_pattern {
-            let pattern = patterns::Pattern::from(glob_pattern.as_str());
+            let pattern = patterns::Pattern::from(glob_pattern.as_str())
+                .set_extended_globbing(shell.options.extended_globbing);
+
             let expansions = pattern.expand(
                 shell.working_dir.as_path(),
-                shell.parser_options().enable_extended_globbing,
                 Some(&patterns::Pattern::accept_all_expand_filter),
             )?;
 
@@ -901,12 +902,11 @@ fn get_file_completions(shell: &Shell, context: &Context, must_be_dir: bool) ->
     let path_filter = |path: &Path| !must_be_dir || shell.get_absolute_path(path).is_dir();
 
     // TODO: Pass through quoting.
-    patterns::Pattern::from(glob)
-        .expand(
-            shell.working_dir.as_path(),
-            shell.options.extended_globbing,
-            Some(&path_filter),
-        )
+    let pattern =
+        patterns::Pattern::from(glob).set_extended_globbing(shell.options.extended_globbing);
+
+    pattern
+        .expand(shell.working_dir.as_path(), Some(&path_filter))
         .unwrap_or_default()
         .into_iter()
         .collect()
diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs
index aba68ba..16291b0 100644
--- a/brush-core/src/expansion.rs
+++ b/brush-core/src/expansion.rs
@@ -376,7 +376,10 @@ impl<'a> WordExpander<'a> {
             .flatten()
             .collect();
 
-        Ok(patterns::Pattern::from(pattern_pieces))
+        let pattern = patterns::Pattern::from(pattern_pieces)
+            .set_extended_globbing(self.parser_options.enable_extended_globbing);
+
+        Ok(pattern)
     }
 
     async fn basic_expand_regex(
@@ -496,11 +499,12 @@ impl<'a> WordExpander<'a> {
     }
 
     fn expand_pathnames_in_field(&self, field: WordField) -> Vec<String> {
-        let pattern = patterns::Pattern::from(field.clone());
+        let pattern = patterns::Pattern::from(field.clone())
+            .set_extended_globbing(self.parser_options.enable_extended_globbing);
+
         let expansions = pattern
             .expand(
                 self.shell.working_dir.as_path(),
-                self.parser_options.enable_extended_globbing,
                 Some(&patterns::Pattern::accept_all_expand_filter),
             )
             .unwrap_or_default();
@@ -779,7 +783,6 @@ impl<'a> WordExpander<'a> {
                 let result = patterns::remove_smallest_matching_suffix(
                     expanded_parameter.as_str(),
                     &expanded_pattern,
-                    self.parser_options.enable_extended_globbing,
                 )?;
                 Ok(Expansion::from(result.to_owned()))
             }
@@ -794,7 +797,6 @@ impl<'a> WordExpander<'a> {
                 let result = patterns::remove_largest_matching_suffix(
                     expanded_parameter.as_str(),
                     &expanded_pattern,
-                    self.parser_options.enable_extended_globbing,
                 )?;
 
                 Ok(Expansion::from(result.to_owned()))
@@ -810,7 +812,6 @@ impl<'a> WordExpander<'a> {
                 let result = patterns::remove_smallest_matching_prefix(
                     expanded_parameter.as_str(),
                     &expanded_pattern,
-                    self.parser_options.enable_extended_globbing,
                 )?;
 
                 Ok(Expansion::from(result.to_owned()))
@@ -826,7 +827,6 @@ impl<'a> WordExpander<'a> {
                 let result = patterns::remove_largest_matching_prefix(
                     expanded_parameter.as_str(),
                     &expanded_pattern,
-                    self.parser_options.enable_extended_globbing,
                 )?;
 
                 Ok(Expansion::from(result.to_owned()))
@@ -963,7 +963,7 @@ impl<'a> WordExpander<'a> {
                 let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
 
                 transform_expansion(expanded_parameter, |s| {
-                    self.uppercase_first_char(s, &expanded_pattern)
+                    Self::uppercase_first_char(s, &expanded_pattern)
                 })
             }
             brush_parser::word::ParameterExpr::UppercasePattern {
@@ -975,7 +975,7 @@ impl<'a> WordExpander<'a> {
                 let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
 
                 transform_expansion(expanded_parameter, |s| {
-                    self.uppercase_pattern(s.as_str(), &expanded_pattern)
+                    Self::uppercase_pattern(s.as_str(), &expanded_pattern)
                 })
             }
             brush_parser::word::ParameterExpr::LowercaseFirstChar {
@@ -987,7 +987,7 @@ impl<'a> WordExpander<'a> {
                 let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
 
                 Ok(transform_expansion(expanded_parameter, |s| {
-                    self.lowercase_first_char(s, &expanded_pattern)
+                    Self::lowercase_first_char(s, &expanded_pattern)
                 })?)
             }
             brush_parser::word::ParameterExpr::LowercasePattern {
@@ -999,7 +999,7 @@ impl<'a> WordExpander<'a> {
                 let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
 
                 Ok(transform_expansion(expanded_parameter, |s| {
-                    self.lowercase_pattern(s.as_str(), &expanded_pattern)
+                    Self::lowercase_pattern(s.as_str(), &expanded_pattern)
                 })?)
             }
             brush_parser::word::ParameterExpr::ReplaceSubstring {
@@ -1016,11 +1016,12 @@ impl<'a> WordExpander<'a> {
                 let replacement = replacement.unwrap_or(String::new());
                 let expanded_replacement = self.basic_expand_to_str(&replacement).await?;
 
-                let regex = patterns::pattern_to_regex(
-                    expanded_pattern.as_str(),
+                let pattern = patterns::Pattern::from(expanded_pattern.as_str())
+                    .set_extended_globbing(self.parser_options.enable_extended_globbing);
+
+                let regex = pattern.to_regex(
                     matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix),
                     matches!(match_kind, brush_parser::word::SubstringMatchKind::Suffix),
-                    self.parser_options.enable_extended_globbing,
                 )?;
 
                 transform_expansion(expanded_parameter, |s| {
@@ -1350,17 +1351,12 @@ impl<'a> WordExpander<'a> {
 
     #[allow(clippy::unwrap_in_result)]
     fn uppercase_first_char(
-        &mut self,
         s: String,
         pattern: &Option<patterns::Pattern>,
     ) -> Result<String, error::Error> {
         if let Some(first_char) = s.chars().next() {
             let applicable = if let Some(pattern) = pattern {
-                pattern.is_empty()
-                    || pattern.exactly_matches(
-                        first_char.to_string().as_str(),
-                        self.shell.options.extended_globbing,
-                    )?
+                pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())?
             } else {
                 true
             };
@@ -1380,17 +1376,12 @@ impl<'a> WordExpander<'a> {
 
     #[allow(clippy::unwrap_in_result)]
     fn lowercase_first_char(
-        &mut self,
         s: String,
         pattern: &Option<patterns::Pattern>,
     ) -> Result<String, error::Error> {
         if let Some(first_char) = s.chars().next() {
             let applicable = if let Some(pattern) = pattern {
-                pattern.is_empty()
-                    || pattern.exactly_matches(
-                        first_char.to_string().as_str(),
-                        self.shell.options.extended_globbing,
-                    )?
+                pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())?
             } else {
                 true
             };
@@ -1409,14 +1400,12 @@ impl<'a> WordExpander<'a> {
     }
 
     fn uppercase_pattern(
-        &mut self,
         s: &str,
         pattern: &Option<patterns::Pattern>,
     ) -> Result<String, error::Error> {
         if let Some(pattern) = pattern {
             if !pattern.is_empty() {
-                let regex =
-                    pattern.to_regex(false, false, self.parser_options.enable_extended_globbing)?;
+                let regex = pattern.to_regex(false, false)?;
                 let result = regex.replace_all(s.as_ref(), |caps: &fancy_regex::Captures| {
                     caps[0].to_uppercase()
                 });
@@ -1430,14 +1419,12 @@ impl<'a> WordExpander<'a> {
     }
 
     fn lowercase_pattern(
-        &mut self,
         s: &str,
         pattern: &Option<patterns::Pattern>,
     ) -> Result<String, error::Error> {
         if let Some(pattern) = pattern {
             if !pattern.is_empty() {
-                let regex =
-                    pattern.to_regex(false, false, self.parser_options.enable_extended_globbing)?;
+                let regex = pattern.to_regex(false, false)?;
                 let result = regex.replace_all(s.as_ref(), |caps: &fancy_regex::Captures| {
                     caps[0].to_lowercase()
                 });
diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs
index bacdf0e..613918a 100644
--- a/brush-core/src/interp.rs
+++ b/brush-core/src/interp.rs
@@ -580,9 +580,7 @@ impl Execute for ast::CaseClauseCommand {
 
             for pattern in &case.patterns {
                 let expanded_pattern = expansion::basic_expand_pattern(shell, pattern).await?;
-                if expanded_pattern
-                    .exactly_matches(expanded_value.as_str(), shell.options.extended_globbing)?
-                {
+                if expanded_pattern.exactly_matches(expanded_value.as_str())? {
                     matches = true;
                     break;
                 }
diff --git a/brush-core/src/patterns.rs b/brush-core/src/patterns.rs
index a4e8239..728621c 100644
--- a/brush-core/src/patterns.rs
+++ b/brush-core/src/patterns.rs
@@ -28,11 +28,26 @@ type PatternWord = Vec<PatternPiece>;
 #[derive(Clone, Debug)]
 pub struct Pattern {
     pieces: PatternWord,
+    enable_extended_globbing: bool,
+    multiline: bool,
+}
+
+impl Default for Pattern {
+    fn default() -> Self {
+        Self {
+            pieces: vec![],
+            enable_extended_globbing: false,
+            multiline: true,
+        }
+    }
 }
 
 impl From<PatternWord> for Pattern {
     fn from(pieces: PatternWord) -> Self {
-        Self { pieces }
+        Self {
+            pieces,
+            ..Default::default()
+        }
     }
 }
 
@@ -40,6 +55,7 @@ impl From<&PatternWord> for Pattern {
     fn from(value: &PatternWord) -> Self {
         Self {
             pieces: value.clone(),
+            ..Default::default()
         }
     }
 }
@@ -48,6 +64,7 @@ impl From<&str> for Pattern {
     fn from(value: &str) -> Self {
         Self {
             pieces: vec![PatternPiece::Pattern(value.to_owned())],
+            ..Default::default()
         }
     }
 }
@@ -56,11 +73,33 @@ impl From<String> for Pattern {
     fn from(value: String) -> Self {
         Self {
             pieces: vec![PatternPiece::Pattern(value)],
+            ..Default::default()
         }
     }
 }
 
 impl Pattern {
+    /// Enables (or disables) extended globbing support for this pattern.
+    ///
+    /// # Arguments
+    ///
+    /// * `value` - Whether or not to enable extended globbing (extglob).
+    pub fn set_extended_globbing(mut self, value: bool) -> Pattern {
+        self.enable_extended_globbing = value;
+        self
+    }
+
+    /// Enables (or disables) multiline support for this pattern.
+    ///
+    /// # Arguments
+    ///
+    /// * `value` - Whether or not to enable multiline matching.
+    #[allow(dead_code)]
+    pub fn set_multiline(mut self, value: bool) -> Pattern {
+        self.multiline = value;
+        self
+    }
+
     /// Returns whether or not the pattern is empty.
     pub fn is_empty(&self) -> bool {
         self.pieces.iter().all(|p| p.as_str().is_empty())
@@ -76,14 +115,12 @@ impl Pattern {
     /// # Arguments
     ///
     /// * `working_dir` - The current working directory, used for relative paths.
-    /// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob).
     /// * `path_filter` - Optionally provides a function that filters paths after expansion.
     #[allow(clippy::too_many_lines)]
     #[allow(clippy::unwrap_in_result)]
     pub(crate) fn expand<PF>(
         &self,
         working_dir: &Path,
-        enable_extended_globbing: bool,
         path_filter: Option<&PF>,
     ) -> Result<Vec<String>, error::Error>
     where
@@ -177,8 +214,10 @@ impl Pattern {
 
             let current_paths = std::mem::take(&mut paths_so_far);
             for current_path in current_paths {
-                let subpattern = Pattern::from(&component);
-                let regex = subpattern.to_regex(true, true, enable_extended_globbing)?;
+                let subpattern =
+                    Pattern::from(&component).set_extended_globbing(self.enable_extended_globbing);
+
+                let regex = subpattern.to_regex(true, true)?;
 
                 let matches_regex = |dir_entry: &std::fs::DirEntry| {
                     regex
@@ -234,12 +273,10 @@ impl Pattern {
     ///   the string.
     /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
     ///   string.
-    /// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob).
     pub(crate) fn to_regex_str(
         &self,
         strict_prefix_match: bool,
         strict_suffix_match: bool,
-        enable_extended_globbing: bool,
     ) -> Result<String, error::Error> {
         let mut regex_str = String::new();
 
@@ -247,6 +284,11 @@ impl Pattern {
             regex_str.push('^');
         }
 
+        if self.multiline {
+            // Set option for multiline matching + set option for allowing '.' pattern to match newline.
+            regex_str.push_str("(?ms)");
+        }
+
         let mut current_pattern = String::new();
         for piece in &self.pieces {
             match piece {
@@ -257,9 +299,7 @@ impl Pattern {
                     if !current_pattern.is_empty() {
                         let regex_piece = pattern_to_regex_str(
                             current_pattern.as_str(),
-                            false,
-                            false,
-                            enable_extended_globbing,
+                            self.enable_extended_globbing,
                         )?;
                         regex_str.push_str(regex_piece.as_str());
                         current_pattern = String::new();
@@ -271,12 +311,8 @@ impl Pattern {
         }
 
         if !current_pattern.is_empty() {
-            let regex_piece = pattern_to_regex_str(
-                current_pattern.as_str(),
-                false,
-                false,
-                enable_extended_globbing,
-            )?;
+            let regex_piece =
+                pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?;
             regex_str.push_str(regex_piece.as_str());
         }
 
@@ -295,18 +331,12 @@ impl Pattern {
     ///   the string.
     /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
     ///   string.
-    /// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob).
     pub(crate) fn to_regex(
         &self,
         strict_prefix_match: bool,
         strict_suffix_match: bool,
-        enable_extended_globbing: bool,
     ) -> Result<fancy_regex::Regex, error::Error> {
-        let regex_str = self.to_regex_str(
-            strict_prefix_match,
-            strict_suffix_match,
-            enable_extended_globbing,
-        )?;
+        let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?;
 
         tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'");
 
@@ -319,13 +349,8 @@ impl Pattern {
     /// # Arguments
     ///
     /// * `value` - The string to check for a match.
-    /// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob).
-    pub(crate) fn exactly_matches(
-        &self,
-        value: &str,
-        enable_extended_globbing: bool,
-    ) -> Result<bool, error::Error> {
-        let re = self.to_regex(true, true, enable_extended_globbing)?;
+    pub(crate) fn exactly_matches(&self, value: &str) -> Result<bool, error::Error> {
+        let re = self.to_regex(true, true)?;
         Ok(re.is_match(value)?)
     }
 }
@@ -346,38 +371,8 @@ fn escape_for_regex(s: &str) -> String {
     escaped
 }
 
-/// Converts a shell pattern to a regular expression.
-///
-/// # Arguments
-///
-/// * `pattern` - The shell pattern to convert.
-/// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of the
-///   string.
-/// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
-///   string.
-/// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob).
-pub(crate) fn pattern_to_regex(
-    pattern: &str,
-    strict_prefix_match: bool,
-    strict_suffix_match: bool,
-    enable_extended_globbing: bool,
-) -> Result<fancy_regex::Regex, error::Error> {
-    let regex_str = pattern_to_regex_str(
-        pattern,
-        strict_prefix_match,
-        strict_suffix_match,
-        enable_extended_globbing,
-    )?;
-
-    let re = regex::compile_regex(regex_str)?;
-
-    Ok(re)
-}
-
 fn pattern_to_regex_str(
     pattern: &str,
-    strict_prefix_match: bool,
-    strict_suffix_match: bool,
     enable_extended_globbing: bool,
 ) -> Result<String, error::Error> {
     // TODO: pattern matching with **
@@ -385,18 +380,10 @@ fn pattern_to_regex_str(
         return error::unimp("pattern matching with '**' pattern");
     }
 
-    let mut regex_str =
-        brush_parser::pattern::pattern_to_regex_str(pattern, enable_extended_globbing)?;
-
-    if strict_prefix_match {
-        regex_str.insert(0, '^');
-    }
-
-    if strict_suffix_match {
-        regex_str.push('$');
-    }
-
-    Ok(regex_str)
+    Ok(brush_parser::pattern::pattern_to_regex_str(
+        pattern,
+        enable_extended_globbing,
+    )?)
 }
 
 /// Removes the largest matching prefix from a string that matches the given pattern.
@@ -409,12 +396,11 @@ fn pattern_to_regex_str(
 pub(crate) fn remove_largest_matching_prefix<'a>(
     s: &'a str,
     pattern: &Option<Pattern>,
-    enable_extended_globbing: bool,
 ) -> Result<&'a str, error::Error> {
     if let Some(pattern) = pattern {
         for i in (0..s.len()).rev() {
             let prefix = &s[0..=i];
-            if pattern.exactly_matches(prefix, enable_extended_globbing)? {
+            if pattern.exactly_matches(prefix)? {
                 return Ok(&s[i + 1..]);
             }
         }
@@ -432,12 +418,11 @@ pub(crate) fn remove_largest_matching_prefix<'a>(
 pub(crate) fn remove_smallest_matching_prefix<'a>(
     s: &'a str,
     pattern: &Option<Pattern>,
-    enable_extended_globbing: bool,
 ) -> Result<&'a str, error::Error> {
     if let Some(pattern) = pattern {
         for i in 0..s.len() {
             let prefix = &s[0..=i];
-            if pattern.exactly_matches(prefix, enable_extended_globbing)? {
+            if pattern.exactly_matches(prefix)? {
                 return Ok(&s[i + 1..]);
             }
         }
@@ -455,12 +440,11 @@ pub(crate) fn remove_smallest_matching_prefix<'a>(
 pub(crate) fn remove_largest_matching_suffix<'a>(
     s: &'a str,
     pattern: &Option<Pattern>,
-    enable_extended_globbing: bool,
 ) -> Result<&'a str, error::Error> {
     if let Some(pattern) = pattern {
         for i in 0..s.len() {
             let suffix = &s[i..];
-            if pattern.exactly_matches(suffix, enable_extended_globbing)? {
+            if pattern.exactly_matches(suffix)? {
                 return Ok(&s[..i]);
             }
         }
@@ -478,12 +462,11 @@ pub(crate) fn remove_largest_matching_suffix<'a>(
 pub(crate) fn remove_smallest_matching_suffix<'a>(
     s: &'a str,
     pattern: &Option<Pattern>,
-    enable_extended_globbing: bool,
 ) -> Result<&'a str, error::Error> {
     if let Some(pattern) = pattern {
         for i in (0..s.len()).rev() {
             let suffix = &s[i..];
-            if pattern.exactly_matches(suffix, enable_extended_globbing)? {
+            if pattern.exactly_matches(suffix)? {
                 return Ok(&s[..i]);
             }
         }
@@ -497,46 +480,41 @@ mod tests {
     use super::*;
     use anyhow::Result;
 
-    fn ext_pattern_to_exact_regex_str(pattern: &str) -> Result<String, error::Error> {
-        pattern_to_regex_str(pattern, true, true, true)
-    }
+    fn pattern_to_exact_regex_str<P>(pattern: P) -> Result<String, error::Error>
+    where
+        P: Into<Pattern>,
+    {
+        let pattern: Pattern = pattern
+            .into()
+            .set_extended_globbing(true)
+            .set_multiline(false);
 
-    fn ext_pattern_str_to_exact_regex_str(word: &PatternWord) -> Result<String, error::Error> {
-        Pattern::from(word).to_regex_str(true, true, true)
+        pattern.to_regex_str(true, true)
     }
 
     #[test]
     fn test_pattern_translation() -> Result<()> {
-        assert_eq!(ext_pattern_to_exact_regex_str("a")?.as_str(), "^a$");
-        assert_eq!(ext_pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
-        assert_eq!(ext_pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
+        assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$");
+        assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
+        assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
+        assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$");
+        assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$");
         assert_eq!(
-            ext_pattern_to_exact_regex_str("a@(b|c)")?.as_str(),
-            "^a(b|c)$"
-        );
-        assert_eq!(
-            ext_pattern_to_exact_regex_str("a?(b|c)")?.as_str(),
-            "^a(b|c)?$"
-        );
-        assert_eq!(
-            ext_pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
+            pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
             "^a(ab|ac)*$"
         );
         assert_eq!(
-            ext_pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
+            pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
             "^a(ab|ac)+$"
         );
-        assert_eq!(ext_pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
-        assert_eq!(
-            ext_pattern_to_exact_regex_str("[ab]*")?.as_str(),
-            "^[ab].*$"
-        );
+        assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
+        assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$");
         assert_eq!(
-            ext_pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
+            pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
             r"^[<{().\[].*$"
         );
-        assert_eq!(ext_pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
-        assert_eq!(ext_pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");
+        assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
+        assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");
 
         Ok(())
     }
@@ -544,12 +522,11 @@ mod tests {
     #[test]
     fn test_pattern_word_translation() -> Result<()> {
         assert_eq!(
-            ext_pattern_str_to_exact_regex_str(&vec![PatternPiece::Pattern("a*".to_owned())])?
-                .as_str(),
+            pattern_to_exact_regex_str(&vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(),
             "^a.*$"
         );
         assert_eq!(
-            ext_pattern_str_to_exact_regex_str(&vec![
+            pattern_to_exact_regex_str(&vec![
                 PatternPiece::Pattern("a*".to_owned()),
                 PatternPiece::Literal("b".to_owned()),
             ])?
@@ -557,7 +534,7 @@ mod tests {
             "^a.*b$"
         );
         assert_eq!(
-            ext_pattern_str_to_exact_regex_str(&vec![
+            pattern_to_exact_regex_str(&vec![
                 PatternPiece::Literal("a*".to_owned()),
                 PatternPiece::Pattern("b".to_owned()),
             ])?
@@ -571,23 +548,23 @@ mod tests {
     #[test]
     fn test_remove_largest_matching_prefix() -> Result<()> {
         assert_eq!(
-            remove_largest_matching_prefix("ooof", &Some(Pattern::from("")), true)?,
+            remove_largest_matching_prefix("ooof", &Some(Pattern::from("")))?,
             "ooof"
         );
         assert_eq!(
-            remove_largest_matching_prefix("ooof", &Some(Pattern::from("x")), true)?,
+            remove_largest_matching_prefix("ooof", &Some(Pattern::from("x")))?,
             "ooof"
         );
         assert_eq!(
-            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o")), true)?,
+            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o")))?,
             "oof"
         );
         assert_eq!(
-            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*o")), true)?,
+            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?,
             "f"
         );
         assert_eq!(
-            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*")), true)?,
+            remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*")))?,
             ""
         );
         Ok(())
@@ -596,27 +573,27 @@ mod tests {
     #[test]
     fn test_remove_smallest_matching_prefix() -> Result<()> {
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("")))?,
             "ooof"
         );
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("x")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("x")))?,
             "ooof"
         );
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o")))?,
             "oof"
         );
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*o")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?,
             "of"
         );
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*")))?,
             "oof"
         );
         assert_eq!(
-            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("ooof")), true)?,
+            remove_smallest_matching_prefix("ooof", &Some(Pattern::from("ooof")))?,
             ""
         );
         Ok(())
@@ -625,23 +602,23 @@ mod tests {
     #[test]
     fn test_remove_largest_matching_suffix() -> Result<()> {
         assert_eq!(
-            remove_largest_matching_suffix("foo", &Some(Pattern::from("")), true)?,
+            remove_largest_matching_suffix("foo", &Some(Pattern::from("")))?,
             "foo"
         );
         assert_eq!(
-            remove_largest_matching_suffix("foo", &Some(Pattern::from("x")), true)?,
+            remove_largest_matching_suffix("foo", &Some(Pattern::from("x")))?,
             "foo"
         );
         assert_eq!(
-            remove_largest_matching_suffix("foo", &Some(Pattern::from("o")), true)?,
+            remove_largest_matching_suffix("foo", &Some(Pattern::from("o")))?,
             "fo"
         );
         assert_eq!(
-            remove_largest_matching_suffix("foo", &Some(Pattern::from("o*")), true)?,
+            remove_largest_matching_suffix("foo", &Some(Pattern::from("o*")))?,
             "f"
         );
         assert_eq!(
-            remove_largest_matching_suffix("foo", &Some(Pattern::from("foo")), true)?,
+            remove_largest_matching_suffix("foo", &Some(Pattern::from("foo")))?,
             ""
         );
         Ok(())
@@ -650,27 +627,27 @@ mod tests {
     #[test]
     fn test_remove_smallest_matching_suffix() -> Result<()> {
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("")))?,
             "fooo"
         );
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("x")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("x")))?,
             "fooo"
         );
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o")))?,
             "foo"
         );
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*o")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*o")))?,
             "fo"
         );
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*")))?,
             "foo"
         );
         assert_eq!(
-            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("fooo")), true)?,
+            remove_smallest_matching_suffix("fooo", &Some(Pattern::from("fooo")))?,
             ""
         );
         Ok(())
diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs
index b6bf2e8..559c4b9 100644
--- a/brush-core/src/shell.rs
+++ b/brush-core/src/shell.rs
@@ -890,13 +890,12 @@ impl Shell {
 
         let mut executables = vec![];
         for dir_str in self.env.get_str("PATH").unwrap_or_default().split(':') {
-            let pattern = std::format!("{dir_str}/{required_glob_pattern}");
+            let pattern =
+                patterns::Pattern::from(std::format!("{dir_str}/{required_glob_pattern}"))
+                    .set_extended_globbing(self.options.extended_globbing);
+
             // TODO: Pass through quoting.
-            if let Ok(entries) = patterns::Pattern::from(pattern).expand(
-                &self.working_dir,
-                self.options.extended_globbing,
-                Some(&is_executable),
-            ) {
+            if let Ok(entries) = pattern.expand(&self.working_dir, Some(&is_executable)) {
                 for entry in entries {
                     executables.push(PathBuf::from(entry));
                 }


Test patch:
diff --git a/brush-core/src/extendedtests.rs b/brush-core/src/extendedtests.rs
index ea3588d..31c48da 100644
--- a/brush-core/src/extendedtests.rs
+++ b/brush-core/src/extendedtests.rs
@@ -358,7 +358,7 @@ async fn apply_binary_predicate(
                 shell.trace_command(std::format!("[[ {s} {op} {expanded_right} ]]"))?;
             }
 
-            pattern.exactly_matches(s.as_str(), shell.options.extended_globbing)
+            pattern.exactly_matches(s.as_str())
         }
         ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
             let s = expansion::basic_expand_word(shell, left).await?;
@@ -369,7 +369,7 @@ async fn apply_binary_predicate(
                 shell.trace_command(std::format!("[[ {s} {op} {expanded_right} ]]"))?;
             }
 
-            let eq = pattern.exactly_matches(s.as_str(), shell.options.extended_globbing)?;
+            let eq = pattern.exactly_matches(s.as_str())?;
             Ok(!eq)
         }
     }
@@ -428,12 +428,16 @@ pub(crate) fn apply_binary_predicate_to_strs(
             apply_binary_arithmetic_predicate(left, right, |left, right| left >= right),
         ),
         ast::BinaryPredicate::StringExactlyMatchesPattern => {
-            let pattern = patterns::Pattern::from(right);
-            pattern.exactly_matches(left, shell.options.extended_globbing)
+            let pattern = patterns::Pattern::from(right)
+                .set_extended_globbing(shell.options.extended_globbing);
+
+            pattern.exactly_matches(left)
         }
         ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
-            let pattern = patterns::Pattern::from(right);
-            let eq = pattern.exactly_matches(left, shell.options.extended_globbing)?;
+            let pattern = patterns::Pattern::from(right)
+                .set_extended_globbing(shell.options.extended_globbing);
+
+            let eq = pattern.exactly_matches(left)?;
             Ok(!eq)
         }
         _ => error::unimp("unsupported test binary predicate"),
diff --git a/brush-shell/tests/cases/compound_cmds/case.yaml b/brush-shell/tests/cases/compound_cmds/case.yaml
index 008e35d..109c7e2 100644
--- a/brush-shell/tests/cases/compound_cmds/case.yaml
+++ b/brush-shell/tests/cases/compound_cmds/case.yaml
@@ -9,6 +9,13 @@ cases:
           esac
     args: ["./script.sh"]
 
+  - name: "Basic catch-all pattern against multi-line input"
+    stdin: |
+      case "$(echo hi; echo there)" in
+      "not-it") echo "did not work" ;;
+      *) echo "caught it" ;;
+      esac
+
   - name: "One-line case statement with double semi"
     stdin: |
       case x in x) echo "hi";; esac