Skip to main content

glob_set/
map.rs

1use alloc::vec::Vec;
2
3use crate::engine::{self, MatchEngine};
4use crate::glob::{Candidate, Glob};
5
6/// A map from glob patterns to values.
7///
8/// `GlobMap<T>` pairs each glob pattern with an associated value of type `T`.
9/// Lookups return the value associated with the first (lowest-index) matching
10/// pattern.
11///
12/// Internally uses the same optimized strategy-based dispatch as [`GlobSet`]
13/// (extension hash, literal, prefix, suffix, Aho-Corasick pre-filter).
14///
15/// [`GlobSet`]: crate::GlobSet
16///
17/// # Example
18///
19/// ```
20/// use glob_set::{Glob, GlobMapBuilder};
21///
22/// let mut builder = GlobMapBuilder::new();
23/// builder.insert(Glob::new("*.rs").unwrap(), "rust");
24/// builder.insert(Glob::new("*.toml").unwrap(), "toml");
25/// let map = builder.build().unwrap();
26///
27/// assert_eq!(map.get("foo.rs"), Some(&"rust"));
28/// assert_eq!(map.get("Cargo.toml"), Some(&"toml"));
29/// assert_eq!(map.get("foo.js"), None);
30/// ```
31#[derive(Clone, Debug)]
32pub struct GlobMap<T> {
33    engine: MatchEngine,
34    values: Vec<T>,
35}
36
37impl<T> GlobMap<T> {
38    /// Return the value associated with the first matching pattern, or `None`.
39    pub fn get(&self, path: impl AsRef<str>) -> Option<&T> {
40        self.engine
41            .first_match(path.as_ref())
42            .map(|idx| &self.values[idx])
43    }
44
45    /// Return the value associated with the first matching pattern for a candidate, or `None`.
46    pub fn get_candidate(&self, candidate: &Candidate<'_>) -> Option<&T> {
47        self.engine
48            .first_match(candidate.path())
49            .map(|idx| &self.values[idx])
50    }
51
52    /// Return references to all values whose patterns match the given path.
53    ///
54    /// Values are returned in match order (same order as [`GlobSet::matches`]).
55    ///
56    /// [`GlobSet::matches`]: crate::GlobSet::matches
57    pub fn get_matches(&self, path: impl AsRef<str>) -> Vec<&T> {
58        let mut indices = Vec::new();
59        self.engine.matches_into(path.as_ref(), &mut indices);
60        indices.iter().map(|&idx| &self.values[idx]).collect()
61    }
62
63    /// Return references to all values whose patterns match the given candidate.
64    pub fn get_matches_candidate(&self, candidate: &Candidate<'_>) -> Vec<&T> {
65        self.get_matches(candidate.path())
66    }
67
68    /// Test whether any pattern matches the given path.
69    pub fn is_match(&self, path: impl AsRef<str>) -> bool {
70        self.engine.is_match(path.as_ref())
71    }
72
73    /// Return the number of patterns in this map.
74    pub fn len(&self) -> usize {
75        self.engine.len()
76    }
77
78    /// Return whether this map is empty.
79    pub fn is_empty(&self) -> bool {
80        self.engine.is_empty()
81    }
82}
83
84/// A builder for constructing a [`GlobMap`].
85#[derive(Clone, Debug)]
86pub struct GlobMapBuilder<T> {
87    entries: Vec<(Glob, T)>,
88}
89
90impl<T> Default for GlobMapBuilder<T> {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl<T> GlobMapBuilder<T> {
97    /// Create a new empty builder.
98    pub fn new() -> Self {
99        Self {
100            entries: Vec::new(),
101        }
102    }
103
104    /// Insert a glob pattern and its associated value.
105    pub fn insert(&mut self, glob: Glob, value: T) -> &mut Self {
106        self.entries.push((glob, value));
107        self
108    }
109
110    /// Build the [`GlobMap`].
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if the Aho-Corasick automaton cannot be constructed.
115    pub fn build(self) -> Result<GlobMap<T>, crate::error::Error> {
116        let (patterns, values): (Vec<Glob>, Vec<T>) = self.entries.into_iter().unzip();
117        let engine = engine::build_engine(patterns)?;
118        Ok(GlobMap { engine, values })
119    }
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used)]
124mod tests {
125    use alloc::string::String;
126
127    use super::*;
128
129    fn build_map(entries: &[(&str, &str)]) -> GlobMap<String> {
130        let mut builder = GlobMapBuilder::new();
131        for &(pat, val) in entries {
132            builder.insert(Glob::new(pat).unwrap(), String::from(val));
133        }
134        builder.build().unwrap()
135    }
136
137    #[test]
138    fn get_returns_first_match() {
139        let map = build_map(&[("*.rs", "rust"), ("**/*.rs", "rust-deep")]);
140        // "foo.rs" matches both, but *.rs is index 0
141        assert_eq!(map.get("foo.rs").map(String::as_str), Some("rust"));
142    }
143
144    #[test]
145    fn get_returns_none_on_no_match() {
146        let map = build_map(&[("*.rs", "rust")]);
147        assert_eq!(map.get("foo.js"), None);
148    }
149
150    #[test]
151    fn get_matches_returns_all() {
152        let map = build_map(&[
153            ("*.rs", "rust"),
154            ("**/*.rs", "rust-deep"),
155            ("*.toml", "toml"),
156        ]);
157        let matches: Vec<&str> = map
158            .get_matches("foo.rs")
159            .into_iter()
160            .map(String::as_str)
161            .collect();
162        assert!(matches.contains(&"rust"));
163        assert!(matches.contains(&"rust-deep"));
164        assert!(!matches.contains(&"toml"));
165    }
166
167    #[test]
168    fn multiple_patterns_correct_priority() {
169        let map = build_map(&[
170            ("**/*.rs", "catch-all-rs"),
171            ("src/**", "src-dir"),
172            ("src/**/*.rs", "src-rs"),
173        ]);
174        // "src/main.rs" matches all three; first is index 0
175        assert_eq!(
176            map.get("src/main.rs").map(String::as_str),
177            Some("catch-all-rs")
178        );
179        // "src/main.js" only matches src/**
180        assert_eq!(map.get("src/main.js").map(String::as_str), Some("src-dir"));
181    }
182
183    #[test]
184    fn empty_map_returns_none() {
185        let map = build_map(&[]);
186        assert_eq!(map.get("anything"), None);
187        assert!(map.is_empty());
188        assert_eq!(map.len(), 0);
189    }
190
191    #[test]
192    fn brace_expansion_works() {
193        let map = build_map(&[("*.{rs,toml}", "rust-or-toml"), ("*.js", "javascript")]);
194        assert_eq!(map.get("main.rs").map(String::as_str), Some("rust-or-toml"));
195        assert_eq!(
196            map.get("Cargo.toml").map(String::as_str),
197            Some("rust-or-toml")
198        );
199        assert_eq!(map.get("app.js").map(String::as_str), Some("javascript"));
200        assert_eq!(map.get("style.css"), None);
201    }
202
203    #[test]
204    fn compound_suffix_works() {
205        let map = build_map(&[("**/*.test.js", "test"), ("**/*.js", "js")]);
206        assert_eq!(map.get("foo.test.js").map(String::as_str), Some("test"));
207        assert_eq!(map.get("foo.js").map(String::as_str), Some("js"));
208    }
209
210    #[test]
211    fn candidate_based_matching() {
212        let map = build_map(&[("**/*.rs", "rust")]);
213        let c = Candidate::new("src\\main.rs");
214        assert_eq!(map.get_candidate(&c).map(String::as_str), Some("rust"));
215    }
216
217    #[test]
218    fn get_matches_candidate() {
219        let map = build_map(&[("**/*.rs", "rust"), ("src/**", "src")]);
220        let c = Candidate::new("src\\main.rs");
221        let matches: Vec<&str> = map
222            .get_matches_candidate(&c)
223            .iter()
224            .map(|s| s.as_str())
225            .collect();
226        assert!(matches.contains(&"rust"));
227        assert!(matches.contains(&"src"));
228    }
229
230    #[test]
231    fn is_match_delegates() {
232        let map = build_map(&[("*.rs", "rust")]);
233        assert!(map.is_match("foo.rs"));
234        assert!(!map.is_match("foo.js"));
235    }
236
237    #[test]
238    fn len_and_is_empty() {
239        let map = build_map(&[("*.rs", "rust"), ("*.toml", "toml")]);
240        assert_eq!(map.len(), 2);
241        assert!(!map.is_empty());
242    }
243
244    #[test]
245    fn literal_pattern_in_map() {
246        let map = build_map(&[("Cargo.toml", "cargo"), ("*.rs", "rust")]);
247        assert_eq!(map.get("Cargo.toml").map(String::as_str), Some("cargo"));
248        assert_eq!(map.get("foo.rs").map(String::as_str), Some("rust"));
249    }
250
251    #[test]
252    fn suffix_pattern_in_map() {
253        let map = build_map(&[("**/foo.txt", "foo"), ("*.rs", "rust")]);
254        assert_eq!(map.get("a/b/foo.txt").map(String::as_str), Some("foo"));
255        assert_eq!(map.get("foo.txt").map(String::as_str), Some("foo"));
256    }
257
258    #[test]
259    fn prefix_pattern_in_map() {
260        let map = build_map(&[("src/**", "source"), ("*.rs", "rust")]);
261        assert_eq!(map.get("src/main.rs").map(String::as_str), Some("source"));
262        assert_eq!(map.get("main.rs").map(String::as_str), Some("rust"));
263    }
264
265    #[test]
266    fn first_match_priority_across_strategies() {
267        // Index 0 is literal (Cargo.toml), index 1 is ext_any (**/*.toml).
268        // For "Cargo.toml", literal match (idx 0) should win.
269        let map = build_map(&[("Cargo.toml", "exact"), ("**/*.toml", "any-toml")]);
270        assert_eq!(map.get("Cargo.toml").map(String::as_str), Some("exact"));
271        assert_eq!(map.get("other.toml").map(String::as_str), Some("any-toml"));
272    }
273
274    #[test]
275    fn always_check_pattern_in_map() {
276        // "*" goes to always_check (no extractable literal)
277        let map = build_map(&[("*.rs", "rust"), ("*", "catch-all")]);
278        assert_eq!(map.get("foo.rs").map(String::as_str), Some("rust"));
279        assert_eq!(map.get("anything").map(String::as_str), Some("catch-all"));
280    }
281}