encre_css/
scanner.rs

1//! Define a structure used to scan content.
2use std::{collections::BTreeSet, sync::Arc};
3
4/// A structure responsible for scanning some content and returning a list of possible classes.
5///
6/// By default, it splits the content by spaces, double quotes, single quotes, backticks and new
7/// lines, while ignoring arbitrary the content inside values/variants and variant groups
8/// by using [`split_ignore_arbitrary`].
9/// It is recommended to use this function when splitting classes with characters which can be
10/// included inside arbitrary strings.
11///
12/// # Example
13///
14/// The following code snippet defines a scanner for extracting classes listed in the `data-en`
15/// HTML attribute.
16///
17/// ```
18/// use encre_css::{Config, Scanner, utils::split_ignore_arbitrary};
19/// use std::collections::BTreeSet;
20///
21/// let mut config = Config::default();
22/// config.scanner = Scanner::from_fn(|content| content.split(r#"data-en=""#)
23///     .filter_map(|v| v.split_once("\"").map(|(classes, _)| classes.split_whitespace()))
24///     .flatten()
25///     .collect::<BTreeSet<&str>>());
26///
27/// let generated = encre_css::generate(
28///     [r#"<h1 data-en="underline"></h1><p data-en="bg-red-200 text-blue-300"></p>"#],
29///     &config,
30/// );
31///
32/// assert!(generated.ends_with(".bg-red-200 {
33///   --en-bg-opacity: 1;
34///   background-color: rgb(254 202 202 / var(--en-bg-opacity));
35/// }
36///
37/// .text-blue-300 {
38///   --en-text-opacity: 1;
39///   color: rgb(147 197 253 / var(--en-text-opacity));
40/// }
41///
42/// .underline {
43///   -webkit-text-decoration-line: underline;
44///   text-decoration-line: underline;
45/// }"));
46/// ```
47///
48/// [`split_ignore_arbitrary`]: crate::utils::split_ignore_arbitrary
49#[allow(missing_debug_implementations)]
50#[allow(clippy::type_complexity)]
51#[derive(Clone)]
52pub struct Scanner {
53    scan_fn: Arc<dyn Fn(&str) -> BTreeSet<&str> + Send + Sync>,
54}
55
56impl Scanner {
57    /// Build a [`Scanner`] from a closure taking some content and returning a list of possible
58    /// classes.
59    pub fn from_fn<T: 'static + Fn(&str) -> BTreeSet<&str> + Send + Sync>(scan_fn: T) -> Self {
60        Self {
61            scan_fn: Arc::new(scan_fn),
62        }
63    }
64
65    pub(crate) fn scan<'a>(&self, val: &'a str) -> BTreeSet<&'a str> {
66        (self.scan_fn)(val)
67    }
68}
69
70impl Default for Scanner {
71    fn default() -> Self {
72        Self {
73            scan_fn: Arc::new(|val| {
74                let mut is_arbitrary = false;
75
76                val.split(|ch| {
77                    // Escape all characters in arbitrary values prefixed by a dash (used to avoid
78                    // ignoring values in, for example, JS arrays, given that they are defined
79                    // using square brackets)
80                    match ch {
81                        '[' => {
82                            is_arbitrary = true;
83                            false
84                        }
85                        ']' => {
86                            is_arbitrary = false;
87                            false
88                        }
89                        _ => {
90                            ch == ' '
91                                || (!is_arbitrary
92                                    && (ch == '\'' || ch == '"' || ch == '`' || ch == '\n'))
93                        }
94                    }
95                })
96                .collect::<BTreeSet<&str>>()
97            }),
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    use std::collections::BTreeSet;
107
108    #[test]
109    fn default_scanner_test() {
110        assert_eq!(
111            Scanner::default().scan("test bg-red-500 'hello' content-[some_[_square]_brackets] foo-bar sm:focus:ring hover:bg-black border-[#333] text-[color:var(--hello)]"),
112            BTreeSet::from([
113                "",
114                "test",
115                "bg-red-500",
116                "hello",
117                "content-[some_[_square]_brackets]",
118                "foo-bar",
119                "sm:focus:ring",
120                "hover:bg-black",
121                "border-[#333]",
122                "text-[color:var(--hello)]",
123            ])
124        );
125    }
126
127    #[test]
128    fn custom_scanner_test() {
129        let scanner = Scanner::from_fn(|val| val.split(|ch| ch == '|').collect::<BTreeSet<&str>>());
130
131        assert_eq!(
132            scanner.scan("test|bg-red-500|'hello'"),
133            BTreeSet::from(["test", "bg-red-500", "'hello'"])
134        );
135    }
136
137    #[test]
138    fn utf8_scan() {
139        assert_eq!(
140            Scanner::default().scan("<div class=\"before:content-[J\u{e4}s\u{f8}n_Doe] content-[\u{2192}]\">\u{306}</div>"),
141            BTreeSet::from([
142                "<div",
143                ">\u{306}</div>",
144                "before:content-[J\u{e4}s\u{f8}n_Doe]",
145                "class=",
146                "content-[\u{2192}]",
147            ])
148        );
149    }
150
151    #[test]
152    fn scan_prevent_splitting_arbitrary_values() {
153        assert_eq!(
154            Scanner::default().scan(r#"<div class="bg-red-300 content-['hello:>"']"></div>"#),
155            BTreeSet::from([
156                "<div",
157                "></div>",
158                "bg-red-300",
159                "class=",
160                "content-['hello:>\"']",
161            ])
162        );
163    }
164
165    #[test]
166    fn scan_with_arbitrary_variant() {
167        assert_eq!(
168            Scanner::default().scan(r#"<div class="[input[type='text']]:block"></div>"#),
169            BTreeSet::from([
170                "<div",
171                "></div>",
172                "class=",
173                "[input[type='text']]:block",
174            ])
175        );
176    }
177}