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