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}