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
280pub fn is_glob_pattern(input: &str) -> bool {
282 has_glob_wildcards(input)
283}
284
285pub 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
300pub 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
310pub 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
345pub 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
356pub fn has_recursive_glob(pattern: &str) -> bool {
358 parse_glob_pattern(pattern)
359 .iter()
360 .any(|token| matches!(token, MatcherToken::DoubleStar))
361}
362
363pub fn has_glob_wildcards(pattern: &str) -> bool {
365 parse_glob_pattern(pattern)
366 .iter()
367 .any(|token| !matches!(token, MatcherToken::Literal(_)))
368}
369
370pub fn normalize_glob_separators(pattern: &str) -> String {
372 pattern.replace('\\', "/")
373}