1use anyhow::{Result, bail};
20use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
21
22#[derive(Clone)]
23pub struct Pattern {
24 included: GlobSet,
25 excluded: GlobSet,
26}
27
28impl Pattern {
29 pub fn new(pattern: &str, negated: bool) -> Result<Self> {
34 Self::from_globs(&[pattern], negated)
35 }
36
37 pub fn fileset(pattern: &str, negated: bool) -> Result<Self> {
40 if pattern.is_empty() || pattern.ends_with('/') || !names_a_path(pattern) {
41 return Self::new(pattern, negated);
42 }
43 Self::from_globs(&[pattern, &format!("{pattern}/**")], negated)
44 }
45
46 fn from_globs(patterns: &[&str], negated: bool) -> Result<Self> {
47 let mut included = GlobSetBuilder::new();
48 let mut excluded = GlobSetBuilder::new();
49 for pattern in patterns {
50 let globs = if negated
51 || ["|", "(", "{,", "!"]
52 .iter()
53 .any(|token| unescaped(pattern).contains(token))
54 {
55 expand(pattern)?
56 } else {
57 vec![((*pattern).to_owned(), false)]
58 };
59 for (glob, excludes) in globs {
60 let glob = if glob.ends_with('/') {
61 format!("{glob}**")
62 } else {
63 glob
64 };
65 let glob = GlobBuilder::new(&glob)
66 .literal_separator(true)
67 .backslash_escape(true)
68 .build()?;
69 if excludes && !negated {
70 excluded.add(glob);
71 } else {
72 included.add(glob);
73 }
74 }
75 }
76 Ok(Self {
77 included: included.build()?,
78 excluded: excluded.build()?,
79 })
80 }
81
82 pub fn is_match(&self, path: impl AsRef<std::path::Path>) -> bool {
85 let path = path.as_ref();
86 let excluded = self.excluded.is_match(path);
87 if self.included.is_empty() {
88 !excluded
89 } else {
90 !excluded && self.included.is_match(path)
91 }
92 }
93}
94
95fn unescaped(pattern: &str) -> String {
98 let mut result = String::new();
99 let mut chars = pattern.chars();
100 while let Some(c) = chars.next() {
101 if c == '\\' {
102 chars.next();
103 } else {
104 result.push(c);
105 }
106 }
107 result
108}
109
110pub fn escape(path: &str) -> String {
113 let mut escaped = String::with_capacity(path.len());
114 for c in path.chars() {
115 if matches!(
116 c,
117 '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '!' | '@' | '+' | ',' | '|' | '\\'
118 ) {
119 escaped.push('\\');
120 }
121 escaped.push(c);
122 }
123 escaped
124}
125
126pub fn literal_path(pattern: &str) -> String {
129 let mut path = String::new();
130 let mut chars = pattern.chars();
131 while let Some(c) = chars.next() {
132 path.push(if c == '\\' {
133 chars.next().unwrap_or('\\')
134 } else {
135 c
136 });
137 }
138 path
139}
140
141pub fn names_a_path(pattern: &str) -> bool {
145 !unescaped(pattern).contains(['*', '?', '[', ']', '{', '}', '(', ')', '!'])
146}
147
148pub fn is_literal(segment: &str) -> bool {
150 !segment.contains([
151 '!', '?', '@', '+', '*', '|', ',', '{', '}', '[', ']', '(', ')', '\\',
152 ])
153}
154
155#[derive(Debug, PartialEq)]
156enum Group {
157 Optional(String),
159 Once(String),
161 Negated(String),
163 NegatedFileName(String),
165 NegatedWildcard(String),
167 Literal(String),
168}
169
170fn expand(pattern: &str) -> Result<Vec<(String, bool)>> {
174 let segments: Vec<_> = pattern.split('/').collect();
175 let mut built = Vec::new();
176 for (index, segment) in segments.iter().enumerate() {
177 let groups = parse_segment(segment)
178 .map_err(|error| anyhow::anyhow!("{error} in glob {pattern:?}"))?;
179 built.push(build_segment(
180 "",
181 &groups,
182 index == segments.len() - 1,
183 false,
184 ));
185 }
186 let mut products: Vec<Vec<&(String, bool)>> = vec![Vec::new()];
188 for options in &built {
189 products = products
190 .iter()
191 .flat_map(|product| {
192 options.iter().map(move |option| {
193 let mut next = product.clone();
194 next.push(option);
195 next
196 })
197 })
198 .collect();
199 }
200 let mut globs: Vec<(String, bool)> = products
201 .into_iter()
202 .map(|product| {
203 let mut path = String::new();
204 let mut excludes = false;
205 let mut whole = false;
206 for (index, (segment, negative)) in product.iter().enumerate() {
207 whole = index == product.len() - 1;
208 path.push_str(segment);
209 path.push('/');
210 if *negative {
211 excludes = true;
212 if !whole {
213 break;
214 }
215 }
216 }
217 if whole {
218 path.pop();
219 }
220 (path, excludes)
221 })
222 .collect();
223 globs.sort_by(|a, b| (!a.1, &a.0).cmp(&(!b.1, &b.0)));
224 globs.dedup();
225 Ok(globs)
226}
227
228fn parse_segment(segment: &str) -> Result<Vec<Group>> {
229 let mut groups = Vec::new();
230 let mut rest = segment;
231 while !rest.is_empty() {
232 if let Some(after) = rest.strip_prefix(['?', '+', '@'])
234 && !after.is_empty()
235 && !after.starts_with('(')
236 {
237 rest = after;
238 }
239 let parsed = [
240 ("(", ")", false),
241 ("*(", ")", true),
242 ("?(", ")", true),
243 ("+(", ")", false),
244 ("@(", ")", false),
245 ("{,", "}", true),
246 ]
247 .into_iter()
248 .find_map(|(open, close, optional)| {
249 let body = rest.strip_prefix(open)?;
250 let end = body.find(close)?;
251 let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
252 let group = if optional {
253 Group::Optional(items)
254 } else {
255 Group::Once(items)
256 };
257 Some((group, &body[end + close.len()..]))
258 })
259 .or_else(|| {
261 let body = rest.strip_prefix("!(")?;
262 let end = body.find(')')?;
263 let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
264 let after = &body[end + 1..];
265 Some(if let Some(after) = after.strip_prefix('.') {
266 (Group::NegatedFileName(items), after)
267 } else if let Some(after) = after.strip_prefix('*') {
268 (Group::NegatedWildcard(items), after)
269 } else {
270 (Group::Negated(items), after)
271 })
272 });
273 if let Some((group, remaining)) = parsed {
274 groups.push(group);
275 rest = remaining;
276 continue;
277 }
278 let end = literal_end(rest);
281 if end == 0 {
282 bail!("unsupported glob syntax at {rest:?}");
283 }
284 groups.push(Group::Literal(rest[..end].to_owned()));
285 rest = &rest[end..];
286 }
287 Ok(groups)
288}
289
290fn literal_end(rest: &str) -> usize {
293 let mut brace = None;
294 let mut special = None;
295 let mut chars = rest.char_indices();
296 while let Some((index, c)) = chars.next() {
297 match c {
298 '\\' => {
299 chars.next();
300 }
301 '{' if brace.is_none() && rest[index + 1..].starts_with(',') => brace = Some(index),
302 '?' | '+' | '@' | '!' | '(' if special.is_none() => special = Some(index),
303 _ => {}
304 }
305 }
306 brace.or(special).unwrap_or(rest.len())
307}
308
309fn display(items: &str) -> String {
310 if items.contains(',') {
311 format!("{{{items}}}")
312 } else {
313 items.to_owned()
314 }
315}
316
317fn build_segment(
320 existing: &str,
321 groups: &[Group],
322 is_last: bool,
323 negative: bool,
324) -> Vec<(String, bool)> {
325 let Some((group, rest)) = groups.split_first() else {
326 return vec![(existing.to_owned(), negative)];
327 };
328 let omitted = if is_last { existing } else { "*" };
330 let both = |off: &str, on: &str, on_negative: bool| {
331 let mut options = build_segment(off, rest, is_last, negative);
332 options.extend(build_segment(on, rest, is_last, on_negative));
333 options
334 };
335 match group {
336 Group::Optional(items) => both(omitted, &format!("{existing}{}", display(items)), negative),
337 Group::Negated(items) => both(omitted, &format!("{existing}{}", display(items)), true),
338 Group::NegatedFileName(items) => {
339 both("*.", &format!("{existing}{}.", display(items)), true)
340 }
341 Group::NegatedWildcard(items) => both("*", &format!("{existing}{}*", display(items)), true),
342 Group::Once(items) => build_segment(
343 &format!("{existing}{}", display(items)),
344 rest,
345 is_last,
346 negative,
347 ),
348 Group::Literal(text) => {
349 build_segment(&format!("{existing}{text}"), rest, is_last, negative)
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 #[test]
357 fn an_escaped_fileset_names_its_directory_exactly() {
358 let named = super::Pattern::fileset(&super::escape("tmp/a!b (1)"), false).unwrap();
359 assert!(named.is_match("tmp/a!b (1)/entry"));
360 assert!(!named.is_match("tmp/ab 1/entry"));
361 }
362
363 use super::*;
364
365 fn nx(pattern: &str) -> Vec<String> {
367 expand(pattern)
368 .unwrap()
369 .into_iter()
370 .map(|(glob, excludes)| if excludes { format!("!{glob}") } else { glob })
371 .collect()
372 }
373
374 #[test]
376 fn expands_like_nx() {
377 assert_eq!(
378 nx("libs/**/?(*.)+spec.ts?(.snap)"),
379 [
380 "libs/**/*.spec.ts",
381 "libs/**/*.spec.ts.snap",
382 "libs/**/spec.ts",
383 "libs/**/spec.ts.snap"
384 ]
385 );
386 for pattern in [
387 "libs/**/?(*.)@spec.ts?(.snap)",
388 "libs/**/?(*.)?spec.ts?(.snap)",
389 ] {
390 assert_eq!(nx(pattern), nx("libs/**/?(*.)+spec.ts?(.snap)"));
391 }
392 assert_eq!(nx("dist/**/*.js"), ["dist/**/*.js"]);
393 assert_eq!(nx("**/*.(js|ts)"), ["**/*.{js,ts}"]);
394 assert_eq!(
395 nx("**/*.spec.ts{,.snap}"),
396 ["**/*.spec.ts", "**/*.spec.ts.snap"]
397 );
398 }
399
400 #[test]
401 fn expands_workspace_patterns() {
402 assert_eq!(
403 nx("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)"),
404 [
405 "app/**/*.{spec,test}.[jt]s",
406 "app/**/*.{spec,test}.[jt]s.snap",
407 "app/**/*.{spec,test}.[jt]sx",
408 "app/**/*.{spec,test}.[jt]sx.snap",
409 "app/**/{spec,test}.[jt]s",
410 "app/**/{spec,test}.[jt]s.snap",
411 "app/**/{spec,test}.[jt]sx",
412 "app/**/{spec,test}.[jt]sx.snap",
413 ]
414 );
415 assert_eq!(nx("biome.json?(c)"), ["biome.json", "biome.jsonc"]);
416 assert_eq!(nx("app/(.cache|dist)/**/*"), ["app/{.cache,dist}/**/*"]);
417 assert_eq!(nx("a?(b)/c"), ["*/c", "ab/c"]);
419 }
420
421 #[test]
422 fn rejects_what_nx_rejects() {
423 assert!(expand("src/a!b").is_err());
424 assert!(expand("file?").is_err());
425 assert!(expand("src/(unterminated").is_err());
426 }
427
428 #[test]
430 fn expands_negated_groups_like_nx() {
431 assert_eq!(
432 nx("dist/!(cache|cache2)/**/!(README|LICENSE).(js|ts)"),
433 [
434 "!dist/*/**/{README,LICENSE}.{js,ts}",
435 "!dist/{cache,cache2}/",
436 "dist/*/**/*.{js,ts}",
437 ]
438 );
439 assert_eq!(
440 nx("dist/**/!(README|LICENSE).(js|ts)"),
441 ["!dist/**/{README,LICENSE}.{js,ts}", "dist/**/*.{js,ts}"]
442 );
443 assert_eq!(
444 nx("dist/!(cache|cache2)/**/*.(js|ts)"),
445 ["!dist/{cache,cache2}/", "dist/*/**/*.{js,ts}"]
446 );
447 assert_eq!(
448 nx("dist/!(cache|cache2)/**/*.js"),
449 ["!dist/{cache,cache2}/", "dist/*/**/*.js"]
450 );
451 assert_eq!(
452 nx("packages/!(package-a)*"),
453 ["!packages/package-a*", "packages/*"]
454 );
455 assert_eq!(
456 nx("packages/!(package-a)*/package.json"),
457 ["!packages/package-a*/", "packages/*/package.json"]
458 );
459 assert_eq!(nx("**/!(package-a)*"), ["!**/package-a*", "**/*"]);
460 assert_eq!(
461 nx("!(test|e2e)/?(*.)+(spec|test).[jt]s!(x)?(.snap)"),
462 [
463 "!*/*.{spec,test}.[jt]sx",
464 "!*/*.{spec,test}.[jt]sx.snap",
465 "!*/{spec,test}.[jt]sx",
466 "!*/{spec,test}.[jt]sx.snap",
467 "!{test,e2e}/",
468 "*/*.{spec,test}.[jt]s",
469 "*/*.{spec,test}.[jt]s.snap",
470 "*/{spec,test}.[jt]s",
471 "*/{spec,test}.[jt]s.snap",
472 ]
473 );
474 }
475
476 #[test]
477 fn negated_groups_exclude_what_they_name() {
478 let pattern = Pattern::new("src/**/!(*.test|*.spec).ts", false).unwrap();
479 assert!(pattern.is_match("src/a/x.ts"));
480 assert!(!pattern.is_match("src/a/x.test.ts") && !pattern.is_match("src/a/x.spec.ts"));
481 let directories = Pattern::new("src/!(a|b)/*", false).unwrap();
482 assert!(directories.is_match("src/c/z.md"));
483 assert!(!directories.is_match("src/a/x.ts"));
484 let odd = Pattern::new("src/**/*.!(ts)", false).unwrap();
487 assert!(!odd.is_match("src/b/y.js") && !odd.is_match("src/b/y.ts"));
488 let whole = Pattern::new("src/**/!(*.test).ts", true).unwrap();
490 assert!(whole.is_match("src/a/x.ts") && whole.is_match("src/a/x.test.ts"));
491 }
492
493 #[test]
494 fn matches_expanded_patterns() {
495 let tests = Pattern::new("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", true).unwrap();
496 for path in [
497 "app/a.spec.ts",
498 "app/deep/b.test.jsx",
499 "app/test.ts",
500 "app/c.spec.tsx.snap",
501 ] {
502 assert!(tests.is_match(path), "{path}");
503 }
504 for path in ["app/a.ts", "app/a.spec.mts", "other/a.spec.ts"] {
505 assert!(!tests.is_match(path), "{path}");
506 }
507 let plain = Pattern::new("src/*.ts", false).unwrap();
508 assert!(plain.is_match("src/a.ts") && !plain.is_match("src/a/b.ts"));
509 }
510
511 #[test]
514 fn a_backslash_escapes_the_next_character() {
515 let route = Pattern::new(r"app/\(marketing\)/**/*", false).unwrap();
516 assert!(route.is_match("app/(marketing)/page.tsx"));
517 assert!(!route.is_match("app/marketing/page.tsx"));
518 let id = Pattern::new(r"src/\[id\].ts", false).unwrap();
519 assert!(id.is_match("src/[id].ts") && !id.is_match("src/i.ts"));
520 let mixed = Pattern::new(r"app/\(a\)/?(*.)+(spec|test).ts", false).unwrap();
521 assert!(mixed.is_match("app/(a)/x.spec.ts") && !mixed.is_match("app/a/x.spec.ts"));
522 assert!(!Pattern::new(r"src\**", false).unwrap().is_match("src/a.ts"));
525 }
526
527 #[test]
529 fn a_fileset_path_with_no_pattern_names_a_file_or_a_directory() {
530 for pattern in ["src", "src/"] {
531 let fileset = Pattern::fileset(pattern, false).unwrap();
532 assert!(fileset.is_match("src/a.ts") && fileset.is_match("src/deep/b.ts"));
533 assert!(!fileset.is_match("srcs/a.ts"));
534 }
535 assert!(
536 Pattern::fileset("package.json", false)
537 .unwrap()
538 .is_match("package.json")
539 );
540 for named in ["@types", "c++", r"\(group\)"] {
541 let fileset = Pattern::fileset(named, false).unwrap();
542 let unescaped = named.replace('\\', "");
543 assert!(fileset.is_match(format!("{unescaped}/x")), "{named}");
544 }
545 let glob = Pattern::fileset("src/*.ts", false).unwrap();
547 assert!(glob.is_match("src/a.ts") && !glob.is_match("src/a.ts/b"));
548 assert!(!Pattern::new("src", false).unwrap().is_match("src/a.ts"));
549 }
550
551 #[test]
553 fn selects_the_files_nx_selects() {
554 let files = [
555 "src/a/x.ts",
556 "src/a/x.test.ts",
557 "src/a/x.spec.ts",
558 "src/b/y.ts",
559 "src/b/y.js",
560 "src/c/deep/z.ts",
561 "src/c/z.md",
562 "src/root.ts",
563 "src/README.md",
564 ];
565 let selected = |pattern: &str| -> Vec<&str> {
566 let pattern = Pattern::new(pattern, false).unwrap();
567 files
568 .iter()
569 .copied()
570 .filter(|file| pattern.is_match(file))
571 .collect()
572 };
573 assert_eq!(
574 selected("src/**/!(*.test).ts"),
575 [
576 "src/a/x.ts",
577 "src/a/x.spec.ts",
578 "src/b/y.ts",
579 "src/c/deep/z.ts",
580 "src/root.ts"
581 ]
582 );
583 assert_eq!(
584 selected("src/!(a)/**"),
585 ["src/b/y.ts", "src/b/y.js", "src/c/deep/z.ts", "src/c/z.md"]
586 );
587 assert_eq!(selected("src/**/*.!(ts)"), [] as [&str; 0]);
588 assert_eq!(selected("src/!(a|b)/*"), ["src/c/z.md"]);
589 assert_eq!(
590 selected("src/*/!(x)*"),
591 ["src/b/y.ts", "src/b/y.js", "src/c/z.md"]
592 );
593 assert_eq!(
594 selected("src/**/!(*.test|*.spec).ts"),
595 ["src/a/x.ts", "src/b/y.ts", "src/c/deep/z.ts", "src/root.ts"]
596 );
597 }
598}