css_compare/
lib.rs

1use std::collections::{HashMap, HashSet};
2
3use lightningcss::error::{Error as CssError, ParserError};
4use lightningcss::properties::font::{FontFamily, FontWeight};
5use lightningcss::properties::Property;
6use lightningcss::rules::font_face::{FontFaceProperty, FontFaceRule, FontStyle};
7use lightningcss::rules::style::StyleRule;
8use lightningcss::rules::unknown::UnknownAtRule;
9use lightningcss::rules::CssRule;
10use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
11use lightningcss::traits::ToCss;
12use lightningcss::values::angle::Angle;
13
14#[derive(Debug)]
15pub enum Error<'a> {
16    Parser(CssError<ParserError<'a>>),
17    MissingStyleProperties {
18        path: String,
19        rules: Vec<String>,
20    },
21    UnexpectedProperties {
22        path: String,
23        rules: Vec<String>,
24    },
25    MismatchFontFace {
26        path: String,
27        expected: String,
28        generated: String,
29    },
30    MismatchRules {
31        path: String,
32        expected: String,
33        generated: String,
34    },
35    MismatchImports {
36        path: String,
37        expected: String,
38        generated: String,
39    },
40    MissingRules {
41        path: String,
42        rules: Vec<String>,
43    },
44    UnexpectedRules {
45        path: String,
46        rules: Vec<String>,
47    },
48}
49
50fn font_family_as_key(item: &FontFamily<'_>) -> String {
51    match item {
52        FontFamily::FamilyName(inner) => inner
53            .to_css_string(PrinterOptions::default())
54            .unwrap_or_default(),
55        FontFamily::Generic(inner) => inner.as_str().to_string(),
56    }
57}
58
59fn font_weight_as_key(item: &FontWeight) -> String {
60    match item {
61        FontWeight::Bolder => "bolder".to_string(),
62        FontWeight::Lighter => "lighter".to_string(),
63        FontWeight::Absolute(inner) => match inner {
64            lightningcss::properties::font::AbsoluteFontWeight::Normal => "normal".into(),
65            lightningcss::properties::font::AbsoluteFontWeight::Bold => "bold".into(),
66            lightningcss::properties::font::AbsoluteFontWeight::Weight(w) => w.to_string(),
67        },
68    }
69}
70
71fn oblique_angle_as_key(item: &Angle) -> String {
72    item.to_css_string(PrinterOptions::default()).unwrap()
73}
74
75fn font_face_as_key(item: &FontFaceRule<'_>) -> String {
76    let mut res = String::default();
77    if let Some(font_family) = item.properties.iter().find_map(|p| match p {
78        FontFaceProperty::FontFamily(inner) => Some(font_family_as_key(inner)),
79        _ => None,
80    }) {
81        res.push_str("font-family:");
82        res.push_str(&font_family);
83        res.push(';');
84    }
85    if let Some(font_weight) = item.properties.iter().find_map(|p| match p {
86        FontFaceProperty::FontWeight(inner) => Some(format!(
87            "{} {}",
88            font_weight_as_key(&inner.0),
89            font_weight_as_key(&inner.1)
90        )),
91        _ => None,
92    }) {
93        res.push_str("font-weight:");
94        res.push_str(&font_weight);
95        res.push(';');
96    }
97    if let Some(font_style) = item.properties.iter().find_map(|p| match p {
98        FontFaceProperty::FontStyle(style) => match style {
99            FontStyle::Normal => Some("normal".to_string()),
100            FontStyle::Italic => Some("italic".to_string()),
101            FontStyle::Oblique(inner) => Some(format!(
102                "{} {}",
103                oblique_angle_as_key(&inner.0),
104                oblique_angle_as_key(&inner.1)
105            )),
106        },
107        _ => None,
108    }) {
109        res.push_str("font-style:");
110        res.push_str(&font_style);
111        res.push(';');
112    }
113    res
114}
115
116fn css_rule_as_key<R: std::fmt::Debug + std::cmp::PartialEq>(rule: &CssRule<'_, R>) -> String {
117    match rule {
118        CssRule::Media(media_inner) => format!(
119            "media({})",
120            media_inner.query.to_css_string(Default::default()).unwrap()
121        ),
122        CssRule::Style(inner) => format!(
123            "style({})",
124            inner
125                .selectors
126                .0
127                .iter()
128                .map(|sel| sel.to_css_string(Default::default()).unwrap())
129                .collect::<Vec<_>>()
130                .join(", "),
131        ),
132        CssRule::Import(inner) => format!("import({})", inner.url),
133        CssRule::Unknown(inner) => format!("unknown({})", inner.name),
134        CssRule::FontFace(inner) => format!("font-face({})", font_face_as_key(inner)),
135        others => todo!("css_rule_as_key {others:?}"),
136    }
137}
138
139fn compare_style_properties<'a>(
140    path: &str,
141    exp: &[Property<'a>],
142    gen: &[Property<'a>],
143    important: bool,
144) -> Result<(), Error<'a>> {
145    let exp_props = exp
146        .iter()
147        .map(|p| {
148            p.to_css_string(important, PrinterOptions::default())
149                .unwrap()
150        })
151        .collect::<HashSet<_>>();
152    let gen_props = gen
153        .iter()
154        .map(|p| {
155            p.to_css_string(important, PrinterOptions::default())
156                .unwrap()
157        })
158        .collect::<HashSet<_>>();
159
160    let diff = exp_props
161        .difference(&gen_props)
162        .cloned()
163        .collect::<Vec<_>>();
164
165    if !diff.is_empty() {
166        return Err(Error::MissingStyleProperties {
167            path: path.to_string(),
168            rules: diff,
169        });
170    }
171
172    let diff = gen_props
173        .difference(&exp_props)
174        .cloned()
175        .collect::<Vec<_>>();
176
177    if !diff.is_empty() {
178        return Err(Error::UnexpectedProperties {
179            path: path.to_string(),
180            rules: diff,
181        });
182    }
183
184    Ok(())
185}
186
187fn compare_style<'a, R: std::fmt::Debug + std::cmp::PartialEq>(
188    path: &str,
189    exp: StyleRule<'a, R>,
190    gen: StyleRule<'a, R>,
191) -> Result<(), Error<'a>> {
192    compare_style_properties(
193        path,
194        &exp.declarations.declarations,
195        &gen.declarations.declarations,
196        false,
197    )?;
198    compare_style_properties(
199        path,
200        &exp.declarations.important_declarations,
201        &gen.declarations.important_declarations,
202        true,
203    )?;
204    Ok(())
205}
206
207fn compare_font_face<'a>(
208    path: &str,
209    exp: FontFaceRule<'a>,
210    gen: FontFaceRule<'a>,
211) -> Result<(), Error<'a>> {
212    let mut exp_props = exp
213        .properties
214        .iter()
215        .map(|prop| prop.to_css_string(PrinterOptions::default()).unwrap())
216        .collect::<Vec<_>>();
217    exp_props.sort();
218    let exp_props = exp_props.join("\n");
219    let mut gen_props = gen
220        .properties
221        .iter()
222        .map(|prop| prop.to_css_string(PrinterOptions::default()).unwrap())
223        .collect::<Vec<_>>();
224    gen_props.sort();
225    let gen_props = gen_props.join("\n");
226    if exp_props != gen_props {
227        Err(Error::MismatchFontFace {
228            path: path.to_string(),
229            expected: exp_props,
230            generated: gen_props,
231        })
232    } else {
233        Ok(())
234    }
235}
236
237fn compare_unknown<'a>(
238    path: &str,
239    exp: UnknownAtRule<'a>,
240    gen: UnknownAtRule<'a>,
241) -> Result<(), Error<'a>> {
242    let exp_str = exp.to_css_string(PrinterOptions::default()).unwrap();
243    let gen_str = gen.to_css_string(PrinterOptions::default()).unwrap();
244    if exp_str != gen_str {
245        Err(Error::MismatchRules {
246            path: path.to_string(),
247            expected: exp_str,
248            generated: gen_str,
249        })
250    } else {
251        Ok(())
252    }
253}
254
255fn compare_rule<'a, R: std::fmt::Debug + std::cmp::PartialEq>(
256    path: &str,
257    exp: CssRule<'a, R>,
258    gen: CssRule<'a, R>,
259) -> Result<(), Error<'a>> {
260    match (exp, gen) {
261        (CssRule::Media(exp), CssRule::Media(gen)) => {
262            compare_rules(path, exp.rules.0, gen.rules.0)?;
263        }
264        (CssRule::Style(exp), CssRule::Style(gen)) => {
265            compare_style(path, exp, gen)?;
266        }
267        (CssRule::Import(exp), CssRule::Import(gen)) => {
268            if exp.url != gen.url {
269                return Err(Error::MismatchImports {
270                    path: path.to_string(),
271                    expected: exp.url.to_string(),
272                    generated: gen.url.to_string(),
273                });
274            }
275        }
276        (CssRule::FontFace(exp), CssRule::FontFace(gen)) => {
277            compare_font_face(path, exp, gen)?;
278        }
279        (CssRule::Unknown(exp), CssRule::Unknown(gen)) => {
280            compare_unknown(path, exp, gen)?;
281        }
282        (exp, gen) => {
283            return Err(Error::MismatchRules {
284                path: path.to_string(),
285                expected: format!("{exp:#?}"),
286                generated: format!("{gen:#?}"),
287            })
288        }
289    }
290    Ok(())
291}
292
293fn compare_rules<'a, R: std::fmt::Debug + std::cmp::PartialEq>(
294    path: &str,
295    exps: Vec<CssRule<'a, R>>,
296    gens: Vec<CssRule<'a, R>>,
297) -> Result<(), Error<'a>> {
298    let exp_map = exps
299        .into_iter()
300        .map(|item| (css_rule_as_key(&item), item))
301        .collect::<HashMap<_, _>>();
302    let gen_map = gens
303        .into_iter()
304        .map(|item| (css_rule_as_key(&item), item))
305        .collect::<HashMap<_, _>>();
306
307    let exp_keys = exp_map.keys().map(|s| s.as_str()).collect::<HashSet<_>>();
308    let gen_keys = gen_map.keys().map(|s| s.as_str()).collect::<HashSet<_>>();
309
310    let diff = exp_keys
311        .difference(&gen_keys)
312        .map(|s| s.to_string())
313        .collect::<Vec<_>>();
314
315    if !diff.is_empty() {
316        return Err(Error::MissingRules {
317            path: path.to_string(),
318            rules: diff,
319        });
320    }
321
322    let diff = gen_keys
323        .difference(&exp_keys)
324        .map(|s| s.to_string())
325        .collect::<Vec<_>>();
326
327    if !diff.is_empty() {
328        return Err(Error::UnexpectedRules {
329            path: path.to_string(),
330            rules: diff,
331        });
332    }
333
334    let mut gen_map = gen_map;
335
336    for (key, exp, gen) in exp_map
337        .into_iter()
338        .filter_map(|(key, exp)| gen_map.remove(&key).map(|gen| (key, exp, gen)))
339    {
340        let path = format!("{path} > {key}");
341        compare_rule(&path, exp, gen)?;
342    }
343
344    Ok(())
345}
346
347pub fn compare<'a>(expected: &'a str, generated: &'a str) -> Result<(), Error<'a>> {
348    let expected_stylesheet =
349        StyleSheet::parse(expected, ParserOptions::default()).map_err(Error::Parser)?;
350    let generated_stylesheet =
351        StyleSheet::parse(generated, ParserOptions::default()).map_err(Error::Parser)?;
352
353    compare_rules(
354        "$",
355        expected_stylesheet.rules.0,
356        generated_stylesheet.rules.0,
357    )?;
358
359    Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364    #[test]
365    fn with_media() {
366        let expected = r#"@media only screen and (min-width:480px) {
367        .mj-column-per-50 {
368            width: 50% !important;
369            max-width: 50%;
370        }
371
372        .mj-column-per-33-333332 {
373            width: 33.333332% !important;
374            max-width: 33.333332%;
375        }
376    }"#;
377        let generated = "@media only screen and (min-width:480px) { .mj-column-per-33-333332 { width:33.333332% !important; max-width:33.333332%; } .mj-column-per-50 { width:50% !important; max-width:50%; }}";
378
379        super::compare(expected, generated).unwrap();
380    }
381
382    #[test]
383    fn with_media_yahoo() {
384        let expected = r#"@media screen, yahoo {
385    .mj-carousel-00000000-icons-cell,
386    .mj-carousel-previous-icons,
387    .mj-carousel-next-icons {
388        display: none !important;
389    }
390    .mj-carousel-00000000-radio-1:checked+*+*+.mj-carousel-content .mj-carousel-00000000-thumbnail-1 {
391        border-color: transparent;
392    }
393}"#;
394        let generated = r#"@media screen, yahoo {
395        .mj-carousel-00000000-icons-cell,
396        .mj-carousel-previous-icons,
397        .mj-carousel-next-icons {
398            display: none !important;
399        }
400        .mj-carousel-00000000-radio-1:checked+*+*+.mj-carousel-content .mj-carousel-00000000-thumbnail-1 {
401            border-color: transparent;
402        }
403    }"#;
404
405        super::compare(expected, generated).unwrap();
406    }
407}