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};
10use regex::bytes::{Regex, RegexBuilder};
11use turbo_rcstr::{RcStr, rcstr};
12use turbo_tasks::{TaskInput, Vc, trace::TraceRawVcs};
13
14use crate::globset::parse;
15
16#[turbo_tasks::value(eq = "manual", serialization = "custom")]
29#[derive(Debug, Clone)]
30pub struct Glob {
31 glob: RcStr,
32 #[turbo_tasks(trace_ignore)]
33 opts: GlobOptions,
34 #[turbo_tasks(trace_ignore)]
35 regex: Regex,
36 #[turbo_tasks(trace_ignore)]
37 directory_match_regex: Regex,
38}
39
40impl PartialEq for Glob {
41 fn eq(&self, other: &Self) -> bool {
42 self.glob == other.glob
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
70#[derive(
71 Copy, Clone, PartialEq, Eq, Hash, Default, TaskInput, TraceRawVcs, Debug, Encode, Decode,
72)]
73
74pub struct GlobOptions {
75 pub contains: bool,
81}
82
83impl Glob {
84 pub fn matches(&self, path: &str) -> bool {
86 self.regex.is_match(path.as_bytes())
87 }
88
89 pub fn can_match_in_directory(&self, path: &str) -> bool {
92 debug_assert!(
93 !path.ends_with('/'),
94 "Path should be a directory name and not end with /"
95 );
96 self.directory_match_regex.is_match(path.as_bytes())
97 }
98
99 pub fn parse(input: RcStr, opts: GlobOptions) -> Result<Glob> {
100 let (glob_re, directory_match_re) = parse(&input, opts)?;
101 let regex = new_regex(glob_re.as_str());
102 let directory_match_regex = new_regex(directory_match_re.as_str());
103
104 Ok(Glob {
105 glob: input,
106 opts,
107 regex,
108 directory_match_regex,
109 })
110 }
111}
112
113#[turbo_tasks::value_impl]
114impl Glob {
115 #[turbo_tasks::function]
116 pub fn new(glob: RcStr, opts: GlobOptions) -> Result<Vc<Self>> {
117 Ok(Self::cell(Glob::parse(glob, opts)?))
118 }
119
120 #[turbo_tasks::function]
121 pub async fn alternatives(globs: Vec<Vc<Glob>>) -> Result<Vc<Self>> {
122 match globs.len() {
123 0 => Ok(Glob::new(rcstr!(""), GlobOptions::default())),
124 1 => Ok(globs.into_iter().next().unwrap()),
125 _ => {
126 let mut new_glob = String::new();
127 new_glob.push('{');
128 let mut opts = None;
129 for (index, glob) in globs.iter().enumerate() {
130 if index > 0 {
131 new_glob.push(',');
132 }
133 let glob = &*glob.await?;
134 if let Some(old_opts) = opts {
135 if old_opts != glob.opts {
136 bail!(
137 "Cannot compose globs with different options via the \
138 `alternatives` function."
139 )
140 }
141 } else {
142 opts = Some(glob.opts);
143 }
144 new_glob.push_str(&glob.glob);
145 }
146 new_glob.push('}');
147 Ok(Glob::new(new_glob.into(), opts.unwrap()))
149 }
150 }
151 }
152}
153
154fn new_regex(pattern: &str) -> Regex {
155 RegexBuilder::new(pattern)
156 .dot_matches_new_line(true)
157 .build()
158 .expect("A successfully parsed glob should produce a valid regex")
159}
160
161#[cfg(test)]
162mod tests {
163 use rstest::*;
164
165 use super::*;
166
167 #[rstest]
168 #[case::file("file.js", "file.js")]
169 #[case::dir_and_file("../public/äöüščří.png", "../public/äöüščří.png")]
170 #[case::dir_and_file("dir/file.js", "dir/file.js")]
171 #[case::file_braces("file.{ts,js}", "file.js")]
172 #[case::dir_and_file_braces("dir/file.{ts,js}", "dir/file.js")]
173 #[case::dir_and_file_dir_braces("{dir,other}/file.{ts,js}", "dir/file.js")]
174 #[case::star("*.js", "file.js")]
175 #[case::dir_star("dir/*.js", "dir/file.js")]
176 #[case::globstar("**/*.js", "file.js")]
177 #[case::globstar("**/*.js", "dir/file.js")]
178 #[case::globstar("**/*.js", "dir/sub/file.js")]
179 #[case::globstar("**/**/*.js", "file.js")]
180 #[case::globstar("**/**/*.js", "dir/sub/file.js")]
181 #[case::globstar("**", "/foo")]
182 #[case::globstar("**", "foo")]
183 #[case::star("*", "foo")]
184 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/sub/file.js")]
185 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/sub/file.js")]
186 #[case::globstar_in_dir("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
187 #[case::globstar_in_dir(
188 "**/next/dist/**/*.shared-runtime.js",
189 "next/dist/shared/lib/app-router-context.shared-runtime.js"
190 )]
191 #[case::star_dir(
192 "**/*/next/dist/server/next.js",
193 "node_modules/next/dist/server/next.js"
194 )]
195 #[case::node_modules_root("**/node_modules/**", "node_modules/next/dist/server/next.js")]
196 #[case::node_modules_root_package(
197 "**/node_modules/next/**",
198 "node_modules/next/dist/server/next.js"
199 )]
200 #[case::node_modules_nested(
201 "**/node_modules/**",
202 "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
203 )]
204 #[case::node_modules_nested_package(
205 "**/node_modules/regenerate-unicode-properties/**",
206 "apps/some-app/node_modules/regenerate-unicode-properties/Script_Extensions/Osage.js"
207 )]
208 #[case::node_modules_pnpm(
209 "**/node_modules/**",
210 "node_modules/.pnpm/regenerate-unicode-properties@9.0.0/node_modules/\
211 regenerate-unicode-properties/Script_Extensions/Osage.js"
212 )]
213 #[case::node_modules_pnpm_package(
214 "**/node_modules/{regenerate,regenerate-unicode-properties}/**",
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_prefixed_package(
219 "**/node_modules/{@blockfrost/blockfrost-js,@highlight-run/node,@libsql/client,@jpg-store/\
220 lucid-cardano,@mikro-orm/core,@mikro-orm/knex,@prisma/client,@sentry/nextjs,@sentry/node,\
221 @swc/core,argon2,autoprefixer,bcrypt,better-sqlite3,canvas,cpu-features,cypress,eslint,\
222 express,next-seo,node-pty,payload,pg,playwright,postcss,prettier,prisma,puppeteer,rimraf,\
223 sharp,shiki,sqlite3,tailwindcss,ts-node,typescript,vscode-oniguruma,webpack,websocket,@\
224 aws-sdk/client-dynamodb,@aws-sdk/lib-dynamodb}/**",
225 "node_modules/.pnpm/@aws-sdk+lib-dynamodb@3.445.0_@aws-sdk+client-dynamodb@3.445.0/\
226 node_modules/@aws-sdk/lib-dynamodb/dist-es/index.js"
227 )]
228 #[case::alternatives_nested1("{a,b/c,d/e/{f,g/h}}", "a")]
229 #[case::alternatives_nested2("{a,b/c,d/e/{f,g/h}}", "b/c")]
230 #[case::alternatives_nested3("{a,b/c,d/e/{f,g/h}}", "d/e/f")]
231 #[case::alternatives_nested4("{a,b/c,d/e/{f,g/h}}", "d/e/g/h")]
232 #[case::alternatives_empty1("react{,-dom}", "react")]
233 #[case::alternatives_empty2("react{,-dom}", "react-dom")]
234 #[case::alternatives_chars("[abc]", "b")]
235 fn glob_match(#[case] glob: &str, #[case] path: &str) {
236 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
237
238 println!("{glob:?} {path}");
239
240 assert!(glob.matches(path));
241 }
242
243 #[rstest]
244 #[case::early_end("*.raw", "hello.raw.js")]
245 #[case::early_end(
246 "**/next/dist/esm/*.shared-runtime.js",
247 "next/dist/shared/lib/app-router-context.shared-runtime.js"
248 )]
249 #[case::star("*", "/foo")]
250 fn glob_not_matching(#[case] glob: &str, #[case] path: &str) {
251 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
252
253 println!("{glob:?} {path}");
254
255 assert!(!glob.matches(path));
256 }
257
258 #[rstest]
259 #[case::dir_and_file_partial("dir/file.js", "dir")]
260 #[case::dir_star_partial("dir/*.js", "dir")]
261 #[case::globstar_partial("**/**/*.js", "dir")]
262 #[case::globstar_partial("**/**/*.js", "dir/sub")]
263 #[case::globstar_partial("**/**/*.js", "dir/sub/file.js")] #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir")]
265 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a")]
266 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b")]
267 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub")]
268 #[case::globstar_in_dir_partial("dir/**/sub/file.js", "dir/a/b/sub/file.js")]
269 fn glob_can_match_directory(#[case] glob: &str, #[case] path: &str) {
270 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
271
272 println!("{glob:?} {path}");
273
274 assert!(glob.can_match_in_directory(path));
275 }
276 #[rstest]
277 #[case::dir_and_file_partial("dir/file.js", "dir/file.js")] #[case::alternatives_chars("[abc]", "b")]
279 fn glob_not_can_match_directory(#[case] glob: &str, #[case] path: &str) {
280 let glob = Glob::parse(RcStr::from(glob), GlobOptions::default()).unwrap();
281
282 println!("{glob:?} {path}");
283
284 assert!(!glob.can_match_in_directory(path));
285 }
286
287 #[rstest]
288 #[case::star("*", "/foo")]
289 #[case::star("*", "foo")]
290 #[case::star("*", "foo/bar")]
291 #[case::prefix("foo/*", "bar/foo/baz")]
292 #[case::dir_match("node_modules/foo", "my_node_modules/foobar")]
294 fn partial_glob_match(#[case] glob: &str, #[case] path: &str) {
295 let glob = Glob::parse(RcStr::from(glob), GlobOptions { contains: true }).unwrap();
296
297 println!("{glob:?} {path}");
298
299 assert!(glob.matches(path));
300 }
301
302 #[rstest]
303 #[case::literal("foo", "bar")]
304 #[case::suffix("*.js", "foo.ts")]
305 #[case::prefix("foo/*", "bar")]
306 #[case::dir_match("/node_modules/", "node_modules/")]
308 fn partial_glob_not_matching(#[case] glob: &str, #[case] path: &str) {
309 let glob = Glob::parse(RcStr::from(glob), GlobOptions { contains: true }).unwrap();
310
311 println!("{glob:?} {path}");
312
313 assert!(!glob.matches(path));
314 }
315}