1pub fn matches(pattern: &str, text: &str) -> bool {
12 matches_with(pattern, text, false)
13}
14
15pub 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#[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
104fn 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
163fn 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 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
210fn 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}