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