Skip to main content

fallow_extract/
css_metrics.rs

1//! Structural CSS analytics computed from the parsed CSS syntax tree.
2//!
3//! `fallow health` consumes these on demand to surface specificity hotspots,
4//! `!important` density, over-complex selectors, and deep nesting: the kind of
5//! codebase-scale structural CSS slop that per-rule linters do not aggregate.
6//! The metrics come from the same lightningcss parse used for CSS Module class
7//! extraction. Callers gate by file extension: lightningcss parses standard CSS,
8//! not Sass, so `.scss` sources are NOT passed here (with error recovery on,
9//! Sass syntax recovers into a partial, inaccurate result rather than failing).
10//! A hard parse failure yields `None`.
11
12use lightningcss::printer::PrinterOptions;
13use lightningcss::properties::Property;
14use lightningcss::properties::animation::AnimationName;
15use lightningcss::properties::custom::{CustomPropertyName, Token, TokenOrValue, Variable};
16use lightningcss::properties::font::FontFamily;
17use lightningcss::rules::CssRule;
18use lightningcss::rules::font_face::FontFaceProperty;
19use lightningcss::rules::keyframes::KeyframesName;
20use lightningcss::rules::style::StyleRule;
21use lightningcss::selector::{Component, Selector};
22use lightningcss::stylesheet::{ParserOptions, StyleSheet};
23use lightningcss::traits::ToCss;
24use lightningcss::values::color::CssColor;
25use lightningcss::visitor::{VisitTypes, Visitor};
26use rustc_hash::FxHashSet;
27
28use fallow_types::extract::{CssAnalytics, CssDeclarationBlock, CssRuleMetric};
29
30/// Selector component count above which a rule is considered over-complex.
31const MAX_PLAIN_COMPLEXITY: u16 = 4;
32
33/// Style-rule nesting depth at or above which a rule is recorded.
34const NOTABLE_NESTING_DEPTH: u8 = 3;
35
36/// Upper bound on per-file recorded rules. Compiled utility frameworks can emit
37/// thousands of `!important` rules; the scalar aggregates stay accurate while
38/// the per-rule finding list is capped to keep output and storage bounded.
39const MAX_NOTABLE_RULES: usize = 500;
40
41/// Minimum declaration count for a rule to be fingerprinted as a duplicate-block
42/// candidate. Small blocks (e.g. `display: flex; align-items: center`) repeat
43/// legitimately, so the floor keeps the signal a strong copy-paste indicator.
44const MIN_BLOCK_DECLARATIONS: usize = 4;
45
46/// Upper bound on per-file declaration-block fingerprints. The `MIN_BLOCK`
47/// floor already bounds compiled utility CSS (whose rules are tiny), so this
48/// only guards a pathological hand-written stylesheet.
49const MAX_DECLARATION_BLOCKS: usize = 2000;
50
51/// Mask for a single 10-bit CSS specificity component.
52const SPECIFICITY_COMPONENT_MASK: u32 = 0x3FF;
53
54/// Compute structural CSS analytics for a standard-CSS stylesheet source.
55///
56/// Returns `None` only on a hard parse failure; with error recovery on,
57/// individual malformed rules are skipped and the rest of the sheet still
58/// contributes. Callers must gate by extension and NOT pass `.scss` sources:
59/// Sass syntax is not standard CSS and recovers into an inaccurate partial
60/// rather than `None`. Parsing runs in CSS Modules mode so `:local()` /
61/// `:global()` selectors are understood.
62#[must_use]
63pub fn compute_css_analytics(source: &str) -> Option<CssAnalytics> {
64    let options = ParserOptions {
65        error_recovery: true,
66        css_modules: Some(lightningcss::css_modules::Config::default()),
67        ..ParserOptions::default()
68    };
69    let mut stylesheet = StyleSheet::parse(source, options).ok()?;
70
71    // Pass 1: walk the rule tree for structural metrics + font-size / z-index
72    // design tokens (these are top-level declaration properties).
73    let mut acc = Accumulator::default();
74    walk_rules(&stylesheet.rules.0, 0, &mut acc);
75
76    // Pass 2: visit every color value (including colors nested inside shorthands
77    // and gradients) for the design-token-sprawl signal. The visitor needs `&mut`,
78    // so it runs after the immutable rule walk above.
79    let mut collector = ValueCollector::default();
80    let _ = collector.visit_stylesheet(&mut stylesheet);
81
82    let mut analytics = acc.analytics;
83    analytics.colors = sorted_vec(collector.colors);
84    analytics.referenced_custom_properties = sorted_vec(collector.referenced_custom_properties);
85    analytics.font_sizes = sorted_vec(acc.font_sizes);
86    analytics.z_indexes = sorted_vec(acc.z_indexes);
87    analytics.box_shadows = sorted_vec(acc.box_shadows);
88    analytics.border_radii = sorted_vec(acc.border_radii);
89    analytics.line_heights = sorted_vec(acc.line_heights);
90    analytics.defined_custom_properties = sorted_vec(acc.defined_custom_properties);
91    analytics.defined_keyframes = sorted_vec(acc.defined_keyframes);
92    analytics.referenced_keyframes = sorted_vec(acc.referenced_keyframes);
93    analytics.registered_custom_properties = sorted_vec(acc.registered_custom_properties);
94    analytics.declared_layers = sorted_vec(acc.declared_layers);
95    analytics.populated_layers = sorted_vec(acc.populated_layers);
96    analytics.defined_font_faces = sorted_vec(acc.defined_font_faces);
97    analytics.referenced_font_families = sorted_vec(acc.referenced_font_families);
98    Some(analytics)
99}
100
101/// Working accumulator threaded through the rule walk: the structural analytics
102/// plus the per-stylesheet sets of distinct `font-size` / `z-index` values.
103#[derive(Default)]
104struct Accumulator {
105    analytics: CssAnalytics,
106    font_sizes: FxHashSet<String>,
107    z_indexes: FxHashSet<String>,
108    box_shadows: FxHashSet<String>,
109    border_radii: FxHashSet<String>,
110    line_heights: FxHashSet<String>,
111    defined_custom_properties: FxHashSet<String>,
112    defined_keyframes: FxHashSet<String>,
113    referenced_keyframes: FxHashSet<String>,
114    registered_custom_properties: FxHashSet<String>,
115    declared_layers: FxHashSet<String>,
116    populated_layers: FxHashSet<String>,
117    defined_font_faces: FxHashSet<String>,
118    referenced_font_families: FxHashSet<String>,
119}
120
121/// The concrete family name of a `font-family` value, or `None` for a generic
122/// keyword (`serif`, `sans-serif`, `monospace`, ...), which is never an authored
123/// `@font-face`.
124fn font_family_name(family: &FontFamily<'_>) -> Option<String> {
125    match family {
126        // Render the family via ToCss and strip surrounding quotes so a declared
127        // `font-family: "Inter"` and a referenced `font-family: Inter` normalize
128        // to the same key.
129        FontFamily::FamilyName(_) => family
130            .to_css_string(PrinterOptions::default())
131            .ok()
132            .map(|s| s.trim_matches(['"', '\'']).to_string()),
133        FontFamily::Generic(_) => None,
134    }
135}
136
137/// Collects value-level design tokens via the lightningcss visitor: every
138/// distinct color (including colors nested in shorthands like `border` /
139/// `background` and gradients, not just standalone `color:` values) and every
140/// `var()` custom-property reference.
141#[derive(Default)]
142struct ValueCollector {
143    colors: FxHashSet<String>,
144    referenced_custom_properties: FxHashSet<String>,
145}
146
147impl Visitor<'_> for ValueCollector {
148    type Error = std::convert::Infallible;
149
150    fn visit_types(&self) -> VisitTypes {
151        VisitTypes::COLORS | VisitTypes::VARIABLES
152    }
153
154    fn visit_color(&mut self, color: &mut CssColor) -> Result<(), Self::Error> {
155        if let Ok(rendered) = color.to_css_string(PrinterOptions::default()) {
156            self.colors.insert(rendered);
157        }
158        Ok(())
159    }
160
161    fn visit_variable(&mut self, var: &mut Variable<'_>) -> Result<(), Self::Error> {
162        self.referenced_custom_properties
163            .insert(var.name.ident.0.to_string());
164        Ok(())
165    }
166}
167
168fn sorted_vec(set: FxHashSet<String>) -> Vec<String> {
169    let mut values: Vec<String> = set.into_iter().collect();
170    values.sort_unstable();
171    values
172}
173
174/// Recursively walk rules, tracking style-rule nesting depth. Grouping rules
175/// (`@media` / `@supports` / `@container` / `@layer {}` / `@document` /
176/// `@starting-style` / `@scope`) pass their nesting depth through unchanged;
177/// only nesting INSIDE a style rule increases the depth.
178fn walk_rules(rules: &[CssRule<'_>], depth: u8, acc: &mut Accumulator) {
179    for rule in rules {
180        match rule {
181            CssRule::Style(style) => {
182                record_style_rule(style, depth, acc);
183                walk_rules(&style.rules.0, depth.saturating_add(1), acc);
184            }
185            CssRule::Media(rule) => walk_rules(&rule.rules.0, depth, acc),
186            CssRule::Supports(rule) => walk_rules(&rule.rules.0, depth, acc),
187            CssRule::Container(rule) => walk_rules(&rule.rules.0, depth, acc),
188            CssRule::LayerBlock(rule) => {
189                // A named `@layer a { }` both declares and populates layer `a`.
190                if let Some(name) = &rule.name {
191                    let name = layer_name_string(name);
192                    acc.declared_layers.insert(name.clone());
193                    acc.populated_layers.insert(name);
194                }
195                walk_rules(&rule.rules.0, depth, acc);
196            }
197            CssRule::LayerStatement(stmt) => {
198                // `@layer a, b, c;` declares ordering but populates nothing.
199                for name in &stmt.names {
200                    acc.declared_layers.insert(layer_name_string(name));
201                }
202            }
203            CssRule::Property(prop) => {
204                acc.registered_custom_properties
205                    .insert(prop.name.0.to_string());
206            }
207            CssRule::FontFace(font_face) => {
208                for property in &font_face.properties {
209                    if let FontFaceProperty::FontFamily(family) = property
210                        && let Some(name) = font_family_name(family)
211                    {
212                        acc.defined_font_faces.insert(name);
213                    }
214                }
215            }
216            CssRule::MozDocument(rule) => walk_rules(&rule.rules.0, depth, acc),
217            CssRule::StartingStyle(rule) => walk_rules(&rule.rules.0, depth, acc),
218            CssRule::Scope(rule) => walk_rules(&rule.rules.0, depth, acc),
219            CssRule::Nesting(rule) => {
220                record_style_rule(&rule.style, depth, acc);
221                walk_rules(&rule.style.rules.0, depth.saturating_add(1), acc);
222            }
223            CssRule::Keyframes(keyframes) => {
224                acc.defined_keyframes
225                    .insert(keyframes_name_string(&keyframes.name));
226            }
227            _ => {}
228        }
229    }
230}
231
232fn layer_name_string(name: &lightningcss::rules::layer::LayerName<'_>) -> String {
233    name.0
234        .iter()
235        .map(std::string::ToString::to_string)
236        .collect::<Vec<_>>()
237        .join(".")
238}
239
240fn keyframes_name_string(name: &KeyframesName<'_>) -> String {
241    match name {
242        KeyframesName::Ident(ident) => ident.0.to_string(),
243        KeyframesName::Custom(value) => value.to_string(),
244    }
245}
246
247fn collect_animation_name(name: &AnimationName<'_>, out: &mut FxHashSet<String>) {
248    if let AnimationName::Ident(ident) = name {
249        out.insert(ident.0.to_string());
250    }
251}
252
253fn record_style_rule(style: &StyleRule<'_>, depth: u8, acc: &mut Accumulator) {
254    let normal = style.declarations.declarations.len();
255    let important = style.declarations.important_declarations.len();
256    let declaration_count = normal + important;
257
258    let analytics = &mut acc.analytics;
259    analytics.rule_count = analytics.rule_count.saturating_add(1);
260    analytics.total_declarations = analytics
261        .total_declarations
262        .saturating_add(saturate_u32(declaration_count));
263    analytics.important_declarations = analytics
264        .important_declarations
265        .saturating_add(saturate_u32(important));
266    if declaration_count == 0 {
267        analytics.empty_rule_count = analytics.empty_rule_count.saturating_add(1);
268    }
269    analytics.max_nesting_depth = analytics.max_nesting_depth.max(depth);
270
271    let (a, b, c, complexity) = rule_selector_metrics(style);
272    let metric = CssRuleMetric {
273        line: style.loc.line.saturating_add(1),
274        col: style.loc.column,
275        specificity_a: a,
276        specificity_b: b,
277        specificity_c: c,
278        complexity,
279        declaration_count: saturate_u16(declaration_count),
280        important_count: saturate_u16(important),
281        nesting_depth: depth,
282    };
283
284    if is_notable(&metric) {
285        if analytics.notable_rules.len() < MAX_NOTABLE_RULES {
286            analytics.notable_rules.push(metric);
287        } else {
288            analytics.notable_truncated = true;
289        }
290    }
291
292    // Fingerprint the declaration block (sorted, !important-tagged) for cross-file
293    // duplicate-block detection, gated on the minimum block size and a per-file cap.
294    if declaration_count >= MIN_BLOCK_DECLARATIONS
295        && analytics.declaration_blocks.len() < MAX_DECLARATION_BLOCKS
296        && let Some(fingerprint) = declaration_block_fingerprint(style)
297    {
298        analytics.declaration_blocks.push(CssDeclarationBlock {
299            fingerprint,
300            line: style.loc.line.saturating_add(1),
301            declaration_count: saturate_u16(declaration_count),
302        });
303    }
304
305    // Design-token values (font-size / z-index, authored form), custom-property
306    // definitions, and animation-name references to @keyframes. Colors and
307    // `var()` references are collected separately by the value visitor.
308    for property in style
309        .declarations
310        .declarations
311        .iter()
312        .chain(style.declarations.important_declarations.iter())
313    {
314        match property {
315            Property::FontSize(font_size) => {
316                if let Ok(rendered) = font_size.to_css_string(PrinterOptions::default()) {
317                    acc.font_sizes.insert(rendered);
318                }
319            }
320            Property::ZIndex(z_index) => {
321                if let Ok(rendered) = z_index.to_css_string(PrinterOptions::default()) {
322                    acc.z_indexes.insert(rendered);
323                }
324            }
325            // Shadow / radius / line-height tokens (design-token-sprawl axes).
326            // The INNER value is serialized (not the property), so the vendor
327            // prefix is dropped and `-webkit-box-shadow: X` collapses to the same
328            // distinct value as `box-shadow: X` rather than inflating the count.
329            Property::BoxShadow(shadows, _) => {
330                let rendered: Vec<String> = shadows
331                    .iter()
332                    .filter_map(|shadow| shadow.to_css_string(PrinterOptions::default()).ok())
333                    .collect();
334                if !rendered.is_empty() && rendered.len() == shadows.len() {
335                    acc.box_shadows.insert(rendered.join(", "));
336                }
337            }
338            Property::BorderRadius(radius, _) => {
339                if let Ok(rendered) = radius.to_css_string(PrinterOptions::default()) {
340                    acc.border_radii.insert(rendered);
341                }
342            }
343            Property::LineHeight(line_height) => {
344                if let Ok(rendered) = line_height.to_css_string(PrinterOptions::default()) {
345                    acc.line_heights.insert(rendered);
346                }
347            }
348            Property::Custom(custom) => {
349                if let CustomPropertyName::Custom(name) = &custom.name {
350                    acc.defined_custom_properties.insert(name.0.to_string());
351                }
352                // A custom-property value can REFERENCE a font family without a
353                // `font-family:` declaration: a Tailwind v4 `--font-*` theme token
354                // (`--font-display: "Departure Mono", monospace`) is the canonical
355                // case. lightningcss's `Property::FontFamily` / `Property::Font`
356                // arms above never see this (a `--*:` declaration is an opaque
357                // token stream), so scan the raw tokens for string / ident values
358                // and credit them as referenced families. Generic keywords
359                // (`serif`, `monospace`) never appear in `defined_font_faces`, so
360                // crediting them here is inert; the `unused_font_faces`
361                // set-difference only ever drops a genuinely-declared family.
362                for token in &custom.value.0 {
363                    if let TokenOrValue::Token(Token::String(value) | Token::Ident(value)) = token {
364                        acc.referenced_font_families.insert(value.to_string());
365                    }
366                }
367            }
368            Property::AnimationName(names, _) => {
369                for name in names {
370                    collect_animation_name(name, &mut acc.referenced_keyframes);
371                }
372            }
373            Property::Animation(animations, _) => {
374                for animation in animations {
375                    collect_animation_name(&animation.name, &mut acc.referenced_keyframes);
376                }
377            }
378            Property::FontFamily(families) => {
379                for family in families {
380                    if let Some(name) = font_family_name(family) {
381                        acc.referenced_font_families.insert(name);
382                    }
383                }
384            }
385            Property::Font(font) => {
386                for family in &font.family {
387                    if let Some(name) = font_family_name(family) {
388                        acc.referenced_font_families.insert(name);
389                    }
390                }
391            }
392            _ => {}
393        }
394    }
395}
396
397/// Fingerprint a rule's declaration block: serialize each declaration (tagging
398/// `!important` ones, which lightningcss stores without the flag, so they do not
399/// collide with their non-important twin), sort for order-insensitivity, join,
400/// and xxh3-hash. Returns `None` if any declaration fails to serialize, so a
401/// partial block is never fingerprinted (a false duplicate match would be worse
402/// than missing one).
403fn declaration_block_fingerprint(style: &StyleRule<'_>) -> Option<u64> {
404    let block = &style.declarations;
405    let mut parts: Vec<String> =
406        Vec::with_capacity(block.declarations.len() + block.important_declarations.len());
407    for decl in &block.declarations {
408        parts.push(decl.to_css_string(false, PrinterOptions::default()).ok()?);
409    }
410    for decl in &block.important_declarations {
411        // `important = true` renders the `!important` suffix, so a block with an
412        // important declaration never collides with its non-important twin.
413        parts.push(decl.to_css_string(true, PrinterOptions::default()).ok()?);
414    }
415    parts.sort_unstable();
416    Some(xxhash_rust::xxh3::xxh3_64(parts.join(";").as_bytes()))
417}
418
419/// Return the rule's `(specificity_a, specificity_b, specificity_c, complexity)`
420/// taking the most specific selector and the most complex selector across the
421/// rule's selector list.
422fn rule_selector_metrics(style: &StyleRule<'_>) -> (u16, u16, u16, u16) {
423    let mut max_spec = 0u32;
424    let mut a = 0u16;
425    let mut b = 0u16;
426    let mut c = 0u16;
427    let mut complexity = 0u16;
428    for selector in &style.selectors.0 {
429        let spec = selector.specificity();
430        if spec >= max_spec {
431            max_spec = spec;
432            a = specificity_component(spec, 20);
433            b = specificity_component(spec, 10);
434            c = specificity_component(spec, 0);
435        }
436        complexity = complexity.max(selector_complexity(selector));
437    }
438    (a, b, c, complexity)
439}
440
441fn specificity_component(specificity: u32, shift: u32) -> u16 {
442    saturate_u16_u32((specificity >> shift) & SPECIFICITY_COMPONENT_MASK)
443}
444
445fn is_notable(metric: &CssRuleMetric) -> bool {
446    metric.specificity_a >= 1
447        || metric.complexity > MAX_PLAIN_COMPLEXITY
448        || metric.important_count >= 1
449        || metric.nesting_depth >= NOTABLE_NESTING_DEPTH
450}
451
452fn selector_complexity(selector: &Selector<'_>) -> u16 {
453    let mut count = 0u16;
454    count_components(selector, &mut count);
455    count
456}
457
458fn count_components(selector: &Selector<'_>, count: &mut u16) {
459    for component in selector.iter_raw_match_order() {
460        *count = count.saturating_add(1);
461        match component {
462            Component::Is(list)
463            | Component::Where(list)
464            | Component::Has(list)
465            | Component::Negation(list)
466            | Component::Any(_, list) => {
467                for nested in list.as_ref() {
468                    count_components(nested, count);
469                }
470            }
471            Component::Slotted(nested) | Component::Host(Some(nested)) => {
472                count_components(nested, count);
473            }
474            Component::NthOf(data) => {
475                for nested in data.selectors() {
476                    count_components(nested, count);
477                }
478            }
479            _ => {}
480        }
481    }
482}
483
484fn saturate_u32(value: usize) -> u32 {
485    u32::try_from(value).unwrap_or(u32::MAX)
486}
487
488fn saturate_u16(value: usize) -> u16 {
489    u16::try_from(value).unwrap_or(u16::MAX)
490}
491
492fn saturate_u16_u32(value: u32) -> u16 {
493    u16::try_from(value).unwrap_or(u16::MAX)
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn analytics(source: &str) -> CssAnalytics {
501        compute_css_analytics(source).expect("standard CSS parses")
502    }
503
504    #[test]
505    fn recovers_partial_metrics_around_a_malformed_rule() {
506        // Error recovery skips the broken rule and still records the valid one,
507        // so a file with one bad rule is not lost wholesale.
508        let a = analytics("#main { color: red; } @@@ broken @@@ .ok { color: blue; }");
509        assert!(a.rule_count >= 1);
510        assert!(a.notable_rules.iter().any(|r| r.specificity_a == 1));
511    }
512
513    #[test]
514    fn counts_declarations_and_important() {
515        let a = analytics(".a { color: red; width: 1px !important; }");
516        assert_eq!(a.rule_count, 1);
517        assert_eq!(a.total_declarations, 2);
518        assert_eq!(a.important_declarations, 1);
519    }
520
521    #[test]
522    fn id_selector_is_notable_with_specificity() {
523        let a = analytics("#main { color: red; }");
524        assert_eq!(a.notable_rules.len(), 1);
525        let rule = &a.notable_rules[0];
526        assert_eq!(rule.specificity_a, 1);
527        assert_eq!(rule.specificity_b, 0);
528        assert_eq!(rule.specificity_c, 0);
529    }
530
531    #[test]
532    fn plain_class_rule_is_not_notable() {
533        let a = analytics(".btn { color: red; }");
534        assert!(a.notable_rules.is_empty(), "got {:?}", a.notable_rules);
535        assert_eq!(a.rule_count, 1);
536    }
537
538    #[test]
539    fn important_declaration_makes_rule_notable() {
540        let a = analytics(".btn { color: red !important; }");
541        assert_eq!(a.notable_rules.len(), 1);
542        assert_eq!(a.notable_rules[0].important_count, 1);
543    }
544
545    #[test]
546    fn empty_rule_counted() {
547        let a = analytics(".a { } .b { color: red; }");
548        assert_eq!(a.rule_count, 2);
549        assert_eq!(a.empty_rule_count, 1);
550    }
551
552    #[test]
553    fn complex_selector_is_notable() {
554        // Five compound selectors joined by combinators exceeds the floor.
555        let a = analytics("div > ul > li > a > span { color: red; }");
556        assert_eq!(a.notable_rules.len(), 1);
557        assert!(a.notable_rules[0].complexity > MAX_PLAIN_COMPLEXITY);
558    }
559
560    #[test]
561    fn nesting_depth_tracked() {
562        let a = analytics(".a { .b { .c { .d { color: red; } } } }");
563        assert!(a.max_nesting_depth >= 3, "got {}", a.max_nesting_depth);
564        // The depth-3 rule (`.d`) crosses the nesting floor.
565        assert!(
566            a.notable_rules
567                .iter()
568                .any(|r| r.nesting_depth >= NOTABLE_NESTING_DEPTH)
569        );
570    }
571
572    #[test]
573    fn specificity_takes_most_specific_selector_in_list() {
574        let a = analytics("#id, .cls { color: red; }");
575        assert_eq!(a.notable_rules.len(), 1);
576        // `#id` (1,0,0) is more specific than `.cls` (0,1,0).
577        assert_eq!(a.notable_rules[0].specificity_a, 1);
578    }
579
580    #[test]
581    fn line_is_one_based() {
582        let a = analytics("\n\n#main { color: red; }");
583        assert_eq!(a.notable_rules[0].line, 3);
584    }
585
586    #[test]
587    fn media_query_rules_walked() {
588        let a = analytics("@media (min-width: 600px) { #main { color: red; } }");
589        assert_eq!(a.rule_count, 1);
590        assert_eq!(a.notable_rules.len(), 1);
591        assert_eq!(a.notable_rules[0].specificity_a, 1);
592    }
593
594    #[test]
595    fn collects_distinct_colors() {
596        let a = analytics(".a { color: red; } .b { color: blue; } .c { color: red; }");
597        assert_eq!(a.colors.len(), 2, "distinct colors deduped: {:?}", a.colors);
598    }
599
600    #[test]
601    fn collects_colors_nested_in_shorthands() {
602        // The color inside the `border` shorthand must be caught, not just the
603        // standalone `background` color: that is the point of the value visitor.
604        let a = analytics(".a { border: 1px solid green; background: yellow; }");
605        assert!(
606            a.colors.len() >= 2,
607            "shorthand + standalone colors collected: {:?}",
608            a.colors
609        );
610    }
611
612    #[test]
613    fn collects_distinct_font_sizes() {
614        let a =
615            analytics(".a { font-size: 14px; } .b { font-size: 14px; } .c { font-size: 1rem; }");
616        assert_eq!(a.font_sizes.len(), 2, "got {:?}", a.font_sizes);
617    }
618
619    #[test]
620    fn collects_distinct_z_indexes() {
621        let a = analytics(".a { z-index: 10; } .b { z-index: 10; } .c { z-index: 999; }");
622        assert_eq!(a.z_indexes.len(), 2, "got {:?}", a.z_indexes);
623    }
624
625    #[test]
626    fn collects_defined_and_referenced_custom_properties() {
627        let a = analytics(":root { --brand: red; --unused: blue; }\n.a { color: var(--brand); }");
628        assert!(
629            a.defined_custom_properties.contains(&"--brand".to_string()),
630            "defined: {:?}",
631            a.defined_custom_properties
632        );
633        assert!(
634            a.defined_custom_properties
635                .contains(&"--unused".to_string())
636        );
637        assert!(
638            a.referenced_custom_properties
639                .contains(&"--brand".to_string()),
640            "referenced: {:?}",
641            a.referenced_custom_properties
642        );
643        assert!(
644            !a.referenced_custom_properties
645                .contains(&"--unused".to_string()),
646            "--unused has no var() reference"
647        );
648    }
649
650    #[test]
651    fn collects_defined_and_referenced_keyframes() {
652        let a = analytics(
653            "@keyframes spin { from {} to {} }\n@keyframes unused { from {} }\n.a { animation-name: spin; }",
654        );
655        assert!(a.defined_keyframes.contains(&"spin".to_string()));
656        assert!(a.defined_keyframes.contains(&"unused".to_string()));
657        assert!(a.referenced_keyframes.contains(&"spin".to_string()));
658        assert!(
659            !a.referenced_keyframes.contains(&"unused".to_string()),
660            "no animation references `unused`"
661        );
662    }
663
664    #[test]
665    fn animation_shorthand_references_keyframes() {
666        let a = analytics("@keyframes pulse { from {} }\n.a { animation: pulse 1s infinite; }");
667        assert!(
668            a.referenced_keyframes.contains(&"pulse".to_string()),
669            "referenced: {:?}",
670            a.referenced_keyframes
671        );
672    }
673
674    #[test]
675    fn fingerprints_blocks_at_floor_order_insensitive() {
676        // Two 4-declaration rules with the same declarations in different order
677        // share a fingerprint; a 3-declaration rule is below the floor and is
678        // not fingerprinted.
679        let a = analytics(
680            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
681             .y { top: 3px; padding: 2px; margin: 1px; color: red; }\n\
682             .z { color: red; margin: 1px; padding: 2px; }\n",
683        );
684        assert_eq!(
685            a.declaration_blocks.len(),
686            2,
687            "two 4-decl rules fingerprinted, the 3-decl one skipped: {:?}",
688            a.declaration_blocks
689        );
690        assert_eq!(
691            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
692            "same declarations in different order share a fingerprint"
693        );
694        assert_eq!(a.declaration_blocks[0].declaration_count, 4);
695    }
696
697    #[test]
698    fn important_distinguishes_block_fingerprint() {
699        let a = analytics(
700            ".x { color: red; margin: 1px; padding: 2px; top: 3px; }\n\
701             .y { color: red !important; margin: 1px; padding: 2px; top: 3px; }\n",
702        );
703        assert_eq!(a.declaration_blocks.len(), 2);
704        assert_ne!(
705            a.declaration_blocks[0].fingerprint, a.declaration_blocks[1].fingerprint,
706            "!important changes the block fingerprint"
707        );
708    }
709}