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 cssparser::{Delimiter, Parser, ParserInput, parse_important};
13use lightningcss::printer::PrinterOptions;
14use lightningcss::properties::Property;
15use lightningcss::properties::animation::AnimationName;
16use lightningcss::properties::box_shadow::BoxShadow;
17use lightningcss::properties::custom::{
18    CustomProperty, CustomPropertyName, Token, TokenOrValue, Variable,
19};
20use lightningcss::properties::font::FontFamily;
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
36/// Selector component count above which a rule is considered over-complex.
37const MAX_PLAIN_COMPLEXITY: u16 = 4;
38
39/// Style-rule nesting depth at or above which a rule is recorded.
40const NOTABLE_NESTING_DEPTH: u8 = 3;
41
42/// Upper bound on per-file recorded rules. Compiled utility frameworks can emit
43/// thousands of `!important` rules; the scalar aggregates stay accurate while
44/// the per-rule finding list is capped to keep output and storage bounded.
45const MAX_NOTABLE_RULES: usize = 500;
46
47/// Minimum declaration count for a rule to be fingerprinted as a duplicate-block
48/// candidate. Small blocks (e.g. `display: flex; align-items: center`) repeat
49/// legitimately, so the floor keeps the signal a strong copy-paste indicator.
50const MIN_BLOCK_DECLARATIONS: usize = 4;
51
52/// Upper bound on per-file declaration-block fingerprints. The `MIN_BLOCK`
53/// floor already bounds compiled utility CSS (whose rules are tiny), so this
54/// only guards a pathological hand-written stylesheet.
55const MAX_DECLARATION_BLOCKS: usize = 2000;
56
57/// Upper bound on per-file located raw values. One noisy compiled stylesheet
58/// should not dominate health or audit output.
59const MAX_RAW_STYLE_VALUES: usize = 200;
60
61/// Mask for a single 10-bit CSS specificity component.
62const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
63
64/// Compute structural CSS analytics for a standard-CSS stylesheet source.
65///
66/// Returns `None` only on a hard parse failure; with error recovery on,
67/// individual malformed rules are skipped and the rest of the sheet still
68/// contributes. Callers must gate by extension and NOT pass `.scss` sources:
69/// Sass syntax is not standard CSS and recovers into an inaccurate partial
70/// rather than `None`. Parsing runs in CSS Modules mode so `:local()` /
71/// `:global()` selectors are understood.
72#[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    // Pass 1: walk the rule tree for structural metrics + font-size / z-index
82    // design tokens (these are top-level declaration properties).
83    let mut acc = Accumulator::default();
84    walk_rules(&stylesheet.rules.0, 0, &mut acc);
85
86    // Pass 2: visit every color value (including colors nested inside shorthands
87    // and gradients) for the design-token-sprawl signal. The visitor needs `&mut`,
88    // so it runs after the immutable rule walk above.
89    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/// Working accumulator threaded through the rule walk: the structural analytics
112/// plus the per-stylesheet sets of distinct `font-size` / `z-index` values.
113#[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
132/// The concrete family name of a `font-family` value, or `None` for a generic
133/// keyword (`serif`, `sans-serif`, `monospace`, ...), which is never an authored
134/// `@font-face`.
135fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
136    match family {
137        // Render the family via ToCss and strip surrounding quotes so a declared
138        // `font-family: "Inter"` and a referenced `font-family: Inter` normalize
139        // to the same key.
140        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/// Collects value-level design tokens via the lightningcss visitor: every
149/// distinct color (including colors nested in shorthands like `border` /
150/// `background` and gradients, not just standalone `color:` values) and every
151/// `var()` custom-property reference.
152#[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/// Parse one CSS color value and return its sRGB channels when lightningcss can
206/// normalize it to RGB. Supports hex, named colors, rgb(), hsl(), and hwb().
207#[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
269/// Recursively walk rules, tracking style-rule nesting depth. Grouping rules
270/// (`@media` / `@supports` / `@container` / `@layer {}` / `@document` /
271/// `@starting-style` / `@scope`) pass their nesting depth through unchanged;
272/// only nesting INSIDE a style rule increases the depth.
273fn 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                // A named `@layer a { }` both declares and populates layer `a`.
285                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                // `@layer a, b, c;` declares ordering but populates nothing.
294                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    // Fingerprint the declaration block (sorted, !important-tagged) for cross-file
388    // duplicate-block detection, gated on the minimum block size and a per-file cap.
389    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
403/// Scan a rule's declarations (normal + `!important`) for design-token values,
404/// custom-property definitions, and `@keyframes` / font-family references,
405/// folding them into `acc`. Colors and `var()` references are collected
406/// separately by the value visitor.
407fn 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
419/// Fold a single declaration's design-token value, custom-property definition,
420/// `@keyframes` reference, or font-family reference into `acc`.
421fn 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        // Shadow / radius / line-height tokens (design-token-sprawl axes).
430        // The INNER value is serialized (not the property), so the vendor
431        // prefix is dropped and `-webkit-box-shadow: X` collapses to the same
432        // distinct value as `box-shadow: X` rather than inflating the count.
433        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::FontFamily(families) => {
452            collect_font_family_references(families, &mut acc.referenced_font_families);
453        }
454        Property::Font(font) => {
455            collect_font_family_references(&font.family, &mut acc.referenced_font_families);
456        }
457        _ => {}
458    }
459}
460
461fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
462    if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
463        out.insert(rendered);
464    }
465}
466
467fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
468    if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
469        return;
470    }
471    let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
472        return;
473    };
474    let Some((property_name, value)) = rendered.split_once(':') else {
475        return;
476    };
477    let property_name = property_name.trim().to_ascii_lowercase();
478    if property_name.starts_with("--") {
479        return;
480    }
481    let value = value.trim().trim_end_matches(';').trim().to_string();
482    let Some(axis) = raw_style_axis(&property_name, &value) else {
483        return;
484    };
485    if !is_raw_style_literal(&value) {
486        return;
487    }
488    let key = format!("{axis}:{property_name}:{value}:{line}");
489    if !acc.raw_style_value_keys.insert(key) {
490        return;
491    }
492    acc.analytics.raw_style_values.push(CssRawStyleValue {
493        axis: axis.to_string(),
494        property: property_name,
495        value,
496        line,
497    });
498}
499
500fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
501    if property_name.contains("color") && looks_like_color_literal(value) {
502        return Some("color");
503    }
504    match property_name {
505        "font-size" => Some("font-size"),
506        "line-height" => Some("line-height"),
507        "border-radius"
508        | "border-top-left-radius"
509        | "border-top-right-radius"
510        | "border-bottom-right-radius"
511        | "border-bottom-left-radius" => Some("radius"),
512        "box-shadow" | "text-shadow" => Some("shadow"),
513        _ => None,
514    }
515}
516
517fn is_raw_style_literal(value: &str) -> bool {
518    let lower = value.to_ascii_lowercase();
519    if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
520        return false;
521    }
522    if matches!(
523        lower.as_str(),
524        "0" | "none"
525            | "normal"
526            | "inherit"
527            | "initial"
528            | "unset"
529            | "revert"
530            | "currentcolor"
531            | "transparent"
532    ) {
533        return false;
534    }
535    lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
536}
537
538fn looks_like_color_literal(value: &str) -> bool {
539    let lower = value.to_ascii_lowercase();
540    lower.starts_with('#')
541        || lower.contains("rgb(")
542        || lower.contains("rgba(")
543        || lower.contains("hsl(")
544        || lower.contains("hsla(")
545        || lower.contains("oklch(")
546        || lower.contains("color-mix(")
547        || matches!(
548            lower.as_str(),
549            "red"
550                | "blue"
551                | "green"
552                | "black"
553                | "white"
554                | "gray"
555                | "grey"
556                | "transparent"
557                | "yellow"
558                | "orange"
559                | "purple"
560                | "pink"
561        )
562}
563
564fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
565    let rendered: Vec<String> = shadows
566        .iter()
567        .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
568        .collect();
569    if !rendered.is_empty() && rendered.len() == shadows.len() {
570        acc.box_shadows.insert(rendered.join(", "));
571    }
572}
573
574fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
575    for name in names {
576        collect_animation_name(name, out);
577    }
578}
579
580fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
581    for family in families {
582        if let Some(name) = font_family_name(family) {
583            out.insert(name);
584        }
585    }
586}
587
588/// Record a custom-property definition and credit any font-family string / ident
589/// values referenced inside its raw token stream.
590fn collect_custom_property_tokens(
591    custom: &CustomProperty<'_>,
592    property: &Property<'_>,
593    acc: &mut Accumulator,
594    rule_line: u32,
595) {
596    if let CustomPropertyName::Custom(name) = &custom.name {
597        let name = name.0.to_string();
598        acc.defined_custom_properties.insert(name.clone());
599        if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
600            && let Some((_, value)) = rendered.split_once(':')
601        {
602            let value = value.trim().trim_end_matches(';').trim();
603            if !value.is_empty() {
604                acc.analytics
605                    .custom_property_definitions
606                    .push(CssCustomPropertyDefinition {
607                        name,
608                        value: value.to_string(),
609                        line: rule_line,
610                    });
611            }
612        }
613    }
614    // A custom-property value can REFERENCE a font family without a
615    // `font-family:` declaration: a Tailwind v4 `--font-*` theme token
616    // (`--font-display: "Departure Mono", monospace`) is the canonical
617    // case. lightningcss's `Property::FontFamily` / `Property::Font`
618    // arms above never see this (a `--*:` declaration is an opaque
619    // token stream), so scan the raw tokens for string / ident values
620    // and credit them as referenced families. Generic keywords
621    // (`serif`, `monospace`) never appear in `defined_font_faces`, so
622    // crediting them here is inert; the `unused_font_faces`
623    // set-difference only ever drops a genuinely-declared family.
624    for token in &custom.value.0 {
625        if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
626            acc.referenced_font_families.insert(value.to_string());
627        }
628    }
629}
630
631/// Fingerprint a rule's declaration block: serialize each declaration (tagging
632/// `!important` ones, which lightningcss stores without the flag, so they do not
633/// collide with their non-important twin), sort for order-insensitivity, join,
634/// and xxh3-hash. Returns `None` if any declaration fails to serialize, so a
635/// partial block is never fingerprinted (a false duplicate match would be worse
636/// than missing one).
637fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
638    let block = &style.declarations;
639    let mut parts: Vec<String> =
640        Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
641    for decl in &block.declarations {
642        parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
643    }
644    for decl in &block.important_declarations {
645        // `important = true` renders the `!important` suffix, so a block with an
646        // important declaration never collides with its non-important twin.
647        parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
648    }
649    parts.sort_unstable();
650    Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
651}
652
653/// Return the rule's `(specificity_a, specificity_b, specificity_c, complexity)`
654/// taking the most specific selector and the most complex selector across the
655/// rule's selector list.
656fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
657    let mut max_spec = 0u32;
658    let mut a = 0u16;
659    let mut b = 0u16;
660    let mut c = 0u16;
661    let mut complexity = 0u16;
662    for selector in &style.selectors.0 {
663        let spec = selector.specificity();
664        if spec >= max_spec {
665            max_spec = spec;
666            a = specificity_component(spec, 20);
667            b = specificity_component(spec, 10);
668            c = specificity_component(spec, 0);
669        }
670        complexity = complexity.max(selector_complexity(selector));
671    }
672    (a, b, c, complexity)
673}
674
675fn specificity_component(specificity: u32, shift: u32) -> u16 {
676    saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
677}
678
679fn is_notable(metric: &CssRuleMetric) -> bool {
680    metric.specificity_a >= 1
681        || metric.complexity > MAX_PLAIN_COMPLEXITY
682        || metric.important_count >= 1
683        || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
684}
685
686fn selector_complexity(selector: &Selector<'_>) -> u16 {
687    let mut count = 0u16;
688    count_components(selector, &mut count);
689    count
690}
691
692fn count_components(selector: &Selector<'_>, count: &mut u16) {
693    for component in selector.iter_raw_match_order() {
694        *count = count.saturating_add(1);
695        match component {
696            Component::Is(list)
697            | Component::Where(list)
698            | Component::Has(list)
699            | Component::Negation(list)
700            | Component::Any(_, list) => {
701                for nested in list.as_ref() {
702                    count_components(nested, count);
703                }
704            }
705            Component::Slotted(nested) | Component::Host(Some(nested)) => {
706                count_components(nested, count);
707            }
708            Component::NthOf(data) => {
709                for nested in data.selectors() {
710                    count_components(nested, count);
711                }
712            }
713            _ => {}
714        }
715    }
716}
717
718fn saturate_u32(value: usize) -> u32 {
719    u32::try_from(value).unwrap_or(u32::MAX)
720}
721
722fn saturate_u16(value: usize) -> u16 {
723    u16::try_from(value).unwrap_or(u16::MAX)
724}
725
726fn saturate_u16_u32(value: u32) -> u16 {
727    u16::try_from(value).unwrap_or(u16::MAX)
728}
729
730#[cfg(all(test, not(miri)))]
731mod tests {
732    use super::*;
733
734    fn analytics(source: &str) -> CssAnalytics {
735        compute_css_analytics(source).expect("standard CSS parses")
736    }
737
738    #[test]
739    fn recovers_partial_metrics_around_a_malformed_rule() {
740        // Error recovery skips the broken rule and still records the valid one,
741        // so a file with one bad rule is not lost wholesale.
742        let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
743        assert!(a.rule_count >= 1);
744        assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
745    }
746
747    #[test]
748    fn counts_declarations_and_important() {
749        let a = analytics(".a { color: red; width: 1px !important; }");
750        assert_eq!(a.rule_count, 1);
751        assert_eq!(a.total_declarations, 2);
752        assert_eq!(a.important_declarations, 1);
753    }
754
755    #[test]
756    fn id_selector_is_notable_with_specificity() {
757        let a = analytics("#main { color: red; }");
758        assert_eq!(a.notable_rules.len(), 1);
759        let rule = &a.notable_rules[0];
760        assert_eq!(rule.specificity_a, 1);
761        assert_eq!(rule.specificity_b, 0);
762        assert_eq!(rule.specificity_c, 0);
763    }
764
765    #[test]
766    fn plain_class_rule_is_not_notable() {
767        let a = analytics(".btn { color: red; }");
768        assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
769        assert_eq!(a.rule_count, 1);
770    }
771
772    #[test]
773    fn important_declaration_makes_rule_notable() {
774        let a = analytics(".btn { color: red !important; }");
775        assert_eq!(a.notable_rules.len(), 1);
776        assert_eq!(a.notable_rules[0].important_count, 1);
777    }
778
779    #[test]
780    fn empty_rule_counted() {
781        let a = analytics(".a { } .b { color: red; }");
782        assert_eq!(a.rule_count, 2);
783        assert_eq!(a.empty_rule_count, 1);
784    }
785
786    #[test]
787    fn complex_selector_is_notable() {
788        // Five compound selectors joined by combinators exceeds the floor.
789        let a = analytics("div > ul > li > a > span { color: red; }");
790        assert_eq!(a.notable_rules.len(), 1);
791        assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
792    }
793
794    #[test]
795    fn nesting_depth_tracked() {
796        let a = analytics(".a { .b { .c { .d { color: red; } } } }");
797        assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
798        // The depth-3 rule (`.d`) crosses the nesting floor.
799        assert!(
800            a.notable_rules
801                .iter()
802                .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
803        );
804    }
805
806    #[test]
807    fn specificity_takes_most_specific_selector_in_list() {
808        let a = analytics("#id, .cls { color: red; }");
809        assert_eq!(a.notable_rules.len(), 1);
810        // `#id` (1,0,0) is more specific than `.cls` (0,1,0).
811        assert_eq!(a.notable_rules[0].specificity_a, 1);
812    }
813
814    #[test]
815    fn line_is_one_based() {
816        let a = analytics("\n\n#main { color: red; }");
817        assert_eq!(a.notable_rules[0].line, 3);
818    }
819
820    #[test]
821    fn media_query_rules_walked() {
822        let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
823        assert_eq!(a.rule_count, 1);
824        assert_eq!(a.notable_rules.len(), 1);
825        assert_eq!(a.notable_rules[0].specificity_a, 1);
826    }
827
828    #[test]
829    fn collects_distinct_colors() {
830        let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
831        assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
832    }
833
834    #[test]
835    fn parses_theme_color_values_to_rgb() {
836        assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
837        assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
838        assert_eq!(
839            parse_css_color_rgb("hsl(0 100% 50%)"),
840            Some((255.0, 0.0, 0.0))
841        );
842        assert!(parse_css_color_rgb("var(--brand)").is_none());
843    }
844
845    #[test]
846    fn direct_color_parser_matches_stylesheet_parser_corpus() {
847        let values = [
848            // Hex and named colors.
849            "#000",
850            "#fff",
851            "#f00",
852            "#0f08",
853            "#112233",
854            "#11223344",
855            "#abcdef",
856            "#ABCDEF",
857            "red",
858            "rebeccapurple",
859            "transparent",
860            // Functional RGB syntax, alpha, and clamping.
861            "rgb(255 0 0)",
862            "rgb(100% 0% 0%)",
863            "rgb(255, 0, 0)",
864            "rgba(255, 0, 0, 0.5)",
865            "rgba(255 0 0 / 0)",
866            "rgba(255 0 0 / 1)",
867            "rgb(255 0 0 / 25%)",
868            "rgb(300 -10 0)",
869            // HSL and HWB normalization.
870            "hsl(0 100% 50%)",
871            "hsl(120deg 100% 25% / 0.5)",
872            "hsla(240, 100%, 50%, 25%)",
873            "hwb(0 0% 0%)",
874            "hwb(120 10% 20% / 50%)",
875            // CSS whitespace and comments.
876            " red ",
877            "\t#f00\n",
878            "rgb( 255  0  0 / .5 )",
879            "r/**/ed",
880            "rgb(255/**/ 0 0)",
881            "/* before */ red /* after */",
882            // Valid CSS colors that do not resolve to a concrete RGBA value.
883            "currentColor",
884            "CURRENTCOLOR",
885            "CanvasText",
886            "ButtonFace",
887            "AccentColorText",
888            "var(--brand)",
889            "color-mix(in srgb, red 50%, blue)",
890            "lab(50% 40 30)",
891            "lch(50% 40 30)",
892            "oklab(50% .1 .1)",
893            "oklch(50% .1 30)",
894            "color(display-p3 1 0 0)",
895            // Malformed or trailing input.
896            "",
897            "#ff",
898            "not-a-color",
899            "rgb(255 0)",
900            "red junk",
901            "red, blue",
902            "red !important junk",
903            "red ! important junk",
904            "red !important !important",
905            "red,",
906            // Declaration-level syntax accepted by the old wrapper.
907            "red !important",
908            "red ! important",
909            "red ! IMPORTANT",
910            "red !/**/important",
911            "red/**/!important",
912            "red ! important;",
913            "red !important ;",
914            "red !important; color: blue",
915            "red !important; junk",
916            "#f00!important",
917            "red;",
918            "red ; ",
919            "red;;",
920            "red; junk",
921            "red; color: blue",
922            "red; --custom: value",
923            "red; :junk",
924            // Error-recovery syntax that requires the stylesheet fallback.
925            "not-a-color; color: blue",
926            "var(--x); color: blue",
927            "red; @media {}",
928            "red; --x: }",
929            "currentColor; color: blue",
930            "CanvasText; color: blue",
931            "lab(50% 40 30); color: blue",
932            "color(display-p3 1 0 0); color: blue",
933            "red; color: blue !important",
934            "rgb(1 2 3); color: blue !important",
935        ];
936
937        let mismatches = values
938            .into_iter()
939            .filter_map(|value| {
940                let direct = parse_css_color_rgb(value);
941                let stylesheet = parse_css_color_rgb_via_stylesheet(value);
942                (direct != stylesheet).then_some((value, direct, stylesheet))
943            })
944            .collect::<Vec<_>>();
945        assert!(mismatches.is_empty(), "parser mismatches: {mismatches:#?}");
946    }
947
948    #[test]
949    fn collects_colors_nested_in_shorthands() {
950        // The color inside the `border` shorthand must be caught, not just the
951        // standalone `background` color: that is the point of the value visitor.
952        let a = analytics(".a { border: 1px solid green; background: yellow; }");
953        assert!(
954            a.colors.len() >= 2,
955            "shorthand + standalone colors collected: {:?}",
956            a.colors
957        );
958    }
959
960    #[test]
961    fn collects_distinct_font_sizes() {
962        let a =
963            analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
964        assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
965    }
966
967    #[test]
968    fn collects_located_raw_style_values_but_skips_tokenized_values() {
969        let a = analytics(
970            ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
971        );
972        assert!(
973            a.raw_style_values
974                .iter()
975                .any(|value| value.axis == "color" && value.property == "color"),
976            "raw color should be located: {:?}",
977            a.raw_style_values
978        );
979        assert!(
980            a.raw_style_values
981                .iter()
982                .any(|value| value.axis == "font-size" && value.value == "14px"),
983            "raw font size should be located: {:?}",
984            a.raw_style_values
985        );
986        assert!(
987            a.raw_style_values
988                .iter()
989                .any(|value| value.axis == "radius" && value.line == 2),
990            "raw radius should be located on the second rule: {:?}",
991            a.raw_style_values
992        );
993        assert!(
994            !a.raw_style_values
995                .iter()
996                .any(|value| value.property == "margin-top"),
997            "layout spacing should not be a raw-value candidate: {:?}",
998            a.raw_style_values
999        );
1000        assert!(
1001            !a.raw_style_values
1002                .iter()
1003                .any(|value| value.property == "z-index"),
1004            "z-index should stay a scale summary, not an audit candidate: {:?}",
1005            a.raw_style_values
1006        );
1007        assert!(
1008            !a.raw_style_values
1009                .iter()
1010                .any(|value| value.value == "transparent"),
1011            "transparent should behave like a reset keyword: {:?}",
1012            a.raw_style_values
1013        );
1014    }
1015
1016    #[test]
1017    fn collects_distinct_z_indexes() {
1018        let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
1019        assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
1020    }
1021
1022    #[test]
1023    fn collects_defined_and_referenced_custom_properties() {
1024        let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
1025        assert!(
1026            a.defined_custom_properties.contains(&"--brand".to_string()),
1027            "defined: {:?}",
1028            a.defined_custom_properties
1029        );
1030        assert!(
1031            a.defined_custom_properties
1032                .contains(&"--unused".to_string())
1033        );
1034        assert!(
1035            a.referenced_custom_properties
1036                .contains(&"--brand".to_string()),
1037            "referenced: {:?}",
1038            a.referenced_custom_properties
1039        );
1040        assert!(
1041            !a.referenced_custom_properties
1042                .contains(&"--unused".to_string()),
1043            "--unused has no var() reference"
1044        );
1045    }
1046
1047    #[test]
1048    fn collects_defined_and_referenced_keyframes() {
1049        let a = analytics(
1050            "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
1051        );
1052        assert!(a.defined_keyframes.contains(&"spin".to_string()));
1053        assert!(a.defined_keyframes.contains(&"unused".to_string()));
1054        assert!(a.referenced_keyframes.contains(&"spin".to_string()));
1055        assert!(
1056            !a.referenced_keyframes.contains(&"unused".to_string()),
1057            "no animation references `unused`"
1058        );
1059    }
1060
1061    #[test]
1062    fn animation_shorthand_references_keyframes() {
1063        let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
1064        assert!(
1065            a.referenced_keyframes.contains(&"pulse".to_string()),
1066            "referenced: {:?}",
1067            a.referenced_keyframes
1068        );
1069    }
1070
1071    #[test]
1072    fn fingerprints_blocks_at_floor_order_insensitive() {
1073        // Two 4-declaration rules with the same declarations in different order
1074        // share a fingerprint; a 3-declaration rule is below the floor and is
1075        // not fingerprinted.
1076        let a = analytics(
1077            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1078             .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
1079             .z { color: red; margin: 1px; padding: 2px; }\n",
1080        );
1081        assert_eq!(
1082            a.declaration_blocks.len(),
1083            2,
1084            "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
1085            a.declaration_blocks
1086        );
1087        assert_eq!(
1088            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1089            "same declarations in different order share a fingerprint"
1090        );
1091        assert_eq!(a.declaration_blocks[0].declaration_count, 4);
1092    }
1093
1094    #[test]
1095    fn important_distinguishes_block_fingerprint() {
1096        let a = analytics(
1097            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1098             .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
1099        );
1100        assert_eq!(a.declaration_blocks.len(), 2);
1101        assert_ne!(
1102            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1103            "!important changes the block fingerprint"
1104        );
1105    }
1106
1107    #[test]
1108    fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
1109        // Load-bearing for the v3 styling-health sprawl drift sub-term: the
1110        // distinct-value sets (box_shadows / border_radii / line_heights) count
1111        // ONLY hardcoded literals. A value referenced via `var(--*)` parses as
1112        // `Property::Unparsed` in lightningcss, so it never reaches the typed
1113        // `Property::BoxShadow` / `BorderRadius` / `LineHeight` arms, and a token
1114        // DEFINITION (`--x: 4px`) is a `Property::Custom`. Both are therefore
1115        // invisible to the sprawl counts. This is what makes a well-tokenized
1116        // design system score 0 sprawl regardless of how many tokens it defines;
1117        // the v3 grade's entire FP-safety rests on this lightningcss behavior, so
1118        // it is pinned here (a future lightningcss change that typed `var()` values
1119        // would break tokenized systems silently and must trip this test).
1120        let tokenized = analytics(
1121            ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
1122             .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
1123             .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
1124        );
1125        assert!(
1126            tokenized.border_radii.is_empty(),
1127            "var()-referenced radii are not counted: {:?}",
1128            tokenized.border_radii
1129        );
1130        assert!(
1131            tokenized.box_shadows.is_empty(),
1132            "var()-referenced shadows are not counted: {:?}",
1133            tokenized.box_shadows
1134        );
1135        assert!(
1136            tokenized.line_heights.is_empty(),
1137            "var()-referenced line-heights are not counted: {:?}",
1138            tokenized.line_heights
1139        );
1140
1141        // Control: hardcoded literal values ARE counted, so the sprawl signal is
1142        // not simply inert. Two distinct hardcoded radii / shadows / line-heights.
1143        let hardcoded = analytics(
1144            ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
1145             .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
1146        );
1147        assert_eq!(
1148            hardcoded.border_radii.len(),
1149            2,
1150            "distinct hardcoded radii counted: {:?}",
1151            hardcoded.border_radii
1152        );
1153        assert_eq!(
1154            hardcoded.box_shadows.len(),
1155            2,
1156            "distinct hardcoded shadows counted: {:?}",
1157            hardcoded.box_shadows
1158        );
1159        assert_eq!(
1160            hardcoded.line_heights.len(),
1161            2,
1162            "distinct hardcoded line-heights counted: {:?}",
1163            hardcoded.line_heights
1164        );
1165    }
1166}