Skip to main content

turbo_tasks_fs/
glob.rs

1use std::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, trace::TraceRawVcs};
14
15use crate::globset::parse;
16
17// Examples:
18// - file.js = File(file.js)
19// - *.js = AnyFile, File(.js)
20// - file*.js = File(file), AnyFile, File(.js)
21// - dir/file.js = File(dir), PathSeparator, File(file.js)
22// - **/*.js = AnyDirectories, PathSeparator, AnyFile, File(.js)
23// - {a/**,*}/file = Alternatives([File(a), PathSeparator, AnyDirectories], [AnyFile]),
24//   PathSeparator, File(file)
25
26// Note: a/**/b does match a/b, so we need some special logic about path
27// separators
28
29#[turbo_tasks::value(eq = "manual", serialization = "custom")]
30#[derive(Debug, Clone)]
31pub struct Glob {
32    glob: RcStr,
33    #[turbo_tasks(trace_ignore)]
34    opts: GlobOptions,
35    #[turbo_tasks(trace_ignore)]
36    regex: Regex,
37    #[turbo_tasks(trace_ignore)]
38    directory_match_regex: Regex,
39}
40
41impl PartialEq for Glob {
42    fn eq(&self, other: &Self) -> bool {
43        self.glob == other.glob && self.opts == other.opts
44    }
45}
46
47impl Eq for Glob {}
48
49impl Display for Glob {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(f, "Glob({})", self.glob)
52    }
53}
54
55impl Encode for Glob {
56    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
57        self.glob.encode(encoder)?;
58        self.opts.encode(encoder)?;
59        Ok(())
60    }
61}
62
63impl<Context> Decode<Context> for Glob {
64    fn decode<D: Decoder<Context = Context>>(decoder: &mut D) -> Result<Self, DecodeError> {
65        let glob = RcStr::decode(decoder)?;
66        let opts = GlobOptions::decode(decoder)?;
67        Glob::parse(glob, opts).map_err(|err| DecodeError::OtherString(err.to_string()))
68    }
69}
70
71impl_borrow_decode!(Glob);
72
73#[turbo_tasks::task_input]
74#[derive(Copy, Clone, PartialEq, Eq, Hash, Default, TraceRawVcs, Debug, Encode, Decode)]
75pub struct GlobOptions {
76    /// Whether the glob is a partial match.
77    /// Allows glob to match any part of the given string(s).
78    /// NOTE: this means that a pattern like `node_modules/package_name` with `contains:true` will
79    /// match `foo_node_modules/package_name_bar` If you want to match a _directory_ named
80    /// `node_modules/package_name` you should use `**/node_modules/package_name/**`
81    pub contains: bool,
82    /// Whether matching should ignore ASCII case differences.
83    pub case_insensitive: bool,
84}
85
86impl Glob {
87    // Returns true if the glob matches the given path.
88    pub fn matches(&self, path: &str) -> bool {
89        self.regex.is_match(path.as_bytes())
90    }
91
92    // Returns true if the glob might match a filename underneath this `path` where the
93    // path represents a directory.
94    pub fn can_match_in_directory(&self, path: &str) -> bool {
95        debug_assert!(
96            !path.ends_with('/'),
97            "Path should be a directory name and not end with /"
98        );
99        self.directory_match_regex.is_match(path.as_bytes())
100    }
101
102    pub fn parse(input: RcStr, opts: GlobOptions) -> Result<Glob> {
103        let (glob_re, directory_match_re) = parse(&input, opts)?;
104        let regex = new_regex(glob_re.as_str(), opts);
105        let directory_match_regex = new_regex(directory_match_re.as_str(), opts);
106
107        Ok(Glob {
108            glob: input,
109            opts,
110            regex,
111            directory_match_regex,
112        })
113    }
114}
115
116#[turbo_tasks::value_impl]
117impl Glob {
118    #[turbo_tasks::function]
119    pub fn new(glob: RcStr, opts: GlobOptions) -> Result<Vc<Self>> {
120        Ok(Self::cell(Glob::parse(glob, opts)?))
121    }
122
123    #[turbo_tasks::function]
124    pub async fn alternatives(globs: Vec<Vc<Glob>>) -> Result<Vc<Self>> {
125        match globs.len() {
126            0 => Ok(Glob::new(rcstr!(""), GlobOptions::default())),
127            1 => Ok(globs.into_iter().next().unwrap()),
128            _ => {
129                let mut new_glob = String::new();
130                new_glob.push('{');
131                let mut opts = None;
132                for (index, glob) in globs.iter().enumerate() {
133                    if index > 0 {
134                        new_glob.push(',');
135                    }
136                    let glob = &*glob.await?;
137                    if let Some(old_opts) = opts {
138                        if old_opts != glob.opts {
139                            bail!(
140                                "Cannot compose globs with different options via the \
141                                 `alternatives` function."
142                            )
143                        }
144                    } else {
145                        opts = Some(glob.opts);
146                    }
147                    new_glob.push_str(&glob.glob);
148                }
149                new_glob.push('}');
150                // The loop must have iterated at least once, so the options must be initialized.
151                Ok(Glob::new(new_glob.into(), opts.unwrap()))
152            }
153        }
154    }
155}
156
157fn new_regex(pattern: &str, opts: GlobOptions) -> Regex {
158    RegexBuilder::new(pattern)
159        // Because we aren't setting the `unicode` flag, this is only ASCII case-insensitive.
160        .case_insensitive(opts.case_insensitive)
161        .dot_matches_new_line(true)
162        .build()
163        .expect("A successfully parsed glob should produce a valid regex")
164}
165
166#[cfg(test)]
167mod tests {
168    use rstest::*;
169
170    use super::*;
171
172    #[rstest]
173    #[case::file("file.js", "file.js")]
174    #[case::dir_and_file("../public/äöüščří.png", "../public/äöüščří.png")]
175    #[case::dir_and_file("dir/file.js", "dir/file.js")]
176    #[case::file_braces("file.{ts,js}", "file.js")]
177    #[case::dir_and_file_braces("dir/file.{ts,js}", "dir/file.js")]
178    #[case::dir_and_file_dir_braces("{dir,other}/file.{ts,js}", "dir/file.js")]
179    #[case::star("*.js", "file.js")]
180    #[case::dir_star("dir/*.js", "dir/file.js")]
181    #[case::globstar("**/*.js", "file.js")]
182    #[case::globstar("**/*.js", "dir/file.js")]
183    #[case::globstar("**/*.js", "dir/sub/file.js")]
184    #[case::globstar("**/**/*.js", "file.js")]
185    #[case::globstar("**/**/*.js", "dir/sub/file.js")]
186    #[case::globstar("**", "/foo")]
187    #[case::globstar("**", "foo")]
188    #[case::star("*", "foo")]
189    #[case::globstar_in_dir("dir/**/sub/file.js", "dir/sub/file.js")]
190    #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/sub/file.js")]
191    #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
192    #[case::globstar_in_dir(
193        "**/next/dist/**/*.shared-runtime.js",
194        "next/dist/shared/lib/app-router-context.shared-runtime.js"
195    )]
196    #[case::star_dir(
197        "**/*/next/dist/server/next.js",
198        "node_modules/next/dist/server/next.js"
199    )]
200    #[case::node_modules_root("**/node_modules/**", "node_modules/next/dist/server/next.js")]
201    #[case::node_modules_root_package(
202        "**/node_modules/next/**",
203        "node_modules/next/dist/server/next.js"
204    )]
205    #[case::node_modules_nested(
206        "**/node_modules/**",
207        "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
208    )]
209    #[case::node_modules_nested_package(
210        "**/node_modules/regenerate-unicode-properties/**",
211        "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
212    )]
213    #[case::node_modules_pnpm(
214        "**/node_modules/**",
215        "node_modules/.pnpm/regenerate-unicode-properties@9.0.0/node_modules/\
216         regenerate-unicode-properties/Script_Extensions/Osage.js"
217    )]
218    #[case::node_modules_pnpm_package(
219        "**/node_modules/{regenerate,regenerate-unicode-properties}/**",
220        "node_modules/.pnpm/regenerate-unicode-properties@9.0.0/node_modules/\
221         regenerate-unicode-properties/Script_Extensions/Osage.js"
222    )]
223    #[case::node_modules_pnpm_prefixed_package(
224        "**/node_modules/{@blockfrost/blockfrost-js,@highlight-run/node,@libsql/client,@jpg-store/\
225         lucid-cardano,@mikro-orm/core,@mikro-orm/knex,@prisma/client,@sentry/nextjs,@sentry/node,\
226         @swc/core,argon2,autoprefixer,bcrypt,better-sqlite3,canvas,cpu-features,cypress,eslint,\
227         express,next-seo,node-pty,payload,pg,playwright,postcss,prettier,prisma,puppeteer,rimraf,\
228         sharp,shiki,sqlite3,tailwindcss,ts-node,typescript,vscode-oniguruma,webpack,websocket,@\
229         aws-sdk/client-dynamodb,@aws-sdk/lib-dynamodb}/**",
230        "node_modules/.pnpm/@aws-sdk+lib-dynamodb@3.445.0_@aws-sdk+client-dynamodb@3.445.0/\
231         node_modules/@aws-sdk/lib-dynamodb/dist-es/index.js"
232    )]
233    #[case::alternatives_nested1("{a,b/c,d/e/{f,g/h}}", "a")]
234    #[case::alternatives_nested2("{a,b/c,d/e/{f,g/h}}", "b/c")]
235    #[case::alternatives_nested3("{a,b/c,d/e/{f,g/h}}", "d/e/f")]
236    #[case::alternatives_nested4("{a,b/c,d/e/{f,g/h}}", "d/e/g/h")]
237    #[case::alternatives_empty1("react{,-dom}", "react")]
238    #[case::alternatives_empty2("react{,-dom}", "react-dom")]
239    #[case::alternatives_chars("[abc]", "b")]
240    fn glob_match(#[case] glob: &str, #[case] path: &str) {
241        let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
242
243        println!("{glob:?} {path}");
244
245        assert!(glob.matches(path));
246    }
247
248    #[rstest]
249    #[case::early_end("*.raw", "hello.raw.js")]
250    #[case::early_end(
251        "**/next/dist/esm/*.shared-runtime.js",
252        "next/dist/shared/lib/app-router-context.shared-runtime.js"
253    )]
254    #[case::star("*", "/foo")]
255    fn glob_not_matching(#[case] glob: &str, #[case] path: &str) {
256        let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
257
258        println!("{glob:?} {path}");
259
260        assert!(!glob.matches(path));
261    }
262
263    #[test]
264    fn glob_case_insensitive_matching() {
265        let case_sensitive =
266            Glob::parse(rcstr!("case-dir/module*.js"), GlobOptions::default()).unwrap();
267        let case_insensitive = Glob::parse(
268            rcstr!("case-dir/module*.js"),
269            GlobOptions {
270                case_insensitive: true,
271                ..Default::default()
272            },
273        )
274        .unwrap();
275
276        assert!(!case_sensitive.matches("Case-Dir/Module.js"));
277        assert!(case_insensitive.matches("Case-Dir/Module.js"));
278    }
279
280    #[test]
281    fn glob_case_insensitive_directory_matching() {
282        let case_sensitive =
283            Glob::parse(rcstr!("case-dir/module*.js"), GlobOptions::default()).unwrap();
284        let case_insensitive = Glob::parse(
285            rcstr!("case-dir/module*.js"),
286            GlobOptions {
287                case_insensitive: true,
288                ..Default::default()
289            },
290        )
291        .unwrap();
292
293        assert!(!case_sensitive.can_match_in_directory("Case-Dir"));
294        assert!(case_insensitive.can_match_in_directory("Case-Dir"));
295    }
296
297    #[rstest]
298    #[case::dir_and_file_partial("dir/file.js", "dir")]
299    #[case::dir_star_partial("dir/*.js", "dir")]
300    #[case::globstar_partial("**/**/*.js", "dir")]
301    #[case::globstar_partial("**/**/*.js", "dir/sub")]
302    #[case::globstar_partial("**/**/*.js", "dir/sub/file.js")] // This demonstrates some ambiguity in naming. `file.js` might be a directory name.
303    #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir")]
304    #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a")]
305    #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b")]
306    #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub")]
307    #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
308    fn glob_can_match_directory(#[case] glob: &str, #[case] path: &str) {
309        let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
310
311        println!("{glob:?} {path}");
312
313        assert!(glob.can_match_in_directory(path));
314    }
315    #[rstest]
316    #[case::dir_and_file_partial("dir/file.js", "dir/file.js")] // even if there was a dir, named `file.js` we know the glob wasn't intended to match it.
317    #[case::alternatives_chars("[abc]", "b")]
318    fn glob_not_can_match_directory(#[case] glob: &str, #[case] path: &str) {
319        let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
320
321        println!("{glob:?} {path}");
322
323        assert!(!glob.can_match_in_directory(path));
324    }
325
326    #[rstest]
327    #[case::star("*", "/foo")]
328    #[case::star("*", "foo")]
329    #[case::star("*", "foo/bar")]
330    #[case::prefix("foo/*", "bar/foo/baz")]
331    // This is a possibly surprising case.
332    #[case::dir_match("node_modules/foo", "my_node_modules/foobar")]
333    fn partial_glob_match(#[case] glob: &str, #[case] path: &str) {
334        let glob = Glob::parse(
335            RcStr::from(glob),
336            GlobOptions {
337                contains: true,
338                ..Default::default()
339            },
340        )
341        .unwrap();
342
343        println!("{glob:?} {path}");
344
345        assert!(glob.matches(path));
346    }
347
348    #[rstest]
349    #[case::literal("foo", "bar")]
350    #[case::suffix("*.js", "foo.ts")]
351    #[case::prefix("foo/*", "bar")]
352    // This is a possibly surprising case
353    #[case::dir_match("/node_modules/", "node_modules/")]
354    fn partial_glob_not_matching(#[case] glob: &str, #[case] path: &str) {
355        let glob = Glob::parse(
356            RcStr::from(glob),
357            GlobOptions {
358                contains: true,
359                ..Default::default()
360            },
361        )
362        .unwrap();
363
364        println!("{glob:?} {path}");
365
366        assert!(!glob.matches(path));
367    }
368}