1use std::{borrow::Cow, fmt::Display};
2
3use anyhow::{Result, bail};
4use bincode::{
5 Decode, Encode,
6 de::Decoder,
7 enc::Encoder,
8 error::{DecodeError, EncodeError},
9 impl_borrow_decode,
10};
11use regex::bytes::{Regex, RegexBuilder};
12use turbo_rcstr::{RcStr, rcstr};
13use turbo_tasks::Vc;
14
15use crate::{FileSystemPath, globset::parse};
16
17#[turbo_tasks::value(eq = "manual", serialization = "custom")]
30#[derive(Debug, Clone)]
31pub struct Glob {
32 glob: RcStr,
33 opts: GlobOptions,
34 #[turbo_tasks(unsafe_ignore)]
35 regex: Regex,
36 #[turbo_tasks(unsafe_ignore)]
37 directory_match_regex: Regex,
38}
39
40impl PartialEq for Glob {
41 fn eq(&self, other: &Self) -> bool {
42 self.glob == other.glob && self.opts == other.opts
43 }
44}
45
46impl Eq for Glob {}
47
48impl Display for Glob {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(f, "Glob({})", self.glob)
51 }
52}
53
54impl Encode for Glob {
55 fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
56 self.glob.encode(encoder)?;
57 self.opts.encode(encoder)?;
58 Ok(())
59 }
60}
61
62impl<Context> Decode<Context> for Glob {
63 fn decode<D: Decoder<Context = Context>>(decoder: &mut D) -> Result<Self, DecodeError> {
64 let glob = RcStr::decode(decoder)?;
65 let opts = GlobOptions::decode(decoder)?;
66 Glob::parse(glob, opts).map_err(|err| DecodeError::OtherString(err.to_string()))
67 }
68}
69
70impl_borrow_decode!(Glob);
71
72#[turbo_tasks::task_input]
73#[derive(Copy, Clone, PartialEq, Eq, Hash, Default, Debug, Encode, Decode)]
74pub struct GlobOptions {
75 pub contains: bool,
84 pub case_insensitive: bool,
86}
87
88impl Glob {
89 pub fn matches(&self, path: &str) -> bool {
91 self.regex.is_match(path.as_bytes())
92 }
93
94 pub fn can_match_in_directory(&self, path: &str) -> bool {
97 assert!(
98 !self.opts.contains,
99 "Glob::can_match_in_directory cannot be used when GlobOptions::contains is true"
100 );
101 debug_assert!(
102 !path.ends_with('/'),
103 "Path should be a directory name and not end with /"
104 );
105 self.directory_match_regex.is_match(path.as_bytes())
106 }
107
108 pub fn parse(input: RcStr, opts: GlobOptions) -> Result<Glob> {
109 let (glob_re, directory_match_re) = parse(&input, opts)?;
110 let regex = new_regex(glob_re.as_str(), opts);
111 let directory_match_regex = new_regex(directory_match_re.as_str(), opts);
112
113 Ok(Glob {
114 glob: input,
115 opts,
116 regex,
117 directory_match_regex,
118 })
119 }
120}
121
122#[turbo_tasks::value_impl]
123impl Glob {
124 #[turbo_tasks::function]
125 pub fn new(glob: RcStr, opts: GlobOptions) -> Result<Vc<Self>> {
126 Ok(Self::cell(Glob::parse(glob, opts)?))
127 }
128
129 #[turbo_tasks::function]
130 pub async fn alternatives(globs: Vec<Vc<Glob>>) -> Result<Vc<Self>> {
131 match globs.len() {
132 0 => Ok(Glob::new(rcstr!(""), GlobOptions::default())),
133 1 => Ok(globs.into_iter().next().unwrap()),
134 _ => {
135 let mut new_glob = String::new();
136 new_glob.push('{');
137 let mut opts = None;
138 for (index, glob) in globs.iter().enumerate() {
139 if index > 0 {
140 new_glob.push(',');
141 }
142 let glob = &*glob.await?;
143 if let Some(old_opts) = opts {
144 if old_opts != glob.opts {
145 bail!(
146 "Cannot compose globs with different options via the \
147 `alternatives` function."
148 )
149 }
150 } else {
151 opts = Some(glob.opts);
152 }
153 new_glob.push_str(&glob.glob);
154 }
155 new_glob.push('}');
156 Ok(Glob::new(new_glob.into(), opts.unwrap()))
158 }
159 }
160 }
161}
162
163pub fn relativize_glob<'a>(
184 glob: &'a str,
185 relative_to: &FileSystemPath,
186) -> Option<(&'a str, FileSystemPath)> {
187 let mut relative_to = Cow::Borrowed(relative_to);
188 let mut remaining = glob;
189 loop {
190 if let Some(stripped) = remaining.strip_prefix("../") {
191 if relative_to.is_root() {
192 return None;
193 }
194 relative_to = Cow::Owned(relative_to.parent());
195 remaining = stripped;
196 } else if let Some(stripped) = remaining.strip_prefix("./") {
197 remaining = stripped;
198 } else {
199 return Some((remaining, relative_to.into_owned()));
200 }
201 }
202}
203
204fn new_regex(pattern: &str, opts: GlobOptions) -> Regex {
205 RegexBuilder::new(pattern)
206 .case_insensitive(opts.case_insensitive)
208 .dot_matches_new_line(true)
209 .build()
210 .expect("A successfully parsed glob should produce a valid regex")
211}
212
213#[cfg(test)]
214mod relativize_glob_tests {
215 use turbo_tasks::ResolvedVc;
216 use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
217
218 use super::*;
219 use crate::NullFileSystem;
220
221 fn path(path: &str) -> FileSystemPath {
222 FileSystemPath {
223 fs: ResolvedVc::upcast(NullFileSystem {}.resolved_cell()),
224 path: path.into(),
225 }
226 }
227
228 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
229 async fn relativizes_leading_segments() {
230 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
231 BackendOptions::default(),
232 noop_backing_storage(),
233 ));
234 tt.run_once(async {
235 let dir = path("project/src/components");
236
237 let (glob, root) = relativize_glob("*.js", &dir).unwrap();
239 assert_eq!(
240 (glob, root.path.as_str()),
241 ("*.js", "project/src/components")
242 );
243 let (glob, root) = relativize_glob("nested/**/*.js", &dir).unwrap();
244 assert_eq!(
245 (glob, root.path.as_str()),
246 ("nested/**/*.js", "project/src/components")
247 );
248
249 let (glob, root) = relativize_glob("./*.js", &dir).unwrap();
251 assert_eq!(
252 (glob, root.path.as_str()),
253 ("*.js", "project/src/components")
254 );
255 let (glob, root) = relativize_glob("././*.js", &dir).unwrap();
256 assert_eq!(
257 (glob, root.path.as_str()),
258 ("*.js", "project/src/components")
259 );
260
261 let (glob, root) = relativize_glob("../*.js", &dir).unwrap();
263 assert_eq!((glob, root.path.as_str()), ("*.js", "project/src"));
264 let (glob, root) = relativize_glob("../../lib/*.js", &dir).unwrap();
265 assert_eq!((glob, root.path.as_str()), ("lib/*.js", "project"));
266
267 let (glob, root) = relativize_glob(".././utils/*.js", &dir).unwrap();
269 assert_eq!((glob, root.path.as_str()), ("utils/*.js", "project/src"));
270 let (glob, root) = relativize_glob("./../lib/*.js", &dir).unwrap();
271 assert_eq!((glob, root.path.as_str()), ("lib/*.js", "project/src"));
272 let (glob, root) = relativize_glob("././../.././x/*.js", &dir).unwrap();
273 assert_eq!((glob, root.path.as_str()), ("x/*.js", "project"));
274
275 let (glob, root) = relativize_glob("../../../*.js", &dir).unwrap();
277 assert_eq!((glob, root.path.as_str()), ("*.js", ""));
278
279 Ok(())
280 })
281 .await
282 .unwrap();
283 }
284
285 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
286 async fn reports_walking_above_the_root() {
287 let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
288 BackendOptions::default(),
289 noop_backing_storage(),
290 ));
291 tt.run_once(async {
292 assert!(relativize_glob("../../../../*.js", &path("project/src/components")).is_none());
294 assert!(relativize_glob("../*.js", &path("")).is_none());
295 assert!(relativize_glob("./../../*.js", &path("project")).is_none());
297 Ok(())
298 })
299 .await
300 .unwrap();
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use rstest::*;
307
308 use super::*;
309
310 #[rstest]
311 #[case::file("file.js", "file.js")]
312 #[case::dir_and_file("../public/äöüščří.png", "../public/äöüščří.png")]
313 #[case::dir_and_file("dir/file.js", "dir/file.js")]
314 #[case::file_braces("file.{ts,js}", "file.js")]
315 #[case::dir_and_file_braces("dir/file.{ts,js}", "dir/file.js")]
316 #[case::dir_and_file_dir_braces("{dir,other}/file.{ts,js}", "dir/file.js")]
317 #[case::star("*.js", "file.js")]
318 #[case::dir_star("dir/*.js", "dir/file.js")]
319 #[case::globstar("**/*.js", "file.js")]
320 #[case::globstar("**/*.js", "dir/file.js")]
321 #[case::globstar("**/*.js", "dir/sub/file.js")]
322 #[case::globstar("**/**/*.js", "file.js")]
323 #[case::globstar("**/**/*.js", "dir/sub/file.js")]
324 #[case::globstar("**", "/foo")]
325 #[case::globstar("**", "foo")]
326 #[case::star("*", "foo")]
327 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/sub/file.js")]
328 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/sub/file.js")]
329 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
330 #[case::globstar_in_dir(
331 "**/next/dist/**/*.shared-runtime.js",
332 "next/dist/shared/lib/app-router-context.shared-runtime.js"
333 )]
334 #[case::star_dir(
335 "**/*/next/dist/server/next.js",
336 "node_modules/next/dist/server/next.js"
337 )]
338 #[case::node_modules_root("**/node_modules/**", "node_modules/next/dist/server/next.js")]
339 #[case::node_modules_root_package(
340 "**/node_modules/next/**",
341 "node_modules/next/dist/server/next.js"
342 )]
343 #[case::node_modules_nested(
344 "**/node_modules/**",
345 "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
346 )]
347 #[case::node_modules_nested_package(
348 "**/node_modules/regenerate-unicode-properties/**",
349 "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
350 )]
351 #[case::node_modules_pnpm(
352 "**/node_modules/**",
353 "node_modules/.pnpm/regenerate-unicode-properties@9.0.0/node_modules/\
354 regenerate-unicode-properties/Script_Extensions/Osage.js"
355 )]
356 #[case::node_modules_pnpm_package(
357 "**/node_modules/{regenerate,regenerate-unicode-properties}/**",
358 "node_modules/.pnpm/regenerate-unicode-properties@9.0.0/node_modules/\
359 regenerate-unicode-properties/Script_Extensions/Osage.js"
360 )]
361 #[case::node_modules_pnpm_prefixed_package(
362 "**/node_modules/{@blockfrost/blockfrost-js,@highlight-run/node,@libsql/client,@jpg-store/\
363 lucid-cardano,@mikro-orm/core,@mikro-orm/knex,@prisma/client,@sentry/nextjs,@sentry/node,\
364 @swc/core,argon2,autoprefixer,bcrypt,better-sqlite3,canvas,cpu-features,cypress,eslint,\
365 express,next-seo,node-pty,payload,pg,playwright,postcss,prettier,prisma,puppeteer,rimraf,\
366 sharp,shiki,sqlite3,tailwindcss,ts-node,typescript,vscode-oniguruma,webpack,websocket,@\
367 aws-sdk/client-dynamodb,@aws-sdk/lib-dynamodb}/**",
368 "node_modules/.pnpm/@aws-sdk+lib-dynamodb@3.445.0_@aws-sdk+client-dynamodb@3.445.0/\
369 node_modules/@aws-sdk/lib-dynamodb/dist-es/index.js"
370 )]
371 #[case::alternatives_nested1("{a,b/c,d/e/{f,g/h}}", "a")]
372 #[case::alternatives_nested2("{a,b/c,d/e/{f,g/h}}", "b/c")]
373 #[case::alternatives_nested3("{a,b/c,d/e/{f,g/h}}", "d/e/f")]
374 #[case::alternatives_nested4("{a,b/c,d/e/{f,g/h}}", "d/e/g/h")]
375 #[case::alternatives_empty1("react{,-dom}", "react")]
376 #[case::alternatives_empty2("react{,-dom}", "react-dom")]
377 #[case::alternatives_chars("[abc]", "b")]
378 #[case::character_range("[a-z].js", "b.js")]
379 fn glob_match(#[case] glob: &str, #[case] path: &str) {
380 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
381
382 println!("{glob:?} {path}");
383
384 assert!(glob.matches(path));
385 }
386
387 #[test]
388 fn glob_rejects_invalid_character_range() {
389 let error = Glob::parse(rcstr!("[z-a]"), GlobOptions::default()).unwrap_err();
390
391 assert!(format!("{error:#}").contains("invalid character range"));
392 }
393
394 #[rstest]
395 #[case::early_end("*.raw", "hello.raw.js")]
396 #[case::early_end(
397 "**/next/dist/esm/*.shared-runtime.js",
398 "next/dist/shared/lib/app-router-context.shared-runtime.js"
399 )]
400 #[case::star("*", "/foo")]
401 fn glob_not_matching(#[case] glob: &str, #[case] path: &str) {
402 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
403
404 println!("{glob:?} {path}");
405
406 assert!(!glob.matches(path));
407 }
408
409 #[test]
410 fn glob_case_insensitive_matching() {
411 let case_sensitive =
412 Glob::parse(rcstr!("case-dir/module*.js"), GlobOptions::default()).unwrap();
413 let case_insensitive = Glob::parse(
414 rcstr!("case-dir/module*.js"),
415 GlobOptions {
416 case_insensitive: true,
417 ..Default::default()
418 },
419 )
420 .unwrap();
421
422 assert!(!case_sensitive.matches("Case-Dir/Module.js"));
423 assert!(case_insensitive.matches("Case-Dir/Module.js"));
424 }
425
426 #[test]
427 fn glob_case_insensitive_directory_matching() {
428 let case_sensitive =
429 Glob::parse(rcstr!("case-dir/module*.js"), GlobOptions::default()).unwrap();
430 let case_insensitive = Glob::parse(
431 rcstr!("case-dir/module*.js"),
432 GlobOptions {
433 case_insensitive: true,
434 ..Default::default()
435 },
436 )
437 .unwrap();
438
439 assert!(!case_sensitive.can_match_in_directory("Case-Dir"));
440 assert!(case_insensitive.can_match_in_directory("Case-Dir"));
441 }
442
443 #[rstest]
444 #[case::dir_and_file_partial("dir/file.js", "dir")]
445 #[case::dir_star_partial("dir/*.js", "dir")]
446 #[case::globstar_partial("**/**/*.js", "dir")]
447 #[case::globstar_partial("**/**/*.js", "dir/sub")]
448 #[case::globstar_partial("**/**/*.js", "dir/sub/file.js")] #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir")]
450 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a")]
451 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b")]
452 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub")]
453 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
454 fn glob_can_match_directory(#[case] glob: &str, #[case] path: &str) {
455 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
456
457 println!("{glob:?} {path}");
458
459 assert!(glob.can_match_in_directory(path));
460 }
461 #[rstest]
462 #[case::dir_and_file_partial("dir/file.js", "dir/file.js")] #[case::alternatives_chars("[abc]", "b")]
464 fn glob_not_can_match_directory(#[case] glob: &str, #[case] path: &str) {
465 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
466
467 println!("{glob:?} {path}");
468
469 assert!(!glob.can_match_in_directory(path));
470 }
471
472 #[rstest]
473 #[case::star("*", "/foo")]
474 #[case::star("*", "foo")]
475 #[case::star("*", "foo/bar")]
476 #[case::prefix("foo/*", "bar/foo/baz")]
477 #[case::dir_match("node_modules/foo", "my_node_modules/foobar")]
479 fn partial_glob_match(#[case] glob: &str, #[case] path: &str) {
480 let glob = Glob::parse(
481 RcStr::from(glob),
482 GlobOptions {
483 contains: true,
484 ..Default::default()
485 },
486 )
487 .unwrap();
488
489 println!("{glob:?} {path}");
490
491 assert!(glob.matches(path));
492 }
493
494 #[rstest]
495 #[case::literal("foo", "bar")]
496 #[case::suffix("*.js", "foo.ts")]
497 #[case::prefix("foo/*", "bar")]
498 #[case::dir_match("/node_modules/", "node_modules/")]
500 fn partial_glob_not_matching(#[case] glob: &str, #[case] path: &str) {
501 let glob = Glob::parse(
502 RcStr::from(glob),
503 GlobOptions {
504 contains: true,
505 ..Default::default()
506 },
507 )
508 .unwrap();
509
510 println!("{glob:?} {path}");
511
512 assert!(!glob.matches(path));
513 }
514
515 #[test]
516 fn literal_glob_directory_pruning() {
517 let pattern = rcstr!(
518 "node_modules/.pnpm/lightningcss-wasm@1.28.2/node_modules/lightningcss-wasm/\
519 lightningcss_node.wasm"
520 );
521 let anchored = Glob::parse(pattern, GlobOptions::default()).unwrap();
522
523 assert!(anchored.can_match_in_directory("node_modules"));
524 assert!(anchored.can_match_in_directory("node_modules/.pnpm"));
525 assert!(!anchored.can_match_in_directory("node_modules/next"));
526 }
527
528 #[test]
529 #[should_panic(
530 expected = "Glob::can_match_in_directory cannot be used when GlobOptions::contains is true"
531 )]
532 fn contains_glob_cannot_match_in_directory() {
533 let glob = Glob::parse(
534 rcstr!("node_modules/package_name"),
535 GlobOptions {
536 contains: true,
537 ..Default::default()
538 },
539 )
540 .unwrap();
541
542 glob.can_match_in_directory("node_modules");
543 }
544}