Skip to main content

brush_core/
patterns.rs

1//! Shell patterns
2
3use crate::{error, regex, sys, trace_categories};
4use std::{collections::VecDeque, path::Path};
5
6/// Represents a piece of a shell pattern.
7#[derive(Clone, Debug)]
8pub(crate) enum PatternPiece {
9    /// A pattern that should be interpreted as a shell pattern.
10    Pattern(String),
11    /// A literal string that should be matched exactly.
12    Literal(String),
13}
14
15impl PatternPiece {
16    pub fn as_str(&self) -> &str {
17        match self {
18            Self::Pattern(s) => s,
19            Self::Literal(s) => s,
20        }
21    }
22}
23
24type PatternWord = Vec<PatternPiece>;
25
26/// Options for filename expansion.
27#[derive(Clone, Debug, Default)]
28pub(crate) struct FilenameExpansionOptions {
29    pub require_dot_in_pattern_to_match_dot_files: bool,
30}
31
32/// Result of a pattern expansion, distinguishing "no glob metacharacters" from
33/// "glob expansion attempted but found no matches".
34#[derive(Debug, Default)]
35pub(crate) enum PatternExpansionResult {
36    /// No glob metacharacters found; no expansion was attempted.
37    #[default]
38    NoGlob,
39    /// Glob expansion was attempted. Contains matching paths (may be empty).
40    Expanded(Vec<String>),
41}
42
43impl PatternExpansionResult {
44    /// Returns the expansion results, regardless of variant.
45    pub fn into_paths(self) -> Vec<String> {
46        match self {
47            Self::NoGlob => vec![],
48            Self::Expanded(paths) => paths,
49        }
50    }
51
52    /// Returns true if glob expansion was attempted but produced no results.
53    pub const fn is_unmatched_glob(&self) -> bool {
54        matches!(self, Self::Expanded(paths) if paths.is_empty())
55    }
56}
57
58/// Encapsulates a shell pattern.
59#[derive(Clone, Debug)]
60pub struct Pattern {
61    pieces: PatternWord,
62    enable_extended_globbing: bool,
63    multiline: bool,
64    case_insensitive: bool,
65}
66
67impl Default for Pattern {
68    fn default() -> Self {
69        Self {
70            pieces: vec![],
71            enable_extended_globbing: false,
72            multiline: true,
73            case_insensitive: false,
74        }
75    }
76}
77
78impl From<PatternWord> for Pattern {
79    fn from(pieces: PatternWord) -> Self {
80        Self {
81            pieces,
82            ..Default::default()
83        }
84    }
85}
86
87impl From<&PatternWord> for Pattern {
88    fn from(value: &PatternWord) -> Self {
89        Self {
90            pieces: value.clone(),
91            ..Default::default()
92        }
93    }
94}
95
96impl From<&str> for Pattern {
97    fn from(value: &str) -> Self {
98        Self {
99            pieces: vec![PatternPiece::Pattern(value.to_owned())],
100            ..Default::default()
101        }
102    }
103}
104
105impl From<String> for Pattern {
106    fn from(value: String) -> Self {
107        Self {
108            pieces: vec![PatternPiece::Pattern(value)],
109            ..Default::default()
110        }
111    }
112}
113
114impl Pattern {
115    /// Enables (or disables) extended globbing support for this pattern.
116    ///
117    /// # Arguments
118    ///
119    /// * `value` - Whether or not to enable extended globbing (extglob).
120    #[must_use]
121    pub const fn set_extended_globbing(mut self, value: bool) -> Self {
122        self.enable_extended_globbing = value;
123        self
124    }
125
126    /// Enables (or disables) multiline support for this pattern.
127    ///
128    /// # Arguments
129    ///
130    /// * `value` - Whether or not to enable multiline matching.
131    #[must_use]
132    pub const fn set_multiline(mut self, value: bool) -> Self {
133        self.multiline = value;
134        self
135    }
136
137    /// Enables (or disables) case-insensitive matching for this pattern.
138    ///
139    /// # Arguments
140    ///
141    /// * `value` - Whether or not to enable case-insensitive matching.
142    #[must_use]
143    pub const fn set_case_insensitive(mut self, value: bool) -> Self {
144        self.case_insensitive = value;
145        self
146    }
147
148    /// Returns whether or not the pattern is empty.
149    pub fn is_empty(&self) -> bool {
150        self.pieces.iter().all(|p| p.as_str().is_empty())
151    }
152
153    /// Placeholder function that always returns true.
154    pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool {
155        true
156    }
157
158    /// Expands the pattern into a list of matching file paths.
159    ///
160    /// # Arguments
161    ///
162    /// * `working_dir` - The current working directory, used for relative paths.
163    /// * `path_filter` - Optionally provides a function that filters paths after expansion.
164    #[expect(clippy::too_many_lines)]
165    pub(crate) fn expand<PF>(
166        &self,
167        working_dir: &Path,
168        path_filter: Option<&PF>,
169        options: &FilenameExpansionOptions,
170    ) -> Result<PatternExpansionResult, error::Error>
171    where
172        PF: Fn(&Path) -> bool,
173    {
174        // If the pattern has no pieces at all, short-circuit; there's nothing to expand.
175        // Note: we intentionally do NOT short-circuit when pieces are present but empty
176        // (e.g. from a quoted empty string ""); those fall through to the literal branch
177        // below which correctly returns Expanded([""]) instead of NoGlob, preserving
178        // the argument even when nullglob is enabled.
179        if self.pieces.is_empty() {
180            return Ok(PatternExpansionResult::NoGlob);
181
182        // Similarly, if we're *confident* the pattern doesn't require expansion, then we
183        // know there's a single expansion (before filtering).
184        } else if !self.pieces.iter().any(|piece| {
185            matches!(piece, PatternPiece::Pattern(_))
186                && requires_expansion(piece.as_str(), self.enable_extended_globbing)
187        }) {
188            let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect();
189
190            if let Some(filter) = path_filter
191                && !filter(Path::new(&concatenated))
192            {
193                // No globs, but the literal was filtered out. Return NoGlob
194                // (not Expanded) so that callers don't mistake this for a
195                // failed glob match (which would trigger failglob).
196                return Ok(PatternExpansionResult::NoGlob);
197            }
198
199            return Ok(PatternExpansionResult::Expanded(vec![concatenated]));
200        }
201
202        tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}");
203
204        let mut components: Vec<PatternWord> = vec![];
205        for piece in &self.pieces {
206            let mut split_result: VecDeque<_> = sys::fs::split_path_for_pattern(piece.as_str())
207                .map(|s| match piece {
208                    PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()),
209                    PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()),
210                })
211                .collect();
212
213            if let Some(first_piece) = split_result.pop_front() {
214                if let Some(last_component) = components.last_mut() {
215                    last_component.push(first_piece);
216                } else {
217                    components.push(vec![first_piece]);
218                }
219            }
220
221            while let Some(piece) = split_result.pop_front() {
222                components.push(vec![piece]);
223            }
224        }
225
226        // Check if the path appears to be absolute by inspecting the first component.
227        // On Unix, a leading `/` produces an empty first component. On Windows, a
228        // drive-letter prefix like `c:` is also recognized. The platform-specific
229        // logic lives in `sys::fs::pattern_path_root`.
230        let absolute_root = components.first().and_then(|first_component| {
231            let flattened: String = first_component.iter().map(|p| p.as_str()).collect();
232            sys::fs::pattern_path_root(&flattened)
233        });
234
235        let prefix_to_remove;
236        let mut paths_so_far = if let Some(root) = absolute_root {
237            prefix_to_remove = None;
238            // Skip the first component; it was consumed to determine the root.
239            components.remove(0);
240            vec![root]
241        } else {
242            // Build a prefix to remove after glob expansion so results are
243            // returned relative to the working directory. The prefix is
244            // normalized to use `/` separators because `push_path_for_pattern`
245            // also uses `/` on Windows (to avoid `PathBuf::push` drive-letter
246            // semantics) β€” if we left `\` here, the strip_prefix below would
247            // miss on Windows and leave results as absolute paths.
248            let working_dir_str = working_dir.to_string_lossy();
249            let mut working_dir_str =
250                sys::fs::normalize_path_separators(&working_dir_str).into_owned();
251            if !working_dir_str.ends_with('/') {
252                working_dir_str.push('/');
253            }
254
255            prefix_to_remove = Some(working_dir_str);
256            vec![working_dir.to_path_buf()]
257        };
258
259        for component in components {
260            if !component.iter().any(|piece| {
261                matches!(piece, PatternPiece::Pattern(_))
262                    && requires_expansion(piece.as_str(), self.enable_extended_globbing)
263            }) {
264                for p in &mut paths_so_far {
265                    let flattened = component
266                        .iter()
267                        .map(|piece| piece.as_str())
268                        .collect::<String>();
269                    sys::fs::push_path_for_pattern(p, &flattened);
270                }
271                continue;
272            }
273
274            let current_paths = std::mem::take(&mut paths_so_far);
275            for current_path in current_paths {
276                let subpattern = Self::from(&component)
277                    .set_extended_globbing(self.enable_extended_globbing)
278                    .set_case_insensitive(self.case_insensitive);
279
280                let subpattern_starts_with_dot = subpattern
281                    .pieces
282                    .first()
283                    .is_some_and(|piece| piece.as_str().starts_with('.'));
284
285                let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files
286                    || subpattern_starts_with_dot;
287
288                let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| {
289                    !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files
290                };
291
292                let regex = subpattern.to_regex(true, true)?;
293                let matches_regex = |dir_entry: &std::fs::DirEntry| {
294                    regex
295                        .is_match(dir_entry.file_name().to_string_lossy().as_ref())
296                        .unwrap_or(false)
297                };
298
299                let mut matching_paths_in_dir: Vec<_> = current_path
300                    .read_dir()
301                    .map_or_else(|_| vec![], |dir| dir.into_iter().collect())
302                    .into_iter()
303                    .filter_map(|result| result.ok())
304                    .filter(matches_regex)
305                    .filter(matches_dotfile_policy)
306                    .map(|entry| entry.path())
307                    .collect();
308
309                matching_paths_in_dir.sort();
310
311                paths_so_far.append(&mut matching_paths_in_dir);
312            }
313        }
314
315        let results: Vec<_> = paths_so_far
316            .into_iter()
317            .filter_map(|path| {
318                if let Some(filter) = path_filter
319                    && !filter(path.as_path())
320                {
321                    return None;
322                }
323
324                // Normalize separators *before* stripping the working-dir
325                // prefix so that `prefix_to_remove` (already normalized to
326                // use `/`) matches paths that may contain a mix of `\` and
327                // `/` on Windows.
328                let path_str = path.to_string_lossy();
329                let normalized = sys::fs::normalize_path_separators(&path_str);
330                let mut path_ref: &str = normalized.as_ref();
331
332                if let Some(prefix_to_remove) = &prefix_to_remove
333                    && let Some(stripped) = path_ref.strip_prefix(prefix_to_remove.as_str())
334                {
335                    path_ref = stripped;
336                }
337
338                Some(path_ref.to_string())
339            })
340            .collect();
341
342        tracing::debug!(target: trace_categories::PATTERN, "  => results: {results:?}");
343
344        Ok(PatternExpansionResult::Expanded(results))
345    }
346
347    /// Converts the pattern to a regular expression string.
348    ///
349    /// # Arguments
350    ///
351    /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
352    ///   the string.
353    /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
354    ///   string.
355    pub(crate) fn to_regex_str(
356        &self,
357        strict_prefix_match: bool,
358        strict_suffix_match: bool,
359    ) -> Result<String, error::Error> {
360        let mut regex_str = String::new();
361
362        if strict_prefix_match {
363            regex_str.push('^');
364        }
365
366        let mut current_pattern = String::new();
367        for piece in &self.pieces {
368            match piece {
369                PatternPiece::Pattern(s) => {
370                    current_pattern.push_str(s);
371                }
372                PatternPiece::Literal(s) => {
373                    for c in s.chars() {
374                        if crate::regex::regex_char_is_special(c) {
375                            current_pattern.push('\\');
376                        }
377                        current_pattern.push(c);
378                    }
379                }
380            }
381        }
382
383        let regex_piece =
384            pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?;
385        regex_str.push_str(regex_piece.as_str());
386
387        if strict_suffix_match {
388            regex_str.push('$');
389        }
390
391        Ok(regex_str)
392    }
393
394    /// Converts the pattern to a regular expression.
395    ///
396    /// # Arguments
397    ///
398    /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
399    ///   the string.
400    /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
401    ///   string.
402    pub(crate) fn to_regex(
403        &self,
404        strict_prefix_match: bool,
405        strict_suffix_match: bool,
406    ) -> Result<fancy_regex::Regex, error::Error> {
407        let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?;
408
409        tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'");
410
411        let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?;
412        Ok(re)
413    }
414
415    /// Checks if the pattern exactly matches the given string. An error result
416    /// is returned if the pattern is found to be invalid or malformed
417    /// during processing.
418    ///
419    /// # Arguments
420    ///
421    /// * `value` - The string to check for a match.
422    pub fn exactly_matches(&self, value: &str) -> Result<bool, error::Error> {
423        let re = self.to_regex(true, true)?;
424        Ok(re.is_match(value)?)
425    }
426}
427
428/// Checks whether a string contains glob metacharacters that would trigger
429/// pathname expansion. Delegates to the pattern parser's grammar, which is
430/// the single source of truth for what constitutes a glob metacharacter.
431fn requires_expansion(s: &str, enable_extended_globbing: bool) -> bool {
432    brush_parser::pattern::pattern_has_glob_metacharacters(s, enable_extended_globbing)
433}
434
435fn pattern_to_regex_str(
436    pattern: &str,
437    enable_extended_globbing: bool,
438) -> Result<String, error::Error> {
439    Ok(brush_parser::pattern::pattern_to_regex_str(
440        pattern,
441        enable_extended_globbing,
442    )?)
443}
444
445/// Removes the largest matching prefix from a string that matches the given pattern.
446///
447/// # Arguments
448///
449/// * `s` - The string to remove the prefix from.
450/// * `pattern` - The pattern to match.
451pub(crate) fn remove_largest_matching_prefix<'a>(
452    s: &'a str,
453    pattern: Option<&Pattern>,
454) -> Result<&'a str, error::Error> {
455    if let Some(pattern) = pattern {
456        let re = pattern.to_regex(true, true)?;
457        let indices = s.char_indices().rev();
458        let mut last_idx = s.len();
459
460        #[allow(
461            clippy::string_slice,
462            reason = "because we get the indices from char_indices()"
463        )]
464        for (idx, _) in indices {
465            let prefix = &s[0..last_idx];
466            if re.is_match(prefix)? {
467                return Ok(&s[last_idx..]);
468            }
469
470            last_idx = idx;
471        }
472    }
473    Ok(s)
474}
475
476/// Removes the smallest matching prefix from a string that matches the given pattern.
477///
478/// # Arguments
479///
480/// * `s` - The string to remove the prefix from.
481/// * `pattern` - The pattern to match.
482pub(crate) fn remove_smallest_matching_prefix<'a>(
483    s: &'a str,
484    pattern: Option<&Pattern>,
485) -> Result<&'a str, error::Error> {
486    if let Some(pattern) = pattern {
487        let re = pattern.to_regex(true, true)?;
488        let mut indices = s.char_indices();
489
490        #[allow(
491            clippy::string_slice,
492            reason = "because we get the indices from char_indices()"
493        )]
494        while indices.next().is_some() {
495            let next_index = indices.offset();
496            let prefix = &s[0..next_index];
497            if re.is_match(prefix)? {
498                return Ok(&s[next_index..]);
499            }
500        }
501    }
502    Ok(s)
503}
504
505/// Removes the largest matching suffix from a string that matches the given pattern.
506///
507/// # Arguments
508///
509/// * `s` - The string to remove the suffix from.
510/// * `pattern` - The pattern to match.
511pub(crate) fn remove_largest_matching_suffix<'a>(
512    s: &'a str,
513    pattern: Option<&Pattern>,
514) -> Result<&'a str, error::Error> {
515    if let Some(pattern) = pattern {
516        let re = pattern.to_regex(true, true)?;
517        #[allow(
518            clippy::string_slice,
519            reason = "because we get the indices from char_indices()"
520        )]
521        for (idx, _) in s.char_indices() {
522            let suffix = &s[idx..];
523            if re.is_match(suffix)? {
524                return Ok(&s[..idx]);
525            }
526        }
527    }
528    Ok(s)
529}
530
531/// Removes the smallest matching suffix from a string that matches the given pattern.
532///
533/// # Arguments
534///
535/// * `s` - The string to remove the suffix from.
536/// * `pattern` - The pattern to match.
537pub(crate) fn remove_smallest_matching_suffix<'a>(
538    s: &'a str,
539    pattern: Option<&Pattern>,
540) -> Result<&'a str, error::Error> {
541    if let Some(pattern) = pattern {
542        let re = pattern.to_regex(true, true)?;
543        #[allow(
544            clippy::string_slice,
545            reason = "because we get the indices from char_indices()"
546        )]
547        for (idx, _) in s.char_indices().rev() {
548            let suffix = &s[idx..];
549            if re.is_match(suffix)? {
550                return Ok(&s[..idx]);
551            }
552        }
553    }
554    Ok(s)
555}
556
557#[cfg(test)]
558#[expect(clippy::panic_in_result_fn)]
559mod tests {
560    use super::*;
561    use anyhow::Result;
562
563    fn pattern_to_exact_regex_str<P>(pattern: P) -> Result<String, error::Error>
564    where
565        P: Into<Pattern>,
566    {
567        let pattern: Pattern = pattern
568            .into()
569            .set_extended_globbing(true)
570            .set_multiline(false);
571
572        pattern.to_regex_str(true, true)
573    }
574
575    #[test]
576    fn test_pattern_translation() -> Result<()> {
577        assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$");
578        assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
579        assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
580        assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$");
581        assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$");
582        assert_eq!(
583            pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
584            "^a(ab|ac)*$"
585        );
586        assert_eq!(
587            pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
588            "^a(ab|ac)+$"
589        );
590        assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
591        assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$");
592        assert_eq!(
593            pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
594            r"^[<{().\[].*$"
595        );
596        assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
597        assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");
598
599        Ok(())
600    }
601
602    #[test]
603    fn test_pattern_word_translation() -> Result<()> {
604        assert_eq!(
605            pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(),
606            "^a.*$"
607        );
608        assert_eq!(
609            pattern_to_exact_regex_str(vec![
610                PatternPiece::Pattern("a*".to_owned()),
611                PatternPiece::Literal("b".to_owned()),
612            ])?
613            .as_str(),
614            "^a.*b$"
615        );
616        assert_eq!(
617            pattern_to_exact_regex_str(vec![
618                PatternPiece::Literal("a*".to_owned()),
619                PatternPiece::Pattern("b".to_owned()),
620            ])?
621            .as_str(),
622            r"^a\*b$"
623        );
624
625        Ok(())
626    }
627
628    #[test]
629    fn test_remove_largest_matching_prefix() -> Result<()> {
630        assert_eq!(
631            remove_largest_matching_prefix("ooof", Some(&Pattern::from("")))?,
632            "ooof"
633        );
634        assert_eq!(
635            remove_largest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
636            "ooof"
637        );
638        assert_eq!(
639            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
640            "oof"
641        );
642        assert_eq!(
643            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
644            "f"
645        );
646        assert_eq!(
647            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
648            ""
649        );
650        assert_eq!(
651            remove_largest_matching_prefix("πŸš€πŸš€πŸš€rocket", Some(&Pattern::from("πŸš€")))?,
652            "πŸš€πŸš€rocket"
653        );
654        Ok(())
655    }
656
657    #[test]
658    fn test_remove_smallest_matching_prefix() -> Result<()> {
659        assert_eq!(
660            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("")))?,
661            "ooof"
662        );
663        assert_eq!(
664            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
665            "ooof"
666        );
667        assert_eq!(
668            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
669            "oof"
670        );
671        assert_eq!(
672            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
673            "of"
674        );
675        assert_eq!(
676            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
677            "oof"
678        );
679        assert_eq!(
680            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("ooof")))?,
681            ""
682        );
683        assert_eq!(
684            remove_smallest_matching_prefix("πŸš€πŸš€πŸš€rocket", Some(&Pattern::from("πŸš€")))?,
685            "πŸš€πŸš€rocket"
686        );
687        Ok(())
688    }
689
690    #[test]
691    fn test_remove_largest_matching_suffix() -> Result<()> {
692        assert_eq!(
693            remove_largest_matching_suffix("foo", Some(&Pattern::from("")))?,
694            "foo"
695        );
696        assert_eq!(
697            remove_largest_matching_suffix("foo", Some(&Pattern::from("x")))?,
698            "foo"
699        );
700        assert_eq!(
701            remove_largest_matching_suffix("foo", Some(&Pattern::from("o")))?,
702            "fo"
703        );
704        assert_eq!(
705            remove_largest_matching_suffix("foo", Some(&Pattern::from("o*")))?,
706            "f"
707        );
708        assert_eq!(
709            remove_largest_matching_suffix("foo", Some(&Pattern::from("foo")))?,
710            ""
711        );
712        assert_eq!(
713            remove_largest_matching_suffix("rocketπŸš€πŸš€πŸš€", Some(&Pattern::from("πŸš€")))?,
714            "rocketπŸš€πŸš€"
715        );
716        Ok(())
717    }
718
719    #[test]
720    fn test_remove_smallest_matching_suffix() -> Result<()> {
721        assert_eq!(
722            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("")))?,
723            "fooo"
724        );
725        assert_eq!(
726            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("x")))?,
727            "fooo"
728        );
729        assert_eq!(
730            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o")))?,
731            "foo"
732        );
733        assert_eq!(
734            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*o")))?,
735            "fo"
736        );
737        assert_eq!(
738            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*")))?,
739            "foo"
740        );
741        assert_eq!(
742            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("fooo")))?,
743            ""
744        );
745        assert_eq!(
746            remove_smallest_matching_suffix("rocketπŸš€πŸš€πŸš€", Some(&Pattern::from("πŸš€")))?,
747            "rocketπŸš€πŸš€"
748        );
749        Ok(())
750    }
751
752    #[test]
753    #[expect(clippy::cognitive_complexity)]
754    fn test_matching() -> Result<()> {
755        assert!(Pattern::from("abc").exactly_matches("abc")?);
756
757        assert!(!Pattern::from("abc").exactly_matches("ABC")?);
758        assert!(!Pattern::from("abc").exactly_matches("xabcx")?);
759        assert!(!Pattern::from("abc").exactly_matches("")?);
760        assert!(!Pattern::from("abc").exactly_matches("abcd")?);
761        assert!(!Pattern::from("abc").exactly_matches("def")?);
762
763        assert!(Pattern::from("*").exactly_matches("")?);
764        assert!(Pattern::from("*").exactly_matches("abc")?);
765        assert!(Pattern::from("*").exactly_matches(" ")?);
766
767        assert!(Pattern::from("a*").exactly_matches("a")?);
768        assert!(Pattern::from("a*").exactly_matches("ab")?);
769        assert!(Pattern::from("a*").exactly_matches("a ")?);
770
771        assert!(!Pattern::from("a*").exactly_matches("A")?);
772        assert!(!Pattern::from("a*").exactly_matches("")?);
773        assert!(!Pattern::from("a*").exactly_matches("bc")?);
774        assert!(!Pattern::from("a*").exactly_matches("xax")?);
775        assert!(!Pattern::from("a*").exactly_matches(" a")?);
776
777        assert!(Pattern::from("*a").exactly_matches("a")?);
778        assert!(Pattern::from("*a").exactly_matches("ba")?);
779        assert!(Pattern::from("*a").exactly_matches("aa")?);
780        assert!(Pattern::from("*a").exactly_matches(" a")?);
781
782        assert!(!Pattern::from("*a").exactly_matches("BA")?);
783        assert!(!Pattern::from("*a").exactly_matches("")?);
784        assert!(!Pattern::from("*a").exactly_matches("ab")?);
785        assert!(!Pattern::from("*a").exactly_matches("xax")?);
786
787        Ok(())
788    }
789
790    fn make_extglob(s: &str) -> Pattern {
791        let pattern = Pattern::from(s).set_extended_globbing(true);
792        let regex_str = pattern.to_regex_str(true, true).unwrap();
793        eprintln!("pattern: '{s}' => regex: '{regex_str}'");
794
795        pattern
796    }
797
798    #[test]
799    fn test_extglob_or_matching() -> Result<()> {
800        assert!(make_extglob("@(a|b)").exactly_matches("a")?);
801        assert!(make_extglob("@(a|b)").exactly_matches("b")?);
802
803        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
804        assert!(!make_extglob("@(a|b)").exactly_matches("c")?);
805        assert!(!make_extglob("@(a|b)").exactly_matches("ab")?);
806
807        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
808        assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?);
809        assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?);
810        assert!(make_extglob("@(a*b|b)").exactly_matches("b")?);
811
812        assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?);
813
814        Ok(())
815    }
816
817    #[test]
818    fn test_extglob_not_matching() -> Result<()> {
819        // Basic cases.
820        assert!(make_extglob("!(a)").exactly_matches("")?);
821        assert!(make_extglob("!(a)").exactly_matches(" ")?);
822        assert!(make_extglob("!(a)").exactly_matches("x")?);
823        assert!(make_extglob("!(a)").exactly_matches(" a ")?);
824        assert!(make_extglob("!(a)").exactly_matches("a ")?);
825        assert!(make_extglob("!(a)").exactly_matches("aa")?);
826        assert!(!make_extglob("!(a)").exactly_matches("a")?);
827
828        assert!(make_extglob("a!(a)a").exactly_matches("aa")?);
829        assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?);
830        assert!(make_extglob("a!(a)a").exactly_matches("aba")?);
831        assert!(!make_extglob("a!(a)a").exactly_matches("a")?);
832        assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?);
833        assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?);
834
835        // Alternates.
836        assert!(make_extglob("!(a|b)").exactly_matches("c")?);
837        assert!(make_extglob("!(a|b)").exactly_matches("ab")?);
838        assert!(make_extglob("!(a|b)").exactly_matches("aa")?);
839        assert!(make_extglob("!(a|b)").exactly_matches("bb")?);
840        assert!(!make_extglob("!(a|b)").exactly_matches("a")?);
841        assert!(!make_extglob("!(a|b)").exactly_matches("b")?);
842
843        Ok(())
844    }
845
846    #[test]
847    fn test_extglob_advanced_not_matching() -> Result<()> {
848        assert!(make_extglob("!(a*)").exactly_matches("b")?);
849        assert!(make_extglob("!(a*)").exactly_matches("")?);
850        assert!(!make_extglob("!(a*)").exactly_matches("a")?);
851        assert!(!make_extglob("!(a*)").exactly_matches("abc")?);
852        assert!(!make_extglob("!(a*)").exactly_matches("aabc")?);
853
854        Ok(())
855    }
856
857    #[test]
858    fn test_extglob_not_degenerate_matching() -> Result<()> {
859        // Degenerate case.
860        assert!(make_extglob("!()").exactly_matches("a")?);
861        assert!(!make_extglob("!()").exactly_matches("")?);
862
863        Ok(())
864    }
865
866    #[test]
867    fn test_extglob_zero_or_more_matching() -> Result<()> {
868        assert!(make_extglob("x*(a)x").exactly_matches("xx")?);
869        assert!(make_extglob("x*(a)x").exactly_matches("xax")?);
870        assert!(make_extglob("x*(a)x").exactly_matches("xaax")?);
871
872        assert!(!make_extglob("x*(a)x").exactly_matches("x")?);
873        assert!(!make_extglob("x*(a)x").exactly_matches("xa")?);
874        assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?);
875
876        assert!(make_extglob("*(a|b)").exactly_matches("")?);
877        assert!(make_extglob("*(a|b)").exactly_matches("a")?);
878        assert!(make_extglob("*(a|b)").exactly_matches("b")?);
879        assert!(make_extglob("*(a|b)").exactly_matches("aba")?);
880        assert!(make_extglob("*(a|b)").exactly_matches("aaa")?);
881
882        assert!(!make_extglob("*(a|b)").exactly_matches("c")?);
883        assert!(!make_extglob("*(a|b)").exactly_matches("ca")?);
884
885        Ok(())
886    }
887
888    #[test]
889    fn test_extglob_one_or_more_matching() -> Result<()> {
890        fn make_extglob(s: &str) -> Pattern {
891            Pattern::from(s).set_extended_globbing(true)
892        }
893
894        assert!(make_extglob("x+(a)x").exactly_matches("xax")?);
895        assert!(make_extglob("x+(a)x").exactly_matches("xaax")?);
896
897        assert!(!make_extglob("x+(a)x").exactly_matches("xx")?);
898        assert!(!make_extglob("x+(a)x").exactly_matches("x")?);
899        assert!(!make_extglob("x+(a)x").exactly_matches("xa")?);
900        assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?);
901
902        assert!(make_extglob("+(a|b)").exactly_matches("a")?);
903        assert!(make_extglob("+(a|b)").exactly_matches("b")?);
904        assert!(make_extglob("+(a|b)").exactly_matches("aba")?);
905        assert!(make_extglob("+(a|b)").exactly_matches("aaa")?);
906
907        assert!(!make_extglob("+(a|b)").exactly_matches("")?);
908        assert!(!make_extglob("+(a|b)").exactly_matches("c")?);
909        assert!(!make_extglob("+(a|b)").exactly_matches("ca")?);
910
911        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?);
912        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?);
913        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?);
914        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?);
915
916        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?);
917        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?);
918        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?);
919
920        Ok(())
921    }
922
923    #[test]
924    fn test_requires_expansion() {
925        // Delegates to the PEG grammar; thorough coverage is in brush-parser.
926        // Here we just verify the integration works.
927        assert!(requires_expansion("*", false));
928        assert!(requires_expansion("[abc]", false));
929        assert!(!requires_expansion("]", false));
930        assert!(!requires_expansion("hello", false));
931        assert!(!requires_expansion("@(a)", false));
932        assert!(requires_expansion("@(a)", true));
933    }
934
935    /// Extracts the `Expanded` payload from a `PatternExpansionResult`,
936    /// failing the test via `anyhow::bail!` otherwise. Avoids `panic!` which
937    /// is forbidden by the workspace clippy config.
938    fn expect_expanded(result: PatternExpansionResult) -> Result<Vec<String>> {
939        let PatternExpansionResult::Expanded(paths) = result else {
940            anyhow::bail!("expected Expanded, got {result:?}");
941        };
942        Ok(paths)
943    }
944
945    /// Regression test for the Windows prefix-strip mismatch fix.
946    ///
947    /// On Windows (pre-fix), `expand` would build the `prefix_to_remove`
948    /// using the platform `MAIN_SEPARATOR` (`\`) while `push_path_for_pattern`
949    /// appended components with `/`. The resulting mismatch made the
950    /// `strip_prefix` call a no-op, so relative globs produced absolute
951    /// paths. This test exercises the expansion path and verifies results
952    /// are returned relative to the working directory.
953    ///
954    /// On Unix, the same code path is exercised (both builds go through the
955    /// shared `normalize_path_separators` helpers), so this test serves as a
956    /// regression guard on both platforms.
957    #[test]
958    fn test_relative_glob_returns_relative_paths() -> Result<()> {
959        let scratch = tempfile::tempdir()?;
960        let sub = scratch.path().join("sub");
961        std::fs::create_dir_all(&sub)?;
962        std::fs::write(sub.join("a.txt"), "")?;
963        std::fs::write(sub.join("b.txt"), "")?;
964
965        let pattern = Pattern::from("sub/*.txt").set_extended_globbing(false);
966        let result = pattern.expand::<fn(&Path) -> bool>(
967            scratch.path(),
968            None,
969            &FilenameExpansionOptions::default(),
970        )?;
971
972        let paths = expect_expanded(result)?;
973
974        let mut sorted = paths.clone();
975        sorted.sort();
976        assert_eq!(
977            sorted,
978            vec!["sub/a.txt".to_string(), "sub/b.txt".to_string()]
979        );
980
981        // None of the results should contain the absolute scratch path.
982        let scratch_str: String = scratch.path().to_string_lossy().into_owned();
983        for p in &paths {
984            assert!(
985                !p.contains(scratch_str.as_str()),
986                "result {p:?} still contains absolute working-dir prefix {scratch_str:?}"
987            );
988        }
989
990        Ok(())
991    }
992
993    /// Verifies absolute-pattern expansion still works after the prefix
994    /// handling changes.
995    #[test]
996    fn test_absolute_glob_returns_absolute_paths() -> Result<()> {
997        let scratch = tempfile::tempdir()?;
998        std::fs::write(scratch.path().join("one.log"), "")?;
999        std::fs::write(scratch.path().join("two.log"), "")?;
1000
1001        let abs_pattern = format!("{}/*.log", scratch.path().to_string_lossy());
1002        // Normalize to forward slashes so the test works consistently across
1003        // platforms; the expander's `pattern_path_root` handles both.
1004        let abs_pattern = abs_pattern.replace('\\', "/");
1005
1006        let pattern = Pattern::from(abs_pattern.as_str()).set_extended_globbing(false);
1007        let result = pattern.expand::<fn(&Path) -> bool>(
1008            Path::new("/"),
1009            None,
1010            &FilenameExpansionOptions::default(),
1011        )?;
1012
1013        let paths = expect_expanded(result)?;
1014
1015        assert_eq!(paths.len(), 2, "unexpected results: {paths:?}");
1016        let scratch_normalized: String = scratch.path().to_string_lossy().replace('\\', "/");
1017        for p in &paths {
1018            // Use a plain byte-level suffix check rather than `Path::extension`
1019            // since the results are strings and clippy flags `ends_with(".log")`
1020            // as potentially case-sensitive. We explicitly wrote lowercase files.
1021            assert!(p.as_bytes().ends_with(b".log"), "unexpected result {p:?}");
1022            // Should still reference the scratch directory (i.e., absolute).
1023            assert!(
1024                p.contains(scratch_normalized.as_str()),
1025                "absolute result {p:?} should contain {scratch_normalized:?}"
1026            );
1027        }
1028
1029        Ok(())
1030    }
1031}