Skip to main content

glob_set/
glob.rs

1use alloc::string::String;
2use core::fmt;
3use core::hash::{Hash, Hasher};
4use core::str::FromStr;
5
6use crate::error::Error;
7use crate::parse;
8
9/// A single glob pattern.
10///
11/// A `Glob` is constructed from a pattern string and can be compiled into a
12/// [`GlobMatcher`] for matching against paths.
13///
14/// # Example
15///
16/// ```
17/// use glob_set::Glob;
18///
19/// let glob = Glob::new("*.rs").unwrap();
20/// let matcher = glob.compile_matcher();
21/// assert!(matcher.is_match("foo.rs"));
22/// ```
23#[derive(Clone, Debug)]
24pub struct Glob {
25    pattern: String,
26}
27
28impl Glob {
29    /// Create a new `Glob` from the given pattern.
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if the pattern is structurally invalid (e.g. unclosed
34    /// character class, unmatched braces, dangling escape).
35    pub fn new(pattern: &str) -> Result<Self, Error> {
36        parse::validate(pattern)?;
37        Ok(Self {
38            pattern: String::from(pattern),
39        })
40    }
41
42    /// Return the original glob pattern.
43    pub fn glob(&self) -> &str {
44        &self.pattern
45    }
46
47    /// Compile this glob into a matcher for matching paths.
48    pub fn compile_matcher(&self) -> GlobMatcher {
49        GlobMatcher { glob: self.clone() }
50    }
51}
52
53impl Eq for Glob {}
54
55impl PartialEq for Glob {
56    fn eq(&self, other: &Self) -> bool {
57        self.pattern == other.pattern
58    }
59}
60
61impl Hash for Glob {
62    fn hash<H: Hasher>(&self, state: &mut H) {
63        self.pattern.hash(state);
64    }
65}
66
67impl fmt::Display for Glob {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        write!(f, "{}", self.pattern)
70    }
71}
72
73impl FromStr for Glob {
74    type Err = Error;
75
76    fn from_str(s: &str) -> Result<Self, Self::Err> {
77        Self::new(s)
78    }
79}
80
81/// A builder for configuring a glob pattern.
82///
83/// Options like `case_insensitive` and `literal_separator` can be set before
84/// building the final [`Glob`].
85#[derive(Clone, Debug)]
86#[allow(clippy::struct_excessive_bools)]
87pub struct GlobBuilder {
88    pattern: String,
89    case_insensitive: bool,
90    literal_separator: bool,
91    backslash_escape: bool,
92    empty_alternates: bool,
93}
94
95impl GlobBuilder {
96    /// Create a new builder from the given pattern.
97    pub fn new(pattern: &str) -> Self {
98        Self {
99            pattern: String::from(pattern),
100            case_insensitive: false,
101            literal_separator: false,
102            backslash_escape: true,
103            empty_alternates: false,
104        }
105    }
106
107    /// Toggle case-insensitive matching.
108    ///
109    /// When enabled, the pattern is lowercased and paths are lowercased at
110    /// match time.
111    pub fn case_insensitive(&mut self, yes: bool) -> &mut Self {
112        self.case_insensitive = yes;
113        self
114    }
115
116    /// Toggle literal separator mode.
117    ///
118    /// This option is accepted for API compatibility but does not currently
119    /// change matching behavior, as `glob-matcher` already treats `*` as
120    /// not crossing separators and `**` as crossing them.
121    pub fn literal_separator(&mut self, yes: bool) -> &mut Self {
122        self.literal_separator = yes;
123        self
124    }
125
126    /// Toggle backslash escaping.
127    ///
128    /// This option is accepted for API compatibility.
129    pub fn backslash_escape(&mut self, yes: bool) -> &mut Self {
130        self.backslash_escape = yes;
131        self
132    }
133
134    /// Toggle empty alternates.
135    ///
136    /// This option is accepted for API compatibility.
137    pub fn empty_alternates(&mut self, yes: bool) -> &mut Self {
138        self.empty_alternates = yes;
139        self
140    }
141
142    /// Build the glob pattern.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the (possibly lowercased) pattern is structurally
147    /// invalid.
148    pub fn build(&self) -> Result<Glob, Error> {
149        let pattern = if self.case_insensitive {
150            self.pattern.to_ascii_lowercase()
151        } else {
152            self.pattern.clone()
153        };
154        parse::validate(&pattern)?;
155        Ok(Glob { pattern })
156    }
157}
158
159/// A compiled matcher for a single glob pattern.
160///
161/// Created by [`Glob::compile_matcher`].
162#[derive(Clone, Debug)]
163pub struct GlobMatcher {
164    glob: Glob,
165}
166
167impl GlobMatcher {
168    /// Return a reference to the underlying `Glob`.
169    pub fn glob(&self) -> &Glob {
170        &self.glob
171    }
172
173    /// Test whether the given path matches this glob pattern.
174    pub fn is_match(&self, path: impl AsRef<str>) -> bool {
175        glob_matcher::glob_match(self.glob.pattern.as_str(), path.as_ref())
176    }
177
178    /// Test whether the given [`Candidate`] matches this glob pattern.
179    pub fn is_match_candidate(&self, candidate: &Candidate<'_>) -> bool {
180        glob_matcher::glob_match(self.glob.pattern.as_str(), candidate.path())
181    }
182}
183
184/// A pre-processed path for matching against multiple patterns.
185///
186/// `Candidate` normalizes backslashes to forward slashes on construction,
187/// which avoids repeated normalization when matching against many patterns.
188#[derive(Clone, Debug)]
189pub struct Candidate<'a> {
190    /// The original or normalized path string.
191    path: CandidatePath<'a>,
192}
193
194#[derive(Clone, Debug)]
195enum CandidatePath<'a> {
196    Borrowed(&'a str),
197    Owned(String),
198}
199
200impl<'a> Candidate<'a> {
201    /// Create a new candidate from a path string.
202    ///
203    /// If the path contains backslashes, they are normalized to forward slashes.
204    pub fn new(path: &'a str) -> Self {
205        if path.contains('\\') {
206            Self {
207                path: CandidatePath::Owned(path.replace('\\', "/")),
208            }
209        } else {
210            Self {
211                path: CandidatePath::Borrowed(path),
212            }
213        }
214    }
215
216    /// Return the normalized path.
217    pub fn path(&self) -> &str {
218        match &self.path {
219            CandidatePath::Borrowed(s) => s,
220            CandidatePath::Owned(s) => s.as_str(),
221        }
222    }
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used)]
227mod tests {
228    use super::*;
229    use alloc::string::ToString;
230
231    #[test]
232    fn glob_new_valid() {
233        assert!(Glob::new("*.rs").is_ok());
234        assert!(Glob::new("**/*.txt").is_ok());
235        assert!(Glob::new("{a,b}").is_ok());
236    }
237
238    #[test]
239    fn glob_new_invalid() {
240        assert!(Glob::new("[unclosed").is_err());
241        assert!(Glob::new("{unclosed").is_err());
242    }
243
244    #[test]
245    fn glob_matcher_basic() {
246        let m = Glob::new("*.rs").unwrap().compile_matcher();
247        assert!(m.is_match("foo.rs"));
248        assert!(m.is_match("bar.rs"));
249        assert!(!m.is_match("foo.txt"));
250        assert!(!m.is_match("src/foo.rs"));
251    }
252
253    #[test]
254    fn glob_matcher_globstar() {
255        let m = Glob::new("**/*.rs").unwrap().compile_matcher();
256        assert!(m.is_match("foo.rs"));
257        assert!(m.is_match("src/foo.rs"));
258        assert!(m.is_match("a/b/c/foo.rs"));
259        assert!(!m.is_match("foo.txt"));
260    }
261
262    #[test]
263    fn glob_matcher_braces() {
264        let m = Glob::new("*.{rs,toml}").unwrap().compile_matcher();
265        assert!(m.is_match("Cargo.toml"));
266        assert!(m.is_match("main.rs"));
267        assert!(!m.is_match("main.js"));
268    }
269
270    #[test]
271    fn glob_builder_case_insensitive() {
272        let g = GlobBuilder::new("*.RS")
273            .case_insensitive(true)
274            .build()
275            .unwrap();
276        let m = g.compile_matcher();
277        // Pattern is lowercased to "*.rs", so we match lowercase paths
278        assert!(m.is_match("foo.rs"));
279        // But uppercase paths won't match directly since glob-match is literal
280        // (case_insensitive only lowercases the pattern)
281    }
282
283    #[test]
284    fn glob_display() {
285        let g = Glob::new("**/*.rs").unwrap();
286        assert_eq!(g.to_string(), "**/*.rs");
287    }
288
289    #[test]
290    fn glob_from_str() {
291        let g: Glob = "*.txt".parse().unwrap();
292        assert_eq!(g.glob(), "*.txt");
293    }
294
295    #[test]
296    fn candidate_no_backslash() {
297        let c = Candidate::new("a/b/c");
298        assert_eq!(c.path(), "a/b/c");
299    }
300
301    #[test]
302    fn candidate_backslash_normalization() {
303        let c = Candidate::new("a\\b\\c");
304        assert_eq!(c.path(), "a/b/c");
305    }
306
307    #[test]
308    fn candidate_matching() {
309        let m = Glob::new("**/*.rs").unwrap().compile_matcher();
310        let c = Candidate::new("src\\main.rs");
311        assert!(m.is_match_candidate(&c));
312    }
313}