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