turbopack/module_options/
rule_condition.rs

1use std::{
2    iter,
3    mem::{replace, take},
4};
5
6use anyhow::{Result, bail};
7use either::Either;
8use serde::{Deserialize, Serialize};
9use smallvec::SmallVec;
10use turbo_esregex::EsRegex;
11use turbo_tasks::{NonLocalValue, ReadRef, ResolvedVc, primitives::Regex, trace::TraceRawVcs};
12use turbo_tasks_fs::{FileContent, FileSystemPath, glob::Glob};
13use turbopack_core::{
14    asset::Asset, reference_type::ReferenceType, source::Source, virtual_source::VirtualSource,
15};
16
17#[derive(Debug, Clone, Serialize, Deserialize, TraceRawVcs, PartialEq, Eq, NonLocalValue)]
18pub enum RuleCondition {
19    All(Vec<RuleCondition>),
20    Any(Vec<RuleCondition>),
21    Not(Box<RuleCondition>),
22    True,
23    False,
24    ReferenceType(ReferenceType),
25    ResourceIsVirtualSource,
26    ResourcePathEquals(FileSystemPath),
27    ResourcePathHasNoExtension,
28    ResourcePathEndsWith(String),
29    ResourcePathInDirectory(String),
30    ResourcePathInExactDirectory(FileSystemPath),
31    ContentTypeStartsWith(String),
32    ContentTypeEmpty,
33    ResourcePathRegex(#[turbo_tasks(trace_ignore)] Regex),
34    ResourcePathEsRegex(#[turbo_tasks(trace_ignore)] ReadRef<EsRegex>),
35    ResourceContentEsRegex(#[turbo_tasks(trace_ignore)] ReadRef<EsRegex>),
36    /// For paths that are within the same filesystem as the `base`, it need to
37    /// match the relative path from base to resource. This includes `./` or
38    /// `../` prefix. For paths in a different filesystem, it need to match
39    /// the resource path in that filesystem without any prefix. This means
40    /// any glob starting with `./` or `../` will only match paths in the
41    /// project. Globs starting with `**` can match any path.
42    ResourcePathGlob {
43        base: FileSystemPath,
44        #[turbo_tasks(trace_ignore)]
45        glob: ReadRef<Glob>,
46    },
47    ResourceBasePathGlob(#[turbo_tasks(trace_ignore)] ReadRef<Glob>),
48}
49
50impl RuleCondition {
51    pub fn all(conditions: Vec<RuleCondition>) -> RuleCondition {
52        RuleCondition::All(conditions)
53    }
54
55    pub fn any(conditions: Vec<RuleCondition>) -> RuleCondition {
56        RuleCondition::Any(conditions)
57    }
58
59    #[allow(clippy::should_implement_trait)]
60    pub fn not(condition: RuleCondition) -> RuleCondition {
61        RuleCondition::Not(Box::new(condition))
62    }
63
64    /// Slightly optimize a `RuleCondition` by flattening nested `Any`, `All`, or `Not` variants.
65    ///
66    /// Does not apply general re-ordering of rules (which may also be a valid optimization using a
67    /// cost heuristic), but does flatten constant `True` and `False` conditions, potentially
68    /// skipping other rules.
69    pub fn flatten(&mut self) {
70        match self {
71            RuleCondition::Any(conds) => {
72                // fast path: flatten children in-place and avoid constructing an additional vec
73                let mut needs_flattening = false;
74                for c in conds.iter_mut() {
75                    c.flatten();
76                    if *c == RuleCondition::True {
77                        // short-circuit: all conditions are side-effect free
78                        *self = RuleCondition::True;
79                        return;
80                    }
81                    needs_flattening = needs_flattening
82                        || matches!(c, RuleCondition::Any(_) | RuleCondition::False);
83                }
84
85                if needs_flattening {
86                    *conds = take(conds)
87                        .into_iter()
88                        .flat_map(|c| match c {
89                            RuleCondition::Any(nested) => {
90                                debug_assert!(!nested.is_empty(), "empty Any should be False");
91                                Either::Left(nested.into_iter())
92                            }
93                            RuleCondition::False => Either::Right(Either::Left(iter::empty())),
94                            c => Either::Right(Either::Right(iter::once(c))),
95                        })
96                        .collect();
97                }
98
99                match conds.len() {
100                    0 => *self = RuleCondition::False,
101                    1 => *self = take(conds).into_iter().next().unwrap(),
102                    _ => {}
103                }
104            }
105            RuleCondition::All(conds) => {
106                // fast path: flatten children in-place and avoid constructing an additional vec
107                let mut needs_flattening = false;
108                for c in conds.iter_mut() {
109                    c.flatten();
110                    if *c == RuleCondition::False {
111                        // short-circuit: all conditions are side-effect free
112                        *self = RuleCondition::False;
113                        return;
114                    }
115                    needs_flattening = needs_flattening
116                        || matches!(c, RuleCondition::All(_) | RuleCondition::True);
117                }
118
119                if needs_flattening {
120                    *conds = take(conds)
121                        .into_iter()
122                        .flat_map(|c| match c {
123                            RuleCondition::All(nested) => {
124                                debug_assert!(!nested.is_empty(), "empty All should be True");
125                                Either::Left(nested.into_iter())
126                            }
127                            RuleCondition::True => Either::Right(Either::Left(iter::empty())),
128                            c => Either::Right(Either::Right(iter::once(c))),
129                        })
130                        .collect();
131                }
132
133                match conds.len() {
134                    0 => *self = RuleCondition::True,
135                    1 => *self = take(conds).into_iter().next().unwrap(),
136                    _ => {}
137                }
138            }
139            RuleCondition::Not(cond) => {
140                match &mut **cond {
141                    // nested `Not`s negate each other
142                    RuleCondition::Not(inner) => {
143                        let inner = &mut **inner;
144                        inner.flatten();
145                        // Use `replace` with a dummy condition instead of `take` since
146                        // `RuleCondition` doesn't implement `Default`.
147                        *self = replace(inner, RuleCondition::False)
148                    }
149                    RuleCondition::True => *self = RuleCondition::False,
150                    RuleCondition::False => *self = RuleCondition::True,
151                    other => other.flatten(),
152                }
153            }
154            _ => {}
155        }
156    }
157
158    pub async fn matches(
159        &self,
160        source: ResolvedVc<Box<dyn Source>>,
161        path: &FileSystemPath,
162        reference_type: &ReferenceType,
163    ) -> Result<bool> {
164        enum Op<'a> {
165            All(&'a [RuleCondition]), // Remaining conditions in an All
166            Any(&'a [RuleCondition]), // Remaining conditions in an Any
167            Not,                      // Inverts the previous condition
168        }
169
170        // Evaluates the condition returning the result and possibly pushing additional operations
171        // onto the stack as a kind of continuation.
172        async fn process_condition<'a, const SZ: usize>(
173            source: ResolvedVc<Box<dyn Source + 'static>>,
174            path: &FileSystemPath,
175            reference_type: &ReferenceType,
176            stack: &mut SmallVec<[Op<'a>; SZ]>,
177            mut cond: &'a RuleCondition,
178        ) -> Result<bool, anyhow::Error> {
179            // Use a loop to avoid recursion and unnecessary stack operations.
180            loop {
181                match cond {
182                    RuleCondition::All(conditions) => {
183                        if conditions.is_empty() {
184                            return Ok(true);
185                        } else {
186                            if conditions.len() > 1 {
187                                stack.push(Op::All(&conditions[1..]));
188                            }
189                            cond = &conditions[0];
190                            // jump directly to the next condition, no need to deal with
191                            // the stack.
192                            continue;
193                        }
194                    }
195                    RuleCondition::Any(conditions) => {
196                        if conditions.is_empty() {
197                            return Ok(false);
198                        } else {
199                            if conditions.len() > 1 {
200                                stack.push(Op::Any(&conditions[1..]));
201                            }
202                            cond = &conditions[0];
203                            continue;
204                        }
205                    }
206                    RuleCondition::Not(inner) => {
207                        stack.push(Op::Not);
208                        cond = inner.as_ref();
209                        continue;
210                    }
211                    RuleCondition::True => {
212                        return Ok(true);
213                    }
214                    RuleCondition::False => {
215                        return Ok(false);
216                    }
217                    RuleCondition::ReferenceType(condition_ty) => {
218                        return Ok(condition_ty.includes(reference_type));
219                    }
220                    RuleCondition::ResourceIsVirtualSource => {
221                        return Ok(ResolvedVc::try_downcast_type::<VirtualSource>(source).is_some());
222                    }
223                    RuleCondition::ResourcePathEquals(other) => {
224                        return Ok(path == other);
225                    }
226                    RuleCondition::ResourcePathEndsWith(end) => {
227                        return Ok(path.path.ends_with(end));
228                    }
229                    RuleCondition::ResourcePathHasNoExtension => {
230                        return Ok(if let Some(i) = path.path.rfind('.') {
231                            if let Some(j) = path.path.rfind('/') {
232                                j > i
233                            } else {
234                                false
235                            }
236                        } else {
237                            true
238                        });
239                    }
240                    RuleCondition::ResourcePathInDirectory(dir) => {
241                        return Ok(path.path.starts_with(&format!("{dir}/"))
242                            || path.path.contains(&format!("/{dir}/")));
243                    }
244                    RuleCondition::ResourcePathInExactDirectory(parent_path) => {
245                        return Ok(path.is_inside_ref(parent_path));
246                    }
247                    RuleCondition::ContentTypeStartsWith(start) => {
248                        let content_type = &source.ident().await?.content_type;
249                        return Ok(content_type
250                            .as_ref()
251                            .is_some_and(|ct| ct.starts_with(start.as_str())));
252                    }
253                    RuleCondition::ContentTypeEmpty => {
254                        return Ok(source.ident().await?.content_type.is_none());
255                    }
256                    RuleCondition::ResourcePathGlob { glob, base } => {
257                        return Ok(if let Some(rel_path) = base.get_relative_path_to(path) {
258                            glob.matches(&rel_path)
259                        } else {
260                            glob.matches(&path.path)
261                        });
262                    }
263                    RuleCondition::ResourceBasePathGlob(glob) => {
264                        let basename = path
265                            .path
266                            .rsplit_once('/')
267                            .map_or(path.path.as_str(), |(_, b)| b);
268                        return Ok(glob.matches(basename));
269                    }
270                    RuleCondition::ResourcePathRegex(_) => {
271                        bail!("ResourcePathRegex not implemented yet");
272                    }
273                    RuleCondition::ResourcePathEsRegex(regex) => {
274                        return Ok(regex.is_match(&path.path));
275                    }
276                    RuleCondition::ResourceContentEsRegex(regex) => {
277                        let content = source.content().file_content().await?;
278                        match &*content {
279                            FileContent::Content(file_content) => {
280                                return Ok(regex.is_match(&file_content.content().to_str()?));
281                            }
282                            FileContent::NotFound => return Ok(false),
283                        }
284                    }
285                }
286            }
287        }
288        // Allocate a small inline stack to avoid heap allocations in the common case where
289        // conditions are not deeply stacked.  Additionally we take care to avoid stack
290        // operations unless strictly necessary.
291        const EXPECTED_SIZE: usize = 8;
292        let mut stack = SmallVec::<[Op; EXPECTED_SIZE]>::with_capacity(EXPECTED_SIZE);
293        let mut result = process_condition(source, path, reference_type, &mut stack, self).await?;
294        while let Some(op) = stack.pop() {
295            match op {
296                Op::All(remaining) => {
297                    // Previous was true, keep going
298                    if result {
299                        if remaining.len() > 1 {
300                            stack.push(Op::All(&remaining[1..]));
301                        }
302                        result = process_condition(
303                            source,
304                            path,
305                            reference_type,
306                            &mut stack,
307                            &remaining[0],
308                        )
309                        .await?;
310                    }
311                }
312                Op::Any(remaining) => {
313                    // Previous was false, keep going
314                    if !result {
315                        if remaining.len() > 1 {
316                            stack.push(Op::Any(&remaining[1..]));
317                        }
318                        // If the stack didn't change, we can loop inline, but we would still need
319                        // to pop the item.  This might be faster since we would avoid the `match`
320                        // but overall, that is quite minor for an enum with 3 cases.
321                        result = process_condition(
322                            source,
323                            path,
324                            reference_type,
325                            &mut stack,
326                            &remaining[0],
327                        )
328                        .await?;
329                    }
330                }
331                Op::Not => {
332                    result = !result;
333                }
334            }
335        }
336        Ok(result)
337    }
338}
339
340#[cfg(test)]
341pub mod tests {
342    use turbo_tasks::Vc;
343    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
344    use turbo_tasks_fs::{FileContent, FileSystem, VirtualFileSystem};
345    use turbopack_core::{asset::AssetContent, file_source::FileSource};
346
347    use super::*;
348
349    #[test]
350    fn flatten_any_with_single_child_collapses() {
351        let mut rc = RuleCondition::Any(vec![RuleCondition::True]);
352        rc.flatten();
353        assert_eq!(rc, RuleCondition::True);
354
355        let mut rc = RuleCondition::Any(vec![RuleCondition::ContentTypeEmpty]);
356        rc.flatten();
357        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
358    }
359
360    #[test]
361    fn flatten_any_nested_and_false() {
362        let mut rc = RuleCondition::Any(vec![
363            RuleCondition::False,
364            RuleCondition::Any(vec![RuleCondition::ContentTypeEmpty, RuleCondition::False]),
365        ]);
366        rc.flatten();
367        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
368    }
369
370    #[test]
371    fn flatten_any_short_circuits_on_true() {
372        let mut rc = RuleCondition::Any(vec![
373            RuleCondition::False,
374            RuleCondition::True,
375            RuleCondition::ContentTypeEmpty,
376        ]);
377        rc.flatten();
378        assert_eq!(rc, RuleCondition::True);
379    }
380
381    #[test]
382    fn flatten_any_empty_becomes_false() {
383        let mut rc = RuleCondition::Any(vec![]);
384        rc.flatten();
385        assert_eq!(rc, RuleCondition::False);
386    }
387
388    #[test]
389    fn flatten_all_with_single_child_collapses() {
390        let mut rc = RuleCondition::All(vec![RuleCondition::ContentTypeEmpty]);
391        rc.flatten();
392        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
393
394        let mut rc = RuleCondition::All(vec![RuleCondition::True]);
395        rc.flatten();
396        assert_eq!(rc, RuleCondition::True);
397    }
398
399    #[test]
400    fn flatten_all_nested_and_true() {
401        let mut rc = RuleCondition::All(vec![
402            RuleCondition::True,
403            RuleCondition::All(vec![RuleCondition::ContentTypeEmpty, RuleCondition::True]),
404        ]);
405        rc.flatten();
406        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
407    }
408
409    #[test]
410    fn flatten_all_short_circuits_on_false() {
411        let mut rc = RuleCondition::All(vec![
412            RuleCondition::True,
413            RuleCondition::False,
414            RuleCondition::ContentTypeEmpty,
415        ]);
416        rc.flatten();
417        assert_eq!(rc, RuleCondition::False);
418    }
419
420    #[test]
421    fn flatten_all_empty_becomes_true() {
422        let mut rc = RuleCondition::All(vec![]);
423        rc.flatten();
424        assert_eq!(rc, RuleCondition::True);
425    }
426
427    #[test]
428    fn flatten_not_of_not() {
429        let mut rc = RuleCondition::Not(Box::new(RuleCondition::Not(Box::new(
430            RuleCondition::All(vec![RuleCondition::ContentTypeEmpty]),
431        ))));
432        rc.flatten();
433        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
434    }
435
436    #[test]
437    fn flatten_not_constants() {
438        let mut rc = RuleCondition::Not(Box::new(RuleCondition::True));
439        rc.flatten();
440        assert_eq!(rc, RuleCondition::False);
441
442        let mut rc = RuleCondition::Not(Box::new(RuleCondition::False));
443        rc.flatten();
444        assert_eq!(rc, RuleCondition::True);
445    }
446
447    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
448    async fn test_rule_condition_leaves() {
449        crate::register();
450        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
451            BackendOptions::default(),
452            noop_backing_storage(),
453        ));
454        tt.run_once(async { run_leaves_test().await })
455            .await
456            .unwrap();
457    }
458
459    #[turbo_tasks::function]
460    pub async fn run_leaves_test() -> Result<()> {
461        let fs = VirtualFileSystem::new();
462        let virtual_path = fs.root().await?.join("foo.js")?;
463        let virtual_source = Vc::upcast::<Box<dyn Source>>(VirtualSource::new(
464            virtual_path.clone(),
465            AssetContent::File(FileContent::NotFound.cell().to_resolved().await?).cell(),
466        ))
467        .to_resolved()
468        .await?;
469
470        let non_virtual_path = fs.root().await?.join("bar.js")?;
471        let non_virtual_source =
472            Vc::upcast::<Box<dyn Source>>(FileSource::new(non_virtual_path.clone()))
473                .to_resolved()
474                .await?;
475
476        {
477            let condition = RuleCondition::ReferenceType(ReferenceType::Runtime);
478            assert!(
479                condition
480                    .matches(virtual_source, &virtual_path, &ReferenceType::Runtime)
481                    .await
482                    .unwrap()
483            );
484            assert!(
485                !condition
486                    .matches(
487                        non_virtual_source,
488                        &non_virtual_path,
489                        &ReferenceType::Css(
490                            turbopack_core::reference_type::CssReferenceSubType::Compose
491                        )
492                    )
493                    .await
494                    .unwrap()
495            );
496        }
497
498        {
499            let condition = RuleCondition::ResourceIsVirtualSource;
500            assert!(
501                condition
502                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
503                    .await
504                    .unwrap()
505            );
506            assert!(
507                !condition
508                    .matches(
509                        non_virtual_source,
510                        &non_virtual_path,
511                        &ReferenceType::Undefined
512                    )
513                    .await
514                    .unwrap()
515            );
516        }
517        {
518            let condition = RuleCondition::ResourcePathEquals(virtual_path.clone());
519            assert!(
520                condition
521                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
522                    .await
523                    .unwrap()
524            );
525            assert!(
526                !condition
527                    .matches(
528                        non_virtual_source,
529                        &non_virtual_path,
530                        &ReferenceType::Undefined
531                    )
532                    .await
533                    .unwrap()
534            );
535        }
536        {
537            let condition = RuleCondition::ResourcePathHasNoExtension;
538            assert!(
539                condition
540                    .matches(
541                        virtual_source,
542                        &fs.root().await?.join("foo")?,
543                        &ReferenceType::Undefined
544                    )
545                    .await
546                    .unwrap()
547            );
548            assert!(
549                !condition
550                    .matches(
551                        non_virtual_source,
552                        &non_virtual_path,
553                        &ReferenceType::Undefined
554                    )
555                    .await
556                    .unwrap()
557            );
558        }
559        {
560            let condition = RuleCondition::ResourcePathEndsWith("foo.js".to_string());
561            assert!(
562                condition
563                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
564                    .await
565                    .unwrap()
566            );
567            assert!(
568                !condition
569                    .matches(
570                        non_virtual_source,
571                        &non_virtual_path,
572                        &ReferenceType::Undefined
573                    )
574                    .await
575                    .unwrap()
576            );
577        }
578        anyhow::Ok(())
579    }
580
581    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
582    async fn test_rule_condition_tree() {
583        crate::register();
584        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
585            BackendOptions::default(),
586            noop_backing_storage(),
587        ));
588        tt.run_once(async { run_rule_condition_tree_test().await })
589            .await
590            .unwrap();
591    }
592
593    #[turbo_tasks::function]
594    pub async fn run_rule_condition_tree_test() -> Result<()> {
595        let fs = VirtualFileSystem::new();
596        let virtual_path = fs.root().await?.join("foo.js")?;
597        let virtual_source = Vc::upcast::<Box<dyn Source>>(VirtualSource::new(
598            virtual_path.clone(),
599            AssetContent::File(FileContent::NotFound.cell().to_resolved().await?).cell(),
600        ))
601        .to_resolved()
602        .await?;
603
604        let non_virtual_path = fs.root().await?.join("bar.js")?;
605        let non_virtual_source =
606            Vc::upcast::<Box<dyn Source>>(FileSource::new(non_virtual_path.clone()))
607                .to_resolved()
608                .await?;
609
610        {
611            // not
612            let condition = RuleCondition::not(RuleCondition::ResourceIsVirtualSource);
613            assert!(
614                !condition
615                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
616                    .await
617                    .unwrap()
618            );
619            assert!(
620                condition
621                    .matches(
622                        non_virtual_source,
623                        &non_virtual_path,
624                        &ReferenceType::Undefined
625                    )
626                    .await
627                    .unwrap()
628            );
629        }
630        {
631            // any
632            // Only one of the conditions matches our virtual source
633            let condition = RuleCondition::any(vec![
634                RuleCondition::ResourcePathInDirectory("doesnt/exist".to_string()),
635                RuleCondition::ResourceIsVirtualSource,
636                RuleCondition::ResourcePathHasNoExtension,
637            ]);
638            assert!(
639                condition
640                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
641                    .await
642                    .unwrap()
643            );
644            assert!(
645                !condition
646                    .matches(
647                        non_virtual_source,
648                        &non_virtual_path,
649                        &ReferenceType::Undefined
650                    )
651                    .await
652                    .unwrap()
653            );
654        }
655        {
656            // all
657            // Only one of the conditions matches our virtual source
658            let condition = RuleCondition::all(vec![
659                RuleCondition::ResourcePathEndsWith("foo.js".to_string()),
660                RuleCondition::ResourceIsVirtualSource,
661                RuleCondition::ResourcePathEquals(virtual_path.clone()),
662            ]);
663            assert!(
664                condition
665                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
666                    .await
667                    .unwrap()
668            );
669            assert!(
670                !condition
671                    .matches(
672                        non_virtual_source,
673                        &non_virtual_path,
674                        &ReferenceType::Undefined
675                    )
676                    .await
677                    .unwrap()
678            );
679        }
680        {
681            // bigger tree
682
683            // Build a simple tree to cover our various composite conditions
684            let condition = RuleCondition::all(vec![
685                RuleCondition::ResourceIsVirtualSource,
686                RuleCondition::ResourcePathEquals(virtual_path.clone()),
687                RuleCondition::Not(Box::new(RuleCondition::ResourcePathHasNoExtension)),
688                RuleCondition::Any(vec![
689                    RuleCondition::ResourcePathEndsWith("foo.js".to_string()),
690                    RuleCondition::ContentTypeEmpty,
691                ]),
692            ]);
693            assert!(
694                condition
695                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
696                    .await
697                    .unwrap()
698            );
699            assert!(
700                !condition
701                    .matches(
702                        non_virtual_source,
703                        &non_virtual_path,
704                        &ReferenceType::Undefined
705                    )
706                    .await
707                    .unwrap()
708            );
709        }
710        anyhow::Ok(())
711    }
712}