Skip to main content

fallow_extract/
css_metrics.rs

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