Skip to main content

yapi_core/
glob.rs

1//! Glob matching with the defaults of minimatch, which pi uses for package
2//! resource patterns and model scopes.
3//!
4//! Supported: brace expansion (`{a,b}`, `{1..3}`), `*` and `?` within a path
5//! segment, `[...]` classes with `!` or `^` negation and ranges, `**` across
6//! segments, and `\` escapes. As in minimatch, `*`, `?` and `**` do not match
7//! a segment's leading `.`, and a leading `./` in the pattern is ignored.
8//! Extglobs such as `+(a|b)` are not supported.
9
10/// Whether `text` matches `pattern`.
11pub fn matches(pattern: &str, text: &str) -> bool {
12    matches_with(pattern, text, false)
13}
14
15/// Whether `text` matches `pattern`, ignoring case when `nocase` is set.
16pub fn matches_with(pattern: &str, text: &str, nocase: bool) -> bool {
17    let fold = |value: &str| {
18        if nocase {
19            value.to_lowercase()
20        } else {
21            value.to_owned()
22        }
23    };
24    let text = fold(text);
25    let text: Vec<&str> = text.split('/').collect();
26    expand_braces(pattern).iter().any(|pattern| {
27        let pattern = fold(pattern);
28        let mut pattern = pattern.as_str();
29        while let Some(rest) = pattern.strip_prefix("./") {
30            pattern = rest;
31        }
32        let segments: Vec<&str> = pattern.split('/').collect();
33        match_segments(&segments, &text)
34    })
35}
36
37fn match_segments(pattern: &[&str], text: &[&str]) -> bool {
38    match (pattern.first(), text.first()) {
39        (None, None) => true,
40        (Some(&"**"), _) => {
41            match_segments(&pattern[1..], text)
42                || text.first().is_some_and(|segment| {
43                    !segment.starts_with('.') && match_segments(pattern, &text[1..])
44                })
45        }
46        (Some(part), Some(segment)) => {
47            match_segment(part, segment) && match_segments(&pattern[1..], &text[1..])
48        }
49        _ => false,
50    }
51}
52
53/// One element of a segment pattern.
54#[derive(Debug)]
55enum Token {
56    Char(char),
57    Any,
58    One,
59    Class {
60        negated: bool,
61        items: Vec<(char, char)>,
62    },
63}
64
65fn tokens(pattern: &str) -> Vec<Token> {
66    let chars: Vec<char> = pattern.chars().collect();
67    let mut out = Vec::new();
68    let mut index = 0;
69    while index < chars.len() {
70        match chars[index] {
71            '\\' if index + 1 < chars.len() => {
72                out.push(Token::Char(chars[index + 1]));
73                index += 2;
74            }
75            '*' => {
76                if !matches!(out.last(), Some(Token::Any)) {
77                    out.push(Token::Any);
78                }
79                index += 1;
80            }
81            '?' => {
82                out.push(Token::One);
83                index += 1;
84            }
85            '[' => match class(&chars, index) {
86                Some((token, end)) => {
87                    out.push(token);
88                    index = end;
89                }
90                None => {
91                    out.push(Token::Char('['));
92                    index += 1;
93                }
94            },
95            other => {
96                out.push(Token::Char(other));
97                index += 1;
98            }
99        }
100    }
101    out
102}
103
104/// The class opening at `start`, and the index after its `]`.
105fn class(chars: &[char], start: usize) -> Option<(Token, usize)> {
106    let mut index = start + 1;
107    let negated = matches!(chars.get(index), Some('!' | '^'));
108    if negated {
109        index += 1;
110    }
111    let mut items = Vec::new();
112    let first = index;
113    while index < chars.len() {
114        let mut low = chars[index];
115        if low == ']' && index > first {
116            return Some((Token::Class { negated, items }, index + 1));
117        }
118        if low == '\\' && index + 1 < chars.len() {
119            index += 1;
120            low = chars[index];
121        }
122        if chars.get(index + 1) == Some(&'-') && chars.get(index + 2).is_some_and(|c| *c != ']') {
123            items.push((low, chars[index + 2]));
124            index += 3;
125        } else {
126            items.push((low, low));
127            index += 1;
128        }
129    }
130    None
131}
132
133fn match_segment(pattern: &str, text: &str) -> bool {
134    let tokens = tokens(pattern);
135    let text: Vec<char> = text.chars().collect();
136    if text.first() == Some(&'.') && !matches!(tokens.first(), Some(Token::Char('.'))) {
137        return false;
138    }
139    match_tokens(&tokens, &text)
140}
141
142fn match_tokens(tokens: &[Token], text: &[char]) -> bool {
143    match tokens.first() {
144        None => text.is_empty(),
145        Some(Token::Any) => (0..=text.len()).any(|skip| match_tokens(&tokens[1..], &text[skip..])),
146        Some(token) => {
147            let Some(&c) = text.first() else {
148                return false;
149            };
150            let ok = match token {
151                Token::Char(expected) => *expected == c,
152                Token::One => true,
153                Token::Class { negated, items } => {
154                    items.iter().any(|(low, high)| (*low..=*high).contains(&c)) != *negated
155                }
156                Token::Any => unreachable!("handled above"),
157            };
158            ok && match_tokens(&tokens[1..], &text[1..])
159        }
160    }
161}
162
163/// minimatch's brace expansion: `{a,b}` alternatives and `{1..3}` ranges,
164/// nested. A brace with neither stays literal.
165fn expand_braces(pattern: &str) -> Vec<String> {
166    let chars: Vec<char> = pattern.chars().collect();
167    let mut depth = 0usize;
168    let mut open = None;
169    let mut index = 0;
170    while index < chars.len() {
171        match chars[index] {
172            '\\' => index += 1,
173            '{' => {
174                if depth == 0 {
175                    open = Some(index);
176                }
177                depth += 1;
178            }
179            '}' if depth > 0 => {
180                depth -= 1;
181                if depth == 0
182                    && let Some(start) = open.take()
183                {
184                    let prefix: String = chars[..start].iter().collect();
185                    let body: String = chars[start + 1..index].iter().collect();
186                    let suffix: String = chars[index + 1..].iter().collect();
187                    let Some(options) = brace_options(&body) else {
188                        // Literal braces: expand what follows them.
189                        return expand_braces(&suffix)
190                            .into_iter()
191                            .map(|rest| format!("{prefix}{{{body}}}{rest}"))
192                            .collect();
193                    };
194                    let mut out = Vec::new();
195                    for option in options {
196                        for expanded in expand_braces(&format!("{prefix}{option}{suffix}")) {
197                            out.push(expanded);
198                        }
199                    }
200                    return out;
201                }
202            }
203            _ => {}
204        }
205        index += 1;
206    }
207    vec![pattern.to_owned()]
208}
209
210/// The alternatives of a brace body, or `None` when it has neither a
211/// top-level comma nor a range.
212fn brace_options(body: &str) -> Option<Vec<String>> {
213    let mut parts = Vec::new();
214    let mut depth = 0usize;
215    let mut current = String::new();
216    let mut chars = body.chars();
217    while let Some(c) = chars.next() {
218        match c {
219            '\\' => {
220                current.push(c);
221                if let Some(next) = chars.next() {
222                    current.push(next);
223                }
224            }
225            '{' => {
226                depth += 1;
227                current.push(c);
228            }
229            '}' => {
230                depth = depth.saturating_sub(1);
231                current.push(c);
232            }
233            ',' if depth == 0 => parts.push(std::mem::take(&mut current)),
234            _ => current.push(c),
235        }
236    }
237    if !parts.is_empty() {
238        parts.push(current);
239        return Some(parts);
240    }
241    let (start, end) = body.split_once("..")?;
242    if let (Ok(start), Ok(end)) = (start.parse::<i64>(), end.parse::<i64>()) {
243        let range: Vec<i64> = if start <= end {
244            (start..=end).collect()
245        } else {
246            (end..=start).rev().collect()
247        };
248        return Some(range.into_iter().map(|n| n.to_string()).collect());
249    }
250    let (mut start_chars, mut end_chars) = (start.chars(), end.chars());
251    match (
252        start_chars.next(),
253        start_chars.next(),
254        end_chars.next(),
255        end_chars.next(),
256    ) {
257        (Some(a), None, Some(b), None) => {
258            let (low, high) = (a.min(b), a.max(b));
259            let mut range: Vec<String> = (low..=high).map(String::from).collect();
260            if a > b {
261                range.reverse();
262            }
263            Some(range)
264        }
265        _ => None,
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn segments_and_wildcards() {
275        assert!(matches("*.md", "a.md"));
276        assert!(!matches("*.md", "dir/a.md"));
277        assert!(matches("prompts/*.md", "prompts/a.md"));
278        assert!(matches("./prompts/*.md", "prompts/a.md"));
279        assert!(matches("**/*.md", "a.md"));
280        assert!(matches("**/*.md", "x/y/a.md"));
281        assert!(!matches("**/*.md", ".git/a.md"));
282        assert!(!matches("*", ".hidden"));
283        assert!(matches(".*", ".hidden"));
284        assert!(matches("a?c", "abc"));
285        assert!(!matches("a?c", "a/c"));
286        assert!(matches("builtin:*", "builtin:mcp"));
287    }
288
289    #[test]
290    fn classes_and_escapes() {
291        assert!(matches("[abc].ts", "b.ts"));
292        assert!(!matches("[!abc].ts", "b.ts"));
293        assert!(matches("[^abc].ts", "d.ts"));
294        assert!(matches("v[0-9]", "v7"));
295        assert!(matches("a\\*", "a*"));
296        assert!(!matches("a\\*", "ab"));
297        assert!(matches("[", "["));
298    }
299
300    #[test]
301    fn braces() {
302        assert!(matches("*.{ts,js}", "a.js"));
303        assert!(!matches("*.{ts,js}", "a.md"));
304        assert!(matches("v{1..3}", "v2"));
305        assert!(!matches("v{1..3}", "v4"));
306        assert!(matches("{a,b{c,d}}", "bd"));
307        assert!(matches("{x}", "{x}"));
308    }
309
310    #[test]
311    fn model_ids_ignore_case() {
312        assert!(matches_with("*SONNET*", "claude-sonnet-4-5", true));
313        assert!(!matches_with(
314            "*sonnet*",
315            "anthropic/claude-sonnet-4-5",
316            true
317        ));
318        assert!(matches_with(
319            "anthropic/*",
320            "anthropic/claude-sonnet-4-5",
321            true
322        ));
323        assert!(!matches_with(
324            "anthropic/*",
325            "openrouter/anthropic/claude",
326            true
327        ));
328        assert!(!matches("*SONNET*", "claude-sonnet-4-5"));
329    }
330}