turbopack/module_options/
rule_condition.rs

1use std::{
2    iter,
3    mem::{replace, take},
4};
5
6use anyhow::Result;
7use either::Either;
8use serde::{Deserialize, Serialize};
9use smallvec::SmallVec;
10use turbo_esregex::EsRegex;
11use turbo_tasks::{NonLocalValue, ReadRef, ResolvedVc, 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    ResourcePathEsRegex(#[turbo_tasks(trace_ignore)] ReadRef<EsRegex>),
34    ResourceContentEsRegex(#[turbo_tasks(trace_ignore)] ReadRef<EsRegex>),
35    /// For paths that are within the same filesystem as the `base`, it need to
36    /// match the relative path from base to resource. This includes `./` or
37    /// `../` prefix. For paths in a different filesystem, it need to match
38    /// the resource path in that filesystem without any prefix. This means
39    /// any glob starting with `./` or `../` will only match paths in the
40    /// project. Globs starting with `**` can match any path.
41    ResourcePathGlob {
42        base: FileSystemPath,
43        #[turbo_tasks(trace_ignore)]
44        glob: ReadRef<Glob>,
45    },
46    ResourceBasePathGlob(#[turbo_tasks(trace_ignore)] ReadRef<Glob>),
47    ResourceQueryContains(String),
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::ResourcePathEsRegex(regex) => {
271                        return Ok(regex.is_match(&path.path));
272                    }
273                    RuleCondition::ResourceContentEsRegex(regex) => {
274                        let content = source.content().file_content().await?;
275                        match &*content {
276                            FileContent::Content(file_content) => {
277                                return Ok(regex.is_match(&file_content.content().to_str()?));
278                            }
279                            FileContent::NotFound => return Ok(false),
280                        }
281                    }
282                    RuleCondition::ResourceQueryContains(query) => {
283                        let ident = source.ident().await?;
284                        return Ok(ident.query.contains(query));
285                    }
286                }
287            }
288        }
289        // Allocate a small inline stack to avoid heap allocations in the common case where
290        // conditions are not deeply stacked.  Additionally we take care to avoid stack
291        // operations unless strictly necessary.
292        const EXPECTED_SIZE: usize = 8;
293        let mut stack = SmallVec::<[Op; EXPECTED_SIZE]>::with_capacity(EXPECTED_SIZE);
294        let mut result = process_condition(source, path, reference_type, &mut stack, self).await?;
295        while let Some(op) = stack.pop() {
296            match op {
297                Op::All(remaining) => {
298                    // Previous was true, keep going
299                    if result {
300                        if remaining.len() > 1 {
301                            stack.push(Op::All(&remaining[1..]));
302                        }
303                        result = process_condition(
304                            source,
305                            path,
306                            reference_type,
307                            &mut stack,
308                            &remaining[0],
309                        )
310                        .await?;
311                    }
312                }
313                Op::Any(remaining) => {
314                    // Previous was false, keep going
315                    if !result {
316                        if remaining.len() > 1 {
317                            stack.push(Op::Any(&remaining[1..]));
318                        }
319                        // If the stack didn't change, we can loop inline, but we would still need
320                        // to pop the item.  This might be faster since we would avoid the `match`
321                        // but overall, that is quite minor for an enum with 3 cases.
322                        result = process_condition(
323                            source,
324                            path,
325                            reference_type,
326                            &mut stack,
327                            &remaining[0],
328                        )
329                        .await?;
330                    }
331                }
332                Op::Not => {
333                    result = !result;
334                }
335            }
336        }
337        Ok(result)
338    }
339}
340
341#[cfg(test)]
342pub mod tests {
343    use turbo_tasks::Vc;
344    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
345    use turbo_tasks_fs::{FileContent, FileSystem, VirtualFileSystem};
346    use turbopack_core::{asset::AssetContent, file_source::FileSource};
347
348    use super::*;
349
350    #[test]
351    fn flatten_any_with_single_child_collapses() {
352        let mut rc = RuleCondition::Any(vec![RuleCondition::True]);
353        rc.flatten();
354        assert_eq!(rc, RuleCondition::True);
355
356        let mut rc = RuleCondition::Any(vec![RuleCondition::ContentTypeEmpty]);
357        rc.flatten();
358        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
359    }
360
361    #[test]
362    fn flatten_any_nested_and_false() {
363        let mut rc = RuleCondition::Any(vec![
364            RuleCondition::False,
365            RuleCondition::Any(vec![RuleCondition::ContentTypeEmpty, RuleCondition::False]),
366        ]);
367        rc.flatten();
368        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
369    }
370
371    #[test]
372    fn flatten_any_short_circuits_on_true() {
373        let mut rc = RuleCondition::Any(vec![
374            RuleCondition::False,
375            RuleCondition::True,
376            RuleCondition::ContentTypeEmpty,
377        ]);
378        rc.flatten();
379        assert_eq!(rc, RuleCondition::True);
380    }
381
382    #[test]
383    fn flatten_any_empty_becomes_false() {
384        let mut rc = RuleCondition::Any(vec![]);
385        rc.flatten();
386        assert_eq!(rc, RuleCondition::False);
387    }
388
389    #[test]
390    fn flatten_all_with_single_child_collapses() {
391        let mut rc = RuleCondition::All(vec![RuleCondition::ContentTypeEmpty]);
392        rc.flatten();
393        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
394
395        let mut rc = RuleCondition::All(vec![RuleCondition::True]);
396        rc.flatten();
397        assert_eq!(rc, RuleCondition::True);
398    }
399
400    #[test]
401    fn flatten_all_nested_and_true() {
402        let mut rc = RuleCondition::All(vec![
403            RuleCondition::True,
404            RuleCondition::All(vec![RuleCondition::ContentTypeEmpty, RuleCondition::True]),
405        ]);
406        rc.flatten();
407        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
408    }
409
410    #[test]
411    fn flatten_all_short_circuits_on_false() {
412        let mut rc = RuleCondition::All(vec![
413            RuleCondition::True,
414            RuleCondition::False,
415            RuleCondition::ContentTypeEmpty,
416        ]);
417        rc.flatten();
418        assert_eq!(rc, RuleCondition::False);
419    }
420
421    #[test]
422    fn flatten_all_empty_becomes_true() {
423        let mut rc = RuleCondition::All(vec![]);
424        rc.flatten();
425        assert_eq!(rc, RuleCondition::True);
426    }
427
428    #[test]
429    fn flatten_not_of_not() {
430        let mut rc = RuleCondition::Not(Box::new(RuleCondition::Not(Box::new(
431            RuleCondition::All(vec![RuleCondition::ContentTypeEmpty]),
432        ))));
433        rc.flatten();
434        assert_eq!(rc, RuleCondition::ContentTypeEmpty);
435    }
436
437    #[test]
438    fn flatten_not_constants() {
439        let mut rc = RuleCondition::Not(Box::new(RuleCondition::True));
440        rc.flatten();
441        assert_eq!(rc, RuleCondition::False);
442
443        let mut rc = RuleCondition::Not(Box::new(RuleCondition::False));
444        rc.flatten();
445        assert_eq!(rc, RuleCondition::True);
446    }
447
448    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
449    async fn test_rule_condition_leaves() {
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        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
584            BackendOptions::default(),
585            noop_backing_storage(),
586        ));
587        tt.run_once(async { run_rule_condition_tree_test().await })
588            .await
589            .unwrap();
590    }
591
592    #[turbo_tasks::function]
593    pub async fn run_rule_condition_tree_test() -> Result<()> {
594        let fs = VirtualFileSystem::new();
595        let virtual_path = fs.root().await?.join("foo.js")?;
596        let virtual_source = Vc::upcast::<Box<dyn Source>>(VirtualSource::new(
597            virtual_path.clone(),
598            AssetContent::File(FileContent::NotFound.cell().to_resolved().await?).cell(),
599        ))
600        .to_resolved()
601        .await?;
602
603        let non_virtual_path = fs.root().await?.join("bar.js")?;
604        let non_virtual_source =
605            Vc::upcast::<Box<dyn Source>>(FileSource::new(non_virtual_path.clone()))
606                .to_resolved()
607                .await?;
608
609        {
610            // not
611            let condition = RuleCondition::not(RuleCondition::ResourceIsVirtualSource);
612            assert!(
613                !condition
614                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
615                    .await
616                    .unwrap()
617            );
618            assert!(
619                condition
620                    .matches(
621                        non_virtual_source,
622                        &non_virtual_path,
623                        &ReferenceType::Undefined
624                    )
625                    .await
626                    .unwrap()
627            );
628        }
629        {
630            // any
631            // Only one of the conditions matches our virtual source
632            let condition = RuleCondition::any(vec![
633                RuleCondition::ResourcePathInDirectory("doesnt/exist".to_string()),
634                RuleCondition::ResourceIsVirtualSource,
635                RuleCondition::ResourcePathHasNoExtension,
636            ]);
637            assert!(
638                condition
639                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
640                    .await
641                    .unwrap()
642            );
643            assert!(
644                !condition
645                    .matches(
646                        non_virtual_source,
647                        &non_virtual_path,
648                        &ReferenceType::Undefined
649                    )
650                    .await
651                    .unwrap()
652            );
653        }
654        {
655            // all
656            // Only one of the conditions matches our virtual source
657            let condition = RuleCondition::all(vec![
658                RuleCondition::ResourcePathEndsWith("foo.js".to_string()),
659                RuleCondition::ResourceIsVirtualSource,
660                RuleCondition::ResourcePathEquals(virtual_path.clone()),
661            ]);
662            assert!(
663                condition
664                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
665                    .await
666                    .unwrap()
667            );
668            assert!(
669                !condition
670                    .matches(
671                        non_virtual_source,
672                        &non_virtual_path,
673                        &ReferenceType::Undefined
674                    )
675                    .await
676                    .unwrap()
677            );
678        }
679        {
680            // bigger tree
681
682            // Build a simple tree to cover our various composite conditions
683            let condition = RuleCondition::all(vec![
684                RuleCondition::ResourceIsVirtualSource,
685                RuleCondition::ResourcePathEquals(virtual_path.clone()),
686                RuleCondition::Not(Box::new(RuleCondition::ResourcePathHasNoExtension)),
687                RuleCondition::Any(vec![
688                    RuleCondition::ResourcePathEndsWith("foo.js".to_string()),
689                    RuleCondition::ContentTypeEmpty,
690                ]),
691            ]);
692            assert!(
693                condition
694                    .matches(virtual_source, &virtual_path, &ReferenceType::Undefined)
695                    .await
696                    .unwrap()
697            );
698            assert!(
699                !condition
700                    .matches(
701                        non_virtual_source,
702                        &non_virtual_path,
703                        &ReferenceType::Undefined
704                    )
705                    .await
706                    .unwrap()
707            );
708        }
709        anyhow::Ok(())
710    }
711}