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}