encre_css/
generator.rs

1//! Define the main [`generate`] function used to scan content and to generate CSS styles.
2use crate::{
3    config::{Config, MaxShortcutDepth},
4    preflight::Preflight,
5    selector::{parse, Modifier, Selector, Variant},
6    utils::buffer::Buffer,
7};
8
9use std::{borrow::Cow, collections::BTreeSet};
10
11/// The context used in the [`Plugin::can_handle`] method.
12///
13/// [`Plugin::can_handle`]: crate::plugins::Plugin::can_handle
14#[derive(Debug)]
15pub struct ContextCanHandle<'a, 'b, 'c> {
16    /// The generator's configuration.
17    pub config: &'a Config,
18
19    /// The modifier which will be checked.
20    pub modifier: &'b Modifier<'c>,
21}
22
23/// The context used in the [`Plugin::handle`] method.
24///
25/// [`Plugin::handle`]: crate::plugins::Plugin::handle
26#[derive(Debug)]
27pub struct ContextHandle<'a, 'b, 'c, 'd, 'e> {
28    /// The generator's configuration.
29    pub config: &'a Config,
30
31    /// The modifier which will have its CSS generated.
32    pub modifier: &'b Modifier<'c>,
33
34    /// The buffer containing the whole generated CSS.
35    pub buffer: &'d mut Buffer,
36
37    // Private fields used in `generate_class` and `generate_at_rules`
38    selector: &'e Selector<'e>,
39}
40
41/// Generate the needed CSS at-rules (e.g @media).
42///
43/// Note: The inner class (e.g. .foo-bar) is not handled by this function, see [`generate_wrapper`].
44///
45/// The second argument, a closure, is called to generate the CSS content of the rule.
46///
47/// # Errors
48///
49/// Returns [`fmt::Error`] indicating whether writing to the buffer succeeded.
50///
51/// [`fmt::Error`]: std::fmt::Error
52pub fn generate_at_rules<T: FnOnce(&mut ContextHandle)>(
53    context: &mut ContextHandle,
54    rule_content_fn: T,
55) {
56    let ContextHandle {
57        buffer, selector, ..
58    } = context;
59
60    if !selector.variants.is_empty() {
61        selector.variants.iter().for_each(|variant| {
62            if variant.template.starts_with('@') {
63                buffer.line(format_args!("{} {{", variant.template));
64                buffer.indent();
65            }
66        });
67    }
68
69    rule_content_fn(context);
70
71    let ContextHandle { buffer, .. } = context;
72    while !buffer.is_unindented() {
73        buffer.unindent();
74
75        if buffer.is_unindented() {
76            buffer.raw("}");
77        } else {
78            buffer.line("}");
79        }
80    }
81}
82
83/// Generate a CSS rule with a class.
84///
85/// Note: At-rules (e.g. @media) are not handled by this function, see [`generate_wrapper`].
86///
87/// The second argument, a closure, is called to generate the CSS content of the rule.
88/// The third argument is used to add a custom string just after the class (e.g. `> *`).
89///
90/// # Errors
91///
92/// Returns [`fmt::Error`] indicating whether writing to the buffer succeeded.
93///
94/// [`fmt::Error`]: std::fmt::Error
95#[allow(clippy::too_many_lines)]
96pub fn generate_class<T: FnOnce(&mut ContextHandle)>(
97    context: &mut ContextHandle,
98    rule_content_fn: T,
99    custom_after_class: &str,
100) {
101    let ContextHandle {
102        buffer, selector, ..
103    } = context;
104
105    // Write the class
106    let mut base_class = String::with_capacity(1 + selector.full.len());
107    base_class.push('.');
108
109    // The browser will automatically replace the escape codes in the classes, so we need to also
110    // replace them in the generated CSS full selector
111    let unescaped_full_selector =
112        crate::selector::parser::replace_escape_codes(Cow::Borrowed(selector.full));
113    unescaped_full_selector
114        .chars()
115        .enumerate()
116        .for_each(|(i, ch)| {
117            if !ch.is_alphanumeric() && ch != '-' && ch != '_' {
118                base_class.push('\\');
119                base_class.push(ch);
120            } else if i == 0 && ch.is_numeric() {
121                // CSS classes must not start with a number, we need to escape it
122                base_class.push_str("\\3");
123                base_class.push(ch);
124            } else {
125                base_class.push(ch);
126            }
127        });
128
129    if !selector.variants.is_empty() {
130        // Variants are applied from right to left
131        // (https://tailwindcss.com/docs/upgrade-guide#variant-stacking-order),
132        // so no need to reverse the variants
133        selector.variants.iter().for_each(|variant| {
134            if !variant.template.starts_with('@') {
135                base_class = variant.template.replace('&', &base_class);
136            }
137        });
138    }
139    buffer.line(format_args!("{base_class}{custom_after_class} {{"));
140
141    // Store the index of the start of the class content (useful when the `important` flag is present)
142    let content_start = buffer.len();
143
144    // Rule content
145    buffer.indent();
146    rule_content_fn(context);
147
148    let ContextHandle {
149        buffer, selector, ..
150    } = context;
151
152    // If the rule is selecting the `::before` or `::after` pseudo elements, we need to generate a
153    // default `content` property
154    if selector
155        .variants
156        .iter()
157        .any(|variant| ["&::before", "&::after"].contains(&&*variant.template))
158    {
159        buffer.line("content: var(--en-content);");
160    }
161
162    // If the `important` flag is present we need to replace all `;\n` or `;\r\n`
163    // to ` !important;\n` or ` !important;\r\n`
164    if selector.is_important {
165        let mut extra_index = 0;
166        let positions = buffer[content_start..]
167            .match_indices('\n')
168            .map(|i| i.0)
169            .collect::<Vec<usize>>();
170
171        for index in positions {
172            if index - 1 == 0 {
173                continue;
174            }
175
176            let index = content_start + extra_index + index;
177            let index = if &buffer[index - 1..index] == "\r" {
178                index - 1
179            } else {
180                index
181            };
182            let replace_with = " !important;";
183            buffer.replace_range(index - 1..index, replace_with);
184            extra_index += replace_with.len() - 1;
185        }
186    }
187
188    buffer.unindent();
189    if buffer.is_unindented() {
190        buffer.raw("}");
191    } else {
192        buffer.line("}");
193    }
194}
195
196/// Generate the complete CSS wrapper needed for a single rule.
197///
198/// This function is a combination of the [`generate_at_rules`] and [`generate_class`] functions.
199///
200/// The second argument, a closure, is called to generate the CSS content of the rule.
201///
202/// # Errors
203///
204/// Returns [`fmt::Error`] indicating whether writing to the buffer succeeded.
205///
206/// [`fmt::Error`]: std::fmt::Error
207pub fn generate_wrapper<T: FnOnce(&mut ContextHandle)>(
208    context: &mut ContextHandle,
209    rule_content_fn: T,
210) {
211    generate_at_rules(context, |context| {
212        generate_class(context, rule_content_fn, "");
213    });
214}
215
216fn resolve_selector<'a>(
217    selector: &'a str,
218    full_class: Option<&'a str>,
219    selectors: &mut BTreeSet<Selector<'a>>,
220    config: &'a Config,
221    config_derived_variants: &[(Cow<'static, str>, Variant<'static>)],
222    depth: MaxShortcutDepth,
223) {
224    if depth.get() == 0 {
225        return;
226    }
227
228    if let Some(expanded) = config.shortcuts.get(selector) {
229        expanded.split(' ').for_each(|shortcut_target| {
230            resolve_selector(
231                shortcut_target,
232                full_class.or(Some(selector)),
233                selectors,
234                config,
235                config_derived_variants,
236                MaxShortcutDepth::new(depth.get() - 1),
237            );
238        });
239    } else {
240        selectors.extend(
241            parse(selector, None, full_class, config, config_derived_variants)
242                .into_iter()
243                .filter_map(Result::ok),
244        );
245    }
246}
247
248/// Generate the CSS styles needed based on the given sources.
249///
250/// Each source will be scanned in order to extract atomic classes, then CSS will be generated for
251/// each class found.
252///
253/// By default, it splits the source by spaces, double quotes, single quotes, backticks and new
254/// lines, while ignoring the content inside arbitrary values/variants and variant groups.
255///
256/// This function also removes duplicated selectors and sorts the generated CSS classes based on
257/// the order in which they were defined to avoid conflicts.
258pub fn generate<'a>(sources: impl IntoIterator<Item = &'a str>, config: &Config) -> String {
259    let config_derived_variants = config.get_derived_variants();
260    let mut selectors = BTreeSet::new();
261
262    // Add selectors from the safelist
263    for safe_selector in config.safelist.iter() {
264        if let Some(expanded) = config.shortcuts.get(&**safe_selector) {
265            expanded.split(' ').for_each(|shortcut_target| {
266                selectors.extend(
267                    parse(
268                        shortcut_target,
269                        None,
270                        Some(safe_selector),
271                        config,
272                        &config_derived_variants,
273                    )
274                    .into_iter()
275                    .filter_map(Result::ok),
276                );
277            });
278        } else {
279            selectors.extend(
280                parse(safe_selector, None, None, config, &config_derived_variants)
281                    .into_iter()
282                    .filter_map(Result::ok),
283            );
284        }
285    }
286
287    for source in sources {
288        let new_selectors = config.scanner.scan(source);
289
290        for selector in new_selectors {
291            resolve_selector(
292                selector,
293                None,
294                &mut selectors,
295                config,
296                &config_derived_variants,
297                config.max_shortcut_depth,
298            );
299        }
300    }
301
302    let preflight = config.preflight.build();
303    let mut buffer = Buffer::with_capacity(10 * selectors.len()); // TODO: More accurate value
304    buffer.raw(&preflight);
305
306    for selector in selectors {
307        if buffer.len() != preflight.len() || config.preflight != Preflight::None {
308            buffer.raw("\n\n");
309        }
310
311        let mut context = ContextHandle {
312            config,
313            modifier: &selector.modifier,
314            buffer: &mut buffer,
315            selector: &selector,
316        };
317
318        if selector.plugin.needs_wrapping() {
319            generate_wrapper(&mut context, |context| selector.plugin.handle(context));
320        } else {
321            selector.plugin.handle(&mut context);
322        }
323    }
324
325    buffer.into_inner()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::{config::DarkMode, utils::testing::base_config};
332
333    use pretty_assertions::assert_eq;
334
335    #[test]
336    fn not_parsing_too_loosely() {
337        let generated = generate(["flex-test-[]"], &base_config());
338        assert!(generated.is_empty());
339    }
340
341    #[test]
342    fn divide_and_space_between_special_class() {
343        let generated = generate(
344            [
345                "hover:space-x-1",
346                "space-x-2",
347                "[&:has(.class)_>_*]:space-y-3",
348                "divide-red-100",
349                "divide-dashed",
350                "divide-x-[11px]",
351                "xl:[&_>_*]:divide-y-2",
352            ],
353            &base_config(),
354        );
355
356        assert_eq!(
357            generated,
358            String::from(
359                r".space-x-2 > :not(:last-child) {
360  --en-space-x-reverse: 0;
361  margin-inline-start: calc(0.5rem * var(--en-space-x-reverse));
362  margin-inline-end: calc(0.5rem * calc(1 - var(--en-space-x-reverse)));
363}
364
365.divide-x-\[11px\] > :not([hidden]) ~ :not([hidden]) {
366  --en-divide-x-reverse: 0;
367  border-inline-start-width: calc(11px * var(--en-divide-x-reverse));
368  border-inline-end-width: calc(11px * calc(1 - var(--en-divide-x-reverse)));
369}
370
371.divide-dashed > :not([hidden]) ~ :not([hidden]) {
372  border-style: dashed;
373}
374
375.divide-red-100 > :not([hidden]) ~ :not([hidden]) {
376  border-color: oklch(93.6% .032 17.717);
377}
378
379.hover\:space-x-1:hover > :not(:last-child) {
380  --en-space-x-reverse: 0;
381  margin-inline-start: calc(0.25rem * var(--en-space-x-reverse));
382  margin-inline-end: calc(0.25rem * calc(1 - var(--en-space-x-reverse)));
383}
384
385@media (width >= 80rem) {
386  .xl\:\[\&_\>_\*\]\:divide-y-2 > * > :not([hidden]) ~ :not([hidden]) {
387    --en-divide-y-reverse: 0;
388    border-block-start-width: calc(2px * var(--en-divide-y-reverse));
389    border-block-end-width: calc(2px * calc(1 - var(--en-divide-y-reverse)));
390  }
391}
392
393.\[\&\:has\(\.class\)_\>_\*\]\:space-y-3:has(.class) > * > :not(:last-child) {
394  --en-space-y-reverse: 0;
395  margin-block-start: calc(0.75rem * var(--en-space-y-reverse));
396  margin-block-end: calc(0.75rem * calc(1 - var(--en-space-y-reverse)));
397}"
398            )
399        );
400    }
401
402    #[test]
403    fn negative_values() {
404        let generated = generate(
405            [
406                "-top-2",
407                "-z-2",
408                "-order-2",
409                "-mb8",
410                "-translate-x-52",
411                "-rotate-90",
412                "-skew-x-2",
413                "-scale-50",
414                "-scroll-mt-2",
415                "-space-x-2",
416                "-indent-2",
417                "-hue-rotate-60",
418                "hover:-hue-rotate-60",
419                "-backdrop-hue-rotate-90",
420            ],
421            &base_config(),
422        );
423
424        assert_eq!(
425            generated,
426            String::from(
427                r".-top-2 {
428  top: -0.5rem;
429}
430
431.-z-2 {
432  z-index: -2;
433}
434
435.-order-2 {
436  order: -2;
437}
438
439.-mb8 {
440  margin-bottom: -2rem;
441}
442
443.-translate-x-52 {
444  --en-translate-x: -13rem;
445  transform: translate3d(var(--en-translate-x), var(--en-translate-y), var(--en-translate-z)) rotateX(var(--en-rotate-x)) rotateY(var(--en-rotate-y)) rotateZ(var(--en-rotate-z)) skewX(var(--en-skew-x)) skewY(var(--en-skew-y)) scale3d(var(--en-scale-x), var(--en-scale-y), var(--en-scale-z));
446}
447
448.-rotate-90 {
449  --en-rotate-x: -90deg;
450  --en-rotate-y: -90deg;
451  transform: translate3d(var(--en-translate-x), var(--en-translate-y), var(--en-translate-z)) rotateX(var(--en-rotate-x)) rotateY(var(--en-rotate-y)) rotateZ(var(--en-rotate-z)) skewX(var(--en-skew-x)) skewY(var(--en-skew-y)) scale3d(var(--en-scale-x), var(--en-scale-y), var(--en-scale-z));
452}
453
454.-skew-x-2 {
455  --en-skew-x: -2deg;
456  transform: translate3d(var(--en-translate-x), var(--en-translate-y), var(--en-translate-z)) rotateX(var(--en-rotate-x)) rotateY(var(--en-rotate-y)) rotateZ(var(--en-rotate-z)) skewX(var(--en-skew-x)) skewY(var(--en-skew-y)) scale3d(var(--en-scale-x), var(--en-scale-y), var(--en-scale-z));
457}
458
459.-scale-50 {
460  --en-scale-x: -0.5;
461  --en-scale-y: -0.5;
462  transform: translate3d(var(--en-translate-x), var(--en-translate-y), var(--en-translate-z)) rotateX(var(--en-rotate-x)) rotateY(var(--en-rotate-y)) rotateZ(var(--en-rotate-z)) skewX(var(--en-skew-x)) skewY(var(--en-skew-y)) scale3d(var(--en-scale-x), var(--en-scale-y), var(--en-scale-z));
463}
464
465.-scroll-mt-2 {
466  scroll-margin-top: -0.5rem;
467}
468
469.-space-x-2 > :not(:last-child) {
470  --en-space-x-reverse: 0;
471  margin-inline-start: calc(-0.5rem * var(--en-space-x-reverse));
472  margin-inline-end: calc(-0.5rem * calc(1 - var(--en-space-x-reverse)));
473}
474
475.-indent-2 {
476  text-indent: -0.5rem;
477}
478
479.-hue-rotate-60 {
480  --en-hue-rotate: hue-rotate(-60deg);
481  filter: var(--en-blur) var(--en-brightness) var(--en-contrast) var(--en-grayscale) var(--en-hue-rotate) var(--en-invert) var(--en-saturate) var(--en-sepia) var(--en-drop-shadow);
482}
483
484.-backdrop-hue-rotate-90 {
485  --en-backdrop-hue-rotate: hue-rotate(-90deg);
486  -webkit-backdrop-filter: var(--en-backdrop-blur) var(--en-backdrop-brightness) var(--en-backdrop-contrast) var(--en-backdrop-grayscale) var(--en-backdrop-hue-rotate) var(--en-backdrop-invert) var(--en-backdrop-opacity) var(--en-backdrop-saturate) var(--en-backdrop-sepia);
487  backdrop-filter: var(--en-backdrop-blur) var(--en-backdrop-brightness) var(--en-backdrop-contrast) var(--en-backdrop-grayscale) var(--en-backdrop-hue-rotate) var(--en-backdrop-invert) var(--en-backdrop-opacity) var(--en-backdrop-saturate) var(--en-backdrop-sepia);
488}
489
490.hover\:-hue-rotate-60:hover {
491  --en-hue-rotate: hue-rotate(-60deg);
492  filter: var(--en-blur) var(--en-brightness) var(--en-contrast) var(--en-grayscale) var(--en-hue-rotate) var(--en-invert) var(--en-saturate) var(--en-sepia) var(--en-drop-shadow);
493}"
494            )
495        );
496    }
497
498    #[test]
499    fn gen_css_for_simple_selector() {
500        let generated = generate(["text-current"], &base_config());
501
502        assert_eq!(
503            generated,
504            String::from(
505                ".text-current {
506  color: currentColor;
507}"
508            )
509        );
510    }
511
512    #[test]
513    fn gen_css_with_important_flag() {
514        let generated = generate(
515            [
516                "!w-full",
517                "!-mb-8",
518                "!shadow-sm",
519                "!-hue-rotate-60",
520                "focus:!w-2",
521                "focus:!-mb-2",
522            ],
523            &base_config(),
524        );
525
526        assert_eq!(
527            generated,
528            String::from(
529                r".\!-mb-8 {
530  margin-bottom: -2rem !important;
531}
532
533.\!w-full {
534  width: 100% !important;
535}
536
537.\!shadow-sm {
538  --en-shadow: 0 1px 3px 0 var(--en-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--en-shadow-color, rgb(0 0 0 / 0.1)) !important;
539  box-shadow: var(--en-inset-shadow, 0 0 #0000), var(--en-inset-ring-shadow, 0 0 #0000), var(--en-ring-offset-shadow, 0 0 #0000), var(--en-ring-shadow, 0 0 #0000), var(--en-shadow) !important;
540}
541
542.\!-hue-rotate-60 {
543  --en-hue-rotate: hue-rotate(-60deg) !important;
544  filter: var(--en-blur) var(--en-brightness) var(--en-contrast) var(--en-grayscale) var(--en-hue-rotate) var(--en-invert) var(--en-saturate) var(--en-sepia) var(--en-drop-shadow) !important;
545}
546
547.focus\:\!-mb-2:focus {
548  margin-bottom: -0.5rem !important;
549}
550
551.focus\:\!w-2:focus {
552  width: 0.5rem !important;
553}",
554            )
555        );
556    }
557
558    #[test]
559    fn gen_css_for_selector_needing_custom_css() {
560        let generated = generate(["animate-pulse", "animate-pulse"], &base_config());
561
562        assert_eq!(
563            generated,
564            String::from(
565                "@-webkit-keyframes pulse {
566  50% {
567    opacity: .5;
568  }
569}
570
571@keyframes pulse {
572  0%, 100% {
573    opacity: 1;
574  }
575  50% {
576    opacity: .5;
577  }
578}
579
580.animate-pulse {
581  -webkit-animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
582  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
583}"
584            )
585        );
586    }
587
588    #[test]
589    fn gen_css_for_arbitrary_value() {
590        let generated = generate(
591            [
592                "w[12px]",
593                "bg-[red]",
594                "bg-[url(../img/image_with_underscores.png)]",
595                "mt-[calc(100%-10px)]",
596                "2xl:pb-[calc((100%/2)-10px+2rem)]",
597            ],
598            &base_config(),
599        );
600
601        assert_eq!(
602            generated,
603            String::from(
604                r".mt-\[calc\(100\%-10px\)\] {
605  margin-top: calc(100% - 10px);
606}
607
608.w\[12px\] {
609  width: 12px;
610}
611
612.bg-\[red\] {
613  background-color: red;
614}
615
616.bg-\[url\(\.\.\/img\/image_with_underscores\.png\)\] {
617  background-image: url(../img/image_with_underscores.png);
618}
619
620@media (width >= 96rem) {
621  .\32xl\:pb-\[calc\(\(100\%\/2\)-10px\+2rem\)\] {
622    padding-bottom: calc((100% / 2) - 10px + 2rem);
623  }
624}"
625            )
626        );
627    }
628
629    #[test]
630    fn gen_css_for_arbitrary_value_with_hint() {
631        let generated = generate(["bg-[color:red]", "hover:bg-[color:red]"], &base_config());
632
633        assert_eq!(
634            generated,
635            String::from(
636                r".bg-\[color\:red\] {
637  background-color: red;
638}
639
640.hover\:bg-\[color\:red\]:hover {
641  background-color: red;
642}"
643            )
644        );
645    }
646
647    #[test]
648    fn gen_css_for_selector_with_simple_variant() {
649        let generated = generate(["focus:w-full"], &base_config());
650
651        assert_eq!(
652            generated,
653            String::from(
654                r".focus\:w-full:focus {
655  width: 100%;
656}"
657            )
658        );
659    }
660
661    #[test]
662    fn gen_selector_css_variants_test() {
663        let generated = generate([
664            "sm:hover:bg-red-400",
665            "focus:hover:bg-red-600",
666            "active:rtl:bg-red-800",
667            "md:focus:selection:bg-blue-100",
668            "rtl:active:focus:lg:underline",
669            "print:ltr:xl:hover:focus:active:text-yellow-300",
670            "2xl:motion-safe:landscape:focus-within:visited:first:odd:checked:open:rtl:bg-purple-100",
671            "hover:file:bg-pink-600",
672            "file:hover:bg-pink-600",
673            "sm:before:target:content-[&#39;Hello_world!&#39;]",
674            "marker:selection:hover:bg-green-200",
675            "group-hover:bg-green-300",
676            "group-focus:bg-green-400",
677            "peer-invalid:bg-red-500",
678            "peer-not-invalid:bg-green-500",
679        ], &base_config());
680
681        assert_eq!(
682            generated,
683            String::from(
684                r#".marker\:selection\:hover\:bg-green-200 *::marker, .marker\:selection\:hover\:bg-green-200::marker *::selection, .marker\:selection\:hover\:bg-green-200 *::marker, .marker\:selection\:hover\:bg-green-200::marker::selection:hover {
685  background-color: oklch(92.5% .084 155.995);
686}
687
688.file\:hover\:bg-pink-600::file-selector-button, .file\:hover\:bg-pink-600::-webkit-file-upload-button:hover {
689  background-color: oklch(59.2% .249 .584);
690}
691
692.hover\:file\:bg-pink-600:hover::file-selector-button, .hover\:file\:bg-pink-600:hover::-webkit-file-upload-button {
693  background-color: oklch(59.2% .249 .584);
694}
695
696.focus\:hover\:bg-red-600:focus:hover {
697  background-color: oklch(57.7% .245 27.325);
698}
699
700[dir="rtl"] .active\:rtl\:bg-red-800:active {
701  background-color: oklch(44.4% .177 26.899);
702}
703
704@media (width >= 64rem) {
705  [dir="rtl"] .rtl\:active\:focus\:lg\:underline:active:focus {
706    -webkit-text-decoration-line: underline;
707    text-decoration-line: underline;
708  }
709}
710
711@media print {
712  @media (width >= 80rem) {
713    [dir="ltr"] .print\:ltr\:xl\:hover\:focus\:active\:text-yellow-300:hover:focus:active {
714      color: oklch(90.5% .182 98.111);
715    }
716  }
717}
718
719@media (width >= 40rem) {
720  .sm\:before\:target\:content-\[\'Hello_world\!\'\]::before:target {
721    --en-content: 'Hello world!';
722    content: var(--en-content);
723  }
724}
725
726@media (width >= 40rem) {
727  .sm\:hover\:bg-red-400:hover {
728    background-color: oklch(70.4% .191 22.216);
729  }
730}
731
732@media (width >= 48rem) {
733  .md\:focus\:selection\:bg-blue-100:focus *::selection, .md\:focus\:selection\:bg-blue-100:focus::selection {
734    background-color: oklch(93.2% .032 255.585);
735  }
736}
737
738@media (width >= 96rem) {
739  @media (prefers-reduced-motion: no-preference) {
740    @media (orientation: landscape) {
741      [dir="rtl"] .\32xl\:motion-safe\:landscape\:focus-within\:visited\:first\:odd\:checked\:open\:rtl\:bg-purple-100:focus-within:visited:first-child:nth-child(odd):checked[open] {
742        background-color: oklch(94.6% .033 307.174);
743      }
744    }
745  }
746}
747
748.group:hover .group-hover\:bg-green-300 {
749  background-color: oklch(87.1% .15 154.449);
750}
751
752.group:focus .group-focus\:bg-green-400 {
753  background-color: oklch(79.2% .209 151.711);
754}
755
756.peer:not(:invalid) ~ .peer-not-invalid\:bg-green-500 {
757  background-color: oklch(72.3% .219 149.579);
758}
759
760.peer:invalid ~ .peer-invalid\:bg-red-500 {
761  background-color: oklch(63.7% .237 25.331);
762}"#
763            )
764        );
765    }
766
767    #[test]
768    fn gen_css_for_duplicated_selectors() {
769        let generated = generate(["bg-red-500 bg-red-500", "bg-red-500"], &base_config());
770
771        assert_eq!(
772            generated,
773            String::from(
774                ".bg-red-500 {
775  background-color: oklch(63.7% .237 25.331);
776}"
777            )
778        );
779    }
780
781    #[test]
782    fn gen_css_for_selector_with_arbitrary_property() {
783        let generated = generate(["hover:[mask-type:luminance]"], &base_config());
784
785        assert_eq!(
786            generated,
787            String::from(
788                r".hover\:\[mask-type\:luminance\]:hover {
789  mask-type: luminance;
790}"
791            )
792        );
793    }
794
795    #[test]
796    fn gen_css_for_selector_with_arbitrary_variant() {
797        let generated = generate(
798            [
799                "[&_>_*]:before:content-[&#39;hello-&#39;]",
800                "[&:has(.active)]:bg-blue-500",
801                "[@supports_(display:grid)]:grid",
802                "[@supports_not_(display:grid)]:float-right",
803            ],
804            &base_config(),
805        );
806
807        assert_eq!(
808            generated,
809            String::from(
810                r"@supports not (display:grid) {
811  .\[\@supports_not_\(display\:grid\)\]\:float-right {
812    float: right;
813  }
814}
815
816@supports (display:grid) {
817  .\[\@supports_\(display\:grid\)\]\:grid {
818    display: grid;
819  }
820}
821
822.\[\&\:has\(\.active\)\]\:bg-blue-500:has(.active) {
823  background-color: oklch(62.3% .214 259.815);
824}
825
826.\[\&_\>_\*\]\:before\:content-\[\'hello-\'\] > *::before {
827  --en-content: 'hello-';
828  content: var(--en-content);
829}"
830            )
831        );
832    }
833
834    #[test]
835    fn gen_css_for_variant_group() {
836        let generated = generate(
837            ["xl:(focus:(outline,outline-red-200),dark:(bg-black,text-white))"],
838            &base_config(),
839        );
840
841        assert_eq!(
842            generated,
843            String::from(
844                r"@media (width >= 80rem) {
845  .xl\:\(focus\:\(outline\,outline-red-200\)\,dark\:\(bg-black\,text-white\)\):focus {
846    outline-color: oklch(88.5% .062 18.334);
847  }
848}
849
850@media (width >= 80rem) {
851  .xl\:\(focus\:\(outline\,outline-red-200\)\,dark\:\(bg-black\,text-white\)\):focus {
852    outline-style: solid;
853  }
854}
855
856@media (prefers-color-scheme: dark) {
857  @media (width >= 80rem) {
858    .xl\:\(focus\:\(outline\,outline-red-200\)\,dark\:\(bg-black\,text-white\)\) {
859      color: #fff;
860    }
861  }
862}
863
864@media (prefers-color-scheme: dark) {
865  @media (width >= 80rem) {
866    .xl\:\(focus\:\(outline\,outline-red-200\)\,dark\:\(bg-black\,text-white\)\) {
867      background-color: #000;
868    }
869  }
870}"
871            )
872        );
873
874        let generated = generate(
875            ["(bg-blue-100,bg-blue-200,bg-blue-300)"],
876            &base_config(),
877        );
878
879        assert_eq!(
880            generated,
881            String::from(r".\(bg-blue-100\,bg-blue-200\,bg-blue-300\) {
882  background-color: oklch(93.2% .032 255.585);
883}
884
885.\(bg-blue-100\,bg-blue-200\,bg-blue-300\) {
886  background-color: oklch(88.2% .059 254.128);
887}
888
889.\(bg-blue-100\,bg-blue-200\,bg-blue-300\) {
890  background-color: oklch(80.9% .105 251.813);
891}"),
892        );
893    }
894
895    #[test]
896    fn default_modifier_values_for_rounded() {
897        let generated = generate([
898            "rounded-tr-sm rounded-tr-md rounded-sm rounded-md rounded-t-sm rounded-bl-xl border-x border border-4 border-t-2",
899        ], &base_config());
900
901        assert_eq!(
902            generated,
903            String::from(
904                ".rounded-md {
905  border-radius: 0.375rem;
906}
907
908.rounded-sm {
909  border-radius: 0.25rem;
910}
911
912.rounded-t-sm {
913  border-top-left-radius: 0.25rem;
914  border-top-right-radius: 0.25rem;
915}
916
917.rounded-tr-md {
918  border-top-right-radius: 0.375rem;
919}
920
921.rounded-tr-sm {
922  border-top-right-radius: 0.25rem;
923}
924
925.rounded-bl-xl {
926  border-bottom-left-radius: 0.75rem;
927}
928
929.border {
930  border-width: 1px;
931}
932
933.border-4 {
934  border-width: 4px;
935}
936
937.border-x {
938  border-inline-width: 1px;
939}
940
941.border-t-2 {
942  border-top-width: 2px;
943}"
944            )
945        );
946    }
947
948    #[test]
949    fn gen_css_for_font_with_spaces() {
950        let generated = generate(
951            [
952                "font-[&#39;Times_New_Roman&#39;,Helvetica,serif]",
953                "font-[Roboto,&#39;Open_Sans&#39;,sans-serif]",
954            ],
955            &base_config(),
956        );
957
958        assert_eq!(
959            generated,
960            String::from(
961                r".font-\[\'Times_New_Roman\'\,Helvetica\,serif\] {
962  font-family: 'Times New Roman',Helvetica,serif;
963}
964
965.font-\[Roboto\,\'Open_Sans\'\,sans-serif\] {
966  font-family: Roboto,'Open Sans',sans-serif;
967}"
968            )
969        );
970    }
971
972    #[test]
973    fn gen_css_for_container() {
974        let generated = generate(["container"], &base_config());
975
976        assert_eq!(
977            generated,
978            String::from(
979                ".container {
980  width: 100%;
981}
982
983@media (width >= 40rem) {
984  .container {
985    max-width: 40rem;
986  }
987}
988
989@media (width >= 48rem) {
990  .container {
991    max-width: 48rem;
992  }
993}
994
995@media (width >= 64rem) {
996  .container {
997    max-width: 64rem;
998  }
999}
1000
1001@media (width >= 80rem) {
1002  .container {
1003    max-width: 80rem;
1004  }
1005}
1006
1007@media (width >= 96rem) {
1008  .container {
1009    max-width: 96rem;
1010  }
1011}"
1012            )
1013        );
1014
1015        let generated = generate(["md:container", "md:mx-auto"], &base_config());
1016
1017        assert_eq!(
1018            generated,
1019            String::from(
1020                r"@media (width >= 48rem) {
1021  .md\:mx-auto {
1022    margin-inline: auto;
1023  }
1024}
1025
1026@media (width >= 48rem) {
1027  .md\:container {
1028    width: 100%;
1029  }
1030}
1031
1032@media (width >= 48rem) {
1033  @media (width >= 40rem) {
1034    .md\:container {
1035      max-width: 40rem;
1036    }
1037  }
1038
1039  @media (width >= 48rem) {
1040    .md\:container {
1041      max-width: 48rem;
1042    }
1043  }
1044
1045  @media (width >= 64rem) {
1046    .md\:container {
1047      max-width: 64rem;
1048    }
1049  }
1050
1051  @media (width >= 80rem) {
1052    .md\:container {
1053      max-width: 80rem;
1054    }
1055  }
1056
1057  @media (width >= 96rem) {
1058    .md\:container {
1059      max-width: 96rem;
1060    }
1061  }
1062}"
1063            )
1064        );
1065    }
1066
1067    #[test]
1068    fn gen_css_for_selector_with_before_after_variant() {
1069        let generated = generate(
1070            [
1071                "before:bg-red-500",
1072                "before:content-[&#39;Hello_world!&#39;]",
1073                "after:rounded-full",
1074                "after:content-[counter(foo)]",
1075            ],
1076            &base_config(),
1077        );
1078
1079        assert_eq!(
1080            generated,
1081            String::from(
1082                r".before\:content-\[\'Hello_world\!\'\]::before {
1083  --en-content: 'Hello world!';
1084  content: var(--en-content);
1085}
1086
1087.before\:bg-red-500::before {
1088  background-color: oklch(63.7% .237 25.331);
1089  content: var(--en-content);
1090}
1091
1092.after\:content-\[counter\(foo\)\]::after {
1093  --en-content: counter(foo);
1094  content: var(--en-content);
1095}
1096
1097.after\:rounded-full::after {
1098  border-radius: 9999px;
1099  content: var(--en-content);
1100}"
1101            )
1102        );
1103    }
1104
1105    #[test]
1106    fn gen_css_for_selector_with_dark_variant() {
1107        let generated = generate(["dark:mt-px"], &base_config());
1108
1109        assert_eq!(
1110            generated,
1111            String::from(
1112                r"@media (prefers-color-scheme: dark) {
1113  .dark\:mt-px {
1114    margin-top: 1px;
1115  }
1116}"
1117            )
1118        );
1119
1120        let mut config = base_config();
1121        config.theme.dark_mode = DarkMode::new_class(".dark");
1122
1123        let generated = generate(["dark:mt-px"], &config);
1124
1125        assert_eq!(
1126            generated,
1127            String::from(
1128                r".dark .dark\:mt-px {
1129  margin-top: 1px;
1130}"
1131            )
1132        );
1133    }
1134
1135    #[test]
1136    fn variant_ordering() {
1137        let generated = generate(["*:first:text-green-400"], &base_config());
1138
1139        assert_eq!(
1140            generated,
1141            String::from(
1142                r".\*\:first\:text-green-400 > *:first-child {
1143  color: oklch(79.2% .209 151.711);
1144}"
1145            )
1146        );
1147
1148        let mut config = base_config();
1149        config.theme.dark_mode = DarkMode::new_class(".dark");
1150
1151        let generated = generate(["dark:mt-px"], &config);
1152
1153        assert_eq!(
1154            generated,
1155            String::from(
1156                r".dark .dark\:mt-px {
1157  margin-top: 1px;
1158}"
1159            )
1160        );
1161    }
1162
1163    #[test]
1164    fn named_group_and_peer() {
1165        let generated = generate(
1166            ["group-checked/item:block peer-checked/item:block peer-not-checked/item:block", "peer-[:focus-within]/item:block", "peer-[:nth-of-type(3)_&]/item:block"],
1167            &base_config(),
1168        );
1169
1170        assert_eq!(
1171            generated,
1172            String::from(
1173                r":nth-of-type(3) .peer\/item ~ .peer-\[\:nth-of-type\(3\)_\&\]\/item\:block {
1174  display: block;
1175}
1176
1177.peer\/item:focus-within ~ .peer-\[\:focus-within\]\/item\:block {
1178  display: block;
1179}
1180
1181.group\/item:checked .group-checked\/item\:block {
1182  display: block;
1183}
1184
1185.peer\/item:not(:checked) ~ .peer-not-checked\/item\:block {
1186  display: block;
1187}
1188
1189.peer\/item:checked ~ .peer-checked\/item\:block {
1190  display: block;
1191}"
1192            )
1193        );
1194    }
1195
1196    #[test]
1197    fn prefixed_variants() {
1198        let generated = generate(
1199            ["supports-[display:flex]:flex nth-of-type-[span]:text-red-500 data-[active]:block"],
1200            &base_config(),
1201        );
1202
1203        assert_eq!(
1204            generated,
1205            String::from(
1206                r".data-\[active\]\:block[data-active] {
1207  display: block;
1208}
1209
1210.nth-of-type-\[span\]\:text-red-500:nth-of-type(span) {
1211  color: oklch(63.7% .237 25.331);
1212}
1213
1214@supports (display:flex) {
1215  .supports-\[display\:flex\]\:flex {
1216    display: flex;
1217  }
1218}"
1219            )
1220        );
1221    }
1222
1223    #[test]
1224    fn layers() {
1225        let mut config = base_config();
1226        config.layers.add("1", 1);
1227        config.layers.add("2", 2);
1228        config.layers.add("3", 3);
1229        config.layers.add("4", 4);
1230
1231        let generated = generate(
1232            ["l-1:bg-red-500 l-2:bg-red-100 l-4:inset-12 l-1:(bg-blue-800,l-2:(bg-blue-700,bg-blue-600,l-3:bg-blue-500))"],
1233            &config,
1234        );
1235
1236        assert_eq!(
1237            generated,
1238            String::from(
1239                r".l-1\:\(bg-blue-800\,l-2\:\(bg-blue-700\,bg-blue-600\,l-3\:bg-blue-500\)\) {
1240  background-color: oklch(42.4% .199 265.638);
1241}
1242
1243.l-1\:bg-red-500 {
1244  background-color: oklch(63.7% .237 25.331);
1245}
1246
1247.l-1\:\(bg-blue-800\,l-2\:\(bg-blue-700\,bg-blue-600\,l-3\:bg-blue-500\)\) {
1248  background-color: oklch(54.6% .245 262.881);
1249}
1250
1251.l-1\:\(bg-blue-800\,l-2\:\(bg-blue-700\,bg-blue-600\,l-3\:bg-blue-500\)\) {
1252  background-color: oklch(48.8% .243 264.376);
1253}
1254
1255.l-2\:bg-red-100 {
1256  background-color: oklch(93.6% .032 17.717);
1257}
1258
1259.l-1\:\(bg-blue-800\,l-2\:\(bg-blue-700\,bg-blue-600\,l-3\:bg-blue-500\)\) {
1260  background-color: oklch(62.3% .214 259.815);
1261}
1262
1263.l-4\:inset-12 {
1264  inset: 3rem;
1265}"
1266            )
1267        );
1268    }
1269
1270    #[test]
1271    fn arbitrary_values_test() {
1272        use std::fs;
1273
1274        let file_content = fs::read_to_string("tests/fixtures/arbitrary-values.html").unwrap();
1275        let _generated = generate([file_content.as_str()], &base_config());
1276    }
1277}