Skip to main content

shrub_core/
patterns.rs

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