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