Skip to main content

use_glob/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::collections::HashMap;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7enum ClassItem {
8    Single(char),
9    Range(char, char),
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13enum MatcherToken {
14    Literal(char),
15    Star,
16    DoubleStar,
17    Question,
18    CharacterClass(Vec<ClassItem>),
19}
20
21fn parse_character_class(
22    pattern_chars: &[char],
23    start_index: usize,
24) -> Option<(Vec<ClassItem>, usize)> {
25    let mut class_chars = Vec::new();
26    let mut pattern_index = start_index + 1;
27
28    while pattern_index < pattern_chars.len() {
29        match pattern_chars[pattern_index] {
30            ']' if !class_chars.is_empty() => {
31                return Some((build_class_items(&class_chars), pattern_index + 1));
32            }
33            '\\' if pattern_index + 1 < pattern_chars.len() => {
34                class_chars.push(pattern_chars[pattern_index + 1]);
35                pattern_index += 2;
36            }
37            character => {
38                class_chars.push(character);
39                pattern_index += 1;
40            }
41        }
42    }
43
44    None
45}
46
47fn build_class_items(class_chars: &[char]) -> Vec<ClassItem> {
48    let mut items = Vec::new();
49    let mut class_index = 0;
50
51    while class_index < class_chars.len() {
52        if class_index + 2 < class_chars.len() && class_chars[class_index + 1] == '-' {
53            items.push(ClassItem::Range(
54                class_chars[class_index],
55                class_chars[class_index + 2],
56            ));
57            class_index += 3;
58        } else {
59            items.push(ClassItem::Single(class_chars[class_index]));
60            class_index += 1;
61        }
62    }
63
64    items
65}
66
67fn parse_glob_pattern(pattern: &str) -> Vec<MatcherToken> {
68    let pattern_chars: Vec<char> = pattern.chars().collect();
69    let mut tokens = Vec::new();
70    let mut pattern_index = 0;
71
72    while pattern_index < pattern_chars.len() {
73        match pattern_chars[pattern_index] {
74            '\\' => {
75                if let Some(next_char) = pattern_chars.get(pattern_index + 1) {
76                    if matches!(next_char, '*' | '?' | '[' | ']' | '\\') {
77                        tokens.push(MatcherToken::Literal(*next_char));
78                        pattern_index += 2;
79                    } else {
80                        tokens.push(MatcherToken::Literal('/'));
81                        pattern_index += 1;
82                    }
83                } else {
84                    tokens.push(MatcherToken::Literal('/'));
85                    pattern_index += 1;
86                }
87            }
88            '*' => {
89                if pattern_chars.get(pattern_index + 1) == Some(&'*') {
90                    tokens.push(MatcherToken::DoubleStar);
91                    pattern_index += 2;
92                } else {
93                    tokens.push(MatcherToken::Star);
94                    pattern_index += 1;
95                }
96            }
97            '?' => {
98                tokens.push(MatcherToken::Question);
99                pattern_index += 1;
100            }
101            '[' => {
102                if let Some((class_items, next_index)) =
103                    parse_character_class(&pattern_chars, pattern_index)
104                {
105                    tokens.push(MatcherToken::CharacterClass(class_items));
106                    pattern_index = next_index;
107                } else {
108                    tokens.push(MatcherToken::Literal('['));
109                    pattern_index += 1;
110                }
111            }
112            literal => {
113                tokens.push(MatcherToken::Literal(literal));
114                pattern_index += 1;
115            }
116        }
117    }
118
119    tokens
120}
121
122fn escape_regex_char(character: char, output: &mut String) {
123    match character {
124        '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => {
125            output.push('\\');
126            output.push(character);
127        }
128        _ => output.push(character),
129    }
130}
131
132fn render_class_character(character: char, output: &mut String) {
133    match character {
134        '\\' | ']' | '^' | '-' => {
135            output.push('\\');
136            output.push(character);
137        }
138        _ => output.push(character),
139    }
140}
141
142fn render_character_class(items: &[ClassItem], output: &mut String) {
143    output.push('[');
144
145    for item in items {
146        match item {
147            ClassItem::Single(character) => render_class_character(*character, output),
148            ClassItem::Range(start, end) => {
149                render_class_character(*start, output);
150                output.push('-');
151                render_class_character(*end, output);
152            }
153        }
154    }
155
156    output.push(']');
157}
158
159fn class_matches(items: &[ClassItem], character: char) -> bool {
160    if character == '/' {
161        return false;
162    }
163
164    items.iter().any(|item| match item {
165        ClassItem::Single(expected) => *expected == character,
166        ClassItem::Range(start, end) => {
167            let lower = (*start).min(*end);
168            let upper = (*start).max(*end);
169            (lower..=upper).contains(&character)
170        }
171    })
172}
173
174fn glob_matches_tokens(
175    tokens: &[MatcherToken],
176    input_chars: &[char],
177    token_index: usize,
178    input_index: usize,
179    memo: &mut HashMap<(usize, usize), bool>,
180) -> bool {
181    if let Some(cached) = memo.get(&(token_index, input_index)) {
182        return *cached;
183    }
184
185    let result = if token_index == tokens.len() {
186        input_index == input_chars.len()
187    } else {
188        match &tokens[token_index] {
189            MatcherToken::Literal(expected) => {
190                input_chars.get(input_index) == Some(expected)
191                    && glob_matches_tokens(
192                        tokens,
193                        input_chars,
194                        token_index + 1,
195                        input_index + 1,
196                        memo,
197                    )
198            }
199            MatcherToken::Question => {
200                input_chars
201                    .get(input_index)
202                    .copied()
203                    .filter(|character| *character != '/')
204                    .is_some()
205                    && glob_matches_tokens(
206                        tokens,
207                        input_chars,
208                        token_index + 1,
209                        input_index + 1,
210                        memo,
211                    )
212            }
213            MatcherToken::CharacterClass(items) => {
214                input_chars
215                    .get(input_index)
216                    .copied()
217                    .filter(|character| class_matches(items, *character))
218                    .is_some()
219                    && glob_matches_tokens(
220                        tokens,
221                        input_chars,
222                        token_index + 1,
223                        input_index + 1,
224                        memo,
225                    )
226            }
227            MatcherToken::Star => {
228                glob_matches_tokens(tokens, input_chars, token_index + 1, input_index, memo)
229                    || input_chars
230                        .get(input_index)
231                        .copied()
232                        .filter(|character| *character != '/')
233                        .is_some_and(|_| {
234                            glob_matches_tokens(
235                                tokens,
236                                input_chars,
237                                token_index,
238                                input_index + 1,
239                                memo,
240                            )
241                        })
242            }
243            MatcherToken::DoubleStar => {
244                glob_matches_tokens(tokens, input_chars, token_index + 1, input_index, memo)
245                    || matches!(
246                        tokens.get(token_index + 1),
247                        Some(MatcherToken::Literal('/'))
248                    ) && glob_matches_tokens(
249                        tokens,
250                        input_chars,
251                        token_index + 2,
252                        input_index,
253                        memo,
254                    )
255                    || input_chars.get(input_index).is_some_and(|_| {
256                        glob_matches_tokens(tokens, input_chars, token_index, input_index + 1, memo)
257                    })
258            }
259        }
260    };
261
262    memo.insert((token_index, input_index), result);
263    result
264}
265
266#[derive(Clone, Debug, Default, PartialEq, Eq)]
267pub struct GlobPattern {
268    pub pattern: String,
269}
270
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub enum GlobToken {
273    Literal(String),
274    Star,
275    DoubleStar,
276    Question,
277    CharacterClass(String),
278}
279
280/// Returns `true` when the input contains any unescaped glob syntax.
281pub fn is_glob_pattern(input: &str) -> bool {
282    has_glob_wildcards(input)
283}
284
285/// Escapes glob metacharacters so they are treated literally.
286pub fn escape_glob(input: &str) -> String {
287    let mut escaped = String::new();
288
289    for character in input.chars() {
290        if matches!(character, '*' | '?' | '[' | ']' | '\\') {
291            escaped.push('\\');
292        }
293
294        escaped.push(character);
295    }
296
297    escaped
298}
299
300/// Returns `true` when the glob pattern matches the full input string.
301pub fn glob_matches(pattern: &str, input: &str) -> bool {
302    let normalized_input = normalize_glob_separators(input);
303    let input_chars: Vec<char> = normalized_input.chars().collect();
304    let tokens = parse_glob_pattern(pattern);
305    let mut memo = HashMap::new();
306
307    glob_matches_tokens(&tokens, &input_chars, 0, 0, &mut memo)
308}
309
310/// Converts a glob pattern into an anchored regex string.
311pub fn glob_to_regex(pattern: &str) -> String {
312    let tokens = parse_glob_pattern(pattern);
313    let mut regex_pattern = String::from("(?s)^");
314    let mut token_index = 0;
315
316    while token_index < tokens.len() {
317        if matches!(tokens.get(token_index), Some(MatcherToken::DoubleStar))
318            && matches!(
319                tokens.get(token_index + 1),
320                Some(MatcherToken::Literal('/'))
321            )
322        {
323            regex_pattern.push_str("(?:.*/)?");
324            token_index += 2;
325            continue;
326        }
327
328        match &tokens[token_index] {
329            MatcherToken::Literal(character) => escape_regex_char(*character, &mut regex_pattern),
330            MatcherToken::Star => regex_pattern.push_str("[^/]*"),
331            MatcherToken::DoubleStar => regex_pattern.push_str(".*"),
332            MatcherToken::Question => regex_pattern.push_str("[^/]"),
333            MatcherToken::CharacterClass(items) => {
334                render_character_class(items, &mut regex_pattern)
335            }
336        }
337
338        token_index += 1;
339    }
340
341    regex_pattern.push('$');
342    regex_pattern
343}
344
345/// Splits a normalized glob pattern on `/` boundaries.
346pub fn split_glob_segments(pattern: &str) -> Vec<String> {
347    let normalized = normalize_glob_separators(pattern);
348
349    if normalized.is_empty() {
350        Vec::new()
351    } else {
352        normalized.split('/').map(ToOwned::to_owned).collect()
353    }
354}
355
356/// Returns `true` when a pattern contains `**` semantics.
357pub fn has_recursive_glob(pattern: &str) -> bool {
358    parse_glob_pattern(pattern)
359        .iter()
360        .any(|token| matches!(token, MatcherToken::DoubleStar))
361}
362
363/// Returns `true` when a pattern contains any wildcard or character-class token.
364pub fn has_glob_wildcards(pattern: &str) -> bool {
365    parse_glob_pattern(pattern)
366        .iter()
367        .any(|token| !matches!(token, MatcherToken::Literal(_)))
368}
369
370/// Normalizes Windows separators to forward slashes.
371pub fn normalize_glob_separators(pattern: &str) -> String {
372    pattern.replace('\\', "/")
373}