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