1use cssparser::{Delimiter, Parser, ParserInput, parse_important};
13use lightningcss::printer::PrinterOptions;
14use lightningcss::properties::animation::AnimationName;
15use lightningcss::properties::box_shadow::BoxShadow;
16use lightningcss::properties::custom::{
17 CustomProperty, CustomPropertyName, Token, TokenOrValue, UnparsedProperty, Variable,
18};
19use lightningcss::properties::font::FontFamily;
20use lightningcss::properties::{Property, PropertyId};
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
42pub const MAX_NOTABLE_RULES: usize = 500;
46
47const MIN_BLOCK_DECLARATIONS: usize = 4;
51
52pub const MAX_DECLARATION_BLOCKS: usize = 2000;
56
57pub const MAX_RAW_STYLE_VALUES: usize = 200;
60
61const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
63
64#[must_use]
74pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
75 crate::parse_style_bounded(source, || css_analytics_unbounded(source)).flatten()
76}
77
78fn css_analytics_unbounded(source: &str) -> Option<CssAnalytics> {
79 let options = ParserOptions {
80 error_recovery: true,
81 css_modules: Some(lightningcss::css_modules::Config::default()),
82 ..ParserOptions::default()
83 };
84 let mut stylesheet = StyleSheet::parse(source, options).ok()?;
85
86 let mut acc = Accumulator::default();
89 walk_rules(&stylesheet.rules.0, 0, &mut acc);
90
91 let mut collector = ValueCollector::default();
95 let _ = collector.visit_stylesheet(&mut stylesheet);
96
97 let mut analytics = acc.analytics;
98 analytics.colors = sorted_vec(collector.colors);
99 analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
100 analytics.font_sizes = sorted_vec(acc.font_sizes);
101 analytics.z_indexes = sorted_vec(acc.z_indexes);
102 analytics.box_shadows = sorted_vec(acc.box_shadows);
103 analytics.border_radii = sorted_vec(acc.border_radii);
104 analytics.line_heights = sorted_vec(acc.line_heights);
105 analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
106 analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
107 analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
108 analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
109 analytics.declared_layers = sorted_vec(acc.declared_layers);
110 analytics.populated_layers = sorted_vec(acc.populated_layers);
111 analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
112 analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
113 Some(analytics)
114}
115
116#[derive(Default)]
119struct Accumulator {
120 analytics: CssAnalytics,
121 font_sizes: FxHashSet<String>,
122 z_indexes: FxHashSet<String>,
123 box_shadows: FxHashSet<String>,
124 border_radii: FxHashSet<String>,
125 line_heights: FxHashSet<String>,
126 defined_custom_properties: FxHashSet<String>,
127 defined_keyframes: FxHashSet<String>,
128 referenced_keyframes: FxHashSet<String>,
129 registered_custom_properties: FxHashSet<String>,
130 declared_layers: FxHashSet<String>,
131 populated_layers: FxHashSet<String>,
132 defined_font_faces: FxHashSet<String>,
133 referenced_font_families: FxHashSet<String>,
134 raw_style_value_keys: FxHashSet<String>,
135}
136
137fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
141 match family {
142 FontFamily::FamilyName(_) => family
146 .to_css_string(PrinterOptions::default())
147 .ok()
148 .map(|s| s.trim_matches(['"', '\'']).to_string()),
149 FontFamily::Generic(_) => None,
150 }
151}
152
153#[derive(Default)]
158struct ValueCollector {
159 colors: FxHashSet<String>,
160 referenced_custom_properties: FxHashSet<String>,
161}
162
163impl Visitor<'_> for ValueCollector {
164 type Error = std::convert::Infallible;
165
166 fn visit_types(&self) -> VisitTypes {
167 VisitTypes::COLORS | VisitTypes::VARIABLES
168 }
169
170 fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
171 if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
172 self.colors.insert(rendered);
173 }
174 Ok(())
175 }
176
177 fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
178 self.referenced_custom_properties
179 .insert(var.name.ident.0.to_string());
180 Ok(())
181 }
182}
183
184#[derive(Default)]
185struct FirstRgbColorCollector {
186 rgb: Option<(f64, f64, f64)>,
187}
188
189impl Visitor<'_> for FirstRgbColorCollector {
190 type Error = std::convert::Infallible;
191
192 fn visit_types(&self) -> VisitTypes {
193 VisitTypes::COLORS
194 }
195
196 fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
197 if self.rgb.is_none()
198 && let CssColor::RGBA(rgba) = color
199 {
200 self.rgb = Some((
201 f64::from(rgba.red),
202 f64::from(rgba.green),
203 f64::from(rgba.blue),
204 ));
205 }
206 Ok(())
207 }
208}
209
210#[must_use]
213pub fn parse_css_color_rgb(value: &str) -> Option<(f64, f64, f64)> {
214 crate::parse_style_bounded(value, || css_color_rgb_unbounded(value)).flatten()
215}
216
217fn css_color_rgb_unbounded(value: &str) -> Option<(f64, f64, f64)> {
218 if let Ok(color) = CssColor::parse_string(value)
219 && let Some(rgb) = concrete_rgb(&color)
220 {
221 return Some(rgb);
222 }
223 if let Some(color) = parse_declaration_color(value)
224 && let Some(rgb) = concrete_rgb(&color)
225 {
226 return Some(rgb);
227 }
228 parse_css_color_rgb_via_stylesheet(value)
229}
230
231fn concrete_rgb(color: &CssColor) -> Option<(f64, f64, f64)> {
232 let CssColor::RGBA(rgba) = color else {
233 return None;
234 };
235 Some((
236 f64::from(rgba.red),
237 f64::from(rgba.green),
238 f64::from(rgba.blue),
239 ))
240}
241
242fn parse_declaration_color(value: &str) -> Option<CssColor> {
243 let mut input = ParserInput::new(value);
244 let mut parser = Parser::new(&mut input);
245 let color = parser
246 .parse_until_before(Delimiter::Semicolon, |input| {
247 let color = CssColor::parse(input)?;
248 let _ = input.try_parse(parse_important);
249 input.expect_exhausted()?;
250 Ok(color)
251 })
252 .ok()?;
253 if !parser.is_exhausted() {
254 parser.expect_semicolon().ok()?;
255 parser.expect_exhausted().ok()?;
256 }
257 Some(color)
258}
259
260fn parse_css_color_rgb_via_stylesheet(value: &str) -> Option<(f64, f64, f64)> {
261 let source = format!(".x{{color:{value};}}");
262 let options = ParserOptions {
263 error_recovery: true,
264 ..ParserOptions::default()
265 };
266 let mut stylesheet = StyleSheet::parse(&source, options).ok()?;
267 let mut collector = FirstRgbColorCollector::default();
268 let _ = collector.visit_stylesheet(&mut stylesheet);
269 collector.rgb
270}
271
272fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
273 let mut values: Vec<String> = set.into_iter().collect();
274 values.sort_unstable();
275 values
276}
277
278fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
283 for rule in rules {
284 match rule {
285 CssRule::Style(style) => {
286 record_style_rule(style, depth, acc);
287 walk_rules(&style.rules.0, depth.saturating_add(1), acc);
288 }
289 CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
290 CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
291 CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
292 CssRule::LayerBlock(rule) => {
293 if let Some(name) = &rule.name {
295 let name = layer_name_string(name);
296 acc.declared_layers.insert(name.clone());
297 acc.populated_layers.insert(name);
298 }
299 walk_rules(&rule.rules.0, depth, acc);
300 }
301 CssRule::LayerStatement(stmt) => {
302 for name in &stmt.names {
304 acc.declared_layers.insert(layer_name_string(name));
305 }
306 }
307 CssRule::Property(prop) => {
308 acc.registered_custom_properties
309 .insert(prop.name.0.to_string());
310 }
311 CssRule::FontFace(font_face) => {
312 for property in &font_face.properties {
313 if let FontFaceProperty::FontFamily(family) = property
314 && let Some(name) = font_family_name(family)
315 {
316 acc.defined_font_faces.insert(name);
317 }
318 }
319 }
320 CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
321 CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
322 CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
323 CssRule::Nesting(rule) => {
324 record_style_rule(&rule.style, depth, acc);
325 walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
326 }
327 CssRule::Keyframes(keyframes) => {
328 acc.defined_keyframes
329 .insert(keyframes_name_string(&keyframes.name));
330 }
331 _ => {}
332 }
333 }
334}
335
336fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
337 name.0
338 .iter()
339 .map(std::string::ToString::to_string)
340 .collect::<Vec<_>>()
341 .join(".")
342}
343
344fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
345 match name {
346 KeyframesName::Ident(ident) => ident.0.to_string(),
347 KeyframesName::Custom(value) => value.to_string(),
348 }
349}
350
351fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
352 if let AnimationName::Ident(ident) = name {
353 out.insert(ident.0.to_string());
354 }
355}
356
357fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
358 let normal = style.declarations.declarations.len();
359 let important = style.declarations.important_declarations.len();
360 let declaration_count = normal + important;
361
362 let analytics = &mut acc.analytics;
363 analytics.rule_count = analytics.rule_count.saturating_add(1);
364 analytics.total_declarations = analytics
365 .total_declarations
366 .saturating_add(saturate_u32(declaration_count));
367 analytics.important_declarations = analytics
368 .important_declarations
369 .saturating_add(saturate_u32(important));
370 if declaration_count == 0 {
371 analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
372 }
373 analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
374
375 let (a, b, c, complexity) = rule_selector_metrics(style);
376 let metric = CssRuleMetric {
377 line: style.loc.line.saturating_add(1),
378 col: style.loc.column,
379 specificity_a: a,
380 specificity_b: b,
381 specificity_c: c,
382 complexity,
383 declaration_count: saturate_u16(declaration_count),
384 important_count: saturate_u16(important),
385 nesting_depth: depth,
386 };
387
388 if is_notable(&metric) {
389 if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
390 analytics.notable_rules.push(metric);
391 } else {
392 analytics.notable_truncated = true;
393 }
394 }
395
396 if declaration_count >= MIN_BLOCK_DECLARATIONS
399 && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
400 && let Some(fingerprint) = declaration_block_fingerprint(style)
401 {
402 analytics.declaration_blocks.push(CssDeclarationBlock {
403 fingerprint,
404 line: style.loc.line.saturating_add(1),
405 declaration_count: saturate_u16(declaration_count),
406 });
407 }
408
409 collect_rule_property_tokens(style, acc, style.loc.line.saturating_add(1));
410}
411
412fn collect_rule_property_tokens(style: &StyleRule<'_>, acc: &mut Accumulator, rule_line: u32) {
417 for property in style
418 .declarations
419 .declarations
420 .iter()
421 .chain(style.declarations.important_declarations.iter())
422 {
423 collect_property_tokens(property, acc, rule_line);
424 collect_raw_style_value(property, acc, rule_line);
425 }
426}
427
428fn collect_property_tokens(property: &Property<'_>, acc: &mut Accumulator, rule_line: u32) {
431 match property {
432 Property::FontSize(font_size) => {
433 insert_rendered_css(font_size, &mut acc.font_sizes);
434 }
435 Property::ZIndex(z_index) => {
436 insert_rendered_css(z_index, &mut acc.z_indexes);
437 }
438 Property::BoxShadow(shadows, _) => collect_box_shadow_tokens(shadows, acc),
443 Property::BorderRadius(radius, _) => {
444 insert_rendered_css(radius, &mut acc.border_radii);
445 }
446 Property::LineHeight(line_height) => {
447 insert_rendered_css(line_height, &mut acc.line_heights);
448 }
449 Property::Custom(custom) => {
450 collect_custom_property_tokens(custom, property, acc, rule_line);
451 }
452 Property::AnimationName(names, _) => {
453 collect_animation_references(names, &mut acc.referenced_keyframes);
454 }
455 Property::Animation(animations, _) => {
456 for animation in animations {
457 collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
458 }
459 }
460 Property::Unparsed(unparsed) => {
461 collect_unparsed_animation_references(unparsed, &mut acc.referenced_keyframes);
462 }
463 Property::FontFamily(families) => {
464 collect_font_family_references(families, &mut acc.referenced_font_families);
465 }
466 Property::Font(font) => {
467 collect_font_family_references(&font.family, &mut acc.referenced_font_families);
468 }
469 _ => {}
470 }
471}
472
473fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
474 if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
475 out.insert(rendered);
476 }
477}
478
479fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
480 if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
481 return;
482 }
483 let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
484 return;
485 };
486 let Some((property_name, value)) = rendered.split_once(':') else {
487 return;
488 };
489 let property_name = property_name.trim().to_ascii_lowercase();
490 if property_name.starts_with("--") {
491 return;
492 }
493 let value = value.trim().trim_end_matches(';').trim().to_string();
494 let Some(axis) = raw_style_axis(&property_name, &value) else {
495 return;
496 };
497 if !is_raw_style_literal(&value) {
498 return;
499 }
500 let key = format!("{axis}:{property_name}:{value}:{line}");
501 if !acc.raw_style_value_keys.insert(key) {
502 return;
503 }
504 acc.analytics.raw_style_values.push(CssRawStyleValue {
505 axis: axis.to_string(),
506 property: property_name,
507 value,
508 line,
509 });
510}
511
512fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
513 if property_name.contains("color") && looks_like_color_literal(value) {
514 return Some("color");
515 }
516 match property_name {
517 "font-size" => Some("font-size"),
518 "line-height" => Some("line-height"),
519 "border-radius"
520 | "border-top-left-radius"
521 | "border-top-right-radius"
522 | "border-bottom-right-radius"
523 | "border-bottom-left-radius" => Some("radius"),
524 "box-shadow" | "text-shadow" => Some("shadow"),
525 _ => None,
526 }
527}
528
529fn is_raw_style_literal(value: &str) -> bool {
530 let lower = value.to_ascii_lowercase();
531 if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
532 return false;
533 }
534 if matches!(
535 lower.as_str(),
536 "0" | "none"
537 | "normal"
538 | "inherit"
539 | "initial"
540 | "unset"
541 | "revert"
542 | "currentcolor"
543 | "transparent"
544 ) {
545 return false;
546 }
547 lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
548}
549
550fn looks_like_color_literal(value: &str) -> bool {
551 let lower = value.to_ascii_lowercase();
552 lower.starts_with('#')
553 || lower.contains("rgb(")
554 || lower.contains("rgba(")
555 || lower.contains("hsl(")
556 || lower.contains("hsla(")
557 || lower.contains("oklch(")
558 || lower.contains("color-mix(")
559 || matches!(
560 lower.as_str(),
561 "red"
562 | "blue"
563 | "green"
564 | "black"
565 | "white"
566 | "gray"
567 | "grey"
568 | "transparent"
569 | "yellow"
570 | "orange"
571 | "purple"
572 | "pink"
573 )
574}
575
576fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
577 let rendered: Vec<String> = shadows
578 .iter()
579 .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
580 .collect();
581 if !rendered.is_empty() && rendered.len() == shadows.len() {
582 acc.box_shadows.insert(rendered.join(", "));
583 }
584}
585
586fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
587 for name in names {
588 collect_animation_name(name, out);
589 }
590}
591
592const ANIMATION_NAME_KEYWORDS: &[&str] = &[
595 "none",
596 "initial",
597 "inherit",
598 "unset",
599 "revert",
600 "revert-layer",
601];
602
603const ANIMATION_SHORTHAND_KEYWORDS: &[&str] = &[
606 "linear",
607 "ease",
608 "ease-in",
609 "ease-out",
610 "ease-in-out",
611 "step-start",
612 "step-end",
613 "infinite",
614 "normal",
615 "reverse",
616 "alternate",
617 "alternate-reverse",
618 "forwards",
619 "backwards",
620 "both",
621 "running",
622 "paused",
623 "auto",
624 "replace",
625 "add",
626 "accumulate",
627];
628
629fn collect_unparsed_animation_references(
640 unparsed: &UnparsedProperty<'_>,
641 out: &mut FxHashSet<String>,
642) {
643 let is_shorthand = match unparsed.property_id {
644 PropertyId::Animation(_) => true,
645 PropertyId::AnimationName(_) => false,
646 _ => return,
647 };
648 if !has_substitution(&unparsed.value.0) {
649 return;
650 }
651 for layer in unparsed
652 .value
653 .0
654 .split(|token| matches!(token, TokenOrValue::Token(Token::Comma)))
655 {
656 if let Some(name) = layer_animation_name(layer, is_shorthand) {
657 out.insert(name);
658 }
659 }
660}
661
662fn has_substitution(tokens: &[TokenOrValue<'_>]) -> bool {
664 tokens.iter().any(|token| match token {
665 TokenOrValue::Var(_) | TokenOrValue::Env(_) => true,
666 TokenOrValue::Function(function) => has_substitution(&function.arguments.0),
667 _ => false,
668 })
669}
670
671fn layer_animation_name(layer: &[TokenOrValue<'_>], is_shorthand: bool) -> Option<String> {
674 let is_keyword = |ident: &str| {
675 ANIMATION_NAME_KEYWORDS
676 .iter()
677 .chain(if is_shorthand {
678 ANIMATION_SHORTHAND_KEYWORDS.iter()
679 } else {
680 [].iter()
681 })
682 .any(|keyword| ident.eq_ignore_ascii_case(keyword))
683 };
684 let mut names = layer.iter().filter_map(|token| match token {
685 TokenOrValue::Token(Token::Ident(ident)) if !is_keyword(ident) => Some(ident.to_string()),
686 TokenOrValue::AnimationName(AnimationName::Ident(ident)) => Some(ident.0.to_string()),
687 _ => None,
688 });
689 let name = names.next()?;
690 names.next().is_none().then_some(name)
691}
692
693fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
694 for family in families {
695 if let Some(name) = font_family_name(family) {
696 out.insert(name);
697 }
698 }
699}
700
701fn collect_custom_property_tokens(
704 custom: &CustomProperty<'_>,
705 property: &Property<'_>,
706 acc: &mut Accumulator,
707 rule_line: u32,
708) {
709 if let CustomPropertyName::Custom(name) = &custom.name {
710 let name = name.0.to_string();
711 acc.defined_custom_properties.insert(name.clone());
712 if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
713 && let Some((_, value)) = rendered.split_once(':')
714 {
715 let value = value.trim().trim_end_matches(';').trim();
716 if !value.is_empty() {
717 acc.analytics
718 .custom_property_definitions
719 .push(CssCustomPropertyDefinition {
720 name,
721 value: value.to_string(),
722 line: rule_line,
723 });
724 }
725 }
726 }
727 for token in &custom.value.0 {
738 if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
739 acc.referenced_font_families.insert(value.to_string());
740 }
741 }
742}
743
744fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
751 let block = &style.declarations;
752 let mut parts: Vec<String> =
753 Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
754 for decl in &block.declarations {
755 parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
756 }
757 for decl in &block.important_declarations {
758 parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
761 }
762 parts.sort_unstable();
763 Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
764}
765
766fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
770 let mut max_spec = 0u32;
771 let mut a = 0u16;
772 let mut b = 0u16;
773 let mut c = 0u16;
774 let mut complexity = 0u16;
775 for selector in &style.selectors.0 {
776 let spec = selector.specificity();
777 if spec >= max_spec {
778 max_spec = spec;
779 a = specificity_component(spec, 20);
780 b = specificity_component(spec, 10);
781 c = specificity_component(spec, 0);
782 }
783 complexity = complexity.max(selector_complexity(selector));
784 }
785 (a, b, c, complexity)
786}
787
788fn specificity_component(specificity: u32, shift: u32) -> u16 {
789 saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
790}
791
792fn is_notable(metric: &CssRuleMetric) -> bool {
793 metric.specificity_a >= 1
794 || metric.complexity > MAX_PLAIN_COMPLEXITY
795 || metric.important_count >= 1
796 || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
797}
798
799fn selector_complexity(selector: &Selector<'_>) -> u16 {
800 let mut count = 0u16;
801 count_components(selector, &mut count);
802 count
803}
804
805fn count_components(selector: &Selector<'_>, count: &mut u16) {
806 for component in selector.iter_raw_match_order() {
807 *count = count.saturating_add(1);
808 match component {
809 Component::Is(list)
810 | Component::Where(list)
811 | Component::Has(list)
812 | Component::Negation(list)
813 | Component::Any(_, list) => {
814 for nested in list.as_ref() {
815 count_components(nested, count);
816 }
817 }
818 Component::Slotted(nested) | Component::Host(Some(nested)) => {
819 count_components(nested, count);
820 }
821 Component::NthOf(data) => {
822 for nested in data.selectors() {
823 count_components(nested, count);
824 }
825 }
826 _ => {}
827 }
828 }
829}
830
831fn saturate_u32(value: usize) -> u32 {
832 u32::try_from(value).unwrap_or(u32::MAX)
833}
834
835fn saturate_u16(value: usize) -> u16 {
836 u16::try_from(value).unwrap_or(u16::MAX)
837}
838
839fn saturate_u16_u32(value: u32) -> u16 {
840 u16::try_from(value).unwrap_or(u16::MAX)
841}
842
843#[cfg(all(test, not(miri)))]
844mod tests {
845 use super::*;
846
847 fn analytics(source: &str) -> CssAnalytics {
848 compute_css_analytics(source).expect("standard CSS parses")
849 }
850
851 #[test]
852 fn recovers_partial_metrics_around_a_malformed_rule() {
853 let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
856 assert!(a.rule_count >= 1);
857 assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
858 }
859
860 #[test]
861 fn counts_declarations_and_important() {
862 let a = analytics(".a { color: red; width: 1px !important; }");
863 assert_eq!(a.rule_count, 1);
864 assert_eq!(a.total_declarations, 2);
865 assert_eq!(a.important_declarations, 1);
866 }
867
868 #[test]
869 fn id_selector_is_notable_with_specificity() {
870 let a = analytics("#main { color: red; }");
871 assert_eq!(a.notable_rules.len(), 1);
872 let rule = &a.notable_rules[0];
873 assert_eq!(rule.specificity_a, 1);
874 assert_eq!(rule.specificity_b, 0);
875 assert_eq!(rule.specificity_c, 0);
876 }
877
878 #[test]
879 fn plain_class_rule_is_not_notable() {
880 let a = analytics(".btn { color: red; }");
881 assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
882 assert_eq!(a.rule_count, 1);
883 }
884
885 #[test]
886 fn important_declaration_makes_rule_notable() {
887 let a = analytics(".btn { color: red !important; }");
888 assert_eq!(a.notable_rules.len(), 1);
889 assert_eq!(a.notable_rules[0].important_count, 1);
890 }
891
892 #[test]
893 fn empty_rule_counted() {
894 let a = analytics(".a { } .b { color: red; }");
895 assert_eq!(a.rule_count, 2);
896 assert_eq!(a.empty_rule_count, 1);
897 }
898
899 #[test]
900 fn complex_selector_is_notable() {
901 let a = analytics("div > ul > li > a > span { color: red; }");
903 assert_eq!(a.notable_rules.len(), 1);
904 assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
905 }
906
907 #[test]
908 fn nesting_depth_tracked() {
909 let a = analytics(".a { .b { .c { .d { color: red; } } } }");
910 assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
911 assert!(
913 a.notable_rules
914 .iter()
915 .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
916 );
917 }
918
919 #[test]
920 fn specificity_takes_most_specific_selector_in_list() {
921 let a = analytics("#id, .cls { color: red; }");
922 assert_eq!(a.notable_rules.len(), 1);
923 assert_eq!(a.notable_rules[0].specificity_a, 1);
925 }
926
927 #[test]
928 fn line_is_one_based() {
929 let a = analytics("\n\n#main { color: red; }");
930 assert_eq!(a.notable_rules[0].line, 3);
931 }
932
933 #[test]
934 fn media_query_rules_walked() {
935 let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
936 assert_eq!(a.rule_count, 1);
937 assert_eq!(a.notable_rules.len(), 1);
938 assert_eq!(a.notable_rules[0].specificity_a, 1);
939 }
940
941 #[test]
942 fn collects_distinct_colors() {
943 let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
944 assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
945 }
946
947 #[test]
948 fn parses_theme_color_values_to_rgb() {
949 assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
950 assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
951 assert_eq!(
952 parse_css_color_rgb("hsl(0 100% 50%)"),
953 Some((255.0, 0.0, 0.0))
954 );
955 assert!(parse_css_color_rgb("var(--brand)").is_none());
956 }
957
958 #[test]
959 fn direct_color_parser_matches_stylesheet_parser_corpus() {
960 let values = [
961 "#000",
963 "#fff",
964 "#f00",
965 "#0f08",
966 "#112233",
967 "#11223344",
968 "#abcdef",
969 "#ABCDEF",
970 "red",
971 "rebeccapurple",
972 "transparent",
973 "rgb(255 0 0)",
975 "rgb(100% 0% 0%)",
976 "rgb(255, 0, 0)",
977 "rgba(255, 0, 0, 0.5)",
978 "rgba(255 0 0 / 0)",
979 "rgba(255 0 0 / 1)",
980 "rgb(255 0 0 / 25%)",
981 "rgb(300 -10 0)",
982 "hsl(0 100% 50%)",
984 "hsl(120deg 100% 25% / 0.5)",
985 "hsla(240, 100%, 50%, 25%)",
986 "hwb(0 0% 0%)",
987 "hwb(120 10% 20% / 50%)",
988 " red ",
990 "\t#f00\n",
991 "rgb( 255 0 0 / .5 )",
992 "r/**/ed",
993 "rgb(255/**/ 0 0)",
994 "/* before */ red /* after */",
995 "currentColor",
997 "CURRENTCOLOR",
998 "CanvasText",
999 "ButtonFace",
1000 "AccentColorText",
1001 "var(--brand)",
1002 "color-mix(in srgb, red 50%, blue)",
1003 "lab(50% 40 30)",
1004 "lch(50% 40 30)",
1005 "oklab(50% .1 .1)",
1006 "oklch(50% .1 30)",
1007 "color(display-p3 1 0 0)",
1008 "",
1010 "#ff",
1011 "not-a-color",
1012 "rgb(255 0)",
1013 "red junk",
1014 "red, blue",
1015 "red !important junk",
1016 "red ! important junk",
1017 "red !important !important",
1018 "red,",
1019 "red !important",
1021 "red ! important",
1022 "red ! IMPORTANT",
1023 "red !/**/important",
1024 "red/**/!important",
1025 "red ! important;",
1026 "red !important ;",
1027 "red !important; color: blue",
1028 "red !important; junk",
1029 "#f00!important",
1030 "red;",
1031 "red ; ",
1032 "red;;",
1033 "red; junk",
1034 "red; color: blue",
1035 "red; --custom: value",
1036 "red; :junk",
1037 "not-a-color; color: blue",
1039 "var(--x); color: blue",
1040 "red; @media {}",
1041 "red; --x: }",
1042 "currentColor; color: blue",
1043 "CanvasText; color: blue",
1044 "lab(50% 40 30); color: blue",
1045 "color(display-p3 1 0 0); color: blue",
1046 "red; color: blue !important",
1047 "rgb(1 2 3); color: blue !important",
1048 ];
1049
1050 let mismatches = values
1051 .into_iter()
1052 .filter_map(|value| {
1053 let direct = parse_css_color_rgb(value);
1054 let stylesheet = parse_css_color_rgb_via_stylesheet(value);
1055 (direct != stylesheet).then_some((value, direct, stylesheet))
1056 })
1057 .collect::<Vec<_>>();
1058 assert!(mismatches.is_empty(), "parser mismatches: {mismatches:#?}");
1059 }
1060
1061 #[test]
1062 fn collects_colors_nested_in_shorthands() {
1063 let a = analytics(".a { border: 1px solid green; background: yellow; }");
1066 assert!(
1067 a.colors.len() >= 2,
1068 "shorthand + standalone colors collected: {:?}",
1069 a.colors
1070 );
1071 }
1072
1073 #[test]
1074 fn collects_distinct_font_sizes() {
1075 let a =
1076 analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
1077 assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
1078 }
1079
1080 #[test]
1081 fn collects_located_raw_style_values_but_skips_tokenized_values() {
1082 let a = analytics(
1083 ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
1084 );
1085 assert!(
1086 a.raw_style_values
1087 .iter()
1088 .any(|value| value.axis == "color" && value.property == "color"),
1089 "raw color should be located: {:?}",
1090 a.raw_style_values
1091 );
1092 assert!(
1093 a.raw_style_values
1094 .iter()
1095 .any(|value| value.axis == "font-size" && value.value == "14px"),
1096 "raw font size should be located: {:?}",
1097 a.raw_style_values
1098 );
1099 assert!(
1100 a.raw_style_values
1101 .iter()
1102 .any(|value| value.axis == "radius" && value.line == 2),
1103 "raw radius should be located on the second rule: {:?}",
1104 a.raw_style_values
1105 );
1106 assert!(
1107 !a.raw_style_values
1108 .iter()
1109 .any(|value| value.property == "margin-top"),
1110 "layout spacing should not be a raw-value candidate: {:?}",
1111 a.raw_style_values
1112 );
1113 assert!(
1114 !a.raw_style_values
1115 .iter()
1116 .any(|value| value.property == "z-index"),
1117 "z-index should stay a scale summary, not an audit candidate: {:?}",
1118 a.raw_style_values
1119 );
1120 assert!(
1121 !a.raw_style_values
1122 .iter()
1123 .any(|value| value.value == "transparent"),
1124 "transparent should behave like a reset keyword: {:?}",
1125 a.raw_style_values
1126 );
1127 }
1128
1129 #[test]
1130 fn collects_distinct_z_indexes() {
1131 let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
1132 assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
1133 }
1134
1135 #[test]
1136 fn collects_defined_and_referenced_custom_properties() {
1137 let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
1138 assert!(
1139 a.defined_custom_properties.contains(&"--brand".to_string()),
1140 "defined: {:?}",
1141 a.defined_custom_properties
1142 );
1143 assert!(
1144 a.defined_custom_properties
1145 .contains(&"--unused".to_string())
1146 );
1147 assert!(
1148 a.referenced_custom_properties
1149 .contains(&"--brand".to_string()),
1150 "referenced: {:?}",
1151 a.referenced_custom_properties
1152 );
1153 assert!(
1154 !a.referenced_custom_properties
1155 .contains(&"--unused".to_string()),
1156 "--unused has no var() reference"
1157 );
1158 }
1159
1160 #[test]
1161 fn collects_defined_and_referenced_keyframes() {
1162 let a = analytics(
1163 "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
1164 );
1165 assert!(a.defined_keyframes.contains(&"spin".to_string()));
1166 assert!(a.defined_keyframes.contains(&"unused".to_string()));
1167 assert!(a.referenced_keyframes.contains(&"spin".to_string()));
1168 assert!(
1169 !a.referenced_keyframes.contains(&"unused".to_string()),
1170 "no animation references `unused`"
1171 );
1172 }
1173
1174 #[test]
1175 fn animation_shorthand_references_keyframes() {
1176 let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
1177 assert!(
1178 a.referenced_keyframes.contains(&"pulse".to_string()),
1179 "referenced: {:?}",
1180 a.referenced_keyframes
1181 );
1182 }
1183
1184 #[test]
1185 fn animation_shorthand_with_var_references_keyframes() {
1186 let a = analytics(
1189 "@keyframes pulse { from {} }\n\
1190 .a { animation: pulse var(--duration) ease; }",
1191 );
1192 assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1193 }
1194
1195 #[test]
1196 fn animation_shorthand_with_var_credits_each_comma_layer() {
1197 let a = analytics(
1198 "@keyframes fade { from {} }\n@keyframes slide { from {} }\n\
1199 .a { animation: fade 1s infinite, slide var(--d) cubic-bezier(0, 0, 1, 1) both; }\n\
1200 .b { -webkit-animation: fade var(--d); }",
1201 );
1202 assert_eq!(
1203 a.referenced_keyframes,
1204 vec!["fade".to_string(), "slide".to_string()]
1205 );
1206 }
1207
1208 #[test]
1209 fn var_in_animation_name_position_stays_unresolved() {
1210 let a = analytics(
1211 "@keyframes pulse { from {} }\n\
1212 .a { animation: var(--name) 1s ease-in-out infinite alternate both; }\n\
1213 .b { animation-name: var(--other); }",
1214 );
1215 assert!(
1216 a.referenced_keyframes.is_empty(),
1217 "no literal name: {:?}",
1218 a.referenced_keyframes
1219 );
1220 }
1221
1222 #[test]
1223 fn invalid_animation_value_credits_no_keyframe() {
1224 let a = analytics(
1227 "@keyframes spin { from {} }\n\
1228 .a { animation: spin 1s smooth; }\n\
1229 .b { animation: spin 1x; }",
1230 );
1231 assert!(
1232 a.referenced_keyframes.is_empty(),
1233 "invalid values credit nothing: {:?}",
1234 a.referenced_keyframes
1235 );
1236 }
1237
1238 #[test]
1239 fn ambiguous_var_layer_credits_no_keyframe() {
1240 let a = analytics(".a { animation: spin var(--d) smooth, fade var(--d); }");
1242 assert_eq!(a.referenced_keyframes, vec!["fade".to_string()]);
1243 }
1244
1245 #[test]
1246 fn animation_composition_keywords_are_not_names() {
1247 let a = analytics(".a { animation: x var(--d) replace add accumulate; }");
1248 assert_eq!(a.referenced_keyframes, vec!["x".to_string()]);
1249 }
1250
1251 #[test]
1252 fn var_nested_in_function_reaches_the_name() {
1253 let a = analytics(".a { animation: pulse calc(var(--d) * 2) ease; }");
1254 assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1255 }
1256
1257 #[test]
1258 fn animation_name_list_with_var_credits_easing_named_keyframe() {
1259 let a = analytics(
1262 "@keyframes ease { from {} }\n\
1263 .a { animation-name: var(--x), ease, none, inherit; }",
1264 );
1265 assert_eq!(a.referenced_keyframes, vec!["ease".to_string()]);
1266 }
1267
1268 #[test]
1269 fn animation_name_list_with_var_credits_literal_names() {
1270 let a = analytics(
1271 "@keyframes spin { from {} }\n\
1272 .a { animation-name: var(--first), spin; }",
1273 );
1274 assert_eq!(a.referenced_keyframes, vec!["spin".to_string()]);
1275 }
1276
1277 #[test]
1278 fn fingerprints_blocks_at_floor_order_insensitive() {
1279 let a = analytics(
1283 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1284 .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
1285 .z { color: red; margin: 1px; padding: 2px; }\n",
1286 );
1287 assert_eq!(
1288 a.declaration_blocks.len(),
1289 2,
1290 "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
1291 a.declaration_blocks
1292 );
1293 assert_eq!(
1294 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1295 "same declarations in different order share a fingerprint"
1296 );
1297 assert_eq!(a.declaration_blocks[0].declaration_count, 4);
1298 }
1299
1300 #[test]
1301 fn important_distinguishes_block_fingerprint() {
1302 let a = analytics(
1303 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1304 .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
1305 );
1306 assert_eq!(a.declaration_blocks.len(), 2);
1307 assert_ne!(
1308 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1309 "!important changes the block fingerprint"
1310 );
1311 }
1312
1313 #[test]
1314 fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
1315 let tokenized = analytics(
1327 ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
1328 .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
1329 .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
1330 );
1331 assert!(
1332 tokenized.border_radii.is_empty(),
1333 "var()-referenced radii are not counted: {:?}",
1334 tokenized.border_radii
1335 );
1336 assert!(
1337 tokenized.box_shadows.is_empty(),
1338 "var()-referenced shadows are not counted: {:?}",
1339 tokenized.box_shadows
1340 );
1341 assert!(
1342 tokenized.line_heights.is_empty(),
1343 "var()-referenced line-heights are not counted: {:?}",
1344 tokenized.line_heights
1345 );
1346
1347 let hardcoded = analytics(
1350 ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
1351 .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
1352 );
1353 assert_eq!(
1354 hardcoded.border_radii.len(),
1355 2,
1356 "distinct hardcoded radii counted: {:?}",
1357 hardcoded.border_radii
1358 );
1359 assert_eq!(
1360 hardcoded.box_shadows.len(),
1361 2,
1362 "distinct hardcoded shadows counted: {:?}",
1363 hardcoded.box_shadows
1364 );
1365 assert_eq!(
1366 hardcoded.line_heights.len(),
1367 2,
1368 "distinct hardcoded line-heights counted: {:?}",
1369 hardcoded.line_heights
1370 );
1371 }
1372}