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` on a hard parse failure and on a stylesheet that nests
67/// brackets too deep for the parser stack; with error recovery on,
68/// individual malformed rules are skipped and the rest of the sheet still
69/// contributes. Callers must gate by extension and NOT pass `.scss` sources:
70/// Sass syntax is not standard CSS and recovers into an inaccurate partial
71/// rather than `None`. Parsing runs in CSS Modules mode so `:local()` /
72/// `:global()` selectors are understood.
73#[must_use]
74pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
75    crate::parse_style_bounded(source, || css_analytics_unbounded(source)).flatten()
76}
77
78fn css_analytics_unbounded(source: &str) -> Option<CssAnalytics> {
79    let options = ParserOptions {
80        error_recovery: true,
81        css_modules: Some(lightningcss::css_modules::Config::default()),
82        ..ParserOptions::default()
83    };
84    let mut stylesheet = StyleSheet::parse(source, options).ok()?;
85
86    // Pass 1: walk the rule tree for structural metrics + font-size / z-index
87    // design tokens (these are top-level declaration properties).
88    let mut acc = Accumulator::default();
89    walk_rules(&stylesheet.rules.0, 0, &mut acc);
90
91    // Pass 2: visit every color value (including colors nested inside shorthands
92    // and gradients) for the design-token-sprawl signal. The visitor needs `&mut`,
93    // so it runs after the immutable rule walk above.
94    let mut collector = ValueCollector::default();
95    let _ = collector.visit_stylesheet(&mut stylesheet);
96
97    let mut analytics = acc.analytics;
98    analytics.colors = sorted_vec(collector.colors);
99    analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
100    analytics.font_sizes = sorted_vec(acc.font_sizes);
101    analytics.z_indexes = sorted_vec(acc.z_indexes);
102    analytics.box_shadows = sorted_vec(acc.box_shadows);
103    analytics.border_radii = sorted_vec(acc.border_radii);
104    analytics.line_heights = sorted_vec(acc.line_heights);
105    analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
106    analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
107    analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
108    analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
109    analytics.declared_layers = sorted_vec(acc.declared_layers);
110    analytics.populated_layers = sorted_vec(acc.populated_layers);
111    analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
112    analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
113    Some(analytics)
114}
115
116/// Working accumulator threaded through the rule walk: the structural analytics
117/// plus the per-stylesheet sets of distinct `font-size` / `z-index` values.
118#[derive(Default)]
119struct Accumulator {
120    analytics: CssAnalytics,
121    font_sizes: FxHashSet<String>,
122    z_indexes: FxHashSet<String>,
123    box_shadows: FxHashSet<String>,
124    border_radii: FxHashSet<String>,
125    line_heights: FxHashSet<String>,
126    defined_custom_properties: FxHashSet<String>,
127    defined_keyframes: FxHashSet<String>,
128    referenced_keyframes: FxHashSet<String>,
129    registered_custom_properties: FxHashSet<String>,
130    declared_layers: FxHashSet<String>,
131    populated_layers: FxHashSet<String>,
132    defined_font_faces: FxHashSet<String>,
133    referenced_font_families: FxHashSet<String>,
134    raw_style_value_keys: FxHashSet<String>,
135}
136
137/// The concrete family name of a `font-family` value, or `None` for a generic
138/// keyword (`serif`, `sans-serif`, `monospace`, ...), which is never an authored
139/// `@font-face`.
140fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
141    match family {
142        // Render the family via ToCss and strip surrounding quotes so a declared
143        // `font-family: "Inter"` and a referenced `font-family: Inter` normalize
144        // to the same key.
145        FontFamily::FamilyName(_) => family
146            .to_css_string(PrinterOptions::default())
147            .ok()
148            .map(|s| s.trim_matches(['"', '\'']).to_string()),
149        FontFamily::Generic(_) => None,
150    }
151}
152
153/// Collects value-level design tokens via the lightningcss visitor: every
154/// distinct color (including colors nested in shorthands like `border` /
155/// `background` and gradients, not just standalone `color:` values) and every
156/// `var()` custom-property reference.
157#[derive(Default)]
158struct ValueCollector {
159    colors: FxHashSet<String>,
160    referenced_custom_properties: FxHashSet<String>,
161}
162
163impl Visitor<'_> for ValueCollector {
164    type Error = std::convert::Infallible;
165
166    fn visit_types(&self) -> VisitTypes {
167        VisitTypes::COLORS | VisitTypes::VARIABLES
168    }
169
170    fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
171        if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
172            self.colors.insert(rendered);
173        }
174        Ok(())
175    }
176
177    fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
178        self.referenced_custom_properties
179            .insert(var.name.ident.0.to_string());
180        Ok(())
181    }
182}
183
184#[derive(Default)]
185struct FirstRgbColorCollector {
186    rgb: Option<(f64, f64, f64)>,
187}
188
189impl Visitor<'_> for FirstRgbColorCollector {
190    type Error = std::convert::Infallible;
191
192    fn visit_types(&self) -> VisitTypes {
193        VisitTypes::COLORS
194    }
195
196    fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
197        if self.rgb.is_none()
198            && let CssColor::RGBA(rgba) = color
199        {
200            self.rgb = Some((
201                f64::from(rgba.red),
202                f64::from(rgba.green),
203                f64::from(rgba.blue),
204            ));
205        }
206        Ok(())
207    }
208}
209
210/// Parse one CSS color value and return its sRGB channels when lightningcss can
211/// normalize it to RGB. Supports hex, named colors, rgb(), hsl(), and hwb().
212#[must_use]
213pub fn parse_css_color_rgb(value: &str) -> Option<(f64, f64, f64)> {
214    crate::parse_style_bounded(value, || css_color_rgb_unbounded(value)).flatten()
215}
216
217fn css_color_rgb_unbounded(value: &str) -> Option<(f64, f64, f64)> {
218    if let Ok(color) = CssColor::parse_string(value)
219        && let Some(rgb) = concrete_rgb(&color)
220    {
221        return Some(rgb);
222    }
223    if let Some(color) = parse_declaration_color(value)
224        && let Some(rgb) = concrete_rgb(&color)
225    {
226        return Some(rgb);
227    }
228    parse_css_color_rgb_via_stylesheet(value)
229}
230
231fn concrete_rgb(color: &CssColor) -> Option<(f64, f64, f64)> {
232    let CssColor::RGBA(rgba) = color else {
233        return None;
234    };
235    Some((
236        f64::from(rgba.red),
237        f64::from(rgba.green),
238        f64::from(rgba.blue),
239    ))
240}
241
242fn parse_declaration_color(value: &str) -> Option<CssColor> {
243    let mut input = ParserInput::new(value);
244    let mut parser = Parser::new(&mut input);
245    let color = parser
246        .parse_until_before(Delimiter::Semicolon, |input| {
247            let color = CssColor::parse(input)?;
248            let _ = input.try_parse(parse_important);
249            input.expect_exhausted()?;
250            Ok(color)
251        })
252        .ok()?;
253    if !parser.is_exhausted() {
254        parser.expect_semicolon().ok()?;
255        parser.expect_exhausted().ok()?;
256    }
257    Some(color)
258}
259
260fn parse_css_color_rgb_via_stylesheet(value: &str) -> Option<(f64, f64, f64)> {
261    let source = format!(".x{{color:{value};}}");
262    let options = ParserOptions {
263        error_recovery: true,
264        ..ParserOptions::default()
265    };
266    let mut stylesheet = StyleSheet::parse(&source, options).ok()?;
267    let mut collector = FirstRgbColorCollector::default();
268    let _ = collector.visit_stylesheet(&mut stylesheet);
269    collector.rgb
270}
271
272fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
273    let mut values: Vec<String> = set.into_iter().collect();
274    values.sort_unstable();
275    values
276}
277
278/// Recursively walk rules, tracking style-rule nesting depth. Grouping rules
279/// (`@media` / `@supports` / `@container` / `@layer {}` / `@document` /
280/// `@starting-style` / `@scope`) pass their nesting depth through unchanged;
281/// only nesting INSIDE a style rule increases the depth.
282fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
283    for rule in rules {
284        match rule {
285            CssRule::Style(style) => {
286                record_style_rule(style, depth, acc);
287                walk_rules(&style.rules.0, depth.saturating_add(1), acc);
288            }
289            CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
290            CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
291            CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
292            CssRule::LayerBlock(rule) => {
293                // A named `@layer a { }` both declares and populates layer `a`.
294                if let Some(name) = &rule.name {
295                    let name = layer_name_string(name);
296                    acc.declared_layers.insert(name.clone());
297                    acc.populated_layers.insert(name);
298                }
299                walk_rules(&rule.rules.0, depth, acc);
300            }
301            CssRule::LayerStatement(stmt) => {
302                // `@layer a, b, c;` declares ordering but populates nothing.
303                for name in &stmt.names {
304                    acc.declared_layers.insert(layer_name_string(name));
305                }
306            }
307            CssRule::Property(prop) => {
308                acc.registered_custom_properties
309                    .insert(prop.name.0.to_string());
310            }
311            CssRule::FontFace(font_face) => {
312                for property in &font_face.properties {
313                    if let FontFaceProperty::FontFamily(family) = property
314                        && let Some(name) = font_family_name(family)
315                    {
316                        acc.defined_font_faces.insert(name);
317                    }
318                }
319            }
320            CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
321            CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
322            CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
323            CssRule::Nesting(rule) => {
324                record_style_rule(&rule.style, depth, acc);
325                walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
326            }
327            CssRule::Keyframes(keyframes) => {
328                acc.defined_keyframes
329                    .insert(keyframes_name_string(&keyframes.name));
330            }
331            _ => {}
332        }
333    }
334}
335
336fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
337    name.0
338        .iter()
339        .map(std::string::ToString::to_string)
340        .collect::<Vec<_>>()
341        .join(".")
342}
343
344fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
345    match name {
346        KeyframesName::Ident(ident) => ident.0.to_string(),
347        KeyframesName::Custom(value) => value.to_string(),
348    }
349}
350
351fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
352    if let AnimationName::Ident(ident) = name {
353        out.insert(ident.0.to_string());
354    }
355}
356
357fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
358    let normal = style.declarations.declarations.len();
359    let important = style.declarations.important_declarations.len();
360    let declaration_count = normal + important;
361
362    let analytics = &mut acc.analytics;
363    analytics.rule_count = analytics.rule_count.saturating_add(1);
364    analytics.total_declarations = analytics
365        .total_declarations
366        .saturating_add(saturate_u32(declaration_count));
367    analytics.important_declarations = analytics
368        .important_declarations
369        .saturating_add(saturate_u32(important));
370    if declaration_count == 0 {
371        analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
372    }
373    analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
374
375    let (a, b, c, complexity) = rule_selector_metrics(style);
376    let metric = CssRuleMetric {
377        line: style.loc.line.saturating_add(1),
378        col: style.loc.column,
379        specificity_a: a,
380        specificity_b: b,
381        specificity_c: c,
382        complexity,
383        declaration_count: saturate_u16(declaration_count),
384        important_count: saturate_u16(important),
385        nesting_depth: depth,
386    };
387
388    if is_notable(&metric) {
389        if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
390            analytics.notable_rules.push(metric);
391        } else {
392            analytics.notable_truncated = true;
393        }
394    }
395
396    // Fingerprint the declaration block (sorted, !important-tagged) for cross-file
397    // duplicate-block detection, gated on the minimum block size and a per-file cap.
398    if declaration_count >= MIN_BLOCK_DECLARATIONS
399        && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
400        && let Some(fingerprint) = declaration_block_fingerprint(style)
401    {
402        analytics.declaration_blocks.push(CssDeclarationBlock {
403            fingerprint,
404            line: style.loc.line.saturating_add(1),
405            declaration_count: saturate_u16(declaration_count),
406        });
407    }
408
409    collect_rule_property_tokens(style, acc, style.loc.line.saturating_add(1));
410}
411
412/// Scan a rule's declarations (normal + `!important`) for design-token values,
413/// custom-property definitions, and `@keyframes` / font-family references,
414/// folding them into `acc`. Colors and `var()` references are collected
415/// separately by the value visitor.
416fn collect_rule_property_tokens(style: &StyleRule<'_>, acc: &mut Accumulator, rule_line: u32) {
417    for property in style
418        .declarations
419        .declarations
420        .iter()
421        .chain(style.declarations.important_declarations.iter())
422    {
423        collect_property_tokens(property, acc, rule_line);
424        collect_raw_style_value(property, acc, rule_line);
425    }
426}
427
428/// Fold a single declaration's design-token value, custom-property definition,
429/// `@keyframes` reference, or font-family reference into `acc`.
430fn collect_property_tokens(property: &Property<'_>, acc: &mut Accumulator, rule_line: u32) {
431    match property {
432        Property::FontSize(font_size) => {
433            insert_rendered_css(font_size, &mut acc.font_sizes);
434        }
435        Property::ZIndex(z_index) => {
436            insert_rendered_css(z_index, &mut acc.z_indexes);
437        }
438        // Shadow / radius / line-height tokens (design-token-sprawl axes).
439        // The INNER value is serialized (not the property), so the vendor
440        // prefix is dropped and `-webkit-box-shadow: X` collapses to the same
441        // distinct value as `box-shadow: X` rather than inflating the count.
442        Property::BoxShadow(shadows, _) => collect_box_shadow_tokens(shadows, acc),
443        Property::BorderRadius(radius, _) => {
444            insert_rendered_css(radius, &mut acc.border_radii);
445        }
446        Property::LineHeight(line_height) => {
447            insert_rendered_css(line_height, &mut acc.line_heights);
448        }
449        Property::Custom(custom) => {
450            collect_custom_property_tokens(custom, property, acc, rule_line);
451        }
452        Property::AnimationName(names, _) => {
453            collect_animation_references(names, &mut acc.referenced_keyframes);
454        }
455        Property::Animation(animations, _) => {
456            for animation in animations {
457                collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
458            }
459        }
460        Property::Unparsed(unparsed) => {
461            collect_unparsed_animation_references(unparsed, &mut acc.referenced_keyframes);
462        }
463        Property::FontFamily(families) => {
464            collect_font_family_references(families, &mut acc.referenced_font_families);
465        }
466        Property::Font(font) => {
467            collect_font_family_references(&font.family, &mut acc.referenced_font_families);
468        }
469        _ => {}
470    }
471}
472
473fn insert_rendered_css<T: ToCss>(value: &T, out: &mut FxHashSet<String>) {
474    if let Ok(rendered) = value.to_css_string(PrinterOptions::default()) {
475        out.insert(rendered);
476    }
477}
478
479fn collect_raw_style_value(property: &Property<'_>, acc: &mut Accumulator, line: u32) {
480    if acc.analytics.raw_style_values.len() >= MAX_RAW_STYLE_VALUES {
481        return;
482    }
483    let Ok(rendered) = property.to_css_string(false, PrinterOptions::default()) else {
484        return;
485    };
486    let Some((property_name, value)) = rendered.split_once(':') else {
487        return;
488    };
489    let property_name = property_name.trim().to_ascii_lowercase();
490    if property_name.starts_with("--") {
491        return;
492    }
493    let value = value.trim().trim_end_matches(';').trim().to_string();
494    let Some(axis) = raw_style_axis(&property_name, &value) else {
495        return;
496    };
497    if !is_raw_style_literal(&value) {
498        return;
499    }
500    let key = format!("{axis}:{property_name}:{value}:{line}");
501    if !acc.raw_style_value_keys.insert(key) {
502        return;
503    }
504    acc.analytics.raw_style_values.push(CssRawStyleValue {
505        axis: axis.to_string(),
506        property: property_name,
507        value,
508        line,
509    });
510}
511
512fn raw_style_axis(property_name: &str, value: &str) -> Option<&'static str> {
513    if property_name.contains("color") && looks_like_color_literal(value) {
514        return Some("color");
515    }
516    match property_name {
517        "font-size" => Some("font-size"),
518        "line-height" => Some("line-height"),
519        "border-radius"
520        | "border-top-left-radius"
521        | "border-top-right-radius"
522        | "border-bottom-right-radius"
523        | "border-bottom-left-radius" => Some("radius"),
524        "box-shadow" | "text-shadow" => Some("shadow"),
525        _ => None,
526    }
527}
528
529fn is_raw_style_literal(value: &str) -> bool {
530    let lower = value.to_ascii_lowercase();
531    if lower.contains("var(") || lower.contains("token(") || lower.contains("theme(") {
532        return false;
533    }
534    if matches!(
535        lower.as_str(),
536        "0" | "none"
537            | "normal"
538            | "inherit"
539            | "initial"
540            | "unset"
541            | "revert"
542            | "currentcolor"
543            | "transparent"
544    ) {
545        return false;
546    }
547    lower.chars().any(|ch| ch.is_ascii_digit()) || looks_like_color_literal(&lower)
548}
549
550fn looks_like_color_literal(value: &str) -> bool {
551    let lower = value.to_ascii_lowercase();
552    lower.starts_with('#')
553        || lower.contains("rgb(")
554        || lower.contains("rgba(")
555        || lower.contains("hsl(")
556        || lower.contains("hsla(")
557        || lower.contains("oklch(")
558        || lower.contains("color-mix(")
559        || matches!(
560            lower.as_str(),
561            "red"
562                | "blue"
563                | "green"
564                | "black"
565                | "white"
566                | "gray"
567                | "grey"
568                | "transparent"
569                | "yellow"
570                | "orange"
571                | "purple"
572                | "pink"
573        )
574}
575
576fn collect_box_shadow_tokens(shadows: &[BoxShadow], acc: &mut Accumulator) {
577    let rendered: Vec<String> = shadows
578        .iter()
579        .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
580        .collect();
581    if !rendered.is_empty() && rendered.len() == shadows.len() {
582        acc.box_shadows.insert(rendered.join(", "));
583    }
584}
585
586fn collect_animation_references(names: &[AnimationName<'_>], out: &mut FxHashSet<String>) {
587    for name in names {
588        collect_animation_name(name, out);
589    }
590}
591
592/// The CSS-wide keywords plus `none`. In an `animation-name` list these are the
593/// only identifiers that are not keyframe names.
594const ANIMATION_NAME_KEYWORDS: &[&str] = &[
595    "none",
596    "initial",
597    "inherit",
598    "unset",
599    "revert",
600    "revert-layer",
601];
602
603/// Keywords that can fill a non-name slot of an `animation` shorthand layer.
604/// The shorthand also rejects every keyword of [`ANIMATION_NAME_KEYWORDS`].
605const ANIMATION_SHORTHAND_KEYWORDS: &[&str] = &[
606    "linear",
607    "ease",
608    "ease-in",
609    "ease-out",
610    "ease-in-out",
611    "step-start",
612    "step-end",
613    "infinite",
614    "normal",
615    "reverse",
616    "alternate",
617    "alternate-reverse",
618    "forwards",
619    "backwards",
620    "both",
621    "running",
622    "paused",
623    "auto",
624    "replace",
625    "add",
626    "accumulate",
627];
628
629/// Credit the literal keyframe names in an `animation` / `animation-name`
630/// declaration that lightningcss keeps as a raw token list because the value
631/// contains `var()` or `env()`, for example
632/// `animation: pulse var(--duration) ease` (issue #3349).
633///
634/// lightningcss also keeps an invalid value as raw tokens. The browser drops
635/// such a declaration, so a value without `var()` or `env()` credits nothing.
636/// In each comma-separated layer, a top-level identifier counts only when it
637/// is the one non-keyword identifier of that layer, so a `var()` in the name
638/// slot or an unknown keyword stays unresolved.
639fn collect_unparsed_animation_references(
640    unparsed: &UnparsedProperty<'_>,
641    out: &mut FxHashSet<String>,
642) {
643    let is_shorthand = match unparsed.property_id {
644        PropertyId::Animation(_) => true,
645        PropertyId::AnimationName(_) => false,
646        _ => return,
647    };
648    if !has_substitution(&unparsed.value.0) {
649        return;
650    }
651    for layer in unparsed
652        .value
653        .0
654        .split(|token| matches!(token, TokenOrValue::Token(Token::Comma)))
655    {
656        if let Some(name) = layer_animation_name(layer, is_shorthand) {
657            out.insert(name);
658        }
659    }
660}
661
662/// Returns `true` when a token list holds a `var()` or `env()` at any depth.
663fn has_substitution(tokens: &[TokenOrValue<'_>]) -> bool {
664    tokens.iter().any(|token| match token {
665        TokenOrValue::Var(_) | TokenOrValue::Env(_) => true,
666        TokenOrValue::Function(function) => has_substitution(&function.arguments.0),
667        _ => false,
668    })
669}
670
671/// Returns the keyframe name of one animation layer when exactly one
672/// top-level identifier in it is not a keyword.
673fn layer_animation_name(layer: &[TokenOrValue<'_>], is_shorthand: bool) -> Option<String> {
674    let is_keyword = |ident: &str| {
675        ANIMATION_NAME_KEYWORDS
676            .iter()
677            .chain(if is_shorthand {
678                ANIMATION_SHORTHAND_KEYWORDS.iter()
679            } else {
680                [].iter()
681            })
682            .any(|keyword| ident.eq_ignore_ascii_case(keyword))
683    };
684    let mut names = layer.iter().filter_map(|token| match token {
685        TokenOrValue::Token(Token::Ident(ident)) if !is_keyword(ident) => Some(ident.to_string()),
686        TokenOrValue::AnimationName(AnimationName::Ident(ident)) => Some(ident.0.to_string()),
687        _ => None,
688    });
689    let name = names.next()?;
690    names.next().is_none().then_some(name)
691}
692
693fn collect_font_family_references(families: &[FontFamily<'_>], out: &mut FxHashSet<String>) {
694    for family in families {
695        if let Some(name) = font_family_name(family) {
696            out.insert(name);
697        }
698    }
699}
700
701/// Record a custom-property definition and credit any font-family string / ident
702/// values referenced inside its raw token stream.
703fn collect_custom_property_tokens(
704    custom: &CustomProperty<'_>,
705    property: &Property<'_>,
706    acc: &mut Accumulator,
707    rule_line: u32,
708) {
709    if let CustomPropertyName::Custom(name) = &custom.name {
710        let name = name.0.to_string();
711        acc.defined_custom_properties.insert(name.clone());
712        if let Ok(rendered) = property.to_css_string(false, PrinterOptions::default())
713            && let Some((_, value)) = rendered.split_once(':')
714        {
715            let value = value.trim().trim_end_matches(';').trim();
716            if !value.is_empty() {
717                acc.analytics
718                    .custom_property_definitions
719                    .push(CssCustomPropertyDefinition {
720                        name,
721                        value: value.to_string(),
722                        line: rule_line,
723                    });
724            }
725        }
726    }
727    // A custom-property value can REFERENCE a font family without a
728    // `font-family:` declaration: a Tailwind v4 `--font-*` theme token
729    // (`--font-display: "Departure Mono", monospace`) is the canonical
730    // case. lightningcss's `Property::FontFamily` / `Property::Font`
731    // arms above never see this (a `--*:` declaration is an opaque
732    // token stream), so scan the raw tokens for string / ident values
733    // and credit them as referenced families. Generic keywords
734    // (`serif`, `monospace`) never appear in `defined_font_faces`, so
735    // crediting them here is inert; the `unused_font_faces`
736    // set-difference only ever drops a genuinely-declared family.
737    for token in &custom.value.0 {
738        if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
739            acc.referenced_font_families.insert(value.to_string());
740        }
741    }
742}
743
744/// Fingerprint a rule's declaration block: serialize each declaration (tagging
745/// `!important` ones, which lightningcss stores without the flag, so they do not
746/// collide with their non-important twin), sort for order-insensitivity, join,
747/// and xxh3-hash. Returns `None` if any declaration fails to serialize, so a
748/// partial block is never fingerprinted (a false duplicate match would be worse
749/// than missing one).
750fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
751    let block = &style.declarations;
752    let mut parts: Vec<String> =
753        Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
754    for decl in &block.declarations {
755        parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
756    }
757    for decl in &block.important_declarations {
758        // `important = true` renders the `!important` suffix, so a block with an
759        // important declaration never collides with its non-important twin.
760        parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
761    }
762    parts.sort_unstable();
763    Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
764}
765
766/// Return the rule's `(specificity_a, specificity_b, specificity_c, complexity)`
767/// taking the most specific selector and the most complex selector across the
768/// rule's selector list.
769fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
770    let mut max_spec = 0u32;
771    let mut a = 0u16;
772    let mut b = 0u16;
773    let mut c = 0u16;
774    let mut complexity = 0u16;
775    for selector in &style.selectors.0 {
776        let spec = selector.specificity();
777        if spec >= max_spec {
778            max_spec = spec;
779            a = specificity_component(spec, 20);
780            b = specificity_component(spec, 10);
781            c = specificity_component(spec, 0);
782        }
783        complexity = complexity.max(selector_complexity(selector));
784    }
785    (a, b, c, complexity)
786}
787
788fn specificity_component(specificity: u32, shift: u32) -> u16 {
789    saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
790}
791
792fn is_notable(metric: &CssRuleMetric) -> bool {
793    metric.specificity_a >= 1
794        || metric.complexity > MAX_PLAIN_COMPLEXITY
795        || metric.important_count >= 1
796        || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
797}
798
799fn selector_complexity(selector: &Selector<'_>) -> u16 {
800    let mut count = 0u16;
801    count_components(selector, &mut count);
802    count
803}
804
805fn count_components(selector: &Selector<'_>, count: &mut u16) {
806    for component in selector.iter_raw_match_order() {
807        *count = count.saturating_add(1);
808        match component {
809            Component::Is(list)
810            | Component::Where(list)
811            | Component::Has(list)
812            | Component::Negation(list)
813            | Component::Any(_, list) => {
814                for nested in list.as_ref() {
815                    count_components(nested, count);
816                }
817            }
818            Component::Slotted(nested) | Component::Host(Some(nested)) => {
819                count_components(nested, count);
820            }
821            Component::NthOf(data) => {
822                for nested in data.selectors() {
823                    count_components(nested, count);
824                }
825            }
826            _ => {}
827        }
828    }
829}
830
831fn saturate_u32(value: usize) -> u32 {
832    u32::try_from(value).unwrap_or(u32::MAX)
833}
834
835fn saturate_u16(value: usize) -> u16 {
836    u16::try_from(value).unwrap_or(u16::MAX)
837}
838
839fn saturate_u16_u32(value: u32) -> u16 {
840    u16::try_from(value).unwrap_or(u16::MAX)
841}
842
843#[cfg(all(test, not(miri)))]
844mod tests {
845    use super::*;
846
847    fn analytics(source: &str) -> CssAnalytics {
848        compute_css_analytics(source).expect("standard CSS parses")
849    }
850
851    #[test]
852    fn recovers_partial_metrics_around_a_malformed_rule() {
853        // Error recovery skips the broken rule and still records the valid one,
854        // so a file with one bad rule is not lost wholesale.
855        let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
856        assert!(a.rule_count >= 1);
857        assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
858    }
859
860    #[test]
861    fn counts_declarations_and_important() {
862        let a = analytics(".a { color: red; width: 1px !important; }");
863        assert_eq!(a.rule_count, 1);
864        assert_eq!(a.total_declarations, 2);
865        assert_eq!(a.important_declarations, 1);
866    }
867
868    #[test]
869    fn id_selector_is_notable_with_specificity() {
870        let a = analytics("#main { color: red; }");
871        assert_eq!(a.notable_rules.len(), 1);
872        let rule = &a.notable_rules[0];
873        assert_eq!(rule.specificity_a, 1);
874        assert_eq!(rule.specificity_b, 0);
875        assert_eq!(rule.specificity_c, 0);
876    }
877
878    #[test]
879    fn plain_class_rule_is_not_notable() {
880        let a = analytics(".btn { color: red; }");
881        assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
882        assert_eq!(a.rule_count, 1);
883    }
884
885    #[test]
886    fn important_declaration_makes_rule_notable() {
887        let a = analytics(".btn { color: red !important; }");
888        assert_eq!(a.notable_rules.len(), 1);
889        assert_eq!(a.notable_rules[0].important_count, 1);
890    }
891
892    #[test]
893    fn empty_rule_counted() {
894        let a = analytics(".a { } .b { color: red; }");
895        assert_eq!(a.rule_count, 2);
896        assert_eq!(a.empty_rule_count, 1);
897    }
898
899    #[test]
900    fn complex_selector_is_notable() {
901        // Five compound selectors joined by combinators exceeds the floor.
902        let a = analytics("div > ul > li > a > span { color: red; }");
903        assert_eq!(a.notable_rules.len(), 1);
904        assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
905    }
906
907    #[test]
908    fn nesting_depth_tracked() {
909        let a = analytics(".a { .b { .c { .d { color: red; } } } }");
910        assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
911        // The depth-3 rule (`.d`) crosses the nesting floor.
912        assert!(
913            a.notable_rules
914                .iter()
915                .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
916        );
917    }
918
919    #[test]
920    fn specificity_takes_most_specific_selector_in_list() {
921        let a = analytics("#id, .cls { color: red; }");
922        assert_eq!(a.notable_rules.len(), 1);
923        // `#id` (1,0,0) is more specific than `.cls` (0,1,0).
924        assert_eq!(a.notable_rules[0].specificity_a, 1);
925    }
926
927    #[test]
928    fn line_is_one_based() {
929        let a = analytics("\n\n#main { color: red; }");
930        assert_eq!(a.notable_rules[0].line, 3);
931    }
932
933    #[test]
934    fn media_query_rules_walked() {
935        let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
936        assert_eq!(a.rule_count, 1);
937        assert_eq!(a.notable_rules.len(), 1);
938        assert_eq!(a.notable_rules[0].specificity_a, 1);
939    }
940
941    #[test]
942    fn collects_distinct_colors() {
943        let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
944        assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
945    }
946
947    #[test]
948    fn parses_theme_color_values_to_rgb() {
949        assert_eq!(parse_css_color_rgb("#f00"), Some((255.0, 0.0, 0.0)));
950        assert_eq!(parse_css_color_rgb("rgb(255 0 0)"), Some((255.0, 0.0, 0.0)));
951        assert_eq!(
952            parse_css_color_rgb("hsl(0 100% 50%)"),
953            Some((255.0, 0.0, 0.0))
954        );
955        assert!(parse_css_color_rgb("var(--brand)").is_none());
956    }
957
958    #[test]
959    fn direct_color_parser_matches_stylesheet_parser_corpus() {
960        let values = [
961            // Hex and named colors.
962            "#000",
963            "#fff",
964            "#f00",
965            "#0f08",
966            "#112233",
967            "#11223344",
968            "#abcdef",
969            "#ABCDEF",
970            "red",
971            "rebeccapurple",
972            "transparent",
973            // Functional RGB syntax, alpha, and clamping.
974            "rgb(255 0 0)",
975            "rgb(100% 0% 0%)",
976            "rgb(255, 0, 0)",
977            "rgba(255, 0, 0, 0.5)",
978            "rgba(255 0 0 / 0)",
979            "rgba(255 0 0 / 1)",
980            "rgb(255 0 0 / 25%)",
981            "rgb(300 -10 0)",
982            // HSL and HWB normalization.
983            "hsl(0 100% 50%)",
984            "hsl(120deg 100% 25% / 0.5)",
985            "hsla(240, 100%, 50%, 25%)",
986            "hwb(0 0% 0%)",
987            "hwb(120 10% 20% / 50%)",
988            // CSS whitespace and comments.
989            " red ",
990            "\t#f00\n",
991            "rgb( 255  0  0 / .5 )",
992            "r/**/ed",
993            "rgb(255/**/ 0 0)",
994            "/* before */ red /* after */",
995            // Valid CSS colors that do not resolve to a concrete RGBA value.
996            "currentColor",
997            "CURRENTCOLOR",
998            "CanvasText",
999            "ButtonFace",
1000            "AccentColorText",
1001            "var(--brand)",
1002            "color-mix(in srgb, red 50%, blue)",
1003            "lab(50% 40 30)",
1004            "lch(50% 40 30)",
1005            "oklab(50% .1 .1)",
1006            "oklch(50% .1 30)",
1007            "color(display-p3 1 0 0)",
1008            // Malformed or trailing input.
1009            "",
1010            "#ff",
1011            "not-a-color",
1012            "rgb(255 0)",
1013            "red junk",
1014            "red, blue",
1015            "red !important junk",
1016            "red ! important junk",
1017            "red !important !important",
1018            "red,",
1019            // Declaration-level syntax accepted by the old wrapper.
1020            "red !important",
1021            "red ! important",
1022            "red ! IMPORTANT",
1023            "red !/**/important",
1024            "red/**/!important",
1025            "red ! important;",
1026            "red !important ;",
1027            "red !important; color: blue",
1028            "red !important; junk",
1029            "#f00!important",
1030            "red;",
1031            "red ; ",
1032            "red;;",
1033            "red; junk",
1034            "red; color: blue",
1035            "red; --custom: value",
1036            "red; :junk",
1037            // Error-recovery syntax that requires the stylesheet fallback.
1038            "not-a-color; color: blue",
1039            "var(--x); color: blue",
1040            "red; @media {}",
1041            "red; --x: }",
1042            "currentColor; color: blue",
1043            "CanvasText; color: blue",
1044            "lab(50% 40 30); color: blue",
1045            "color(display-p3 1 0 0); color: blue",
1046            "red; color: blue !important",
1047            "rgb(1 2 3); color: blue !important",
1048        ];
1049
1050        let mismatches = values
1051            .into_iter()
1052            .filter_map(|value| {
1053                let direct = parse_css_color_rgb(value);
1054                let stylesheet = parse_css_color_rgb_via_stylesheet(value);
1055                (direct != stylesheet).then_some((value, direct, stylesheet))
1056            })
1057            .collect::<Vec<_>>();
1058        assert!(mismatches.is_empty(), "parser mismatches: {mismatches:#?}");
1059    }
1060
1061    #[test]
1062    fn collects_colors_nested_in_shorthands() {
1063        // The color inside the `border` shorthand must be caught, not just the
1064        // standalone `background` color: that is the point of the value visitor.
1065        let a = analytics(".a { border: 1px solid green; background: yellow; }");
1066        assert!(
1067            a.colors.len() >= 2,
1068            "shorthand + standalone colors collected: {:?}",
1069            a.colors
1070        );
1071    }
1072
1073    #[test]
1074    fn collects_distinct_font_sizes() {
1075        let a =
1076            analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
1077        assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
1078    }
1079
1080    #[test]
1081    fn collects_located_raw_style_values_but_skips_tokenized_values() {
1082        let a = analytics(
1083            ".a { color: red; font-size: 14px; margin-top: 1rem; z-index: 10; color: transparent; }\n.b { border-radius: 6px; }",
1084        );
1085        assert!(
1086            a.raw_style_values
1087                .iter()
1088                .any(|value| value.axis == "color" && value.property == "color"),
1089            "raw color should be located: {:?}",
1090            a.raw_style_values
1091        );
1092        assert!(
1093            a.raw_style_values
1094                .iter()
1095                .any(|value| value.axis == "font-size" && value.value == "14px"),
1096            "raw font size should be located: {:?}",
1097            a.raw_style_values
1098        );
1099        assert!(
1100            a.raw_style_values
1101                .iter()
1102                .any(|value| value.axis == "radius" && value.line == 2),
1103            "raw radius should be located on the second rule: {:?}",
1104            a.raw_style_values
1105        );
1106        assert!(
1107            !a.raw_style_values
1108                .iter()
1109                .any(|value| value.property == "margin-top"),
1110            "layout spacing should not be a raw-value candidate: {:?}",
1111            a.raw_style_values
1112        );
1113        assert!(
1114            !a.raw_style_values
1115                .iter()
1116                .any(|value| value.property == "z-index"),
1117            "z-index should stay a scale summary, not an audit candidate: {:?}",
1118            a.raw_style_values
1119        );
1120        assert!(
1121            !a.raw_style_values
1122                .iter()
1123                .any(|value| value.value == "transparent"),
1124            "transparent should behave like a reset keyword: {:?}",
1125            a.raw_style_values
1126        );
1127    }
1128
1129    #[test]
1130    fn collects_distinct_z_indexes() {
1131        let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
1132        assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
1133    }
1134
1135    #[test]
1136    fn collects_defined_and_referenced_custom_properties() {
1137        let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
1138        assert!(
1139            a.defined_custom_properties.contains(&"--brand".to_string()),
1140            "defined: {:?}",
1141            a.defined_custom_properties
1142        );
1143        assert!(
1144            a.defined_custom_properties
1145                .contains(&"--unused".to_string())
1146        );
1147        assert!(
1148            a.referenced_custom_properties
1149                .contains(&"--brand".to_string()),
1150            "referenced: {:?}",
1151            a.referenced_custom_properties
1152        );
1153        assert!(
1154            !a.referenced_custom_properties
1155                .contains(&"--unused".to_string()),
1156            "--unused has no var() reference"
1157        );
1158    }
1159
1160    #[test]
1161    fn collects_defined_and_referenced_keyframes() {
1162        let a = analytics(
1163            "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
1164        );
1165        assert!(a.defined_keyframes.contains(&"spin".to_string()));
1166        assert!(a.defined_keyframes.contains(&"unused".to_string()));
1167        assert!(a.referenced_keyframes.contains(&"spin".to_string()));
1168        assert!(
1169            !a.referenced_keyframes.contains(&"unused".to_string()),
1170            "no animation references `unused`"
1171        );
1172    }
1173
1174    #[test]
1175    fn animation_shorthand_references_keyframes() {
1176        let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
1177        assert!(
1178            a.referenced_keyframes.contains(&"pulse".to_string()),
1179            "referenced: {:?}",
1180            a.referenced_keyframes
1181        );
1182    }
1183
1184    #[test]
1185    fn animation_shorthand_with_var_references_keyframes() {
1186        // A `var()` in the shorthand makes lightningcss keep the declaration as
1187        // an unparsed token list. The literal name must still be credited.
1188        let a = analytics(
1189            "@keyframes pulse { from {} }\n\
1190             .a { animation: pulse var(--duration) ease; }",
1191        );
1192        assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1193    }
1194
1195    #[test]
1196    fn animation_shorthand_with_var_credits_each_comma_layer() {
1197        let a = analytics(
1198            "@keyframes fade { from {} }\n@keyframes slide { from {} }\n\
1199             .a { animation: fade 1s infinite, slide var(--d) cubic-bezier(0, 0, 1, 1) both; }\n\
1200             .b { -webkit-animation: fade var(--d); }",
1201        );
1202        assert_eq!(
1203            a.referenced_keyframes,
1204            vec!["fade".to_string(), "slide".to_string()]
1205        );
1206    }
1207
1208    #[test]
1209    fn var_in_animation_name_position_stays_unresolved() {
1210        let a = analytics(
1211            "@keyframes pulse { from {} }\n\
1212             .a { animation: var(--name) 1s ease-in-out infinite alternate both; }\n\
1213             .b { animation-name: var(--other); }",
1214        );
1215        assert!(
1216            a.referenced_keyframes.is_empty(),
1217            "no literal name: {:?}",
1218            a.referenced_keyframes
1219        );
1220    }
1221
1222    #[test]
1223    fn invalid_animation_value_credits_no_keyframe() {
1224        // An invalid value also reaches the unparsed path. The browser drops
1225        // the declaration, so no name in it counts as a reference.
1226        let a = analytics(
1227            "@keyframes spin { from {} }\n\
1228             .a { animation: spin 1s smooth; }\n\
1229             .b { animation: spin 1x; }",
1230        );
1231        assert!(
1232            a.referenced_keyframes.is_empty(),
1233            "invalid values credit nothing: {:?}",
1234            a.referenced_keyframes
1235        );
1236    }
1237
1238    #[test]
1239    fn ambiguous_var_layer_credits_no_keyframe() {
1240        // Two non-keyword identifiers in one layer leave the name unknown.
1241        let a = analytics(".a { animation: spin var(--d) smooth, fade var(--d); }");
1242        assert_eq!(a.referenced_keyframes, vec!["fade".to_string()]);
1243    }
1244
1245    #[test]
1246    fn animation_composition_keywords_are_not_names() {
1247        let a = analytics(".a { animation: x var(--d) replace add accumulate; }");
1248        assert_eq!(a.referenced_keyframes, vec!["x".to_string()]);
1249    }
1250
1251    #[test]
1252    fn var_nested_in_function_reaches_the_name() {
1253        let a = analytics(".a { animation: pulse calc(var(--d) * 2) ease; }");
1254        assert_eq!(a.referenced_keyframes, vec!["pulse".to_string()]);
1255    }
1256
1257    #[test]
1258    fn animation_name_list_with_var_credits_easing_named_keyframe() {
1259        // In `animation-name`, only `none` and the CSS-wide keywords are not
1260        // names, so a keyframe called `ease` counts.
1261        let a = analytics(
1262            "@keyframes ease { from {} }\n\
1263             .a { animation-name: var(--x), ease, none, inherit; }",
1264        );
1265        assert_eq!(a.referenced_keyframes, vec!["ease".to_string()]);
1266    }
1267
1268    #[test]
1269    fn animation_name_list_with_var_credits_literal_names() {
1270        let a = analytics(
1271            "@keyframes spin { from {} }\n\
1272             .a { animation-name: var(--first), spin; }",
1273        );
1274        assert_eq!(a.referenced_keyframes, vec!["spin".to_string()]);
1275    }
1276
1277    #[test]
1278    fn fingerprints_blocks_at_floor_order_insensitive() {
1279        // Two 4-declaration rules with the same declarations in different order
1280        // share a fingerprint; a 3-declaration rule is below the floor and is
1281        // not fingerprinted.
1282        let a = analytics(
1283            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1284             .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
1285             .z { color: red; margin: 1px; padding: 2px; }\n",
1286        );
1287        assert_eq!(
1288            a.declaration_blocks.len(),
1289            2,
1290            "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
1291            a.declaration_blocks
1292        );
1293        assert_eq!(
1294            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1295            "same declarations in different order share a fingerprint"
1296        );
1297        assert_eq!(a.declaration_blocks[0].declaration_count, 4);
1298    }
1299
1300    #[test]
1301    fn important_distinguishes_block_fingerprint() {
1302        let a = analytics(
1303            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
1304             .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
1305        );
1306        assert_eq!(a.declaration_blocks.len(), 2);
1307        assert_ne!(
1308            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
1309            "!important changes the block fingerprint"
1310        );
1311    }
1312
1313    #[test]
1314    fn var_referenced_and_token_defined_values_are_not_counted_as_distinct() {
1315        // Load-bearing for the v3 styling-health sprawl drift sub-term: the
1316        // distinct-value sets (box_shadows / border_radii / line_heights) count
1317        // ONLY hardcoded literals. A value referenced via `var(--*)` parses as
1318        // `Property::Unparsed` in lightningcss, so it never reaches the typed
1319        // `Property::BoxShadow` / `BorderRadius` / `LineHeight` arms, and a token
1320        // DEFINITION (`--x: 4px`) is a `Property::Custom`. Both are therefore
1321        // invisible to the sprawl counts. This is what makes a well-tokenized
1322        // design system score 0 sprawl regardless of how many tokens it defines;
1323        // the v3 grade's entire FP-safety rests on this lightningcss behavior, so
1324        // it is pinned here (a future lightningcss change that typed `var()` values
1325        // would break tokenized systems silently and must trip this test).
1326        let tokenized = analytics(
1327            ":root { --r: 4px; --s: 0 1px 2px #0000001a; --lh: 1.5; }\n\
1328             .a { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n\
1329             .b { border-radius: var(--r); box-shadow: var(--s); line-height: var(--lh); }\n",
1330        );
1331        assert!(
1332            tokenized.border_radii.is_empty(),
1333            "var()-referenced radii are not counted: {:?}",
1334            tokenized.border_radii
1335        );
1336        assert!(
1337            tokenized.box_shadows.is_empty(),
1338            "var()-referenced shadows are not counted: {:?}",
1339            tokenized.box_shadows
1340        );
1341        assert!(
1342            tokenized.line_heights.is_empty(),
1343            "var()-referenced line-heights are not counted: {:?}",
1344            tokenized.line_heights
1345        );
1346
1347        // Control: hardcoded literal values ARE counted, so the sprawl signal is
1348        // not simply inert. Two distinct hardcoded radii / shadows / line-heights.
1349        let hardcoded = analytics(
1350            ".a { border-radius: 4px; box-shadow: 0 1px 2px #0000001a; line-height: 1.4; }\n\
1351             .b { border-radius: 6px; box-shadow: 0 2px 4px #0000001f; line-height: 1.6; }\n",
1352        );
1353        assert_eq!(
1354            hardcoded.border_radii.len(),
1355            2,
1356            "distinct hardcoded radii counted: {:?}",
1357            hardcoded.border_radii
1358        );
1359        assert_eq!(
1360            hardcoded.box_shadows.len(),
1361            2,
1362            "distinct hardcoded shadows counted: {:?}",
1363            hardcoded.box_shadows
1364        );
1365        assert_eq!(
1366            hardcoded.line_heights.len(),
1367            2,
1368            "distinct hardcoded line-heights counted: {:?}",
1369            hardcoded.line_heights
1370        );
1371    }
1372}