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