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]
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::Unparsed(unparsed) => {
452 collect_unparsed_animation_references(unparsed, &mut acc.referenced_keyframes);
453 }
454 Property::FontFamily(families) => {
455 collect_font_family_references(families, &mut acc.referenced_font_families);
456 }
457 Property::Font(font) => {
458 collect_font_family_references(&font.family, &mut acc.referenced_font_families);
459 }
460 _ => {}
461 }
462}
463
464fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
465 if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
466 out.insert(rendered);
467 }
468}
469
470fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
471 if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
472 return;
473 }
474 let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
475 return;
476 };
477 let Some((property_name, value)) = rendered.split_once(':') else {
478 return;
479 };
480 let property_name = property_name.trim().to_ascii_lowercase();
481 if property_name.starts_with("--") {
482 return;
483 }
484 let value = value.trim().trim_end_matches(';').trim().to_string();
485 let Some(axis) = raw_style_axis(&property_name, &value) else {
486 return;
487 };
488 if !is_raw_style_literal(&value) {
489 return;
490 }
491 let key = format!("{axis}:{property_name}:{value}:{line}");
492 if !acc.raw_style_value_keys.insert(key) {
493 return;
494 }
495 acc.analytics.raw_style_values.push(CssRawStyleValue {
496 axis: axis.to_string(),
497 property: property_name,
498 value,
499 line,
500 });
501}
502
503fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
504 if property_name.contains("color") && looks_like_color_literal(value) {
505 return Some("color");
506 }
507 match property_name {
508 "font-size" => Some("font-size"),
509 "line-height" => Some("line-height"),
510 "border-radius"
511 | "border-top-left-radius"
512 | "border-top-right-radius"
513 | "border-bottom-right-radius"
514 | "border-bottom-left-radius" => Some("radius"),
515 "box-shadow" | "text-shadow" => Some("shadow"),
516 _ => None,
517 }
518}
519
520fn is_raw_style_literal(value: &str) -> bool {
521 let lower = value.to_ascii_lowercase();
522 if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
523 return false;
524 }
525 if matches!(
526 lower.as_str(),
527 "0" | "none"
528 | "normal"
529 | "inherit"
530 | "initial"
531 | "unset"
532 | "revert"
533 | "currentcolor"
534 | "transparent"
535 ) {
536 return false;
537 }
538 lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
539}
540
541fn looks_like_color_literal(value: &str) -> bool {
542 let lower = value.to_ascii_lowercase();
543 lower.starts_with('#')
544 || lower.contains("rgb(")
545 || lower.contains("rgba(")
546 || lower.contains("hsl(")
547 || lower.contains("hsla(")
548 || lower.contains("oklch(")
549 || lower.contains("color-mix(")
550 || matches!(
551 lower.as_str(),
552 "red"
553 | "blue"
554 | "green"
555 | "black"
556 | "white"
557 | "gray"
558 | "grey"
559 | "transparent"
560 | "yellow"
561 | "orange"
562 | "purple"
563 | "pink"
564 )
565}
566
567fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
568 let rendered: Vec<String> = shadows
569 .iter()
570 .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
571 .collect();
572 if !rendered.is_empty() && rendered.len() == shadows.len() {
573 acc.box_shadows.insert(rendered.join(", "));
574 }
575}
576
577fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
578 for name in names {
579 collect_animation_name(name, out);
580 }
581}
582
583const ANIMATION_NAME_KEYWORDS: &[&str] = &[
586 "none",
587 "initial",
588 "inherit",
589 "unset",
590 "revert",
591 "revert-layer",
592];
593
594const ANIMATION_SHORTHAND_KEYWORDS: &[&str] = &[
597 "linear",
598 "ease",
599 "ease-in",
600 "ease-out",
601 "ease-in-out",
602 "step-start",
603 "step-end",
604 "infinite",
605 "normal",
606 "reverse",
607 "alternate",
608 "alternate-reverse",
609 "forwards",
610 "backwards",
611 "both",
612 "running",
613 "paused",
614 "auto",
615 "replace",
616 "add",
617 "accumulate",
618];
619
620fn collect_unparsed_animation_references(
631 unparsed: &UnparsedProperty<'_>,
632 out: &mut FxHashSet<String>,
633) {
634 let is_shorthand = match unparsed.property_id {
635 PropertyId::Animation(_) => true,
636 PropertyId::AnimationName(_) => false,
637 _ => return,
638 };
639 if !has_substitution(&unparsed.value.0) {
640 return;
641 }
642 for layer in unparsed
643 .value
644 .0
645 .split(|token| matches!(token, TokenOrValue::Token(Token::Comma)))
646 {
647 if let Some(name) = layer_animation_name(layer, is_shorthand) {
648 out.insert(name);
649 }
650 }
651}
652
653fn has_substitution(tokens: &[TokenOrValue<'_>]) -> bool {
655 tokens.iter().any(|token| match token {
656 TokenOrValue::Var(_) | TokenOrValue::Env(_) => true,
657 TokenOrValue::Function(function) => has_substitution(&function.arguments.0),
658 _ => false,
659 })
660}
661
662fn layer_animation_name(layer: &[TokenOrValue<'_>], is_shorthand: bool) -> Option<String> {
665 let is_keyword = |ident: &str| {
666 ANIMATION_NAME_KEYWORDS
667 .iter()
668 .chain(if is_shorthand {
669 ANIMATION_SHORTHAND_KEYWORDS.iter()
670 } else {
671 [].iter()
672 })
673 .any(|keyword| ident.eq_ignore_ascii_case(keyword))
674 };
675 let mut names = layer.iter().filter_map(|token| match token {
676 TokenOrValue::Token(Token::Ident(ident)) if !is_keyword(ident) => Some(ident.to_string()),
677 TokenOrValue::AnimationName(AnimationName::Ident(ident)) => Some(ident.0.to_string()),
678 _ => None,
679 });
680 let name = names.next()?;
681 names.next().is_none().then_some(name)
682}
683
684fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
685 for family in families {
686 if let Some(name) = font_family_name(family) {
687 out.insert(name);
688 }
689 }
690}
691
692fn collect_custom_property_tokens(
695 custom: &CustomProperty<'_>,
696 property: &Property<'_>,
697 acc: &mut Accumulator,
698 rule_line: u32,
699) {
700 if let CustomPropertyName::Custom(name) = &custom.name {
701 let name = name.0.to_string();
702 acc.defined_custom_properties.insert(name.clone());
703 if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
704 && let Some((_, value)) = rendered.split_once(':')
705 {
706 let value = value.trim().trim_end_matches(';').trim();
707 if !value.is_empty() {
708 acc.analytics
709 .custom_property_definitions
710 .push(CssCustomPropertyDefinition {
711 name,
712 value: value.to_string(),
713 line: rule_line,
714 });
715 }
716 }
717 }
718 for token in &custom.value.0 {
729 if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
730 acc.referenced_font_families.insert(value.to_string());
731 }
732 }
733}
734
735fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
742 let block = &style.declarations;
743 let mut parts: Vec<String> =
744 Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
745 for decl in &block.declarations {
746 parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
747 }
748 for decl in &block.important_declarations {
749 parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
752 }
753 parts.sort_unstable();
754 Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
755}
756
757fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
761 let mut max_spec = 0u32;
762 let mut a = 0u16;
763 let mut b = 0u16;
764 let mut c = 0u16;
765 let mut complexity = 0u16;
766 for selector in &style.selectors.0 {
767 let spec = selector.specificity();
768 if spec >= max_spec {
769 max_spec = spec;
770 a = specificity_component(spec, 20);
771 b = specificity_component(spec, 10);
772 c = specificity_component(spec, 0);
773 }
774 complexity = complexity.max(selector_complexity(selector));
775 }
776 (a, b, c, complexity)
777}
778
779fn specificity_component(specificity: u32, shift: u32) -> u16 {
780 saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
781}
782
783fn is_notable(metric: &CssRuleMetric) -> bool {
784 metric.specificity_a >= 1
785 || metric.complexity > MAX_PLAIN_COMPLEXITY
786 || metric.important_count >= 1
787 || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
788}
789
790fn selector_complexity(selector: &Selector<'_>) -> u16 {
791 let mut count = 0u16;
792 count_components(selector, &mut count);
793 count
794}
795
796fn count_components(selector: &Selector<'_>, count: &mut u16) {
797 for component in selector.iter_raw_match_order() {
798 *count = count.saturating_add(1);
799 match component {
800 Component::Is(list)
801 | Component::Where(list)
802 | Component::Has(list)
803 | Component::Negation(list)
804 | Component::Any(_, list) => {
805 for nested in list.as_ref() {
806 count_components(nested, count);
807 }
808 }
809 Component::Slotted(nested) | Component::Host(Some(nested)) => {
810 count_components(nested, count);
811 }
812 Component::NthOf(data) => {
813 for nested in data.selectors() {
814 count_components(nested, count);
815 }
816 }
817 _ => {}
818 }
819 }
820}
821
822fn saturate_u32(value: usize) -> u32 {
823 u32::try_from(value).unwrap_or(u32::MAX)
824}
825
826fn saturate_u16(value: usize) -> u16 {
827 u16::try_from(value).unwrap_or(u16::MAX)
828}
829
830fn saturate_u16_u32(value: u32) -> u16 {
831 u16::try_from(value).unwrap_or(u16::MAX)
832}
833
834#[cfg(all(test, not(miri)))]
835mod tests {
836 use super::*;
837
838 fn analytics(source: &str) -> CssAnalytics {
839 compute_css_analytics(source).expect("standard CSS parses")
840 }
841
842 #[test]
843 fn recovers_partial_metrics_around_a_malformed_rule() {
844 let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
847 assert!(a.rule_count >= 1);
848 assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
849 }
850
851 #[test]
852 fn counts_declarations_and_important() {
853 let a = analytics(".a { color: red; width: 1px !important; }");
854 assert_eq!(a.rule_count, 1);
855 assert_eq!(a.total_declarations, 2);
856 assert_eq!(a.important_declarations, 1);
857 }
858
859 #[test]
860 fn id_selector_is_notable_with_specificity() {
861 let a = analytics("#main { color: red; }");
862 assert_eq!(a.notable_rules.len(), 1);
863 let rule = &a.notable_rules[0];
864 assert_eq!(rule.specificity_a, 1);
865 assert_eq!(rule.specificity_b, 0);
866 assert_eq!(rule.specificity_c, 0);
867 }
868
869 #[test]
870 fn plain_class_rule_is_not_notable() {
871 let a = analytics(".btn { color: red; }");
872 assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
873 assert_eq!(a.rule_count, 1);
874 }
875
876 #[test]
877 fn important_declaration_makes_rule_notable() {
878 let a = analytics(".btn { color: red !important; }");
879 assert_eq!(a.notable_rules.len(), 1);
880 assert_eq!(a.notable_rules[0].important_count, 1);
881 }
882
883 #[test]
884 fn empty_rule_counted() {
885 let a = analytics(".a { } .b { color: red; }");
886 assert_eq!(a.rule_count, 2);
887 assert_eq!(a.empty_rule_count, 1);
888 }
889
890 #[test]
891 fn complex_selector_is_notable() {
892 let a = analytics("div > ul > li > a > span { color: red; }");
894 assert_eq!(a.notable_rules.len(), 1);
895 assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
896 }
897
898 #[test]
899 fn nesting_depth_tracked() {
900 let a = analytics(".a { .b { .c { .d { color: red; } } } }");
901 assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
902 assert!(
904 a.notable_rules
905 .iter()
906 .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
907 );
908 }
909
910 #[test]
911 fn specificity_takes_most_specific_selector_in_list() {
912 let a = analytics("#id, .cls { color: red; }");
913 assert_eq!(a.notable_rules.len(), 1);
914 assert_eq!(a.notable_rules[0].specificity_a, 1);
916 }
917
918 #[test]
919 fn line_is_one_based() {
920 let a = analytics("\n\n#main { color: red; }");
921 assert_eq!(a.notable_rules[0].line, 3);
922 }
923
924 #[test]
925 fn media_query_rules_walked() {
926 let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
927 assert_eq!(a.rule_count, 1);
928 assert_eq!(a.notable_rules.len(), 1);
929 assert_eq!(a.notable_rules[0].specificity_a, 1);
930 }
931
932 #[test]
933 fn collects_distinct_colors() {
934 let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
935 assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
936 }
937
938 #[test]
939 fn parses_theme_color_values_to_rgb() {
940 assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
941 assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
942 assert_eq!(
943 parse_css_color_rgb("hsl(0 100% 50%)"),
944 Some((255.0, 0.0, 0.0))
945 );
946 assert!(parse_css_color_rgb("var(--brand)").is_none());
947 }
948
949 #[test]
950 fn direct_color_parser_matches_stylesheet_parser_corpus() {
951 let values = [
952 "#000",
954 "#fff",
955 "#f00",
956 "#0f08",
957 "#112233",
958 "#11223344",
959 "#abcdef",
960 "#ABCDEF",
961 "red",
962 "rebeccapurple",
963 "transparent",
964 "rgb(255 0 0)",
966 "rgb(100% 0% 0%)",
967 "rgb(255, 0, 0)",
968 "rgba(255, 0, 0, 0.5)",
969 "rgba(255 0 0 / 0)",
970 "rgba(255 0 0 / 1)",
971 "rgb(255 0 0 / 25%)",
972 "rgb(300 -10 0)",
973 "hsl(0 100% 50%)",
975 "hsl(120deg 100% 25% / 0.5)",
976 "hsla(240, 100%, 50%, 25%)",
977 "hwb(0 0% 0%)",
978 "hwb(120 10% 20% / 50%)",
979 " red ",
981 "\t#f00\n",
982 "rgb( 255 0 0 / .5 )",
983 "r/**/ed",
984 "rgb(255/**/ 0 0)",
985 "/* before */ red /* after */",
986 "currentColor",
988 "CURRENTCOLOR",
989 "CanvasText",
990 "ButtonFace",
991 "AccentColorText",
992 "var(--brand)",
993 "color-mix(in srgb, red 50%, blue)",
994 "lab(50% 40 30)",
995 "lch(50% 40 30)",
996 "oklab(50% .1 .1)",
997 "oklch(50% .1 30)",
998 "color(display-p3 1 0 0)",
999 "",
1001 "#ff",
1002 "not-a-color",
1003 "rgb(255 0)",
1004 "red junk",
1005 "red, blue",
1006 "red !important junk",
1007 "red ! important junk",
1008 "red !important !important",
1009 "red,",
1010 "red !important",
1012 "red ! important",
1013 "red ! IMPORTANT",
1014 "red !/**/important",
1015 "red/**/!important",
1016 "red ! important;",
1017 "red !important ;",
1018 "red !important; color: blue",
1019 "red !important; junk",
1020 "#f00!important",
1021 "red;",
1022 "red ; ",
1023 "red;;",
1024 "red; junk",
1025 "red; color: blue",
1026 "red; --custom: value",
1027 "red; :junk",
1028 "not-a-color; color: blue",
1030 "var(--x); color: blue",
1031 "red; @media {}",
1032 "red; --x: }",
1033 "currentColor; color: blue",
1034 "CanvasText; color: blue",
1035 "lab(50% 40 30); color: blue",
1036 "color(display-p3 1 0 0); color: blue",
1037 "red; color: blue !important",
1038 "rgb(1 2 3); color: blue !important",
1039 ];
1040
1041 let mismatches = values
1042 .into_iter()
1043 .filter_map(|value| {
1044 let direct = parse_css_color_rgb(value);
1045 let stylesheet = parse_css_color_rgb_via_stylesheet(value);
1046 (direct != stylesheet).then_some((value, direct, stylesheet))
1047 })
1048 .collect::<Vec<_>>();
1049 assert!(mismatches.is_empty(), "parser mismatches: {mismatches:#?}");
1050 }
1051
1052 #[test]
1053 fn collects_colors_nested_in_shorthands() {
1054 let a = analytics(".a { border: 1px solid green; background: yellow; }");
1057 assert!(
1058 a.colors.len() >= 2,
1059 "shorthand + standalone colors collected: {:?}",
1060 a.colors
1061 );
1062 }
1063
1064 #[test]
1065 fn collects_distinct_font_sizes() {
1066 let a =
1067 analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
1068 assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
1069 }
1070
1071 #[test]
1072 fn collects_located_raw_style_values_but_skips_tokenized_values() {
1073 let a = analytics(
1074 ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
1075 );
1076 assert!(
1077 a.raw_style_values
1078 .iter()
1079 .any(|value| value.axis == "color" && value.property == "color"),
1080 "raw color should be located: {:?}",
1081 a.raw_style_values
1082 );
1083 assert!(
1084 a.raw_style_values
1085 .iter()
1086 .any(|value| value.axis == "font-size" && value.value == "14px"),
1087 "raw font size should be located: {:?}",
1088 a.raw_style_values
1089 );
1090 assert!(
1091 a.raw_style_values
1092 .iter()
1093 .any(|value| value.axis == "radius" && value.line == 2),
1094 "raw radius should be located on the second rule: {:?}",
1095 a.raw_style_values
1096 );
1097 assert!(
1098 !a.raw_style_values
1099 .iter()
1100 .any(|value| value.property == "margin-top"),
1101 "layout spacing should not be a raw-value candidate: {:?}",
1102 a.raw_style_values
1103 );
1104 assert!(
1105 !a.raw_style_values
1106 .iter()
1107 .any(|value| value.property == "z-index"),
1108 "z-index should stay a scale summary, not an audit candidate: {:?}",
1109 a.raw_style_values
1110 );
1111 assert!(
1112 !a.raw_style_values
1113 .iter()
1114 .any(|value| value.value == "transparent"),
1115 "transparent should behave like a reset keyword: {:?}",
1116 a.raw_style_values
1117 );
1118 }
1119
1120 #[test]
1121 fn collects_distinct_z_indexes() {
1122 let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
1123 assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
1124 }
1125
1126 #[test]
1127 fn collects_defined_and_referenced_custom_properties() {
1128 let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
1129 assert!(
1130 a.defined_custom_properties.contains(&"--brand".to_string()),
1131 "defined: {:?}",
1132 a.defined_custom_properties
1133 );
1134 assert!(
1135 a.defined_custom_properties
1136 .contains(&"--unused".to_string())
1137 );
1138 assert!(
1139 a.referenced_custom_properties
1140 .contains(&"--brand".to_string()),
1141 "referenced: {:?}",
1142 a.referenced_custom_properties
1143 );
1144 assert!(
1145 !a.referenced_custom_properties
1146 .contains(&"--unused".to_string()),
1147 "--unused has no var() reference"
1148 );
1149 }
1150
1151 #[test]
1152 fn collects_defined_and_referenced_keyframes() {
1153 let a = analytics(
1154 "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
1155 );
1156 assert!(a.defined_keyframes.contains(&"spin".to_string()));
1157 assert!(a.defined_keyframes.contains(&"unused".to_string()));
1158 assert!(a.referenced_keyframes.contains(&"spin".to_string()));
1159 assert!(
1160 !a.referenced_keyframes.contains(&"unused".to_string()),
1161 "no animation references `unused`"
1162 );
1163 }
1164
1165 #[test]
1166 fn animation_shorthand_references_keyframes() {
1167 let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
1168 assert!(
1169 a.referenced_keyframes.contains(&"pulse".to_string()),
1170 "referenced: {:?}",
1171 a.referenced_keyframes
1172 );
1173 }
1174
1175 #[test]
1176 fn animation_shorthand_with_var_references_keyframes() {
1177 let a = analytics(
1180 "@keyframes pulse { from {} }\n\
1181 .a { animation: pulse var(--duration) ease; }",
1182 );
1183 assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1184 }
1185
1186 #[test]
1187 fn animation_shorthand_with_var_credits_each_comma_layer() {
1188 let a = analytics(
1189 "@keyframes fade { from {} }\n@keyframes slide { from {} }\n\
1190 .a { animation: fade 1s infinite, slide var(--d) cubic-bezier(0, 0, 1, 1) both; }\n\
1191 .b { -webkit-animation: fade var(--d); }",
1192 );
1193 assert_eq!(
1194 a.referenced_keyframes,
1195 vec!["fade".to_string(), "slide".to_string()]
1196 );
1197 }
1198
1199 #[test]
1200 fn var_in_animation_name_position_stays_unresolved() {
1201 let a = analytics(
1202 "@keyframes pulse { from {} }\n\
1203 .a { animation: var(--name) 1s ease-in-out infinite alternate both; }\n\
1204 .b { animation-name: var(--other); }",
1205 );
1206 assert!(
1207 a.referenced_keyframes.is_empty(),
1208 "no literal name: {:?}",
1209 a.referenced_keyframes
1210 );
1211 }
1212
1213 #[test]
1214 fn invalid_animation_value_credits_no_keyframe() {
1215 let a = analytics(
1218 "@keyframes spin { from {} }\n\
1219 .a { animation: spin 1s smooth; }\n\
1220 .b { animation: spin 1x; }",
1221 );
1222 assert!(
1223 a.referenced_keyframes.is_empty(),
1224 "invalid values credit nothing: {:?}",
1225 a.referenced_keyframes
1226 );
1227 }
1228
1229 #[test]
1230 fn ambiguous_var_layer_credits_no_keyframe() {
1231 let a = analytics(".a { animation: spin var(--d) smooth, fade var(--d); }");
1233 assert_eq!(a.referenced_keyframes, vec!["fade".to_string()]);
1234 }
1235
1236 #[test]
1237 fn animation_composition_keywords_are_not_names() {
1238 let a = analytics(".a { animation: x var(--d) replace add accumulate; }");
1239 assert_eq!(a.referenced_keyframes, vec!["x".to_string()]);
1240 }
1241
1242 #[test]
1243 fn var_nested_in_function_reaches_the_name() {
1244 let a = analytics(".a { animation: pulse calc(var(--d) * 2) ease; }");
1245 assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1246 }
1247
1248 #[test]
1249 fn animation_name_list_with_var_credits_easing_named_keyframe() {
1250 let a = analytics(
1253 "@keyframes ease { from {} }\n\
1254 .a { animation-name: var(--x), ease, none, inherit; }",
1255 );
1256 assert_eq!(a.referenced_keyframes, vec!["ease".to_string()]);
1257 }
1258
1259 #[test]
1260 fn animation_name_list_with_var_credits_literal_names() {
1261 let a = analytics(
1262 "@keyframes spin { from {} }\n\
1263 .a { animation-name: var(--first), spin; }",
1264 );
1265 assert_eq!(a.referenced_keyframes, vec!["spin".to_string()]);
1266 }
1267
1268 #[test]
1269 fn fingerprints_blocks_at_floor_order_insensitive() {
1270 let a = analytics(
1274 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1275 .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
1276 .z { color: red; margin: 1px; padding: 2px; }\n",
1277 );
1278 assert_eq!(
1279 a.declaration_blocks.len(),
1280 2,
1281 "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
1282 a.declaration_blocks
1283 );
1284 assert_eq!(
1285 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1286 "same declarations in different order share a fingerprint"
1287 );
1288 assert_eq!(a.declaration_blocks[0].declaration_count, 4);
1289 }
1290
1291 #[test]
1292 fn important_distinguishes_block_fingerprint() {
1293 let a = analytics(
1294 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1295 .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
1296 );
1297 assert_eq!(a.declaration_blocks.len(), 2);
1298 assert_ne!(
1299 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1300 "!important changes the block fingerprint"
1301 );
1302 }
1303
1304 #[test]
1305 fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
1306 let tokenized = analytics(
1318 ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
1319 .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
1320 .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
1321 );
1322 assert!(
1323 tokenized.border_radii.is_empty(),
1324 "var()-referenced radii are not counted: {:?}",
1325 tokenized.border_radii
1326 );
1327 assert!(
1328 tokenized.box_shadows.is_empty(),
1329 "var()-referenced shadows are not counted: {:?}",
1330 tokenized.box_shadows
1331 );
1332 assert!(
1333 tokenized.line_heights.is_empty(),
1334 "var()-referenced line-heights are not counted: {:?}",
1335 tokenized.line_heights
1336 );
1337
1338 let hardcoded = analytics(
1341 ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
1342 .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
1343 );
1344 assert_eq!(
1345 hardcoded.border_radii.len(),
1346 2,
1347 "distinct hardcoded radii counted: {:?}",
1348 hardcoded.border_radii
1349 );
1350 assert_eq!(
1351 hardcoded.box_shadows.len(),
1352 2,
1353 "distinct hardcoded shadows counted: {:?}",
1354 hardcoded.box_shadows
1355 );
1356 assert_eq!(
1357 hardcoded.line_heights.len(),
1358 2,
1359 "distinct hardcoded line-heights counted: {:?}",
1360 hardcoded.line_heights
1361 );
1362 }
1363}