Skip to main content

fallow_extract/
css_metrics.rs

1//! Structural CSS analytics computed from the parsed CSS syntax tree.
2//!
3//! `fallow health` consumes these on demand to surface specificity hotspots,
4//! `!important` density, over-complex selectors, and deep nesting: the kind of
5//! codebase-scale structural CSS slop that per-rule linters do not aggregate.
6//! The metrics come from the same lightningcss parse used for CSS Module class
7//! extraction. Callers gate by file extension: lightningcss parses standard CSS,
8//! not Sass, so `.scss` sources are NOT passed here (with error recovery on,
9//! Sass syntax recovers into a partial, inaccurate result rather than failing).
10//! A hard parse failure yields `None`.
11
12use 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::{
32    CssAnalytics, CssCustomPropertyDefinition, CssDeclarationBlock, CssRawStyleValue, CssRuleMetric,
33};
34
35/// Selector component count above which a rule is considered over-complex.
36const MAX_PLAIN_COMPLEXITY: u16 = 4;
37
38/// Style-rule nesting depth at or above which a rule is recorded.
39const NOTABLE_NESTING_DEPTH: u8 = 3;
40
41/// Upper bound on per-file recorded rules. Compiled utility frameworks can emit
42/// thousands of `!important` rules; the scalar aggregates stay accurate while
43/// the per-rule finding list is capped to keep output and storage bounded.
44const MAX_NOTABLE_RULES: usize = 500;
45
46/// Minimum declaration count for a rule to be fingerprinted as a duplicate-block
47/// candidate. Small blocks (e.g. `display: flex; align-items: center`) repeat
48/// legitimately, so the floor keeps the signal a strong copy-paste indicator.
49const MIN_BLOCK_DECLARATIONS: usize = 4;
50
51/// Upper bound on per-file declaration-block fingerprints. The `MIN_BLOCK`
52/// floor already bounds compiled utility CSS (whose rules are tiny), so this
53/// only guards a pathological hand-written stylesheet.
54const MAX_DECLARATION_BLOCKS: usize = 2000;
55
56/// Upper bound on per-file located raw values. One noisy compiled stylesheet
57/// should not dominate health or audit output.
58const MAX_RAW_STYLE_VALUES: usize = 200;
59
60/// Mask for a single 10-bit CSS specificity component.
61const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
62
63/// Compute structural CSS analytics for a standard-CSS stylesheet source.
64///
65/// Returns `None` only on a hard parse failure; with error recovery on,
66/// individual malformed rules are skipped and the rest of the sheet still
67/// contributes. Callers must gate by extension and NOT pass `.scss` sources:
68/// Sass syntax is not standard CSS and recovers into an inaccurate partial
69/// rather than `None`. Parsing runs in CSS Modules mode so `:local()` /
70/// `:global()` selectors are understood.
71#[must_use]
72pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
73    let options = ParserOptions {
74        error_recovery: true,
75        css_modules: Some(lightningcss::css_modules::Config::default()),
76        ..ParserOptions::default()
77    };
78    let mut stylesheet = StyleSheet::parse(source, options).ok()?;
79
80    // Pass 1: walk the rule tree for structural metrics + font-size / z-index
81    // design tokens (these are top-level declaration properties).
82    let mut acc = Accumulator::default();
83    walk_rules(&stylesheet.rules.0, 0, &mut acc);
84
85    // Pass 2: visit every color value (including colors nested inside shorthands
86    // and gradients) for the design-token-sprawl signal. The visitor needs `&mut`,
87    // so it runs after the immutable rule walk above.
88    let mut collector = ValueCollector::default();
89    let _ = collector.visit_stylesheet(&mut stylesheet);
90
91    let mut analytics = acc.analytics;
92    analytics.colors = sorted_vec(collector.colors);
93    analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
94    analytics.font_sizes = sorted_vec(acc.font_sizes);
95    analytics.z_indexes = sorted_vec(acc.z_indexes);
96    analytics.box_shadows = sorted_vec(acc.box_shadows);
97    analytics.border_radii = sorted_vec(acc.border_radii);
98    analytics.line_heights = sorted_vec(acc.line_heights);
99    analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
100    analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
101    analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
102    analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
103    analytics.declared_layers = sorted_vec(acc.declared_layers);
104    analytics.populated_layers = sorted_vec(acc.populated_layers);
105    analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
106    analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
107    Some(analytics)
108}
109
110/// Working accumulator threaded through the rule walk: the structural analytics
111/// plus the per-stylesheet sets of distinct `font-size` / `z-index` values.
112#[derive(Default)]
113struct Accumulator {
114    analytics: CssAnalytics,
115    font_sizes: FxHashSet<String>,
116    z_indexes: FxHashSet<String>,
117    box_shadows: FxHashSet<String>,
118    border_radii: FxHashSet<String>,
119    line_heights: FxHashSet<String>,
120    defined_custom_properties: FxHashSet<String>,
121    defined_keyframes: FxHashSet<String>,
122    referenced_keyframes: FxHashSet<String>,
123    registered_custom_properties: FxHashSet<String>,
124    declared_layers: FxHashSet<String>,
125    populated_layers: FxHashSet<String>,
126    defined_font_faces: FxHashSet<String>,
127    referenced_font_families: FxHashSet<String>,
128    raw_style_value_keys: FxHashSet<String>,
129}
130
131/// The concrete family name of a `font-family` value, or `None` for a generic
132/// keyword (`serif`, `sans-serif`, `monospace`, ...), which is never an authored
133/// `@font-face`.
134fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
135    match family {
136        // Render the family via ToCss and strip surrounding quotes so a declared
137        // `font-family: "Inter"` and a referenced `font-family: Inter` normalize
138        // to the same key.
139        FontFamily::FamilyName(_) => family
140            .to_css_string(PrinterOptions::default())
141            .ok()
142            .map(|s| s.trim_matches(['"', '\'']).to_string()),
143        FontFamily::Generic(_) => None,
144    }
145}
146
147/// Collects value-level design tokens via the lightningcss visitor: every
148/// distinct color (including colors nested in shorthands like `border` /
149/// `background` and gradients, not just standalone `color:` values) and every
150/// `var()` custom-property reference.
151#[derive(Default)]
152struct ValueCollector {
153    colors: FxHashSet<String>,
154    referenced_custom_properties: FxHashSet<String>,
155}
156
157impl Visitor<'_> for ValueCollector {
158    type Error = std::convert::Infallible;
159
160    fn visit_types(&self) -> VisitTypes {
161        VisitTypes::COLORS | VisitTypes::VARIABLES
162    }
163
164    fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
165        if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
166            self.colors.insert(rendered);
167        }
168        Ok(())
169    }
170
171    fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
172        self.referenced_custom_properties
173            .insert(var.name.ident.0.to_string());
174        Ok(())
175    }
176}
177
178#[derive(Default)]
179struct FirstRgbColorCollector {
180    rgb: Option<(f64, f64, f64)>,
181}
182
183impl Visitor<'_> for FirstRgbColorCollector {
184    type Error = std::convert::Infallible;
185
186    fn visit_types(&self) -> VisitTypes {
187        VisitTypes::COLORS
188    }
189
190    fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
191        if self.rgb.is_none()
192            && let CssColor::RGBA(rgba) = color
193        {
194            self.rgb = Some((
195                f64::from(rgba.red),
196                f64::from(rgba.green),
197                f64::from(rgba.blue),
198            ));
199        }
200        Ok(())
201    }
202}
203
204/// Parse one CSS color value and return its sRGB channels when lightningcss can
205/// normalize it to RGB. Supports hex, named colors, rgb(), hsl(), and hwb().
206#[must_use]
207pub fn parse_css_color_rgb(value: &str) -> Option<(f64, f64, f64)> {
208    let source = format!(".x{{color:{value};}}");
209    let options = ParserOptions {
210        error_recovery: true,
211        ..ParserOptions::default()
212    };
213    let mut stylesheet = StyleSheet::parse(&source, options).ok()?;
214    let mut collector = FirstRgbColorCollector::default();
215    let _ = collector.visit_stylesheet(&mut stylesheet);
216    collector.rgb
217}
218
219fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
220    let mut values: Vec<String> = set.into_iter().collect();
221    values.sort_unstable();
222    values
223}
224
225/// Recursively walk rules, tracking style-rule nesting depth. Grouping rules
226/// (`@media` / `@supports` / `@container` / `@layer {}` / `@document` /
227/// `@starting-style` / `@scope`) pass their nesting depth through unchanged;
228/// only nesting INSIDE a style rule increases the depth.
229fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
230    for rule in rules {
231        match rule {
232            CssRule::Style(style) => {
233                record_style_rule(style, depth, acc);
234                walk_rules(&style.rules.0, depth.saturating_add(1), acc);
235            }
236            CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
237            CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
238            CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
239            CssRule::LayerBlock(rule) => {
240                // A named `@layer a { }` both declares and populates layer `a`.
241                if let Some(name) = &rule.name {
242                    let name = layer_name_string(name);
243                    acc.declared_layers.insert(name.clone());
244                    acc.populated_layers.insert(name);
245                }
246                walk_rules(&rule.rules.0, depth, acc);
247            }
248            CssRule::LayerStatement(stmt) => {
249                // `@layer a, b, c;` declares ordering but populates nothing.
250                for name in &stmt.names {
251                    acc.declared_layers.insert(layer_name_string(name));
252                }
253            }
254            CssRule::Property(prop) => {
255                acc.registered_custom_properties
256                    .insert(prop.name.0.to_string());
257            }
258            CssRule::FontFace(font_face) => {
259                for property in &font_face.properties {
260                    if let FontFaceProperty::FontFamily(family) = property
261                        && let Some(name) = font_family_name(family)
262                    {
263                        acc.defined_font_faces.insert(name);
264                    }
265                }
266            }
267            CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
268            CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
269            CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
270            CssRule::Nesting(rule) => {
271                record_style_rule(&rule.style, depth, acc);
272                walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
273            }
274            CssRule::Keyframes(keyframes) => {
275                acc.defined_keyframes
276                    .insert(keyframes_name_string(&keyframes.name));
277            }
278            _ => {}
279        }
280    }
281}
282
283fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
284    name.0
285        .iter()
286        .map(std::string::ToString::to_string)
287        .collect::<Vec<_>>()
288        .join(".")
289}
290
291fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
292    match name {
293        KeyframesName::Ident(ident) => ident.0.to_string(),
294        KeyframesName::Custom(value) => value.to_string(),
295    }
296}
297
298fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
299    if let AnimationName::Ident(ident) = name {
300        out.insert(ident.0.to_string());
301    }
302}
303
304fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
305    let normal = style.declarations.declarations.len();
306    let important = style.declarations.important_declarations.len();
307    let declaration_count = normal + important;
308
309    let analytics = &mut acc.analytics;
310    analytics.rule_count = analytics.rule_count.saturating_add(1);
311    analytics.total_declarations = analytics
312        .total_declarations
313        .saturating_add(saturate_u32(declaration_count));
314    analytics.important_declarations = analytics
315        .important_declarations
316        .saturating_add(saturate_u32(important));
317    if declaration_count == 0 {
318        analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
319    }
320    analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
321
322    let (a, b, c, complexity) = rule_selector_metrics(style);
323    let metric = CssRuleMetric {
324        line: style.loc.line.saturating_add(1),
325        col: style.loc.column,
326        specificity_a: a,
327        specificity_b: b,
328        specificity_c: c,
329        complexity,
330        declaration_count: saturate_u16(declaration_count),
331        important_count: saturate_u16(important),
332        nesting_depth: depth,
333    };
334
335    if is_notable(&metric) {
336        if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
337            analytics.notable_rules.push(metric);
338        } else {
339            analytics.notable_truncated = true;
340        }
341    }
342
343    // Fingerprint the declaration block (sorted, !important-tagged) for cross-file
344    // duplicate-block detection, gated on the minimum block size and a per-file cap.
345    if declaration_count >= MIN_BLOCK_DECLARATIONS
346        && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
347        && let Some(fingerprint) = declaration_block_fingerprint(style)
348    {
349        analytics.declaration_blocks.push(CssDeclarationBlock {
350            fingerprint,
351            line: style.loc.line.saturating_add(1),
352            declaration_count: saturate_u16(declaration_count),
353        });
354    }
355
356    collect_rule_property_tokens(style, acc, style.loc.line.saturating_add(1));
357}
358
359/// Scan a rule's declarations (normal + `!important`) for design-token values,
360/// custom-property definitions, and `@keyframes` / font-family references,
361/// folding them into `acc`. Colors and `var()` references are collected
362/// separately by the value visitor.
363fn collect_rule_property_tokens(style: &StyleRule<'_>, acc: &mut Accumulator, rule_line: u32) {
364    for property in style
365        .declarations
366        .declarations
367        .iter()
368        .chain(style.declarations.important_declarations.iter())
369    {
370        collect_property_tokens(property, acc, rule_line);
371        collect_raw_style_value(property, acc, rule_line);
372    }
373}
374
375/// Fold a single declaration's design-token value, custom-property definition,
376/// `@keyframes` reference, or font-family reference into `acc`.
377fn collect_property_tokens(property: &Property<'_>, acc: &mut Accumulator, rule_line: u32) {
378    match property {
379        Property::FontSize(font_size) => {
380            insert_rendered_css(font_size, &mut acc.font_sizes);
381        }
382        Property::ZIndex(z_index) => {
383            insert_rendered_css(z_index, &mut acc.z_indexes);
384        }
385        // Shadow / radius / line-height tokens (design-token-sprawl axes).
386        // The INNER value is serialized (not the property), so the vendor
387        // prefix is dropped and `-webkit-box-shadow: X` collapses to the same
388        // distinct value as `box-shadow: X` rather than inflating the count.
389        Property::BoxShadow(shadows, _) => collect_box_shadow_tokens(shadows, acc),
390        Property::BorderRadius(radius, _) => {
391            insert_rendered_css(radius, &mut acc.border_radii);
392        }
393        Property::LineHeight(line_height) => {
394            insert_rendered_css(line_height, &mut acc.line_heights);
395        }
396        Property::Custom(custom) => {
397            collect_custom_property_tokens(custom, property, acc, rule_line);
398        }
399        Property::AnimationName(names, _) => {
400            collect_animation_references(names, &mut acc.referenced_keyframes);
401        }
402        Property::Animation(animations, _) => {
403            for animation in animations {
404                collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
405            }
406        }
407        Property::FontFamily(families) => {
408            collect_font_family_references(families, &mut acc.referenced_font_families);
409        }
410        Property::Font(font) => {
411            collect_font_family_references(&font.family, &mut acc.referenced_font_families);
412        }
413        _ => {}
414    }
415}
416
417fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
418    if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
419        out.insert(rendered);
420    }
421}
422
423fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
424    if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
425        return;
426    }
427    let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
428        return;
429    };
430    let Some((property_name, value)) = rendered.split_once(':') else {
431        return;
432    };
433    let property_name = property_name.trim().to_ascii_lowercase();
434    if property_name.starts_with("--") {
435        return;
436    }
437    let value = value.trim().trim_end_matches(';').trim().to_string();
438    let Some(axis) = raw_style_axis(&property_name, &value) else {
439        return;
440    };
441    if !is_raw_style_literal(&value) {
442        return;
443    }
444    let key = format!("{axis}:{property_name}:{value}:{line}");
445    if !acc.raw_style_value_keys.insert(key) {
446        return;
447    }
448    acc.analytics.raw_style_values.push(CssRawStyleValue {
449        axis: axis.to_string(),
450        property: property_name,
451        value,
452        line,
453    });
454}
455
456fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
457    if property_name.contains("color") && looks_like_color_literal(value) {
458        return Some("color");
459    }
460    match property_name {
461        "font-size" => Some("font-size"),
462        "line-height" => Some("line-height"),
463        "border-radius"
464        | "border-top-left-radius"
465        | "border-top-right-radius"
466        | "border-bottom-right-radius"
467        | "border-bottom-left-radius" => Some("radius"),
468        "box-shadow" | "text-shadow" => Some("shadow"),
469        _ => None,
470    }
471}
472
473fn is_raw_style_literal(value: &str) -> bool {
474    let lower = value.to_ascii_lowercase();
475    if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
476        return false;
477    }
478    if matches!(
479        lower.as_str(),
480        "0" | "none"
481            | "normal"
482            | "inherit"
483            | "initial"
484            | "unset"
485            | "revert"
486            | "currentcolor"
487            | "transparent"
488    ) {
489        return false;
490    }
491    lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
492}
493
494fn looks_like_color_literal(value: &str) -> bool {
495    let lower = value.to_ascii_lowercase();
496    lower.starts_with('#')
497        || lower.contains("rgb(")
498        || lower.contains("rgba(")
499        || lower.contains("hsl(")
500        || lower.contains("hsla(")
501        || lower.contains("oklch(")
502        || lower.contains("color-mix(")
503        || matches!(
504            lower.as_str(),
505            "red"
506                | "blue"
507                | "green"
508                | "black"
509                | "white"
510                | "gray"
511                | "grey"
512                | "transparent"
513                | "yellow"
514                | "orange"
515                | "purple"
516                | "pink"
517        )
518}
519
520fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
521    let rendered: Vec<String> = shadows
522        .iter()
523        .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
524        .collect();
525    if !rendered.is_empty() && rendered.len() == shadows.len() {
526        acc.box_shadows.insert(rendered.join(", "));
527    }
528}
529
530fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
531    for name in names {
532        collect_animation_name(name, out);
533    }
534}
535
536fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
537    for family in families {
538        if let Some(name) = font_family_name(family) {
539            out.insert(name);
540        }
541    }
542}
543
544/// Record a custom-property definition and credit any font-family string / ident
545/// values referenced inside its raw token stream.
546fn collect_custom_property_tokens(
547    custom: &CustomProperty<'_>,
548    property: &Property<'_>,
549    acc: &mut Accumulator,
550    rule_line: u32,
551) {
552    if let CustomPropertyName::Custom(name) = &custom.name {
553        let name = name.0.to_string();
554        acc.defined_custom_properties.insert(name.clone());
555        if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
556            && let Some((_, value)) = rendered.split_once(':')
557        {
558            let value = value.trim().trim_end_matches(';').trim();
559            if !value.is_empty() {
560                acc.analytics
561                    .custom_property_definitions
562                    .push(CssCustomPropertyDefinition {
563                        name,
564                        value: value.to_string(),
565                        line: rule_line,
566                    });
567            }
568        }
569    }
570    // A custom-property value can REFERENCE a font family without a
571    // `font-family:` declaration: a Tailwind v4 `--font-*` theme token
572    // (`--font-display: "Departure Mono", monospace`) is the canonical
573    // case. lightningcss's `Property::FontFamily` / `Property::Font`
574    // arms above never see this (a `--*:` declaration is an opaque
575    // token stream), so scan the raw tokens for string / ident values
576    // and credit them as referenced families. Generic keywords
577    // (`serif`, `monospace`) never appear in `defined_font_faces`, so
578    // crediting them here is inert; the `unused_font_faces`
579    // set-difference only ever drops a genuinely-declared family.
580    for token in &custom.value.0 {
581        if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
582            acc.referenced_font_families.insert(value.to_string());
583        }
584    }
585}
586
587/// Fingerprint a rule's declaration block: serialize each declaration (tagging
588/// `!important` ones, which lightningcss stores without the flag, so they do not
589/// collide with their non-important twin), sort for order-insensitivity, join,
590/// and xxh3-hash. Returns `None` if any declaration fails to serialize, so a
591/// partial block is never fingerprinted (a false duplicate match would be worse
592/// than missing one).
593fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
594    let block = &style.declarations;
595    let mut parts: Vec<String> =
596        Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
597    for decl in &block.declarations {
598        parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
599    }
600    for decl in &block.important_declarations {
601        // `important = true` renders the `!important` suffix, so a block with an
602        // important declaration never collides with its non-important twin.
603        parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
604    }
605    parts.sort_unstable();
606    Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
607}
608
609/// Return the rule's `(specificity_a, specificity_b, specificity_c, complexity)`
610/// taking the most specific selector and the most complex selector across the
611/// rule's selector list.
612fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
613    let mut max_spec = 0u32;
614    let mut a = 0u16;
615    let mut b = 0u16;
616    let mut c = 0u16;
617    let mut complexity = 0u16;
618    for selector in &style.selectors.0 {
619        let spec = selector.specificity();
620        if spec >= max_spec {
621            max_spec = spec;
622            a = specificity_component(spec, 20);
623            b = specificity_component(spec, 10);
624            c = specificity_component(spec, 0);
625        }
626        complexity = complexity.max(selector_complexity(selector));
627    }
628    (a, b, c, complexity)
629}
630
631fn specificity_component(specificity: u32, shift: u32) -> u16 {
632    saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
633}
634
635fn is_notable(metric: &CssRuleMetric) -> bool {
636    metric.specificity_a >= 1
637        || metric.complexity > MAX_PLAIN_COMPLEXITY
638        || metric.important_count >= 1
639        || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
640}
641
642fn selector_complexity(selector: &Selector<'_>) -> u16 {
643    let mut count = 0u16;
644    count_components(selector, &mut count);
645    count
646}
647
648fn count_components(selector: &Selector<'_>, count: &mut u16) {
649    for component in selector.iter_raw_match_order() {
650        *count = count.saturating_add(1);
651        match component {
652            Component::Is(list)
653            | Component::Where(list)
654            | Component::Has(list)
655            | Component::Negation(list)
656            | Component::Any(_, list) => {
657                for nested in list.as_ref() {
658                    count_components(nested, count);
659                }
660            }
661            Component::Slotted(nested) | Component::Host(Some(nested)) => {
662                count_components(nested, count);
663            }
664            Component::NthOf(data) => {
665                for nested in data.selectors() {
666                    count_components(nested, count);
667                }
668            }
669            _ => {}
670        }
671    }
672}
673
674fn saturate_u32(value: usize) -> u32 {
675    u32::try_from(value).unwrap_or(u32::MAX)
676}
677
678fn saturate_u16(value: usize) -> u16 {
679    u16::try_from(value).unwrap_or(u16::MAX)
680}
681
682fn saturate_u16_u32(value: u32) -> u16 {
683    u16::try_from(value).unwrap_or(u16::MAX)
684}
685
686#[cfg(all(test, not(miri)))]
687mod tests {
688    use super::*;
689
690    fn analytics(source: &str) -> CssAnalytics {
691        compute_css_analytics(source).expect("standard CSS parses")
692    }
693
694    #[test]
695    fn recovers_partial_metrics_around_a_malformed_rule() {
696        // Error recovery skips the broken rule and still records the valid one,
697        // so a file with one bad rule is not lost wholesale.
698        let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
699        assert!(a.rule_count >= 1);
700        assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
701    }
702
703    #[test]
704    fn counts_declarations_and_important() {
705        let a = analytics(".a { color: red; width: 1px !important; }");
706        assert_eq!(a.rule_count, 1);
707        assert_eq!(a.total_declarations, 2);
708        assert_eq!(a.important_declarations, 1);
709    }
710
711    #[test]
712    fn id_selector_is_notable_with_specificity() {
713        let a = analytics("#main { color: red; }");
714        assert_eq!(a.notable_rules.len(), 1);
715        let rule = &a.notable_rules[0];
716        assert_eq!(rule.specificity_a, 1);
717        assert_eq!(rule.specificity_b, 0);
718        assert_eq!(rule.specificity_c, 0);
719    }
720
721    #[test]
722    fn plain_class_rule_is_not_notable() {
723        let a = analytics(".btn { color: red; }");
724        assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
725        assert_eq!(a.rule_count, 1);
726    }
727
728    #[test]
729    fn important_declaration_makes_rule_notable() {
730        let a = analytics(".btn { color: red !important; }");
731        assert_eq!(a.notable_rules.len(), 1);
732        assert_eq!(a.notable_rules[0].important_count, 1);
733    }
734
735    #[test]
736    fn empty_rule_counted() {
737        let a = analytics(".a { } .b { color: red; }");
738        assert_eq!(a.rule_count, 2);
739        assert_eq!(a.empty_rule_count, 1);
740    }
741
742    #[test]
743    fn complex_selector_is_notable() {
744        // Five compound selectors joined by combinators exceeds the floor.
745        let a = analytics("div > ul > li > a > span { color: red; }");
746        assert_eq!(a.notable_rules.len(), 1);
747        assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
748    }
749
750    #[test]
751    fn nesting_depth_tracked() {
752        let a = analytics(".a { .b { .c { .d { color: red; } } } }");
753        assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
754        // The depth-3 rule (`.d`) crosses the nesting floor.
755        assert!(
756            a.notable_rules
757                .iter()
758                .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
759        );
760    }
761
762    #[test]
763    fn specificity_takes_most_specific_selector_in_list() {
764        let a = analytics("#id, .cls { color: red; }");
765        assert_eq!(a.notable_rules.len(), 1);
766        // `#id` (1,0,0) is more specific than `.cls` (0,1,0).
767        assert_eq!(a.notable_rules[0].specificity_a, 1);
768    }
769
770    #[test]
771    fn line_is_one_based() {
772        let a = analytics("\n\n#main { color: red; }");
773        assert_eq!(a.notable_rules[0].line, 3);
774    }
775
776    #[test]
777    fn media_query_rules_walked() {
778        let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
779        assert_eq!(a.rule_count, 1);
780        assert_eq!(a.notable_rules.len(), 1);
781        assert_eq!(a.notable_rules[0].specificity_a, 1);
782    }
783
784    #[test]
785    fn collects_distinct_colors() {
786        let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
787        assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
788    }
789
790    #[test]
791    fn parses_theme_color_values_to_rgb() {
792        assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
793        assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
794        assert_eq!(
795            parse_css_color_rgb("hsl(0 100% 50%)"),
796            Some((255.0, 0.0, 0.0))
797        );
798        assert!(parse_css_color_rgb("var(--brand)").is_none());
799    }
800
801    #[test]
802    fn collects_colors_nested_in_shorthands() {
803        // The color inside the `border` shorthand must be caught, not just the
804        // standalone `background` color: that is the point of the value visitor.
805        let a = analytics(".a { border: 1px solid green; background: yellow; }");
806        assert!(
807            a.colors.len() >= 2,
808            "shorthand + standalone colors collected: {:?}",
809            a.colors
810        );
811    }
812
813    #[test]
814    fn collects_distinct_font_sizes() {
815        let a =
816            analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
817        assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
818    }
819
820    #[test]
821    fn collects_located_raw_style_values_but_skips_tokenized_values() {
822        let a = analytics(
823            ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
824        );
825        assert!(
826            a.raw_style_values
827                .iter()
828                .any(|value| value.axis == "color" && value.property == "color"),
829            "raw color should be located: {:?}",
830            a.raw_style_values
831        );
832        assert!(
833            a.raw_style_values
834                .iter()
835                .any(|value| value.axis == "font-size" && value.value == "14px"),
836            "raw font size should be located: {:?}",
837            a.raw_style_values
838        );
839        assert!(
840            a.raw_style_values
841                .iter()
842                .any(|value| value.axis == "radius" && value.line == 2),
843            "raw radius should be located on the second rule: {:?}",
844            a.raw_style_values
845        );
846        assert!(
847            !a.raw_style_values
848                .iter()
849                .any(|value| value.property == "margin-top"),
850            "layout spacing should not be a raw-value candidate: {:?}",
851            a.raw_style_values
852        );
853        assert!(
854            !a.raw_style_values
855                .iter()
856                .any(|value| value.property == "z-index"),
857            "z-index should stay a scale summary, not an audit candidate: {:?}",
858            a.raw_style_values
859        );
860        assert!(
861            !a.raw_style_values
862                .iter()
863                .any(|value| value.value == "transparent"),
864            "transparent should behave like a reset keyword: {:?}",
865            a.raw_style_values
866        );
867    }
868
869    #[test]
870    fn collects_distinct_z_indexes() {
871        let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
872        assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
873    }
874
875    #[test]
876    fn collects_defined_and_referenced_custom_properties() {
877        let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
878        assert!(
879            a.defined_custom_properties.contains(&"--brand".to_string()),
880            "defined: {:?}",
881            a.defined_custom_properties
882        );
883        assert!(
884            a.defined_custom_properties
885                .contains(&"--unused".to_string())
886        );
887        assert!(
888            a.referenced_custom_properties
889                .contains(&"--brand".to_string()),
890            "referenced: {:?}",
891            a.referenced_custom_properties
892        );
893        assert!(
894            !a.referenced_custom_properties
895                .contains(&"--unused".to_string()),
896            "--unused has no var() reference"
897        );
898    }
899
900    #[test]
901    fn collects_defined_and_referenced_keyframes() {
902        let a = analytics(
903            "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
904        );
905        assert!(a.defined_keyframes.contains(&"spin".to_string()));
906        assert!(a.defined_keyframes.contains(&"unused".to_string()));
907        assert!(a.referenced_keyframes.contains(&"spin".to_string()));
908        assert!(
909            !a.referenced_keyframes.contains(&"unused".to_string()),
910            "no animation references `unused`"
911        );
912    }
913
914    #[test]
915    fn animation_shorthand_references_keyframes() {
916        let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
917        assert!(
918            a.referenced_keyframes.contains(&"pulse".to_string()),
919            "referenced: {:?}",
920            a.referenced_keyframes
921        );
922    }
923
924    #[test]
925    fn fingerprints_blocks_at_floor_order_insensitive() {
926        // Two 4-declaration rules with the same declarations in different order
927        // share a fingerprint; a 3-declaration rule is below the floor and is
928        // not fingerprinted.
929        let a = analytics(
930            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
931             .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
932             .z { color: red; margin: 1px; padding: 2px; }\n",
933        );
934        assert_eq!(
935            a.declaration_blocks.len(),
936            2,
937            "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
938            a.declaration_blocks
939        );
940        assert_eq!(
941            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
942            "same declarations in different order share a fingerprint"
943        );
944        assert_eq!(a.declaration_blocks[0].declaration_count, 4);
945    }
946
947    #[test]
948    fn important_distinguishes_block_fingerprint() {
949        let a = analytics(
950            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
951             .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
952        );
953        assert_eq!(a.declaration_blocks.len(), 2);
954        assert_ne!(
955            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
956            "!important changes the block fingerprint"
957        );
958    }
959
960    #[test]
961    fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
962        // Load-bearing for the v3 styling-health sprawl drift sub-term: the
963        // distinct-value sets (box_shadows / border_radii / line_heights) count
964        // ONLY hardcoded literals. A value referenced via `var(--*)` parses as
965        // `Property::Unparsed` in lightningcss, so it never reaches the typed
966        // `Property::BoxShadow` / `BorderRadius` / `LineHeight` arms, and a token
967        // DEFINITION (`--x: 4px`) is a `Property::Custom`. Both are therefore
968        // invisible to the sprawl counts. This is what makes a well-tokenized
969        // design system score 0 sprawl regardless of how many tokens it defines;
970        // the v3 grade's entire FP-safety rests on this lightningcss behavior, so
971        // it is pinned here (a future lightningcss change that typed `var()` values
972        // would break tokenized systems silently and must trip this test).
973        let tokenized = analytics(
974            ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
975             .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
976             .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
977        );
978        assert!(
979            tokenized.border_radii.is_empty(),
980            "var()-referenced radii are not counted: {:?}",
981            tokenized.border_radii
982        );
983        assert!(
984            tokenized.box_shadows.is_empty(),
985            "var()-referenced shadows are not counted: {:?}",
986            tokenized.box_shadows
987        );
988        assert!(
989            tokenized.line_heights.is_empty(),
990            "var()-referenced line-heights are not counted: {:?}",
991            tokenized.line_heights
992        );
993
994        // Control: hardcoded literal values ARE counted, so the sprawl signal is
995        // not simply inert. Two distinct hardcoded radii / shadows / line-heights.
996        let hardcoded = analytics(
997            ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
998             .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
999        );
1000        assert_eq!(
1001            hardcoded.border_radii.len(),
1002            2,
1003            "distinct hardcoded radii counted: {:?}",
1004            hardcoded.border_radii
1005        );
1006        assert_eq!(
1007            hardcoded.box_shadows.len(),
1008            2,
1009            "distinct hardcoded shadows counted: {:?}",
1010            hardcoded.box_shadows
1011        );
1012        assert_eq!(
1013            hardcoded.line_heights.len(),
1014            2,
1015            "distinct hardcoded line-heights counted: {:?}",
1016            hardcoded.line_heights
1017        );
1018    }
1019}