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#[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 pub contains: bool,
82 pub case_insensitive: bool,
84}
85
86impl Glob {
87 pub fn matches(&self, path: &str) -> bool {
89 self.regex.is_match(path.as_bytes())
90 }
91
92 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 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 .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")] #[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")] #[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 #[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 #[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}