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::animation::AnimationName;
15use lightningcss::properties::box_shadow::BoxShadow;
16use lightningcss::properties::custom::{
17    CustomProperty, CustomPropertyName, Token, TokenOrValue, UnparsedProperty, Variable,
18};
19use lightningcss::properties::font::FontFamily;
20use lightningcss::properties::{Property, PropertyId};
21use lightningcss::rules::CssRule;
22use lightningcss::rules::font_face::FontFaceProperty;
23use lightningcss::rules::keyframes::KeyframesName;
24use lightningcss::rules::style::StyleRule;
25use lightningcss::selector::{Component, Selector};
26use lightningcss::stylesheet::{ParserOptions, StyleSheet};
27use lightningcss::traits::{Parse, ToCss};
28use lightningcss::values::color::CssColor;
29use lightningcss::visitor::{VisitTypes, Visitor};
30use rustc_hash::FxHashSet;
31
32use fallow_types::extract::{
33    CssAnalytics, CssCustomPropertyDefinition, CssDeclarationBlock, CssRawStyleValue, CssRuleMetric,
34};
35
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.
45pub const 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.
55pub const 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.
59pub const 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::Unparsed(unparsed) => {
452            collect_unparsed_animation_references(unparsed, &mut acc.referenced_keyframes);
453        }
454        Property::FontFamily(families) => {
455            collect_font_family_references(families, &mut acc.referenced_font_families);
456        }
457        Property::Font(font) => {
458            collect_font_family_references(&font.family, &mut acc.referenced_font_families);
459        }
460        _ => {}
461    }
462}
463
464fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
465    if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
466        out.insert(rendered);
467    }
468}
469
470fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
471    if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
472        return;
473    }
474    let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
475        return;
476    };
477    let Some((property_name, value)) = rendered.split_once(':') else {
478        return;
479    };
480    let property_name = property_name.trim().to_ascii_lowercase();
481    if property_name.starts_with("--") {
482        return;
483    }
484    let value = value.trim().trim_end_matches(';').trim().to_string();
485    let Some(axis) = raw_style_axis(&property_name, &value) else {
486        return;
487    };
488    if !is_raw_style_literal(&value) {
489        return;
490    }
491    let key = format!("{axis}:{property_name}:{value}:{line}");
492    if !acc.raw_style_value_keys.insert(key) {
493        return;
494    }
495    acc.analytics.raw_style_values.push(CssRawStyleValue {
496        axis: axis.to_string(),
497        property: property_name,
498        value,
499        line,
500    });
501}
502
503fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
504    if property_name.contains("color") && looks_like_color_literal(value) {
505        return Some("color");
506    }
507    match property_name {
508        "font-size" => Some("font-size"),
509        "line-height" => Some("line-height"),
510        "border-radius"
511        | "border-top-left-radius"
512        | "border-top-right-radius"
513        | "border-bottom-right-radius"
514        | "border-bottom-left-radius" => Some("radius"),
515        "box-shadow" | "text-shadow" => Some("shadow"),
516        _ => None,
517    }
518}
519
520fn is_raw_style_literal(value: &str) -> bool {
521    let lower = value.to_ascii_lowercase();
522    if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
523        return false;
524    }
525    if matches!(
526        lower.as_str(),
527        "0" | "none"
528            | "normal"
529            | "inherit"
530            | "initial"
531            | "unset"
532            | "revert"
533            | "currentcolor"
534            | "transparent"
535    ) {
536        return false;
537    }
538    lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
539}
540
541fn looks_like_color_literal(value: &str) -> bool {
542    let lower = value.to_ascii_lowercase();
543    lower.starts_with('#')
544        || lower.contains("rgb(")
545        || lower.contains("rgba(")
546        || lower.contains("hsl(")
547        || lower.contains("hsla(")
548        || lower.contains("oklch(")
549        || lower.contains("color-mix(")
550        || matches!(
551            lower.as_str(),
552            "red"
553                | "blue"
554                | "green"
555                | "black"
556                | "white"
557                | "gray"
558                | "grey"
559                | "transparent"
560                | "yellow"
561                | "orange"
562                | "purple"
563                | "pink"
564        )
565}
566
567fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
568    let rendered: Vec<String> = shadows
569        .iter()
570        .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
571        .collect();
572    if !rendered.is_empty() && rendered.len() == shadows.len() {
573        acc.box_shadows.insert(rendered.join(", "));
574    }
575}
576
577fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
578    for name in names {
579        collect_animation_name(name, out);
580    }
581}
582
583/// The CSS-wide keywords plus `none`. In an `animation-name` list these are the
584/// only identifiers that are not keyframe names.
585const ANIMATION_NAME_KEYWORDS: &[&str] = &[
586    "none",
587    "initial",
588    "inherit",
589    "unset",
590    "revert",
591    "revert-layer",
592];
593
594/// Keywords that can fill a non-name slot of an `animation` shorthand layer.
595/// The shorthand also rejects every keyword of [`ANIMATION_NAME_KEYWORDS`].
596const ANIMATION_SHORTHAND_KEYWORDS: &[&str] = &[
597    "linear",
598    "ease",
599    "ease-in",
600    "ease-out",
601    "ease-in-out",
602    "step-start",
603    "step-end",
604    "infinite",
605    "normal",
606    "reverse",
607    "alternate",
608    "alternate-reverse",
609    "forwards",
610    "backwards",
611    "both",
612    "running",
613    "paused",
614    "auto",
615    "replace",
616    "add",
617    "accumulate",
618];
619
620/// Credit the literal keyframe names in an `animation` / `animation-name`
621/// declaration that lightningcss keeps as a raw token list because the value
622/// contains `var()` or `env()`, for example
623/// `animation: pulse var(--duration) ease` (issue #3349).
624///
625/// lightningcss also keeps an invalid value as raw tokens. The browser drops
626/// such a declaration, so a value without `var()` or `env()` credits nothing.
627/// In each comma-separated layer, a top-level identifier counts only when it
628/// is the one non-keyword identifier of that layer, so a `var()` in the name
629/// slot or an unknown keyword stays unresolved.
630fn collect_unparsed_animation_references(
631    unparsed: &UnparsedProperty<'_>,
632    out: &mut FxHashSet<String>,
633) {
634    let is_shorthand = match unparsed.property_id {
635        PropertyId::Animation(_) => true,
636        PropertyId::AnimationName(_) => false,
637        _ => return,
638    };
639    if !has_substitution(&unparsed.value.0) {
640        return;
641    }
642    for layer in unparsed
643        .value
644        .0
645        .split(|token| matches!(token, TokenOrValue::Token(Token::Comma)))
646    {
647        if let Some(name) = layer_animation_name(layer, is_shorthand) {
648            out.insert(name);
649        }
650    }
651}
652
653/// Returns `true` when a token list holds a `var()` or `env()` at any depth.
654fn has_substitution(tokens: &[TokenOrValue<'_>]) -> bool {
655    tokens.iter().any(|token| match token {
656        TokenOrValue::Var(_) | TokenOrValue::Env(_) => true,
657        TokenOrValue::Function(function) => has_substitution(&function.arguments.0),
658        _ => false,
659    })
660}
661
662/// Returns the keyframe name of one animation layer when exactly one
663/// top-level identifier in it is not a keyword.
664fn layer_animation_name(layer: &[TokenOrValue<'_>], is_shorthand: bool) -> Option<String> {
665    let is_keyword = |ident: &str| {
666        ANIMATION_NAME_KEYWORDS
667            .iter()
668            .chain(if is_shorthand {
669                ANIMATION_SHORTHAND_KEYWORDS.iter()
670            } else {
671                [].iter()
672            })
673            .any(|keyword| ident.eq_ignore_ascii_case(keyword))
674    };
675    let mut names = layer.iter().filter_map(|token| match token {
676        TokenOrValue::Token(Token::Ident(ident)) if !is_keyword(ident) => Some(ident.to_string()),
677        TokenOrValue::AnimationName(AnimationName::Ident(ident)) => Some(ident.0.to_string()),
678        _ => None,
679    });
680    let name = names.next()?;
681    names.next().is_none().then_some(name)
682}
683
684fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
685    for family in families {
686        if let Some(name) = font_family_name(family) {
687            out.insert(name);
688        }
689    }
690}
691
692/// Record a custom-property definition and credit any font-family string / ident
693/// values referenced inside its raw token stream.
694fn collect_custom_property_tokens(
695    custom: &CustomProperty<'_>,
696    property: &Property<'_>,
697    acc: &mut Accumulator,
698    rule_line: u32,
699) {
700    if let CustomPropertyName::Custom(name) = &custom.name {
701        let name = name.0.to_string();
702        acc.defined_custom_properties.insert(name.clone());
703        if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
704            && let Some((_, value)) = rendered.split_once(':')
705        {
706            let value = value.trim().trim_end_matches(';').trim();
707            if !value.is_empty() {
708                acc.analytics
709                    .custom_property_definitions
710                    .push(CssCustomPropertyDefinition {
711                        name,
712                        value: value.to_string(),
713                        line: rule_line,
714                    });
715            }
716        }
717    }
718    // A custom-property value can REFERENCE a font family without a
719    // `font-family:` declaration: a Tailwind v4 `--font-*` theme token
720    // (`--font-display: "Departure Mono", monospace`) is the canonical
721    // case. lightningcss's `Property::FontFamily` / `Property::Font`
722    // arms above never see this (a `--*:` declaration is an opaque
723    // token stream), so scan the raw tokens for string / ident values
724    // and credit them as referenced families. Generic keywords
725    // (`serif`, `monospace`) never appear in `defined_font_faces`, so
726    // crediting them here is inert; the `unused_font_faces`
727    // set-difference only ever drops a genuinely-declared family.
728    for token in &custom.value.0 {
729        if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
730            acc.referenced_font_families.insert(value.to_string());
731        }
732    }
733}
734
735/// Fingerprint a rule's declaration block: serialize each declaration (tagging
736/// `!important` ones, which lightningcss stores without the flag, so they do not
737/// collide with their non-important twin), sort for order-insensitivity, join,
738/// and xxh3-hash. Returns `None` if any declaration fails to serialize, so a
739/// partial block is never fingerprinted (a false duplicate match would be worse
740/// than missing one).
741fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
742    let block = &style.declarations;
743    let mut parts: Vec<String> =
744        Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
745    for decl in &block.declarations {
746        parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
747    }
748    for decl in &block.important_declarations {
749        // `important = true` renders the `!important` suffix, so a block with an
750        // important declaration never collides with its non-important twin.
751        parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
752    }
753    parts.sort_unstable();
754    Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
755}
756
757/// Return the rule's `(specificity_a, specificity_b, specificity_c, complexity)`
758/// taking the most specific selector and the most complex selector across the
759/// rule's selector list.
760fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
761    let mut max_spec = 0u32;
762    let mut a = 0u16;
763    let mut b = 0u16;
764    let mut c = 0u16;
765    let mut complexity = 0u16;
766    for selector in &style.selectors.0 {
767        let spec = selector.specificity();
768        if spec >= max_spec {
769            max_spec = spec;
770            a = specificity_component(spec, 20);
771            b = specificity_component(spec, 10);
772            c = specificity_component(spec, 0);
773        }
774        complexity = complexity.max(selector_complexity(selector));
775    }
776    (a, b, c, complexity)
777}
778
779fn specificity_component(specificity: u32, shift: u32) -> u16 {
780    saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
781}
782
783fn is_notable(metric: &CssRuleMetric) -> bool {
784    metric.specificity_a >= 1
785        || metric.complexity > MAX_PLAIN_COMPLEXITY
786        || metric.important_count >= 1
787        || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
788}
789
790fn selector_complexity(selector: &Selector<'_>) -> u16 {
791    let mut count = 0u16;
792    count_components(selector, &mut count);
793    count
794}
795
796fn count_components(selector: &Selector<'_>, count: &mut u16) {
797    for component in selector.iter_raw_match_order() {
798        *count = count.saturating_add(1);
799        match component {
800            Component::Is(list)
801            | Component::Where(list)
802            | Component::Has(list)
803            | Component::Negation(list)
804            | Component::Any(_, list) => {
805                for nested in list.as_ref() {
806                    count_components(nested, count);
807                }
808            }
809            Component::Slotted(nested) | Component::Host(Some(nested)) => {
810                count_components(nested, count);
811            }
812            Component::NthOf(data) => {
813                for nested in data.selectors() {
814                    count_components(nested, count);
815                }
816            }
817            _ => {}
818        }
819    }
820}
821
822fn saturate_u32(value: usize) -> u32 {
823    u32::try_from(value).unwrap_or(u32::MAX)
824}
825
826fn saturate_u16(value: usize) -> u16 {
827    u16::try_from(value).unwrap_or(u16::MAX)
828}
829
830fn saturate_u16_u32(value: u32) -> u16 {
831    u16::try_from(value).unwrap_or(u16::MAX)
832}
833
834#[cfg(all(test, not(miri)))]
835mod tests {
836    use super::*;
837
838    fn analytics(source: &str) -> CssAnalytics {
839        compute_css_analytics(source).expect("standard CSS parses")
840    }
841
842    #[test]
843    fn recovers_partial_metrics_around_a_malformed_rule() {
844        // Error recovery skips the broken rule and still records the valid one,
845        // so a file with one bad rule is not lost wholesale.
846        let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
847        assert!(a.rule_count >= 1);
848        assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
849    }
850
851    #[test]
852    fn counts_declarations_and_important() {
853        let a = analytics(".a { color: red; width: 1px !important; }");
854        assert_eq!(a.rule_count, 1);
855        assert_eq!(a.total_declarations, 2);
856        assert_eq!(a.important_declarations, 1);
857    }
858
859    #[test]
860    fn id_selector_is_notable_with_specificity() {
861        let a = analytics("#main { color: red; }");
862        assert_eq!(a.notable_rules.len(), 1);
863        let rule = &a.notable_rules[0];
864        assert_eq!(rule.specificity_a, 1);
865        assert_eq!(rule.specificity_b, 0);
866        assert_eq!(rule.specificity_c, 0);
867    }
868
869    #[test]
870    fn plain_class_rule_is_not_notable() {
871        let a = analytics(".btn { color: red; }");
872        assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
873        assert_eq!(a.rule_count, 1);
874    }
875
876    #[test]
877    fn important_declaration_makes_rule_notable() {
878        let a = analytics(".btn { color: red !important; }");
879        assert_eq!(a.notable_rules.len(), 1);
880        assert_eq!(a.notable_rules[0].important_count, 1);
881    }
882
883    #[test]
884    fn empty_rule_counted() {
885        let a = analytics(".a { } .b { color: red; }");
886        assert_eq!(a.rule_count, 2);
887        assert_eq!(a.empty_rule_count, 1);
888    }
889
890    #[test]
891    fn complex_selector_is_notable() {
892        // Five compound selectors joined by combinators exceeds the floor.
893        let a = analytics("div > ul > li > a > span { color: red; }");
894        assert_eq!(a.notable_rules.len(), 1);
895        assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
896    }
897
898    #[test]
899    fn nesting_depth_tracked() {
900        let a = analytics(".a { .b { .c { .d { color: red; } } } }");
901        assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
902        // The depth-3 rule (`.d`) crosses the nesting floor.
903        assert!(
904            a.notable_rules
905                .iter()
906                .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
907        );
908    }
909
910    #[test]
911    fn specificity_takes_most_specific_selector_in_list() {
912        let a = analytics("#id, .cls { color: red; }");
913        assert_eq!(a.notable_rules.len(), 1);
914        // `#id` (1,0,0) is more specific than `.cls` (0,1,0).
915        assert_eq!(a.notable_rules[0].specificity_a, 1);
916    }
917
918    #[test]
919    fn line_is_one_based() {
920        let a = analytics("\n\n#main { color: red; }");
921        assert_eq!(a.notable_rules[0].line, 3);
922    }
923
924    #[test]
925    fn media_query_rules_walked() {
926        let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
927        assert_eq!(a.rule_count, 1);
928        assert_eq!(a.notable_rules.len(), 1);
929        assert_eq!(a.notable_rules[0].specificity_a, 1);
930    }
931
932    #[test]
933    fn collects_distinct_colors() {
934        let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
935        assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
936    }
937
938    #[test]
939    fn parses_theme_color_values_to_rgb() {
940        assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
941        assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
942        assert_eq!(
943            parse_css_color_rgb("hsl(0 100% 50%)"),
944            Some((255.0, 0.0, 0.0))
945        );
946        assert!(parse_css_color_rgb("var(--brand)").is_none());
947    }
948
949    #[test]
950    fn direct_color_parser_matches_stylesheet_parser_corpus() {
951        let values = [
952            // Hex and named colors.
953            "#000",
954            "#fff",
955            "#f00",
956            "#0f08",
957            "#112233",
958            "#11223344",
959            "#abcdef",
960            "#ABCDEF",
961            "red",
962            "rebeccapurple",
963            "transparent",
964            // Functional RGB syntax, alpha, and clamping.
965            "rgb(255 0 0)",
966            "rgb(100% 0% 0%)",
967            "rgb(255, 0, 0)",
968            "rgba(255, 0, 0, 0.5)",
969            "rgba(255 0 0 / 0)",
970            "rgba(255 0 0 / 1)",
971            "rgb(255 0 0 / 25%)",
972            "rgb(300 -10 0)",
973            // HSL and HWB normalization.
974            "hsl(0 100% 50%)",
975            "hsl(120deg 100% 25% / 0.5)",
976            "hsla(240, 100%, 50%, 25%)",
977            "hwb(0 0% 0%)",
978            "hwb(120 10% 20% / 50%)",
979            // CSS whitespace and comments.
980            " red ",
981            "\t#f00\n",
982            "rgb( 255  0  0 / .5 )",
983            "r/**/ed",
984            "rgb(255/**/ 0 0)",
985            "/* before */ red /* after */",
986            // Valid CSS colors that do not resolve to a concrete RGBA value.
987            "currentColor",
988            "CURRENTCOLOR",
989            "CanvasText",
990            "ButtonFace",
991            "AccentColorText",
992            "var(--brand)",
993            "color-mix(in srgb, red 50%, blue)",
994            "lab(50% 40 30)",
995            "lch(50% 40 30)",
996            "oklab(50% .1 .1)",
997            "oklch(50% .1 30)",
998            "color(display-p3 1 0 0)",
999            // Malformed or trailing input.
1000            "",
1001            "#ff",
1002            "not-a-color",
1003            "rgb(255 0)",
1004            "red junk",
1005            "red, blue",
1006            "red !important junk",
1007            "red ! important junk",
1008            "red !important !important",
1009            "red,",
1010            // Declaration-level syntax accepted by the old wrapper.
1011            "red !important",
1012            "red ! important",
1013            "red ! IMPORTANT",
1014            "red !/**/important",
1015            "red/**/!important",
1016            "red ! important;",
1017            "red !important ;",
1018            "red !important; color: blue",
1019            "red !important; junk",
1020            "#f00!important",
1021            "red;",
1022            "red ; ",
1023            "red;;",
1024            "red; junk",
1025            "red; color: blue",
1026            "red; --custom: value",
1027            "red; :junk",
1028            // Error-recovery syntax that requires the stylesheet fallback.
1029            "not-a-color; color: blue",
1030            "var(--x); color: blue",
1031            "red; @media {}",
1032            "red; --x: }",
1033            "currentColor; color: blue",
1034            "CanvasText; color: blue",
1035            "lab(50% 40 30); color: blue",
1036            "color(display-p3 1 0 0); color: blue",
1037            "red; color: blue !important",
1038            "rgb(1 2 3); color: blue !important",
1039        ];
1040
1041        let mismatches = values
1042            .into_iter()
1043            .filter_map(|value| {
1044                let direct = parse_css_color_rgb(value);
1045                let stylesheet = parse_css_color_rgb_via_stylesheet(value);
1046                (direct != stylesheet).then_some((value, direct, stylesheet))
1047            })
1048            .collect::<Vec<_>>();
1049        assert!(mismatches.is_empty(), "parser mismatches: {mismatches:#?}");
1050    }
1051
1052    #[test]
1053    fn collects_colors_nested_in_shorthands() {
1054        // The color inside the `border` shorthand must be caught, not just the
1055        // standalone `background` color: that is the point of the value visitor.
1056        let a = analytics(".a { border: 1px solid green; background: yellow; }");
1057        assert!(
1058            a.colors.len() >= 2,
1059            "shorthand + standalone colors collected: {:?}",
1060            a.colors
1061        );
1062    }
1063
1064    #[test]
1065    fn collects_distinct_font_sizes() {
1066        let a =
1067            analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
1068        assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
1069    }
1070
1071    #[test]
1072    fn collects_located_raw_style_values_but_skips_tokenized_values() {
1073        let a = analytics(
1074            ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
1075        );
1076        assert!(
1077            a.raw_style_values
1078                .iter()
1079                .any(|value| value.axis == "color" && value.property == "color"),
1080            "raw color should be located: {:?}",
1081            a.raw_style_values
1082        );
1083        assert!(
1084            a.raw_style_values
1085                .iter()
1086                .any(|value| value.axis == "font-size" && value.value == "14px"),
1087            "raw font size should be located: {:?}",
1088            a.raw_style_values
1089        );
1090        assert!(
1091            a.raw_style_values
1092                .iter()
1093                .any(|value| value.axis == "radius" && value.line == 2),
1094            "raw radius should be located on the second rule: {:?}",
1095            a.raw_style_values
1096        );
1097        assert!(
1098            !a.raw_style_values
1099                .iter()
1100                .any(|value| value.property == "margin-top"),
1101            "layout spacing should not be a raw-value candidate: {:?}",
1102            a.raw_style_values
1103        );
1104        assert!(
1105            !a.raw_style_values
1106                .iter()
1107                .any(|value| value.property == "z-index"),
1108            "z-index should stay a scale summary, not an audit candidate: {:?}",
1109            a.raw_style_values
1110        );
1111        assert!(
1112            !a.raw_style_values
1113                .iter()
1114                .any(|value| value.value == "transparent"),
1115            "transparent should behave like a reset keyword: {:?}",
1116            a.raw_style_values
1117        );
1118    }
1119
1120    #[test]
1121    fn collects_distinct_z_indexes() {
1122        let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
1123        assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
1124    }
1125
1126    #[test]
1127    fn collects_defined_and_referenced_custom_properties() {
1128        let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
1129        assert!(
1130            a.defined_custom_properties.contains(&"--brand".to_string()),
1131            "defined: {:?}",
1132            a.defined_custom_properties
1133        );
1134        assert!(
1135            a.defined_custom_properties
1136                .contains(&"--unused".to_string())
1137        );
1138        assert!(
1139            a.referenced_custom_properties
1140                .contains(&"--brand".to_string()),
1141            "referenced: {:?}",
1142            a.referenced_custom_properties
1143        );
1144        assert!(
1145            !a.referenced_custom_properties
1146                .contains(&"--unused".to_string()),
1147            "--unused has no var() reference"
1148        );
1149    }
1150
1151    #[test]
1152    fn collects_defined_and_referenced_keyframes() {
1153        let a = analytics(
1154            "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
1155        );
1156        assert!(a.defined_keyframes.contains(&"spin".to_string()));
1157        assert!(a.defined_keyframes.contains(&"unused".to_string()));
1158        assert!(a.referenced_keyframes.contains(&"spin".to_string()));
1159        assert!(
1160            !a.referenced_keyframes.contains(&"unused".to_string()),
1161            "no animation references `unused`"
1162        );
1163    }
1164
1165    #[test]
1166    fn animation_shorthand_references_keyframes() {
1167        let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
1168        assert!(
1169            a.referenced_keyframes.contains(&"pulse".to_string()),
1170            "referenced: {:?}",
1171            a.referenced_keyframes
1172        );
1173    }
1174
1175    #[test]
1176    fn animation_shorthand_with_var_references_keyframes() {
1177        // A `var()` in the shorthand makes lightningcss keep the declaration as
1178        // an unparsed token list. The literal name must still be credited.
1179        let a = analytics(
1180            "@keyframes pulse { from {} }\n\
1181             .a { animation: pulse var(--duration) ease; }",
1182        );
1183        assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1184    }
1185
1186    #[test]
1187    fn animation_shorthand_with_var_credits_each_comma_layer() {
1188        let a = analytics(
1189            "@keyframes fade { from {} }\n@keyframes slide { from {} }\n\
1190             .a { animation: fade 1s infinite, slide var(--d) cubic-bezier(0, 0, 1, 1) both; }\n\
1191             .b { -webkit-animation: fade var(--d); }",
1192        );
1193        assert_eq!(
1194            a.referenced_keyframes,
1195            vec!["fade".to_string(), "slide".to_string()]
1196        );
1197    }
1198
1199    #[test]
1200    fn var_in_animation_name_position_stays_unresolved() {
1201        let a = analytics(
1202            "@keyframes pulse { from {} }\n\
1203             .a { animation: var(--name) 1s ease-in-out infinite alternate both; }\n\
1204             .b { animation-name: var(--other); }",
1205        );
1206        assert!(
1207            a.referenced_keyframes.is_empty(),
1208            "no literal name: {:?}",
1209            a.referenced_keyframes
1210        );
1211    }
1212
1213    #[test]
1214    fn invalid_animation_value_credits_no_keyframe() {
1215        // An invalid value also reaches the unparsed path. The browser drops
1216        // the declaration, so no name in it counts as a reference.
1217        let a = analytics(
1218            "@keyframes spin { from {} }\n\
1219             .a { animation: spin 1s smooth; }\n\
1220             .b { animation: spin 1x; }",
1221        );
1222        assert!(
1223            a.referenced_keyframes.is_empty(),
1224            "invalid values credit nothing: {:?}",
1225            a.referenced_keyframes
1226        );
1227    }
1228
1229    #[test]
1230    fn ambiguous_var_layer_credits_no_keyframe() {
1231        // Two non-keyword identifiers in one layer leave the name unknown.
1232        let a = analytics(".a { animation: spin var(--d) smooth, fade var(--d); }");
1233        assert_eq!(a.referenced_keyframes, vec!["fade".to_string()]);
1234    }
1235
1236    #[test]
1237    fn animation_composition_keywords_are_not_names() {
1238        let a = analytics(".a { animation: x var(--d) replace add accumulate; }");
1239        assert_eq!(a.referenced_keyframes, vec!["x".to_string()]);
1240    }
1241
1242    #[test]
1243    fn var_nested_in_function_reaches_the_name() {
1244        let a = analytics(".a { animation: pulse calc(var(--d) * 2) ease; }");
1245        assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1246    }
1247
1248    #[test]
1249    fn animation_name_list_with_var_credits_easing_named_keyframe() {
1250        // In `animation-name`, only `none` and the CSS-wide keywords are not
1251        // names, so a keyframe called `ease` counts.
1252        let a = analytics(
1253            "@keyframes ease { from {} }\n\
1254             .a { animation-name: var(--x), ease, none, inherit; }",
1255        );
1256        assert_eq!(a.referenced_keyframes, vec!["ease".to_string()]);
1257    }
1258
1259    #[test]
1260    fn animation_name_list_with_var_credits_literal_names() {
1261        let a = analytics(
1262            "@keyframes spin { from {} }\n\
1263             .a { animation-name: var(--first), spin; }",
1264        );
1265        assert_eq!(a.referenced_keyframes, vec!["spin".to_string()]);
1266    }
1267
1268    #[test]
1269    fn fingerprints_blocks_at_floor_order_insensitive() {
1270        // Two 4-declaration rules with the same declarations in different order
1271        // share a fingerprint; a 3-declaration rule is below the floor and is
1272        // not fingerprinted.
1273        let a = analytics(
1274            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1275             .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
1276             .z { color: red; margin: 1px; padding: 2px; }\n",
1277        );
1278        assert_eq!(
1279            a.declaration_blocks.len(),
1280            2,
1281            "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
1282            a.declaration_blocks
1283        );
1284        assert_eq!(
1285            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1286            "same declarations in different order share a fingerprint"
1287        );
1288        assert_eq!(a.declaration_blocks[0].declaration_count, 4);
1289    }
1290
1291    #[test]
1292    fn important_distinguishes_block_fingerprint() {
1293        let a = analytics(
1294            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1295             .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
1296        );
1297        assert_eq!(a.declaration_blocks.len(), 2);
1298        assert_ne!(
1299            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1300            "!important changes the block fingerprint"
1301        );
1302    }
1303
1304    #[test]
1305    fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
1306        // Load-bearing for the v3 styling-health sprawl drift sub-term: the
1307        // distinct-value sets (box_shadows / border_radii / line_heights) count
1308        // ONLY hardcoded literals. A value referenced via `var(--*)` parses as
1309        // `Property::Unparsed` in lightningcss, so it never reaches the typed
1310        // `Property::BoxShadow` / `BorderRadius` / `LineHeight` arms, and a token
1311        // DEFINITION (`--x: 4px`) is a `Property::Custom`. Both are therefore
1312        // invisible to the sprawl counts. This is what makes a well-tokenized
1313        // design system score 0 sprawl regardless of how many tokens it defines;
1314        // the v3 grade's entire FP-safety rests on this lightningcss behavior, so
1315        // it is pinned here (a future lightningcss change that typed `var()` values
1316        // would break tokenized systems silently and must trip this test).
1317        let tokenized = analytics(
1318            ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
1319             .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
1320             .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
1321        );
1322        assert!(
1323            tokenized.border_radii.is_empty(),
1324            "var()-referenced radii are not counted: {:?}",
1325            tokenized.border_radii
1326        );
1327        assert!(
1328            tokenized.box_shadows.is_empty(),
1329            "var()-referenced shadows are not counted: {:?}",
1330            tokenized.box_shadows
1331        );
1332        assert!(
1333            tokenized.line_heights.is_empty(),
1334            "var()-referenced line-heights are not counted: {:?}",
1335            tokenized.line_heights
1336        );
1337
1338        // Control: hardcoded literal values ARE counted, so the sprawl signal is
1339        // not simply inert. Two distinct hardcoded radii / shadows / line-heights.
1340        let hardcoded = analytics(
1341            ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
1342             .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
1343        );
1344        assert_eq!(
1345            hardcoded.border_radii.len(),
1346            2,
1347            "distinct hardcoded radii counted: {:?}",
1348            hardcoded.border_radii
1349        );
1350        assert_eq!(
1351            hardcoded.box_shadows.len(),
1352            2,
1353            "distinct hardcoded shadows counted: {:?}",
1354            hardcoded.box_shadows
1355        );
1356        assert_eq!(
1357            hardcoded.line_heights.len(),
1358            2,
1359            "distinct hardcoded line-heights counted: {:?}",
1360            hardcoded.line_heights
1361        );
1362    }
1363}