1use lightningcss::printer::PrinterOptions;
13use lightningcss::properties::Property;
14use lightningcss::properties::animation::AnimationName;
15use lightningcss::properties::box_shadow::BoxShadow;
16use lightningcss::properties::custom::{
17 CustomProperty, CustomPropertyName, Token, TokenOrValue, Variable,
18};
19use lightningcss::properties::font::FontFamily;
20use lightningcss::rules::CssRule;
21use lightningcss::rules::font_face::FontFaceProperty;
22use lightningcss::rules::keyframes::KeyframesName;
23use lightningcss::rules::style::StyleRule;
24use lightningcss::selector::{Component, Selector};
25use lightningcss::stylesheet::{ParserOptions, StyleSheet};
26use lightningcss::traits::ToCss;
27use lightningcss::values::color::CssColor;
28use lightningcss::visitor::{VisitTypes, Visitor};
29use rustc_hash::FxHashSet;
30
31use fallow_types::extract::{CssAnalytics, CssDeclarationBlock, CssRuleMetric};
32
33const MAX_PLAIN_COMPLEXITY: u16 = 4;
35
36const NOTABLE_NESTING_DEPTH: u8 = 3;
38
39const MAX_NOTABLE_RULES: usize = 500;
43
44const MIN_BLOCK_DECLARATIONS: usize = 4;
48
49const MAX_DECLARATION_BLOCKS: usize = 2000;
53
54const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
56
57#[must_use]
66pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
67 let options = ParserOptions {
68 error_recovery: true,
69 css_modules: Some(lightningcss::css_modules::Config::default()),
70 ..ParserOptions::default()
71 };
72 let mut stylesheet = StyleSheet::parse(source, options).ok()?;
73
74 let mut acc = Accumulator::default();
77 walk_rules(&stylesheet.rules.0, 0, &mut acc);
78
79 let mut collector = ValueCollector::default();
83 let _ = collector.visit_stylesheet(&mut stylesheet);
84
85 let mut analytics = acc.analytics;
86 analytics.colors = sorted_vec(collector.colors);
87 analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
88 analytics.font_sizes = sorted_vec(acc.font_sizes);
89 analytics.z_indexes = sorted_vec(acc.z_indexes);
90 analytics.box_shadows = sorted_vec(acc.box_shadows);
91 analytics.border_radii = sorted_vec(acc.border_radii);
92 analytics.line_heights = sorted_vec(acc.line_heights);
93 analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
94 analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
95 analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
96 analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
97 analytics.declared_layers = sorted_vec(acc.declared_layers);
98 analytics.populated_layers = sorted_vec(acc.populated_layers);
99 analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
100 analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
101 Some(analytics)
102}
103
104#[derive(Default)]
107struct Accumulator {
108 analytics: CssAnalytics,
109 font_sizes: FxHashSet<String>,
110 z_indexes: FxHashSet<String>,
111 box_shadows: FxHashSet<String>,
112 border_radii: FxHashSet<String>,
113 line_heights: FxHashSet<String>,
114 defined_custom_properties: FxHashSet<String>,
115 defined_keyframes: FxHashSet<String>,
116 referenced_keyframes: FxHashSet<String>,
117 registered_custom_properties: FxHashSet<String>,
118 declared_layers: FxHashSet<String>,
119 populated_layers: FxHashSet<String>,
120 defined_font_faces: FxHashSet<String>,
121 referenced_font_families: FxHashSet<String>,
122}
123
124fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
128 match family {
129 FontFamily::FamilyName(_) => family
133 .to_css_string(PrinterOptions::default())
134 .ok()
135 .map(|s| s.trim_matches(['"', '\'']).to_string()),
136 FontFamily::Generic(_) => None,
137 }
138}
139
140#[derive(Default)]
145struct ValueCollector {
146 colors: FxHashSet<String>,
147 referenced_custom_properties: FxHashSet<String>,
148}
149
150impl Visitor<'_> for ValueCollector {
151 type Error = std::convert::Infallible;
152
153 fn visit_types(&self) -> VisitTypes {
154 VisitTypes::COLORS | VisitTypes::VARIABLES
155 }
156
157 fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
158 if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
159 self.colors.insert(rendered);
160 }
161 Ok(())
162 }
163
164 fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
165 self.referenced_custom_properties
166 .insert(var.name.ident.0.to_string());
167 Ok(())
168 }
169}
170
171fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
172 let mut values: Vec<String> = set.into_iter().collect();
173 values.sort_unstable();
174 values
175}
176
177fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
182 for rule in rules {
183 match rule {
184 CssRule::Style(style) => {
185 record_style_rule(style, depth, acc);
186 walk_rules(&style.rules.0, depth.saturating_add(1), acc);
187 }
188 CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
189 CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
190 CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
191 CssRule::LayerBlock(rule) => {
192 if let Some(name) = &rule.name {
194 let name = layer_name_string(name);
195 acc.declared_layers.insert(name.clone());
196 acc.populated_layers.insert(name);
197 }
198 walk_rules(&rule.rules.0, depth, acc);
199 }
200 CssRule::LayerStatement(stmt) => {
201 for name in &stmt.names {
203 acc.declared_layers.insert(layer_name_string(name));
204 }
205 }
206 CssRule::Property(prop) => {
207 acc.registered_custom_properties
208 .insert(prop.name.0.to_string());
209 }
210 CssRule::FontFace(font_face) => {
211 for property in &font_face.properties {
212 if let FontFaceProperty::FontFamily(family) = property
213 && let Some(name) = font_family_name(family)
214 {
215 acc.defined_font_faces.insert(name);
216 }
217 }
218 }
219 CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
220 CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
221 CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
222 CssRule::Nesting(rule) => {
223 record_style_rule(&rule.style, depth, acc);
224 walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
225 }
226 CssRule::Keyframes(keyframes) => {
227 acc.defined_keyframes
228 .insert(keyframes_name_string(&keyframes.name));
229 }
230 _ => {}
231 }
232 }
233}
234
235fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
236 name.0
237 .iter()
238 .map(std::string::ToString::to_string)
239 .collect::<Vec<_>>()
240 .join(".")
241}
242
243fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
244 match name {
245 KeyframesName::Ident(ident) => ident.0.to_string(),
246 KeyframesName::Custom(value) => value.to_string(),
247 }
248}
249
250fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
251 if let AnimationName::Ident(ident) = name {
252 out.insert(ident.0.to_string());
253 }
254}
255
256fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
257 let normal = style.declarations.declarations.len();
258 let important = style.declarations.important_declarations.len();
259 let declaration_count = normal + important;
260
261 let analytics = &mut acc.analytics;
262 analytics.rule_count = analytics.rule_count.saturating_add(1);
263 analytics.total_declarations = analytics
264 .total_declarations
265 .saturating_add(saturate_u32(declaration_count));
266 analytics.important_declarations = analytics
267 .important_declarations
268 .saturating_add(saturate_u32(important));
269 if declaration_count == 0 {
270 analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
271 }
272 analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
273
274 let (a, b, c, complexity) = rule_selector_metrics(style);
275 let metric = CssRuleMetric {
276 line: style.loc.line.saturating_add(1),
277 col: style.loc.column,
278 specificity_a: a,
279 specificity_b: b,
280 specificity_c: c,
281 complexity,
282 declaration_count: saturate_u16(declaration_count),
283 important_count: saturate_u16(important),
284 nesting_depth: depth,
285 };
286
287 if is_notable(&metric) {
288 if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
289 analytics.notable_rules.push(metric);
290 } else {
291 analytics.notable_truncated = true;
292 }
293 }
294
295 if declaration_count >= MIN_BLOCK_DECLARATIONS
298 && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
299 && let Some(fingerprint) = declaration_block_fingerprint(style)
300 {
301 analytics.declaration_blocks.push(CssDeclarationBlock {
302 fingerprint,
303 line: style.loc.line.saturating_add(1),
304 declaration_count: saturate_u16(declaration_count),
305 });
306 }
307
308 collect_rule_property_tokens(style, acc);
309}
310
311fn collect_rule_property_tokens(style: &StyleRule<'_>, acc: &mut Accumulator) {
316 for property in style
317 .declarations
318 .declarations
319 .iter()
320 .chain(style.declarations.important_declarations.iter())
321 {
322 collect_property_tokens(property, acc);
323 }
324}
325
326fn collect_property_tokens(property: &Property<'_>, acc: &mut Accumulator) {
329 match property {
330 Property::FontSize(font_size) => {
331 insert_rendered_css(font_size, &mut acc.font_sizes);
332 }
333 Property::ZIndex(z_index) => {
334 insert_rendered_css(z_index, &mut acc.z_indexes);
335 }
336 Property::BoxShadow(shadows, _) => collect_box_shadow_tokens(shadows, acc),
341 Property::BorderRadius(radius, _) => {
342 insert_rendered_css(radius, &mut acc.border_radii);
343 }
344 Property::LineHeight(line_height) => {
345 insert_rendered_css(line_height, &mut acc.line_heights);
346 }
347 Property::Custom(custom) => collect_custom_property_tokens(custom, acc),
348 Property::AnimationName(names, _) => {
349 collect_animation_references(names, &mut acc.referenced_keyframes);
350 }
351 Property::Animation(animations, _) => {
352 for animation in animations {
353 collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
354 }
355 }
356 Property::FontFamily(families) => {
357 collect_font_family_references(families, &mut acc.referenced_font_families);
358 }
359 Property::Font(font) => {
360 collect_font_family_references(&font.family, &mut acc.referenced_font_families);
361 }
362 _ => {}
363 }
364}
365
366fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
367 if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
368 out.insert(rendered);
369 }
370}
371
372fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
373 let rendered: Vec<String> = shadows
374 .iter()
375 .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
376 .collect();
377 if !rendered.is_empty() && rendered.len() == shadows.len() {
378 acc.box_shadows.insert(rendered.join(", "));
379 }
380}
381
382fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
383 for name in names {
384 collect_animation_name(name, out);
385 }
386}
387
388fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
389 for family in families {
390 if let Some(name) = font_family_name(family) {
391 out.insert(name);
392 }
393 }
394}
395
396fn collect_custom_property_tokens(custom: &CustomProperty<'_>, acc: &mut Accumulator) {
399 if let CustomPropertyName::Custom(name) = &custom.name {
400 acc.defined_custom_properties.insert(name.0.to_string());
401 }
402 for token in &custom.value.0 {
413 if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
414 acc.referenced_font_families.insert(value.to_string());
415 }
416 }
417}
418
419fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
426 let block = &style.declarations;
427 let mut parts: Vec<String> =
428 Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
429 for decl in &block.declarations {
430 parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
431 }
432 for decl in &block.important_declarations {
433 parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
436 }
437 parts.sort_unstable();
438 Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
439}
440
441fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
445 let mut max_spec = 0u32;
446 let mut a = 0u16;
447 let mut b = 0u16;
448 let mut c = 0u16;
449 let mut complexity = 0u16;
450 for selector in &style.selectors.0 {
451 let spec = selector.specificity();
452 if spec >= max_spec {
453 max_spec = spec;
454 a = specificity_component(spec, 20);
455 b = specificity_component(spec, 10);
456 c = specificity_component(spec, 0);
457 }
458 complexity = complexity.max(selector_complexity(selector));
459 }
460 (a, b, c, complexity)
461}
462
463fn specificity_component(specificity: u32, shift: u32) -> u16 {
464 saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
465}
466
467fn is_notable(metric: &CssRuleMetric) -> bool {
468 metric.specificity_a >= 1
469 || metric.complexity > MAX_PLAIN_COMPLEXITY
470 || metric.important_count >= 1
471 || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
472}
473
474fn selector_complexity(selector: &Selector<'_>) -> u16 {
475 let mut count = 0u16;
476 count_components(selector, &mut count);
477 count
478}
479
480fn count_components(selector: &Selector<'_>, count: &mut u16) {
481 for component in selector.iter_raw_match_order() {
482 *count = count.saturating_add(1);
483 match component {
484 Component::Is(list)
485 | Component::Where(list)
486 | Component::Has(list)
487 | Component::Negation(list)
488 | Component::Any(_, list) => {
489 for nested in list.as_ref() {
490 count_components(nested, count);
491 }
492 }
493 Component::Slotted(nested) | Component::Host(Some(nested)) => {
494 count_components(nested, count);
495 }
496 Component::NthOf(data) => {
497 for nested in data.selectors() {
498 count_components(nested, count);
499 }
500 }
501 _ => {}
502 }
503 }
504}
505
506fn saturate_u32(value: usize) -> u32 {
507 u32::try_from(value).unwrap_or(u32::MAX)
508}
509
510fn saturate_u16(value: usize) -> u16 {
511 u16::try_from(value).unwrap_or(u16::MAX)
512}
513
514fn saturate_u16_u32(value: u32) -> u16 {
515 u16::try_from(value).unwrap_or(u16::MAX)
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn analytics(source: &str) -> CssAnalytics {
523 compute_css_analytics(source).expect("standard CSS parses")
524 }
525
526 #[test]
527 fn recovers_partial_metrics_around_a_malformed_rule() {
528 let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
531 assert!(a.rule_count >= 1);
532 assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
533 }
534
535 #[test]
536 fn counts_declarations_and_important() {
537 let a = analytics(".a { color: red; width: 1px !important; }");
538 assert_eq!(a.rule_count, 1);
539 assert_eq!(a.total_declarations, 2);
540 assert_eq!(a.important_declarations, 1);
541 }
542
543 #[test]
544 fn id_selector_is_notable_with_specificity() {
545 let a = analytics("#main { color: red; }");
546 assert_eq!(a.notable_rules.len(), 1);
547 let rule = &a.notable_rules[0];
548 assert_eq!(rule.specificity_a, 1);
549 assert_eq!(rule.specificity_b, 0);
550 assert_eq!(rule.specificity_c, 0);
551 }
552
553 #[test]
554 fn plain_class_rule_is_not_notable() {
555 let a = analytics(".btn { color: red; }");
556 assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
557 assert_eq!(a.rule_count, 1);
558 }
559
560 #[test]
561 fn important_declaration_makes_rule_notable() {
562 let a = analytics(".btn { color: red !important; }");
563 assert_eq!(a.notable_rules.len(), 1);
564 assert_eq!(a.notable_rules[0].important_count, 1);
565 }
566
567 #[test]
568 fn empty_rule_counted() {
569 let a = analytics(".a { } .b { color: red; }");
570 assert_eq!(a.rule_count, 2);
571 assert_eq!(a.empty_rule_count, 1);
572 }
573
574 #[test]
575 fn complex_selector_is_notable() {
576 let a = analytics("div > ul > li > a > span { color: red; }");
578 assert_eq!(a.notable_rules.len(), 1);
579 assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
580 }
581
582 #[test]
583 fn nesting_depth_tracked() {
584 let a = analytics(".a { .b { .c { .d { color: red; } } } }");
585 assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
586 assert!(
588 a.notable_rules
589 .iter()
590 .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
591 );
592 }
593
594 #[test]
595 fn specificity_takes_most_specific_selector_in_list() {
596 let a = analytics("#id, .cls { color: red; }");
597 assert_eq!(a.notable_rules.len(), 1);
598 assert_eq!(a.notable_rules[0].specificity_a, 1);
600 }
601
602 #[test]
603 fn line_is_one_based() {
604 let a = analytics("\n\n#main { color: red; }");
605 assert_eq!(a.notable_rules[0].line, 3);
606 }
607
608 #[test]
609 fn media_query_rules_walked() {
610 let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
611 assert_eq!(a.rule_count, 1);
612 assert_eq!(a.notable_rules.len(), 1);
613 assert_eq!(a.notable_rules[0].specificity_a, 1);
614 }
615
616 #[test]
617 fn collects_distinct_colors() {
618 let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
619 assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
620 }
621
622 #[test]
623 fn collects_colors_nested_in_shorthands() {
624 let a = analytics(".a { border: 1px solid green; background: yellow; }");
627 assert!(
628 a.colors.len() >= 2,
629 "shorthand + standalone colors collected: {:?}",
630 a.colors
631 );
632 }
633
634 #[test]
635 fn collects_distinct_font_sizes() {
636 let a =
637 analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
638 assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
639 }
640
641 #[test]
642 fn collects_distinct_z_indexes() {
643 let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
644 assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
645 }
646
647 #[test]
648 fn collects_defined_and_referenced_custom_properties() {
649 let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
650 assert!(
651 a.defined_custom_properties.contains(&"--brand".to_string()),
652 "defined: {:?}",
653 a.defined_custom_properties
654 );
655 assert!(
656 a.defined_custom_properties
657 .contains(&"--unused".to_string())
658 );
659 assert!(
660 a.referenced_custom_properties
661 .contains(&"--brand".to_string()),
662 "referenced: {:?}",
663 a.referenced_custom_properties
664 );
665 assert!(
666 !a.referenced_custom_properties
667 .contains(&"--unused".to_string()),
668 "--unused has no var() reference"
669 );
670 }
671
672 #[test]
673 fn collects_defined_and_referenced_keyframes() {
674 let a = analytics(
675 "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
676 );
677 assert!(a.defined_keyframes.contains(&"spin".to_string()));
678 assert!(a.defined_keyframes.contains(&"unused".to_string()));
679 assert!(a.referenced_keyframes.contains(&"spin".to_string()));
680 assert!(
681 !a.referenced_keyframes.contains(&"unused".to_string()),
682 "no animation references `unused`"
683 );
684 }
685
686 #[test]
687 fn animation_shorthand_references_keyframes() {
688 let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
689 assert!(
690 a.referenced_keyframes.contains(&"pulse".to_string()),
691 "referenced: {:?}",
692 a.referenced_keyframes
693 );
694 }
695
696 #[test]
697 fn fingerprints_blocks_at_floor_order_insensitive() {
698 let a = analytics(
702 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
703 .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
704 .z { color: red; margin: 1px; padding: 2px; }\n",
705 );
706 assert_eq!(
707 a.declaration_blocks.len(),
708 2,
709 "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
710 a.declaration_blocks
711 );
712 assert_eq!(
713 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
714 "same declarations in different order share a fingerprint"
715 );
716 assert_eq!(a.declaration_blocks[0].declaration_count, 4);
717 }
718
719 #[test]
720 fn important_distinguishes_block_fingerprint() {
721 let a = analytics(
722 ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
723 .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
724 );
725 assert_eq!(a.declaration_blocks.len(), 2);
726 assert_ne!(
727 a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
728 "!important changes the block fingerprint"
729 );
730 }
731}