1use lightningcss::printer::PrinterOptions;
13use lightningcss::properties::Property;
14use lightningcss::properties::animation::AnimationName;
15use lightningcss::properties::box_shadow::BoxShadow;
16use lightningcss::properties::custom::{
17 CustomProperty, CustomPropertyName, Token, TokenOrValue, Variable,
18};
19use lightningcss::properties::font::FontFamily;
20use lightningcss::rules::CssRule;
21use lightningcss::rules::font_face::FontFaceProperty;
22use lightningcss::rules::keyframes::KeyframesName;
23use lightningcss::rules::style::StyleRule;
24use lightningcss::selector::{Component, Selector};
25use lightningcss::stylesheet::{ParserOptions, StyleSheet};
26use lightningcss::traits::ToCss;
27use lightningcss::values::color::CssColor;
28use lightningcss::visitor::{VisitTypes, Visitor};
29use rustc_hash::FxHashSet;
30
31use fallow_types::extract::{
32 CssAnalytics, CssCustomPropertyDefinition, CssDeclarationBlock, CssRawStyleValue, CssRuleMetric,
33};
34
35const MAX_PLAIN_COMPLEXITY: u16 = 4;
37
38const NOTABLE_NESTING_DEPTH: u8 = 3;
40
41const MAX_NOTABLE_RULES: usize = 500;
45
46const MIN_BLOCK_DECLARATIONS: usize = 4;
50
51const MAX_DECLARATION_BLOCKS: usize = 2000;
55
56const MAX_RAW_STYLE_VALUES: usize = 200;
59
60const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
62
63#[must_use]
72pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
73 let options = ParserOptions {
74 error_recovery: true,
75 css_modules: Some(lightningcss::css_modules::Config::default()),
76 ..ParserOptions::default()
77 };
78 let mut stylesheet = StyleSheet::parse(source, options).ok()?;
79
80 let mut acc = Accumulator::default();
83 walk_rules(&stylesheet.rules.0, 0, &mut acc);
84
85 let mut collector = ValueCollector::default();
89 let _ = collector.visit_stylesheet(&mut stylesheet);
90
91 let mut analytics = acc.analytics;
92 analytics.colors = sorted_vec(collector.colors);
93 analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
94 analytics.font_sizes = sorted_vec(acc.font_sizes);
95 analytics.z_indexes = sorted_vec(acc.z_indexes);
96 analytics.box_shadows = sorted_vec(acc.box_shadows);
97 analytics.border_radii = sorted_vec(acc.border_radii);
98 analytics.line_heights = sorted_vec(acc.line_heights);
99 analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
100 analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
101 analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
102 analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
103 analytics.declared_layers = sorted_vec(acc.declared_layers);
104 analytics.populated_layers = sorted_vec(acc.populated_layers);
105 analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
106 analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
107 Some(analytics)
108}
109
110#[derive(Default)]
113struct Accumulator {
114 analytics: CssAnalytics,
115 font_sizes: FxHashSet<String>,
116 z_indexes: FxHashSet<String>,
117 box_shadows: FxHashSet<String>,
118 border_radii: FxHashSet<String>,
119 line_heights: FxHashSet<String>,
120 defined_custom_properties: FxHashSet<String>,
121 defined_keyframes: FxHashSet<String>,
122 referenced_keyframes: FxHashSet<String>,
123 registered_custom_properties: FxHashSet<String>,
124 declared_layers: FxHashSet<String>,
125 populated_layers: FxHashSet<String>,
126 defined_font_faces: FxHashSet<String>,
127 referenced_font_families: FxHashSet<String>,
128 raw_style_value_keys: FxHashSet<String>,
129}
130
131fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
135 match family {
136 FontFamily::FamilyName(_) => family
140 .to_css_string(PrinterOptions::default())
141 .ok()
142 .map(|s| s.trim_matches(['"', '\'']).to_string()),
143 FontFamily::Generic(_) => None,
144 }
145}
146
147#[derive(Default)]
152struct ValueCollector {
153 colors: FxHashSet<String>,
154 referenced_custom_properties: FxHashSet<String>,
155}
156
157impl Visitor<'_> for ValueCollector {
158 type Error = std::convert::Infallible;
159
160 fn visit_types(&self) -> VisitTypes {
161 VisitTypes::COLORS | VisitTypes::VARIABLES
162 }
163
164 fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
165 if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
166 self.colors.insert(rendered);
167 }
168 Ok(())
169 }
170
171 fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
172 self.referenced_custom_properties
173 .insert(var.name.ident.0.to_string());
174 Ok(())
175 }
176}
177
178#[derive(Default)]
179struct FirstRgbColorCollector {
180 rgb: Option<(f64, f64, f64)>,
181}
182
183impl Visitor<'_> for FirstRgbColorCollector {
184 type Error = std::convert::Infallible;
185
186 fn visit_types(&self) -> VisitTypes {
187 VisitTypes::COLORS
188 }
189
190 fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
191 if self.rgb.is_none()
192 && let CssColor::RGBA(rgba) = color
193 {
194 self.rgb = Some((
195 f64::from(rgba.red),
196 f64::from(rgba.green),
197 f64::from(rgba.blue),
198 ));
199 }
200 Ok(())
201 }
202}
203
204#[must_use]
207pub fn parse_css_color_rgb(value: &str) -> Option<(f64, f64, f64)> {
208 let source = format!(".x{{color:{value};}}");
209 let options = ParserOptions {
210 error_recovery: true,
211 ..ParserOptions::default()
212 };
213 let mut stylesheet = StyleSheet::parse(&source, options).ok()?;
214 let mut collector = FirstRgbColorCollector::default();
215 let _ = collector.visit_stylesheet(&mut stylesheet);
216 collector.rgb
217}
218
219fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
220 let mut values: Vec<String> = set.into_iter().collect();
221 values.sort_unstable();
222 values
223}
224
225fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
230 for rule in rules {
231 match rule {
232 CssRule::Style(style) => {
233 record_style_rule(style, depth, acc);
234 walk_rules(&style.rules.0, depth.saturating_add(1), acc);
235 }
236 CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
237 CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
238 CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
239 CssRule::LayerBlock(rule) => {
240 if let Some(name) = &rule.name {
242 let name = layer_name_string(name);
243 acc.declared_layers.insert(name.clone());
244 acc.populated_layers.insert(name);
245 }
246 walk_rules(&rule.rules.0, depth, acc);
247 }
248 CssRule::LayerStatement(stmt) => {
249 for name in &stmt.names {
251 acc.declared_layers.insert(layer_name_string(name));
252 }
253 }
254 CssRule::Property(prop) => {
255 acc.registered_custom_properties
256 .insert(prop.name.0.to_string());
257 }
258 CssRule::FontFace(font_face) => {
259 for property in &font_face.properties {
260 if let FontFaceProperty::FontFamily(family) = property
261 && let Some(name) = font_family_name(family)
262 {
263 acc.defined_font_faces.insert(name);
264 }
265 }
266 }
267 CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
268 CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
269 CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
270 CssRule::Nesting(rule) => {
271 record_style_rule(&rule.style, depth, acc);
272 walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
273 }
274 CssRule::Keyframes(keyframes) => {
275 acc.defined_keyframes
276 .insert(keyframes_name_string(&keyframes.name));
277 }
278 _ => {}
279 }
280 }
281}
282
283fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
284 name.0
285 .iter()
286 .map(std::string::ToString::to_string)
287 .collect::<Vec<_>>()
288 .join(".")
289}
290
291fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
292 match name {
293 KeyframesName::Ident(ident) => ident.0.to_string(),
294 KeyframesName::Custom(value) => value.to_string(),
295 }
296}
297
298fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
299 if let AnimationName::Ident(ident) = name {
300 out.insert(ident.0.to_string());
301 }
302}
303
304fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
305 let normal = style.declarations.declarations.len();
306 let important = style.declarations.important_declarations.len();
307 let declaration_count = normal + important;
308
309 let analytics = &mut acc.analytics;
310 analytics.rule_count = analytics.rule_count.saturating_add(1);
311 analytics.total_declarations = analytics
312 .total_declarations
313 .saturating_add(saturate_u32(declaration_count));
314 analytics.important_declarations = analytics
315 .important_declarations
316 .saturating_add(saturate_u32(important));
317 if declaration_count == 0 {
318 analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
319 }
320 analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
321
322 let (a, b, c, complexity) = rule_selector_metrics(style);
323 let metric = CssRuleMetric {
324 line: style.loc.line.saturating_add(1),
325 col: style.loc.column,
326 specificity_a: a,
327 specificity_b: b,
328 specificity_c: c,
329 complexity,
330 declaration_count: saturate_u16(declaration_count),
331 important_count: saturate_u16(important),
332 nesting_depth: depth,
333 };
334
335 if is_notable(&metric) {
336 if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
337 analytics.notable_rules.push(metric);
338 } else {
339 analytics.notable_truncated = true;
340 }
341 }
342
343 if declaration_count >= MIN_BLOCK_DECLARATIONS
346 && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
347 && let Some(fingerprint) = declaration_block_fingerprint(style)
348 {
349 analytics.declaration_blocks.push(CssDeclarationBlock {
350 fingerprint,
351 line: style.loc.line.saturating_add(1),
352 declaration_count: saturate_u16(declaration_count),
353 });
354 }
355
356 collect_rule_property_tokens(style, acc, style.loc.line.saturating_add(1));
357}
358
359fn collect_rule_property_tokens(style: &StyleRule<'_>, acc: &mut Accumulator, rule_line: u32) {
364 for property in style
365 .declarations
366 .declarations
367 .iter()
368 .chain(style.declarations.important_declarations.iter())
369 {
370 collect_property_tokens(property, acc, rule_line);
371 collect_raw_style_value(property, acc, rule_line);
372 }
373}
374
375fn collect_property_tokens(property: &Property<'_>, acc: &mut Accumulator, rule_line: u32) {
378 match property {
379 Property::FontSize(font_size) => {
380 insert_rendered_css(font_size, &mut acc.font_sizes);
381 }
382 Property::ZIndex(z_index) => {
383 insert_rendered_css(z_index, &mut acc.z_indexes);
384 }
385 Property::BoxShadow(shadows, _) => collect_box_shadow_tokens(shadows, acc),
390 Property::BorderRadius(radius, _) => {
391 insert_rendered_css(radius, &mut acc.border_radii);
392 }
393 Property::LineHeight(line_height) => {
394 insert_rendered_css(line_height, &mut acc.line_heights);
395 }
396 Property::Custom(custom) => {
397 collect_custom_property_tokens(custom, property, acc, rule_line);
398 }
399 Property::AnimationName(names, _) => {
400 collect_animation_references(names, &mut acc.referenced_keyframes);
401 }
402 Property::Animation(animations, _) => {
403 for animation in animations {
404 collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
405 }
406 }
407 Property::FontFamily(families) => {
408 collect_font_family_references(families, &mut acc.referenced_font_families);
409 }
410 Property::Font(font) => {
411 collect_font_family_references(&font.family, &mut acc.referenced_font_families);
412 }
413 _ => {}
414 }
415}
416
417fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
418 if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
419 out.insert(rendered);
420 }
421}
422
423fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
424 if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
425 return;
426 }
427 let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
428 return;
429 };
430 let Some((property_name, value)) = rendered.split_once(':') else {
431 return;
432 };
433 let property_name = property_name.trim().to_ascii_lowercase();
434 if property_name.starts_with("--") {
435 return;
436 }
437 let value = value.trim().trim_end_matches(';').trim().to_string();
438 let Some(axis) = raw_style_axis(&property_name, &value) else {
439 return;
440 };
441 if !is_raw_style_literal(&value) {
442 return;
443 }
444 let key = format!("{axis}:{property_name}:{value}:{line}");
445 if !acc.raw_style_value_keys.insert(key) {
446 return;
447 }
448 acc.analytics.raw_style_values.push(CssRawStyleValue {
449 axis: axis.to_string(),
450 property: property_name,
451 value,
452 line,
453 });
454}
455
456fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
457 if property_name.contains("color") && looks_like_color_literal(value) {
458 return Some("color");
459 }
460 match property_name {
461 "font-size" => Some("font-size"),
462 "line-height" => Some("line-height"),
463 "border-radius"
464 | "border-top-left-radius"
465 | "border-top-right-radius"
466 | "border-bottom-right-radius"
467 | "border-bottom-left-radius" => Some("radius"),
468 "box-shadow" | "text-shadow" => Some("shadow"),
469 _ => None,
470 }
471}
472
473fn is_raw_style_literal(value: &str) -> bool {
474 let lower = value.to_ascii_lowercase();
475 if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
476 return false;
477 }
478 if matches!(
479 lower.as_str(),
480 "0" | "none"
481 | "normal"
482 | "inherit"
483 | "initial"
484 | "unset"
485 | "revert"
486 | "currentcolor"
487 | "transparent"
488 ) {
489 return false;
490 }
491 lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
492}
493
494fn looks_like_color_literal(value: &str) -> bool {
495 let lower = value.to_ascii_lowercase();
496 lower.starts_with('#')
497 || lower.contains("rgb(")
498 || lower.contains("rgba(")
499 || lower.contains("hsl(")
500 || lower.contains("hsla(")
501 || lower.contains("oklch(")
502 || lower.contains("color-mix(")
503 || matches!(
504 lower.as_str(),
505 "red"
506 | "blue"
507 | "green"
508 | "black"
509 | "white"
510 | "gray"
511 | "grey"
512 | "transparent"
513 | "yellow"
514 | "orange"
515 | "purple"
516 | "pink"
517 )
518}
519
520fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
521 let rendered: Vec<String> = shadows
522 .iter()
523 .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
524 .collect();
525 if !rendered.is_empty() && rendered.len() == shadows.len() {
526 acc.box_shadows.insert(rendered.join(", "));
527 }
528}
529
530fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
531 for name in names {
532 collect_animation_name(name, out);
533 }
534}
535
536fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
537 for family in families {
538 if let Some(name) = font_family_name(family) {
539 out.insert(name);
540 }
541 }
542}
543
544fn collect_custom_property_tokens(
547 custom: &CustomProperty<'_>,
548 property: &Property<'_>,
549 acc: &mut Accumulator,
550 rule_line: u32,
551) {
552 if let CustomPropertyName::Custom(name) = &custom.name {
553 let name = name.0.to_string();
554 acc.defined_custom_properties.insert(name.clone());
555 if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
556 && let Some((_, value)) = rendered.split_once(':')
557 {
558 let value = value.trim().trim_end_matches(';').trim();
559 if !value.is_empty() {
560 acc.analytics
561 .custom_property_definitions
562 .push(CssCustomPropertyDefinition {
563 name,
564 value: value.to_string(),
565 line: rule_line,
566 });
567 }
568 }
569 }
570 for token in &custom.value.0 {
581 if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
582 acc.referenced_font_families.insert(value.to_string());
583 }
584 }
585}
586
587fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
594 let block = &style.declarations;
595 let mut parts: Vec<String> =
596 Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
597 for decl in &block.declarations {
598 parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
599 }
600 for decl in &block.important_declarations {
601 parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
604 }
605 parts.sort_unstable();
606 Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
607}
608
609fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
613 let mut max_spec = 0u32;
614 let mut a = 0u16;
615 let mut b = 0u16;
616 let mut c = 0u16;
617 let mut complexity = 0u16;
618 for selector in &style.selectors.0 {
619 let spec = selector.specificity();
620 if spec >= max_spec {
621 max_spec = spec;
622 a = specificity_component(spec, 20);
623 b = specificity_component(spec, 10);
624 c = specificity_component(spec, 0);
625 }
626 complexity = complexity.max(selector_complexity(selector));
627 }
628 (a, b, c, complexity)
629}
630
631fn specificity_component(specificity: u32, shift: u32) -> u16 {
632 saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
633}
634
635fn is_notable(metric: &CssRuleMetric) -> bool {
636 metric.specificity_a >= 1
637 || metric.complexity > MAX_PLAIN_COMPLEXITY
638 || metric.important_count >= 1
639 || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
640}
641
642fn selector_complexity(selector: &Selector<'_>) -> u16 {
643 let mut count = 0u16;
644 count_components(selector, &mut count);
645 count
646}
647
648fn count_components(selector: &Selector<'_>, count: &mut u16) {
649 for component in selector.iter_raw_match_order() {
650 *count = count.saturating_add(1);
651 match component {
652 Component::Is(list)
653 | Component::Where(list)
654 | Component::Has(list)
655 | Component::Negation(list)
656 | Component::Any(_, list) => {
657 for nested in list.as_ref() {
658 count_components(nested, count);
659 }
660 }
661 Component::Slotted(nested) | Component::Host(Some(nested)) => {
662 count_components(nested, count);
663 }
664 Component::NthOf(data) => {
665 for nested in data.selectors() {
666 count_components(nested, count);
667 }
668 }
669 _ => {}
670 }
671 }
672}
673
674fn saturate_u32(value: usize) -> u32 {
675 u32::try_from(value).unwrap_or(u32::MAX)
676}
677
678fn saturate_u16(value: usize) -> u16 {
679 u16::try_from(value).unwrap_or(u16::MAX)
680}
681
682fn saturate_u16_u32(value: u32) -> u16 {
683 u16::try_from(value).unwrap_or(u16::MAX)
684}
685
686#[cfg(all(test, not(miri)))]
687mod tests {
688 use super::*;
689
690 fn analytics(source: &str) -> CssAnalytics {
691 compute_css_analytics(source).expect("standard CSS parses")
692 }
693
694 #[test]
695 fn recovers_partial_metrics_around_a_malformed_rule() {
696 let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
699 assert!(a.rule_count >= 1);
700 assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
701 }
702
703 #[test]
704 fn counts_declarations_and_important() {
705 let a = analytics(".a { color: red; width: 1px !important; }");
706 assert_eq!(a.rule_count, 1);
707 assert_eq!(a.total_declarations, 2);
708 assert_eq!(a.important_declarations, 1);
709 }
710
711 #[test]
712 fn id_selector_is_notable_with_specificity() {
713 let a = analytics("#main { color: red; }");
714 assert_eq!(a.notable_rules.len(), 1);
715 let rule = &a.notable_rules[0];
716 assert_eq!(rule.specificity_a, 1);
717 assert_eq!(rule.specificity_b, 0);
718 assert_eq!(rule.specificity_c, 0);
719 }
720
721 #[test]
722 fn plain_class_rule_is_not_notable() {
723 let a = analytics(".btn { color: red; }");
724 assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
725 assert_eq!(a.rule_count, 1);
726 }
727
728 #[test]
729 fn important_declaration_makes_rule_notable() {
730 let a = analytics(".btn { color: red !important; }");
731 assert_eq!(a.notable_rules.len(), 1);
732 assert_eq!(a.notable_rules[0].important_count, 1);
733 }
734
735 #[test]
736 fn empty_rule_counted() {
737 let a = analytics(".a { } .b { color: red; }");
738 assert_eq!(a.rule_count, 2);
739 assert_eq!(a.empty_rule_count, 1);
740 }
741
742 #[test]
743 fn complex_selector_is_notable() {
744 let a = analytics("div > ul > li > a > span { color: red; }");
746 assert_eq!(a.notable_rules.len(), 1);
747 assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
748 }
749
750 #[test]
751 fn nesting_depth_tracked() {
752 let a = analytics(".a { .b { .c { .d { color: red; } } } }");
753 assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
754 assert!(
756 a.notable_rules
757 .iter()
758 .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
759 );
760 }
761
762 #[test]
763 fn specificity_takes_most_specific_selector_in_list() {
764 let a = analytics("#id, .cls { color: red; }");
765 assert_eq!(a.notable_rules.len(), 1);
766 assert_eq!(a.notable_rules[0].specificity_a, 1);
768 }
769
770 #[test]
771 fn line_is_one_based() {
772 let a = analytics("\n\n#main { color: red; }");
773 assert_eq!(a.notable_rules[0].line, 3);
774 }
775
776 #[test]
777 fn media_query_rules_walked() {
778 let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
779 assert_eq!(a.rule_count, 1);
780 assert_eq!(a.notable_rules.len(), 1);
781 assert_eq!(a.notable_rules[0].specificity_a, 1);
782 }
783
784 #[test]
785 fn collects_distinct_colors() {
786 let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
787 assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
788 }
789
790 #[test]
791 fn parses_theme_color_values_to_rgb() {
792 assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
793 assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
794 assert_eq!(
795 parse_css_color_rgb("hsl(0 100% 50%)"),
796 Some((255.0, 0.0, 0.0))
797 );
798 assert!(parse_css_color_rgb("var(--brand)").is_none());
799 }
800
801 #[test]
802 fn collects_colors_nested_in_shorthands() {
803 let a = analytics(".a { border: 1px solid green; background: yellow; }");
806 assert!(
807 a.colors.len() >= 2,
808 "shorthand + standalone colors collected: {:?}",
809 a.colors
810 );
811 }
812
813 #[test]
814 fn collects_distinct_font_sizes() {
815 let a =
816 analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
817 assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
818 }
819
820 #[test]
821 fn collects_located_raw_style_values_but_skips_tokenized_values() {
822 let a = analytics(
823 ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
824 );
825 assert!(
826 a.raw_style_values
827 .iter()
828 .any(|value| value.axis == "color" && value.property == "color"),
829 "raw color should be located: {:?}",
830 a.raw_style_values
831 );
832 assert!(
833 a.raw_style_values
834 .iter()
835 .any(|value| value.axis == "font-size" && value.value == "14px"),
836 "raw font size should be located: {:?}",
837 a.raw_style_values
838 );
839 assert!(
840 a.raw_style_values
841 .iter()
842 .any(|value| value.axis == "radius" && value.line == 2),
843 "raw radius should be located on the second rule: {:?}",
844 a.raw_style_values
845 );
846 assert!(
847 !a.raw_style_values
848 .iter()
849 .any(|value| value.property == "margin-top"),
850 "layout spacing should not be a raw-value candidate: {:?}",
851 a.raw_style_values
852 );
853 assert!(
854 !a.raw_style_values
855 .iter()
856 .any(|value| value.property == "z-index"),
857 "z-index should stay a scale summary, not an audit candidate: {:?}",
858 a.raw_style_values
859 );
860 assert!(
861 !a.raw_style_values
862 .iter()
863 .any(|value| value.value == "transparent"),
864 "transparent should behave like a reset keyword: {:?}",
865 a.raw_style_values
866 );
867 }
868
869 #[test]
870 fn collects_distinct_z_indexes() {
871 let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
872 assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
873 }
874
875 #[test]
876 fn collects_defined_and_referenced_custom_properties() {
877 let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
878 assert!(
879 a.defined_custom_properties.contains(&"--brand".to_string()),
880 "defined: {:?}",
881 a.defined_custom_properties
882 );
883 assert!(
884 a.defined_custom_properties
885 .contains(&"--unused".to_string())
886 );
887 assert!(
888 a.referenced_custom_properties
889 .contains(&"--brand".to_string()),
890 "referenced: {:?}",
891 a.referenced_custom_properties
892 );
893 assert!(
894 !a.referenced_custom_properties
895 .contains(&"--unused".to_string()),
896 "--unused has no var() reference"
897 );
898 }
899
900 #[test]
901 fn collects_defined_and_referenced_keyframes() {
902 let a = analytics(
903 "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
904 );
905 assert!(a.defined_keyframes.contains(&"spin".to_string()));
906 assert!(a.defined_keyframes.contains(&"unused".to_string()));
907 assert!(a.referenced_keyframes.contains(&"spin".to_string()));
908 assert!(
909 !a.referenced_keyframes.contains(&"unused".to_string()),
910 "no animation references `unused`"
911 );
912 }
913
914 #[test]
915 fn animation_shorthand_references_keyframes() {
916 let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
917 assert!(
918 a.referenced_keyframes.contains(&"pulse".to_string()),
919 "referenced: {:?}",
920 a.referenced_keyframes
921 );
922 }
923
924 #[test]
925 fn fingerprints_blocks_at_floor_order_insensitive() {
926 let a = analytics(
930 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
931 .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
932 .z { color: red; margin: 1px; padding: 2px; }\n",
933 );
934 assert_eq!(
935 a.declaration_blocks.len(),
936 2,
937 "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
938 a.declaration_blocks
939 );
940 assert_eq!(
941 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
942 "same declarations in different order share a fingerprint"
943 );
944 assert_eq!(a.declaration_blocks[0].declaration_count, 4);
945 }
946
947 #[test]
948 fn important_distinguishes_block_fingerprint() {
949 let a = analytics(
950 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
951 .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
952 );
953 assert_eq!(a.declaration_blocks.len(), 2);
954 assert_ne!(
955 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
956 "!important changes the block fingerprint"
957 );
958 }
959
960 #[test]
961 fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
962 let tokenized = analytics(
974 ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
975 .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
976 .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
977 );
978 assert!(
979 tokenized.border_radii.is_empty(),
980 "var()-referenced radii are not counted: {:?}",
981 tokenized.border_radii
982 );
983 assert!(
984 tokenized.box_shadows.is_empty(),
985 "var()-referenced shadows are not counted: {:?}",
986 tokenized.box_shadows
987 );
988 assert!(
989 tokenized.line_heights.is_empty(),
990 "var()-referenced line-heights are not counted: {:?}",
991 tokenized.line_heights
992 );
993
994 let hardcoded = analytics(
997 ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
998 .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
999 );
1000 assert_eq!(
1001 hardcoded.border_radii.len(),
1002 2,
1003 "distinct hardcoded radii counted: {:?}",
1004 hardcoded.border_radii
1005 );
1006 assert_eq!(
1007 hardcoded.box_shadows.len(),
1008 2,
1009 "distinct hardcoded shadows counted: {:?}",
1010 hardcoded.box_shadows
1011 );
1012 assert_eq!(
1013 hardcoded.line_heights.len(),
1014 2,
1015 "distinct hardcoded line-heights counted: {:?}",
1016 hardcoded.line_heights
1017 );
1018 }
1019}