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> {
33 let globs = if negated
34 || ["|", "(", "{,", "!"]
35 .iter()
36 .any(|token| pattern.contains(token))
37 {
38 expand(pattern)?
39 } else {
40 vec![(pattern.to_owned(), false)]
41 };
42 let mut included = GlobSetBuilder::new();
43 let mut excluded = GlobSetBuilder::new();
44 for (glob, excludes) in globs {
45 let glob = if glob.ends_with('/') {
46 format!("{glob}**")
47 } else {
48 glob
49 };
50 let glob = GlobBuilder::new(&glob)
51 .literal_separator(true)
52 .backslash_escape(false)
53 .build()?;
54 if excludes && !negated {
55 excluded.add(glob);
56 } else {
57 included.add(glob);
58 }
59 }
60 Ok(Self {
61 included: included.build()?,
62 excluded: excluded.build()?,
63 })
64 }
65
66 pub fn is_match(&self, path: impl AsRef<std::path::Path>) -> bool {
69 let path = path.as_ref();
70 let excluded = self.excluded.is_match(path);
71 if self.included.is_empty() {
72 !excluded
73 } else {
74 !excluded && self.included.is_match(path)
75 }
76 }
77}
78
79pub fn is_literal(segment: &str) -> bool {
81 !segment.contains([
82 '!', '?', '@', '+', '*', '|', ',', '{', '}', '[', ']', '(', ')',
83 ])
84}
85
86#[derive(Debug, PartialEq)]
87enum Group {
88 Optional(String),
90 Once(String),
92 Negated(String),
94 NegatedFileName(String),
96 NegatedWildcard(String),
98 Literal(String),
99}
100
101fn expand(pattern: &str) -> Result<Vec<(String, bool)>> {
105 let segments: Vec<_> = pattern.split('/').collect();
106 let mut built = Vec::new();
107 for (index, segment) in segments.iter().enumerate() {
108 let groups = parse_segment(segment)
109 .map_err(|error| anyhow::anyhow!("{error} in glob {pattern:?}"))?;
110 built.push(build_segment(
111 "",
112 &groups,
113 index == segments.len() - 1,
114 false,
115 ));
116 }
117 let mut products: Vec<Vec<&(String, bool)>> = vec![Vec::new()];
119 for options in &built {
120 products = products
121 .iter()
122 .flat_map(|product| {
123 options.iter().map(move |option| {
124 let mut next = product.clone();
125 next.push(option);
126 next
127 })
128 })
129 .collect();
130 }
131 let mut globs: Vec<(String, bool)> = products
132 .into_iter()
133 .map(|product| {
134 let mut path = String::new();
135 let mut excludes = false;
136 let mut whole = false;
137 for (index, (segment, negative)) in product.iter().enumerate() {
138 whole = index == product.len() - 1;
139 path.push_str(segment);
140 path.push('/');
141 if *negative {
142 excludes = true;
143 if !whole {
144 break;
145 }
146 }
147 }
148 if whole {
149 path.pop();
150 }
151 (path, excludes)
152 })
153 .collect();
154 globs.sort_by(|a, b| (!a.1, &a.0).cmp(&(!b.1, &b.0)));
155 globs.dedup();
156 Ok(globs)
157}
158
159fn parse_segment(segment: &str) -> Result<Vec<Group>> {
160 let mut groups = Vec::new();
161 let mut rest = segment;
162 while !rest.is_empty() {
163 if let Some(after) = rest.strip_prefix(['?', '+', '@'])
165 && !after.is_empty()
166 && !after.starts_with('(')
167 {
168 rest = after;
169 }
170 let parsed = [
171 ("(", ")", false),
172 ("*(", ")", true),
173 ("?(", ")", true),
174 ("+(", ")", false),
175 ("@(", ")", false),
176 ("{,", "}", true),
177 ]
178 .into_iter()
179 .find_map(|(open, close, optional)| {
180 let body = rest.strip_prefix(open)?;
181 let end = body.find(close)?;
182 let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
183 let group = if optional {
184 Group::Optional(items)
185 } else {
186 Group::Once(items)
187 };
188 Some((group, &body[end + close.len()..]))
189 })
190 .or_else(|| {
192 let body = rest.strip_prefix("!(")?;
193 let end = body.find(')')?;
194 let items = body[..end].split(['|', ',']).collect::<Vec<_>>().join(",");
195 let after = &body[end + 1..];
196 Some(if let Some(after) = after.strip_prefix('.') {
197 (Group::NegatedFileName(items), after)
198 } else if let Some(after) = after.strip_prefix('*') {
199 (Group::NegatedWildcard(items), after)
200 } else {
201 (Group::Negated(items), after)
202 })
203 });
204 if let Some((group, remaining)) = parsed {
205 groups.push(group);
206 rest = remaining;
207 continue;
208 }
209 let end = rest
211 .find("{,")
212 .unwrap_or_else(|| rest.find(['?', '+', '@', '!', '(']).unwrap_or(rest.len()));
213 if end == 0 {
214 bail!("unsupported glob syntax at {rest:?}");
215 }
216 groups.push(Group::Literal(rest[..end].to_owned()));
217 rest = &rest[end..];
218 }
219 Ok(groups)
220}
221
222fn display(items: &str) -> String {
223 if items.contains(',') {
224 format!("{{{items}}}")
225 } else {
226 items.to_owned()
227 }
228}
229
230fn build_segment(
233 existing: &str,
234 groups: &[Group],
235 is_last: bool,
236 negative: bool,
237) -> Vec<(String, bool)> {
238 let Some((group, rest)) = groups.split_first() else {
239 return vec![(existing.to_owned(), negative)];
240 };
241 let omitted = if is_last { existing } else { "*" };
243 let both = |off: &str, on: &str, on_negative: bool| {
244 let mut options = build_segment(off, rest, is_last, negative);
245 options.extend(build_segment(on, rest, is_last, on_negative));
246 options
247 };
248 match group {
249 Group::Optional(items) => both(omitted, &format!("{existing}{}", display(items)), negative),
250 Group::Negated(items) => both(omitted, &format!("{existing}{}", display(items)), true),
251 Group::NegatedFileName(items) => {
252 both("*.", &format!("{existing}{}.", display(items)), true)
253 }
254 Group::NegatedWildcard(items) => both("*", &format!("{existing}{}*", display(items)), true),
255 Group::Once(items) => build_segment(
256 &format!("{existing}{}", display(items)),
257 rest,
258 is_last,
259 negative,
260 ),
261 Group::Literal(text) => {
262 build_segment(&format!("{existing}{text}"), rest, is_last, negative)
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn nx(pattern: &str) -> Vec<String> {
273 expand(pattern)
274 .unwrap()
275 .into_iter()
276 .map(|(glob, excludes)| if excludes { format!("!{glob}") } else { glob })
277 .collect()
278 }
279
280 #[test]
282 fn expands_like_nx() {
283 assert_eq!(
284 nx("libs/**/?(*.)+spec.ts?(.snap)"),
285 [
286 "libs/**/*.spec.ts",
287 "libs/**/*.spec.ts.snap",
288 "libs/**/spec.ts",
289 "libs/**/spec.ts.snap"
290 ]
291 );
292 for pattern in [
293 "libs/**/?(*.)@spec.ts?(.snap)",
294 "libs/**/?(*.)?spec.ts?(.snap)",
295 ] {
296 assert_eq!(nx(pattern), nx("libs/**/?(*.)+spec.ts?(.snap)"));
297 }
298 assert_eq!(nx("dist/**/*.js"), ["dist/**/*.js"]);
299 assert_eq!(nx("**/*.(js|ts)"), ["**/*.{js,ts}"]);
300 assert_eq!(
301 nx("**/*.spec.ts{,.snap}"),
302 ["**/*.spec.ts", "**/*.spec.ts.snap"]
303 );
304 }
305
306 #[test]
307 fn expands_workspace_patterns() {
308 assert_eq!(
309 nx("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)"),
310 [
311 "app/**/*.{spec,test}.[jt]s",
312 "app/**/*.{spec,test}.[jt]s.snap",
313 "app/**/*.{spec,test}.[jt]sx",
314 "app/**/*.{spec,test}.[jt]sx.snap",
315 "app/**/{spec,test}.[jt]s",
316 "app/**/{spec,test}.[jt]s.snap",
317 "app/**/{spec,test}.[jt]sx",
318 "app/**/{spec,test}.[jt]sx.snap",
319 ]
320 );
321 assert_eq!(nx("biome.json?(c)"), ["biome.json", "biome.jsonc"]);
322 assert_eq!(nx("app/(.cache|dist)/**/*"), ["app/{.cache,dist}/**/*"]);
323 assert_eq!(nx("a?(b)/c"), ["*/c", "ab/c"]);
325 }
326
327 #[test]
328 fn rejects_what_nx_rejects() {
329 assert!(expand("src/a!b").is_err());
330 assert!(expand("file?").is_err());
331 assert!(expand("src/(unterminated").is_err());
332 }
333
334 #[test]
336 fn expands_negated_groups_like_nx() {
337 assert_eq!(
338 nx("dist/!(cache|cache2)/**/!(README|LICENSE).(js|ts)"),
339 [
340 "!dist/*/**/{README,LICENSE}.{js,ts}",
341 "!dist/{cache,cache2}/",
342 "dist/*/**/*.{js,ts}",
343 ]
344 );
345 assert_eq!(
346 nx("dist/**/!(README|LICENSE).(js|ts)"),
347 ["!dist/**/{README,LICENSE}.{js,ts}", "dist/**/*.{js,ts}"]
348 );
349 assert_eq!(
350 nx("dist/!(cache|cache2)/**/*.(js|ts)"),
351 ["!dist/{cache,cache2}/", "dist/*/**/*.{js,ts}"]
352 );
353 assert_eq!(
354 nx("dist/!(cache|cache2)/**/*.js"),
355 ["!dist/{cache,cache2}/", "dist/*/**/*.js"]
356 );
357 assert_eq!(
358 nx("packages/!(package-a)*"),
359 ["!packages/package-a*", "packages/*"]
360 );
361 assert_eq!(
362 nx("packages/!(package-a)*/package.json"),
363 ["!packages/package-a*/", "packages/*/package.json"]
364 );
365 assert_eq!(nx("**/!(package-a)*"), ["!**/package-a*", "**/*"]);
366 assert_eq!(
367 nx("!(test|e2e)/?(*.)+(spec|test).[jt]s!(x)?(.snap)"),
368 [
369 "!*/*.{spec,test}.[jt]sx",
370 "!*/*.{spec,test}.[jt]sx.snap",
371 "!*/{spec,test}.[jt]sx",
372 "!*/{spec,test}.[jt]sx.snap",
373 "!{test,e2e}/",
374 "*/*.{spec,test}.[jt]s",
375 "*/*.{spec,test}.[jt]s.snap",
376 "*/{spec,test}.[jt]s",
377 "*/{spec,test}.[jt]s.snap",
378 ]
379 );
380 }
381
382 #[test]
383 fn negated_groups_exclude_what_they_name() {
384 let pattern = Pattern::new("src/**/!(*.test|*.spec).ts", false).unwrap();
385 assert!(pattern.is_match("src/a/x.ts"));
386 assert!(!pattern.is_match("src/a/x.test.ts") && !pattern.is_match("src/a/x.spec.ts"));
387 let directories = Pattern::new("src/!(a|b)/*", false).unwrap();
388 assert!(directories.is_match("src/c/z.md"));
389 assert!(!directories.is_match("src/a/x.ts"));
390 let odd = Pattern::new("src/**/*.!(ts)", false).unwrap();
393 assert!(!odd.is_match("src/b/y.js") && !odd.is_match("src/b/y.ts"));
394 let whole = Pattern::new("src/**/!(*.test).ts", true).unwrap();
396 assert!(whole.is_match("src/a/x.ts") && whole.is_match("src/a/x.test.ts"));
397 }
398
399 #[test]
400 fn matches_expanded_patterns() {
401 let tests = Pattern::new("app/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", true).unwrap();
402 for path in [
403 "app/a.spec.ts",
404 "app/deep/b.test.jsx",
405 "app/test.ts",
406 "app/c.spec.tsx.snap",
407 ] {
408 assert!(tests.is_match(path), "{path}");
409 }
410 for path in ["app/a.ts", "app/a.spec.mts", "other/a.spec.ts"] {
411 assert!(!tests.is_match(path), "{path}");
412 }
413 let plain = Pattern::new("src/*.ts", false).unwrap();
414 assert!(plain.is_match("src/a.ts") && !plain.is_match("src/a/b.ts"));
415 }
416
417 #[test]
419 fn selects_the_files_nx_selects() {
420 let files = [
421 "src/a/x.ts",
422 "src/a/x.test.ts",
423 "src/a/x.spec.ts",
424 "src/b/y.ts",
425 "src/b/y.js",
426 "src/c/deep/z.ts",
427 "src/c/z.md",
428 "src/root.ts",
429 "src/README.md",
430 ];
431 let selected = |pattern: &str| -> Vec<&str> {
432 let pattern = Pattern::new(pattern, false).unwrap();
433 files
434 .iter()
435 .copied()
436 .filter(|file| pattern.is_match(file))
437 .collect()
438 };
439 assert_eq!(
440 selected("src/**/!(*.test).ts"),
441 [
442 "src/a/x.ts",
443 "src/a/x.spec.ts",
444 "src/b/y.ts",
445 "src/c/deep/z.ts",
446 "src/root.ts"
447 ]
448 );
449 assert_eq!(
450 selected("src/!(a)/**"),
451 ["src/b/y.ts", "src/b/y.js", "src/c/deep/z.ts", "src/c/z.md"]
452 );
453 assert_eq!(selected("src/**/*.!(ts)"), [] as [&str; 0]);
454 assert_eq!(selected("src/!(a|b)/*"), ["src/c/z.md"]);
455 assert_eq!(
456 selected("src/*/!(x)*"),
457 ["src/b/y.ts", "src/b/y.js", "src/c/z.md"]
458 );
459 assert_eq!(
460 selected("src/**/!(*.test|*.spec).ts"),
461 ["src/a/x.ts", "src/b/y.ts", "src/c/deep/z.ts", "src/root.ts"]
462 );
463 }
464}