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    pub fn exactly_matches(&self, value: &str) -> Result<bool, error::Error> {
442        let re = self.to_regex(true, true)?;
443        Ok(re.is_match(value)?)
444    }
445}
446
447/// Checks whether a string contains glob metacharacters that would trigger
448/// pathname expansion. Delegates to the pattern parser's grammar, which is
449/// the single source of truth for what constitutes a glob metacharacter.
450fn requires_expansion(s: &str, enable_extended_globbing: bool) -> bool {
451    shrub_parser::pattern::pattern_has_glob_metacharacters(s, enable_extended_globbing)
452}
453
454fn pattern_to_regex_str(
455    pattern: &str,
456    enable_extended_globbing: bool,
457) -> Result<String, error::Error> {
458    Ok(shrub_parser::pattern::pattern_to_regex_str(
459        pattern,
460        enable_extended_globbing,
461    )?)
462}
463
464/// Removes the largest matching prefix from a string that matches the given
465/// pattern.
466///
467/// # Arguments
468///
469/// * `s` - The string to remove the prefix from.
470/// * `pattern` - The pattern to match.
471pub(crate) fn remove_largest_matching_prefix<'a>(
472    s: &'a str,
473    pattern: Option<&Pattern>,
474) -> Result<&'a str, error::Error> {
475    if let Some(pattern) = pattern {
476        let re = pattern.to_regex(true, true)?;
477        let indices = s.char_indices().rev();
478        let mut last_idx = s.len();
479
480        #[allow(
481            clippy::string_slice,
482            reason = "because we get the indices from char_indices()"
483        )]
484        for (idx, _) in indices {
485            let prefix = &s[0..last_idx];
486            if re.is_match(prefix)? {
487                return Ok(&s[last_idx..]);
488            }
489
490            last_idx = idx;
491        }
492    }
493    Ok(s)
494}
495
496/// Removes the smallest matching prefix from a string that matches the given
497/// pattern.
498///
499/// # Arguments
500///
501/// * `s` - The string to remove the prefix from.
502/// * `pattern` - The pattern to match.
503pub(crate) fn remove_smallest_matching_prefix<'a>(
504    s: &'a str,
505    pattern: Option<&Pattern>,
506) -> Result<&'a str, error::Error> {
507    if let Some(pattern) = pattern {
508        let re = pattern.to_regex(true, true)?;
509        let mut indices = s.char_indices();
510
511        #[allow(
512            clippy::string_slice,
513            reason = "because we get the indices from char_indices()"
514        )]
515        while indices.next().is_some() {
516            let next_index = indices.offset();
517            let prefix = &s[0..next_index];
518            if re.is_match(prefix)? {
519                return Ok(&s[next_index..]);
520            }
521        }
522    }
523    Ok(s)
524}
525
526/// Removes the largest matching suffix from a string that matches the given
527/// pattern.
528///
529/// # Arguments
530///
531/// * `s` - The string to remove the suffix from.
532/// * `pattern` - The pattern to match.
533pub(crate) fn remove_largest_matching_suffix<'a>(
534    s: &'a str,
535    pattern: Option<&Pattern>,
536) -> Result<&'a str, error::Error> {
537    if let Some(pattern) = pattern {
538        let re = pattern.to_regex(true, true)?;
539        #[allow(
540            clippy::string_slice,
541            reason = "because we get the indices from char_indices()"
542        )]
543        for (idx, _) in s.char_indices() {
544            let suffix = &s[idx..];
545            if re.is_match(suffix)? {
546                return Ok(&s[..idx]);
547            }
548        }
549    }
550    Ok(s)
551}
552
553/// Removes the smallest matching suffix from a string that matches the given
554/// pattern.
555///
556/// # Arguments
557///
558/// * `s` - The string to remove the suffix from.
559/// * `pattern` - The pattern to match.
560pub(crate) fn remove_smallest_matching_suffix<'a>(
561    s: &'a str,
562    pattern: Option<&Pattern>,
563) -> Result<&'a str, error::Error> {
564    if let Some(pattern) = pattern {
565        let re = pattern.to_regex(true, true)?;
566        #[allow(
567            clippy::string_slice,
568            reason = "because we get the indices from char_indices()"
569        )]
570        for (idx, _) in s.char_indices().rev() {
571            let suffix = &s[idx..];
572            if re.is_match(suffix)? {
573                return Ok(&s[..idx]);
574            }
575        }
576    }
577    Ok(s)
578}
579
580#[cfg(test)]
581#[expect(clippy::panic_in_result_fn)]
582mod tests {
583    use anyhow::Result;
584
585    use super::*;
586
587    fn pattern_to_exact_regex_str<P>(pattern: P) -> Result<String, error::Error>
588    where
589        P: Into<Pattern>,
590    {
591        let pattern: Pattern = pattern
592            .into()
593            .set_extended_globbing(true)
594            .set_multiline(false);
595
596        pattern.to_regex_str(true, true)
597    }
598
599    #[test]
600    fn test_pattern_translation() -> Result<()> {
601        assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$");
602        assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
603        assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
604        assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$");
605        assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$");
606        assert_eq!(
607            pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
608            "^a(ab|ac)*$"
609        );
610        assert_eq!(
611            pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
612            "^a(ab|ac)+$"
613        );
614        assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
615        assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$");
616        assert_eq!(
617            pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
618            r"^[<{().\[].*$"
619        );
620        assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
621        assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");
622
623        Ok(())
624    }
625
626    #[test]
627    fn test_pattern_word_translation() -> Result<()> {
628        assert_eq!(
629            pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(),
630            "^a.*$"
631        );
632        assert_eq!(
633            pattern_to_exact_regex_str(vec![
634                PatternPiece::Pattern("a*".to_owned()),
635                PatternPiece::Literal("b".to_owned()),
636            ])?
637            .as_str(),
638            "^a.*b$"
639        );
640        assert_eq!(
641            pattern_to_exact_regex_str(vec![
642                PatternPiece::Literal("a*".to_owned()),
643                PatternPiece::Pattern("b".to_owned()),
644            ])?
645            .as_str(),
646            r"^a\*b$"
647        );
648
649        Ok(())
650    }
651
652    #[test]
653    fn test_remove_largest_matching_prefix() -> Result<()> {
654        assert_eq!(
655            remove_largest_matching_prefix("ooof", Some(&Pattern::from("")))?,
656            "ooof"
657        );
658        assert_eq!(
659            remove_largest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
660            "ooof"
661        );
662        assert_eq!(
663            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
664            "oof"
665        );
666        assert_eq!(
667            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
668            "f"
669        );
670        assert_eq!(
671            remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
672            ""
673        );
674        assert_eq!(
675            remove_largest_matching_prefix("πŸš€πŸš€πŸš€rocket", Some(&Pattern::from("πŸš€")))?,
676            "πŸš€πŸš€rocket"
677        );
678        Ok(())
679    }
680
681    #[test]
682    fn test_remove_smallest_matching_prefix() -> Result<()> {
683        assert_eq!(
684            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("")))?,
685            "ooof"
686        );
687        assert_eq!(
688            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("x")))?,
689            "ooof"
690        );
691        assert_eq!(
692            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o")))?,
693            "oof"
694        );
695        assert_eq!(
696            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?,
697            "of"
698        );
699        assert_eq!(
700            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*")))?,
701            "oof"
702        );
703        assert_eq!(
704            remove_smallest_matching_prefix("ooof", Some(&Pattern::from("ooof")))?,
705            ""
706        );
707        assert_eq!(
708            remove_smallest_matching_prefix("πŸš€πŸš€πŸš€rocket", Some(&Pattern::from("πŸš€")))?,
709            "πŸš€πŸš€rocket"
710        );
711        Ok(())
712    }
713
714    #[test]
715    fn test_remove_largest_matching_suffix() -> Result<()> {
716        assert_eq!(
717            remove_largest_matching_suffix("foo", Some(&Pattern::from("")))?,
718            "foo"
719        );
720        assert_eq!(
721            remove_largest_matching_suffix("foo", Some(&Pattern::from("x")))?,
722            "foo"
723        );
724        assert_eq!(
725            remove_largest_matching_suffix("foo", Some(&Pattern::from("o")))?,
726            "fo"
727        );
728        assert_eq!(
729            remove_largest_matching_suffix("foo", Some(&Pattern::from("o*")))?,
730            "f"
731        );
732        assert_eq!(
733            remove_largest_matching_suffix("foo", Some(&Pattern::from("foo")))?,
734            ""
735        );
736        assert_eq!(
737            remove_largest_matching_suffix("rocketπŸš€πŸš€πŸš€", Some(&Pattern::from("πŸš€")))?,
738            "rocketπŸš€πŸš€"
739        );
740        Ok(())
741    }
742
743    #[test]
744    fn test_remove_smallest_matching_suffix() -> Result<()> {
745        assert_eq!(
746            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("")))?,
747            "fooo"
748        );
749        assert_eq!(
750            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("x")))?,
751            "fooo"
752        );
753        assert_eq!(
754            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o")))?,
755            "foo"
756        );
757        assert_eq!(
758            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*o")))?,
759            "fo"
760        );
761        assert_eq!(
762            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*")))?,
763            "foo"
764        );
765        assert_eq!(
766            remove_smallest_matching_suffix("fooo", Some(&Pattern::from("fooo")))?,
767            ""
768        );
769        assert_eq!(
770            remove_smallest_matching_suffix("rocketπŸš€πŸš€πŸš€", Some(&Pattern::from("πŸš€")))?,
771            "rocketπŸš€πŸš€"
772        );
773        Ok(())
774    }
775
776    #[test]
777    #[expect(clippy::cognitive_complexity)]
778    fn test_matching() -> Result<()> {
779        assert!(Pattern::from("abc").exactly_matches("abc")?);
780
781        assert!(!Pattern::from("abc").exactly_matches("ABC")?);
782        assert!(!Pattern::from("abc").exactly_matches("xabcx")?);
783        assert!(!Pattern::from("abc").exactly_matches("")?);
784        assert!(!Pattern::from("abc").exactly_matches("abcd")?);
785        assert!(!Pattern::from("abc").exactly_matches("def")?);
786
787        assert!(Pattern::from("*").exactly_matches("")?);
788        assert!(Pattern::from("*").exactly_matches("abc")?);
789        assert!(Pattern::from("*").exactly_matches(" ")?);
790
791        assert!(Pattern::from("a*").exactly_matches("a")?);
792        assert!(Pattern::from("a*").exactly_matches("ab")?);
793        assert!(Pattern::from("a*").exactly_matches("a ")?);
794
795        assert!(!Pattern::from("a*").exactly_matches("A")?);
796        assert!(!Pattern::from("a*").exactly_matches("")?);
797        assert!(!Pattern::from("a*").exactly_matches("bc")?);
798        assert!(!Pattern::from("a*").exactly_matches("xax")?);
799        assert!(!Pattern::from("a*").exactly_matches(" a")?);
800
801        assert!(Pattern::from("*a").exactly_matches("a")?);
802        assert!(Pattern::from("*a").exactly_matches("ba")?);
803        assert!(Pattern::from("*a").exactly_matches("aa")?);
804        assert!(Pattern::from("*a").exactly_matches(" a")?);
805
806        assert!(!Pattern::from("*a").exactly_matches("BA")?);
807        assert!(!Pattern::from("*a").exactly_matches("")?);
808        assert!(!Pattern::from("*a").exactly_matches("ab")?);
809        assert!(!Pattern::from("*a").exactly_matches("xax")?);
810
811        Ok(())
812    }
813
814    fn make_extglob(s: &str) -> Pattern {
815        let pattern = Pattern::from(s).set_extended_globbing(true);
816        let regex_str = pattern.to_regex_str(true, true).unwrap();
817        eprintln!("pattern: '{s}' => regex: '{regex_str}'");
818
819        pattern
820    }
821
822    #[test]
823    fn test_extglob_or_matching() -> Result<()> {
824        assert!(make_extglob("@(a|b)").exactly_matches("a")?);
825        assert!(make_extglob("@(a|b)").exactly_matches("b")?);
826
827        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
828        assert!(!make_extglob("@(a|b)").exactly_matches("c")?);
829        assert!(!make_extglob("@(a|b)").exactly_matches("ab")?);
830
831        assert!(!make_extglob("@(a|b)").exactly_matches("")?);
832        assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?);
833        assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?);
834        assert!(make_extglob("@(a*b|b)").exactly_matches("b")?);
835
836        assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?);
837
838        Ok(())
839    }
840
841    #[test]
842    fn test_extglob_not_matching() -> Result<()> {
843        // Basic cases.
844        assert!(make_extglob("!(a)").exactly_matches("")?);
845        assert!(make_extglob("!(a)").exactly_matches(" ")?);
846        assert!(make_extglob("!(a)").exactly_matches("x")?);
847        assert!(make_extglob("!(a)").exactly_matches(" a ")?);
848        assert!(make_extglob("!(a)").exactly_matches("a ")?);
849        assert!(make_extglob("!(a)").exactly_matches("aa")?);
850        assert!(!make_extglob("!(a)").exactly_matches("a")?);
851
852        assert!(make_extglob("a!(a)a").exactly_matches("aa")?);
853        assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?);
854        assert!(make_extglob("a!(a)a").exactly_matches("aba")?);
855        assert!(!make_extglob("a!(a)a").exactly_matches("a")?);
856        assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?);
857        assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?);
858
859        // Alternates.
860        assert!(make_extglob("!(a|b)").exactly_matches("c")?);
861        assert!(make_extglob("!(a|b)").exactly_matches("ab")?);
862        assert!(make_extglob("!(a|b)").exactly_matches("aa")?);
863        assert!(make_extglob("!(a|b)").exactly_matches("bb")?);
864        assert!(!make_extglob("!(a|b)").exactly_matches("a")?);
865        assert!(!make_extglob("!(a|b)").exactly_matches("b")?);
866
867        Ok(())
868    }
869
870    #[test]
871    fn test_extglob_advanced_not_matching() -> Result<()> {
872        assert!(make_extglob("!(a*)").exactly_matches("b")?);
873        assert!(make_extglob("!(a*)").exactly_matches("")?);
874        assert!(!make_extglob("!(a*)").exactly_matches("a")?);
875        assert!(!make_extglob("!(a*)").exactly_matches("abc")?);
876        assert!(!make_extglob("!(a*)").exactly_matches("aabc")?);
877
878        Ok(())
879    }
880
881    #[test]
882    fn test_extglob_not_degenerate_matching() -> Result<()> {
883        // Degenerate case.
884        assert!(make_extglob("!()").exactly_matches("a")?);
885        assert!(!make_extglob("!()").exactly_matches("")?);
886
887        Ok(())
888    }
889
890    #[test]
891    fn test_extglob_zero_or_more_matching() -> Result<()> {
892        assert!(make_extglob("x*(a)x").exactly_matches("xx")?);
893        assert!(make_extglob("x*(a)x").exactly_matches("xax")?);
894        assert!(make_extglob("x*(a)x").exactly_matches("xaax")?);
895
896        assert!(!make_extglob("x*(a)x").exactly_matches("x")?);
897        assert!(!make_extglob("x*(a)x").exactly_matches("xa")?);
898        assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?);
899
900        assert!(make_extglob("*(a|b)").exactly_matches("")?);
901        assert!(make_extglob("*(a|b)").exactly_matches("a")?);
902        assert!(make_extglob("*(a|b)").exactly_matches("b")?);
903        assert!(make_extglob("*(a|b)").exactly_matches("aba")?);
904        assert!(make_extglob("*(a|b)").exactly_matches("aaa")?);
905
906        assert!(!make_extglob("*(a|b)").exactly_matches("c")?);
907        assert!(!make_extglob("*(a|b)").exactly_matches("ca")?);
908
909        Ok(())
910    }
911
912    #[test]
913    fn test_extglob_one_or_more_matching() -> Result<()> {
914        fn make_extglob(s: &str) -> Pattern {
915            Pattern::from(s).set_extended_globbing(true)
916        }
917
918        assert!(make_extglob("x+(a)x").exactly_matches("xax")?);
919        assert!(make_extglob("x+(a)x").exactly_matches("xaax")?);
920
921        assert!(!make_extglob("x+(a)x").exactly_matches("xx")?);
922        assert!(!make_extglob("x+(a)x").exactly_matches("x")?);
923        assert!(!make_extglob("x+(a)x").exactly_matches("xa")?);
924        assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?);
925
926        assert!(make_extglob("+(a|b)").exactly_matches("a")?);
927        assert!(make_extglob("+(a|b)").exactly_matches("b")?);
928        assert!(make_extglob("+(a|b)").exactly_matches("aba")?);
929        assert!(make_extglob("+(a|b)").exactly_matches("aaa")?);
930
931        assert!(!make_extglob("+(a|b)").exactly_matches("")?);
932        assert!(!make_extglob("+(a|b)").exactly_matches("c")?);
933        assert!(!make_extglob("+(a|b)").exactly_matches("ca")?);
934
935        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?);
936        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?);
937        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?);
938        assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?);
939
940        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?);
941        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?);
942        assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?);
943
944        Ok(())
945    }
946
947    #[test]
948    fn test_requires_expansion() {
949        // Delegates to the PEG grammar; thorough coverage is in shrub-parser.
950        // Here we just verify the integration works.
951        assert!(requires_expansion("*", false));
952        assert!(requires_expansion("[abc]", false));
953        assert!(!requires_expansion("]", false));
954        assert!(!requires_expansion("hello", false));
955        assert!(!requires_expansion("@(a)", false));
956        assert!(requires_expansion("@(a)", true));
957    }
958
959    /// Extracts the `Expanded` payload from a `PatternExpansionResult`,
960    /// failing the test via `anyhow::bail!` otherwise. Avoids `panic!` which
961    /// is forbidden by the workspace clippy config.
962    fn expect_expanded(result: PatternExpansionResult) -> Result<Vec<String>> {
963        let PatternExpansionResult::Expanded(paths) = result else {
964            anyhow::bail!("expected Expanded, got {result:?}");
965        };
966        Ok(paths)
967    }
968
969    /// Regression test for the Windows prefix-strip mismatch fix.
970    ///
971    /// On Windows (pre-fix), `expand` would build the `prefix_to_remove`
972    /// using the platform `MAIN_SEPARATOR` (`\`) while `push_path_for_pattern`
973    /// appended components with `/`. The resulting mismatch made the
974    /// `strip_prefix` call a no-op, so relative globs produced absolute
975    /// paths. This test exercises the expansion path and verifies results
976    /// are returned relative to the working directory.
977    ///
978    /// On Unix, the same code path is exercised (both builds go through the
979    /// shared `normalize_path_separators` helpers), so this test serves as a
980    /// regression guard on both platforms.
981    #[test]
982    fn test_relative_glob_returns_relative_paths() -> Result<()> {
983        let scratch = tempfile::tempdir()?;
984        let sub = scratch.path().join("sub");
985        std::fs::create_dir_all(&sub)?;
986        std::fs::write(sub.join("a.txt"), "")?;
987        std::fs::write(sub.join("b.txt"), "")?;
988
989        let pattern = Pattern::from("sub/*.txt").set_extended_globbing(false);
990        let result = pattern.expand::<fn(&Path) -> bool>(
991            scratch.path(),
992            None,
993            &FilenameExpansionOptions::default(),
994        )?;
995
996        let paths = expect_expanded(result)?;
997
998        let mut sorted = paths.clone();
999        sorted.sort();
1000        assert_eq!(
1001            sorted,
1002            vec!["sub/a.txt".to_string(), "sub/b.txt".to_string()]
1003        );
1004
1005        // None of the results should contain the absolute scratch path.
1006        let scratch_str: String = scratch.path().to_string_lossy().into_owned();
1007        for p in &paths {
1008            assert!(
1009                !p.contains(scratch_str.as_str()),
1010                "result {p:?} still contains absolute working-dir prefix {scratch_str:?}"
1011            );
1012        }
1013
1014        Ok(())
1015    }
1016
1017    /// Verifies absolute-pattern expansion still works after the prefix
1018    /// handling changes.
1019    #[test]
1020    fn test_absolute_glob_returns_absolute_paths() -> Result<()> {
1021        let scratch = tempfile::tempdir()?;
1022        std::fs::write(scratch.path().join("one.log"), "")?;
1023        std::fs::write(scratch.path().join("two.log"), "")?;
1024
1025        let abs_pattern = format!("{}/*.log", scratch.path().to_string_lossy());
1026        // Normalize to forward slashes so the test works consistently across
1027        // platforms; the expander's `pattern_path_root` handles both.
1028        let abs_pattern = abs_pattern.replace('\\', "/");
1029
1030        let pattern = Pattern::from(abs_pattern.as_str()).set_extended_globbing(false);
1031        let result = pattern.expand::<fn(&Path) -> bool>(
1032            Path::new("/"),
1033            None,
1034            &FilenameExpansionOptions::default(),
1035        )?;
1036
1037        let paths = expect_expanded(result)?;
1038
1039        assert_eq!(paths.len(), 2, "unexpected results: {paths:?}");
1040        let scratch_normalized: String = scratch.path().to_string_lossy().replace('\\', "/");
1041        for p in &paths {
1042            // Use a plain byte-level suffix check rather than `Path::extension`
1043            // since the results are strings and clippy flags
1044            // `ends_with(".log")` as potentially case-sensitive. We
1045            // explicitly wrote lowercase files.
1046            assert!(p.as_bytes().ends_with(b".log"), "unexpected result {p:?}");
1047            // Should still reference the scratch directory (i.e., absolute).
1048            assert!(
1049                p.contains(scratch_normalized.as_str()),
1050                "absolute result {p:?} should contain {scratch_normalized:?}"
1051            );
1052        }
1053
1054        Ok(())
1055    }
1056}