Skip to main content

fallow_engine/health/css_analytics/
mod.rs

1//! CSS analytics execution for `fallow health`.
2
3use fallow_config::ResolvedConfig;
4
5use super::package_json::{
6    class_matches_dependency_prefix, dependency_class_prefixes, project_uses_tailwind,
7    project_uses_tailwind_plugin, published_css_paths,
8};
9use super::runtime_filter::relative_to_root;
10use super::tailwind_theme;
11
12mod classes;
13mod cva;
14mod markup_scan;
15mod near_duplicates;
16mod preprocessor;
17mod theme_tokens;
18mod token_consumers;
19
20use classes::*;
21use cva::*;
22use markup_scan::*;
23use near_duplicates::*;
24use preprocessor::*;
25use theme_tokens::*;
26use token_consumers::*;
27
28const MAX_REPORTED_RAW_STYLE_VALUES: usize = 200;
29
30/// The per-run scan filters shared by every CSS and markup health scanner:
31/// resolved config, the ignore globset, the optional changed-file set, and
32/// the optional workspace roots.
33#[derive(Clone, Copy)]
34pub(super) struct HealthScanCtx<'a> {
35    pub(super) config: &'a ResolvedConfig,
36    pub(super) ignore_set: &'a globset::GlobSet,
37    pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
38    pub(super) output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
39    pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
40}
41
42/// Session-owned styling inputs that can be reused by health, audit, and future
43/// editor surfaces without rebuilding every source reference corpus.
44#[derive(Clone, Debug)]
45pub struct StylingAnalysisArtifacts {
46    reference_surface: CssReferenceSurface,
47    class_inventory: CssClassInventory,
48    whole_scope_walk: CssWalkAccum,
49}
50
51pub(super) fn build_styling_analysis_artifacts(
52    files: &[fallow_types::discover::DiscoveredFile],
53    config: &ResolvedConfig,
54) -> StylingAnalysisArtifacts {
55    let ignore_set = super::ignore::build_ignore_set(&config.health.ignore);
56    StylingAnalysisArtifacts {
57        reference_surface: css_reference_surface(files, config, &ignore_set),
58        class_inventory: css_class_inventory(files, config, &ignore_set),
59        whole_scope_walk: walk_css_files(
60            files,
61            HealthScanCtx {
62                config,
63                ignore_set: &ignore_set,
64                changed_files: None,
65                output_changed_files: None,
66                ws_roots: None,
67            },
68        ),
69    }
70}
71
72/// Compute structural CSS analytics, honoring the same ignore / changed-since /
73/// workspace filters as the rest of `fallow health`. Standard CSS is parsed for
74/// structural metrics; preprocessor sources are only used by candidate checks
75/// that can stay conservative without expanding Sass/Less semantics. Only
76/// stylesheets with a structurally notable rule are listed individually; the
77/// summary aggregates every analyzed stylesheet. Returns `None` when no
78/// stylesheet was analyzed.
79/// Project-wide CSS token accumulator: distinct design-token values plus the
80/// custom-property / `@keyframes` definition and reference sets, with the first
81/// stylesheet that defines/references each keyframe name so a candidate can be
82/// located. Populated per stylesheet during the discovery walk, then finalized
83/// into the summary counts and the two located keyframe candidate lists.
84#[derive(Clone, Default, Debug)]
85struct CssTokenSets {
86    colors: rustc_hash::FxHashSet<String>,
87    font_sizes: rustc_hash::FxHashSet<String>,
88    z_indexes: rustc_hash::FxHashSet<String>,
89    box_shadows: rustc_hash::FxHashSet<String>,
90    border_radii: rustc_hash::FxHashSet<String>,
91    line_heights: rustc_hash::FxHashSet<String>,
92    defined_custom_props: rustc_hash::FxHashSet<String>,
93    referenced_custom_props: rustc_hash::FxHashSet<String>,
94    defined_keyframes: rustc_hash::FxHashSet<String>,
95    referenced_keyframes: rustc_hash::FxHashSet<String>,
96    keyframes_definers: rustc_hash::FxHashMap<String, String>,
97    keyframe_referencers: rustc_hash::FxHashMap<String, String>,
98    /// Declaration-block fingerprint -> (declaration count, occurrences as
99    /// `(path, line)`), for cross-file duplicate-block detection.
100    declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
101    /// `@property` registrations + cascade-layer declarations / populations for
102    /// cross-file unused-at-rule detection, with the first defining file per name.
103    registered_custom_props: rustc_hash::FxHashSet<String>,
104    declared_layers: rustc_hash::FxHashSet<String>,
105    populated_layers: rustc_hash::FxHashSet<String>,
106    property_registrars: rustc_hash::FxHashMap<String, String>,
107    layer_declarers: rustc_hash::FxHashMap<String, String>,
108    /// `@font-face`-declared families + referenced font families for cross-file
109    /// dead-web-font detection, with the first declaring file per family.
110    defined_font_faces: rustc_hash::FxHashSet<String>,
111    referenced_font_families: rustc_hash::FxHashSet<String>,
112    font_face_definers: rustc_hash::FxHashMap<String, String>,
113    /// Tailwind v4 `@theme` tokens (custom-property name without `--`) -> first
114    /// definition, for token reachability and drift candidates.
115    theme_token_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
116    /// CSS custom properties with literal values, including non-`@theme`
117    /// variables, for raw-style nearest-token suggestions.
118    custom_property_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
119    /// Utility tokens referenced in `@apply` bodies across all CSS, so a theme
120    /// token whose utility is applied only in plain CSS is credited as used.
121    apply_tokens: rustc_hash::FxHashSet<String>,
122    /// Custom-property names (without `--`) read via `var()` inside `@theme`
123    /// interiors (lightningcss skips the unknown at-rule, so these are tracked
124    /// separately and never pollute the shared `referenced_custom_props` set
125    /// the `@property` / unreferenced-custom-property candidates diff against).
126    theme_var_reads: rustc_hash::FxHashSet<String>,
127    /// Located `@theme`-interior `var()` reads: `(name, path, line)` per read.
128    theme_var_reads_located: Vec<(String, String, u32)>,
129    /// Located regular-CSS `var()` reads: `(name, path, line)` per read.
130    css_var_reads_located: Vec<(String, String, u32)>,
131    /// Located class-shaped tokens inside `@apply` bodies: `(token, path, line)`.
132    apply_uses_located: Vec<(String, String, u32)>,
133    /// `true` when any analyzed stylesheet declares a Tailwind `@plugin`
134    /// directive: a plugin can consume theme tokens via `theme()` / `addUtilities`
135    /// invisibly to the markup / CSS / `var()` scan, so the unused-theme-token
136    /// candidate hard-abstains on plugin projects (the DI blind spot).
137    any_plugin_directive: bool,
138    /// Located raw CSS declaration values from authored structural stylesheets.
139    raw_style_values: Vec<fallow_output::RawStyleValue>,
140}
141
142#[derive(Clone, Debug)]
143struct ThemeTokenDefinition {
144    path: String,
145    line: u32,
146    value: String,
147}
148
149impl CssTokenSets {
150    /// Group declaration-block fingerprints seen in 2+ rules into located
151    /// duplicate-block candidates, set the summary counts, and sort by estimated
152    /// savings descending (then first occurrence path).
153    fn group_duplicate_blocks(
154        &self,
155        summary: &mut fallow_output::CssAnalyticsSummary,
156    ) -> Vec<fallow_output::CssDuplicateBlock> {
157        use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
158
159        let mut groups: Vec<CssDuplicateBlock> = self
160            .declaration_blocks
161            .values()
162            .filter(|(_, occurrences)| occurrences.len() >= 2)
163            .map(|(declaration_count, occurrences)| {
164                let occurrence_count = saturate_len(occurrences.len());
165                let estimated_savings = occurrence_count
166                    .saturating_sub(1)
167                    .saturating_mul(u32::from(*declaration_count));
168                let mut occ: Vec<CssBlockOccurrence> = occurrences
169                    .iter()
170                    .map(|(path, line)| CssBlockOccurrence {
171                        path: path.clone(),
172                        line: *line,
173                    })
174                    .collect();
175                occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
176                CssDuplicateBlock {
177                    declaration_count: *declaration_count,
178                    occurrence_count,
179                    estimated_savings,
180                    occurrences: occ,
181                    actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
182                }
183            })
184            .collect();
185        // Highest-savings groups first; tie-break on the first occurrence path for
186        // deterministic output.
187        groups.sort_by(|a, b| {
188            b.estimated_savings
189                .cmp(&a.estimated_savings)
190                .then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
191        });
192        summary.duplicate_declaration_blocks = saturate_len(groups.len());
193        summary.duplicate_declarations_total = groups
194            .iter()
195            .fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
196        groups
197    }
198
199    /// Fold one stylesheet's analytics into the project-wide token sets,
200    /// recording the first-defining file (`rel`) per located name.
201    fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
202        self.record_design_tokens(analytics);
203        self.record_custom_properties(analytics, rel);
204        self.record_keyframes(analytics, rel);
205        self.record_declaration_blocks(analytics, rel);
206        self.record_font_faces_and_layers(analytics, rel);
207        self.record_raw_style_values(analytics, rel);
208    }
209
210    fn record_design_tokens(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
211        self.colors.extend(analytics.colors.iter().cloned());
212        self.font_sizes.extend(analytics.font_sizes.iter().cloned());
213        self.z_indexes.extend(analytics.z_indexes.iter().cloned());
214        self.box_shadows
215            .extend(analytics.box_shadows.iter().cloned());
216        self.border_radii
217            .extend(analytics.border_radii.iter().cloned());
218        self.line_heights
219            .extend(analytics.line_heights.iter().cloned());
220    }
221
222    fn record_custom_properties(
223        &mut self,
224        analytics: &fallow_types::extract::CssAnalytics,
225        rel: &str,
226    ) {
227        self.defined_custom_props
228            .extend(analytics.defined_custom_properties.iter().cloned());
229        for token in &analytics.custom_property_definitions {
230            self.custom_property_definers
231                .entry(token.name.clone())
232                .or_insert_with(|| ThemeTokenDefinition {
233                    path: rel.to_owned(),
234                    line: token.line,
235                    value: token.value.clone(),
236                });
237        }
238        self.referenced_custom_props
239            .extend(analytics.referenced_custom_properties.iter().cloned());
240        for name in &analytics.registered_custom_properties {
241            self.registered_custom_props.insert(name.clone());
242            self.property_registrars
243                .entry(name.clone())
244                .or_insert_with(|| rel.to_owned());
245        }
246    }
247
248    fn record_keyframes(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
249        for keyframes in &analytics.referenced_keyframes {
250            self.referenced_keyframes.insert(keyframes.clone());
251            self.keyframe_referencers
252                .entry(keyframes.clone())
253                .or_insert_with(|| rel.to_owned());
254        }
255        for keyframes in &analytics.defined_keyframes {
256            self.defined_keyframes.insert(keyframes.clone());
257            self.keyframes_definers
258                .entry(keyframes.clone())
259                .or_insert_with(|| rel.to_owned());
260        }
261    }
262
263    fn record_declaration_blocks(
264        &mut self,
265        analytics: &fallow_types::extract::CssAnalytics,
266        rel: &str,
267    ) {
268        for block in &analytics.declaration_blocks {
269            self.declaration_blocks
270                .entry(block.fingerprint)
271                .or_insert_with(|| (block.declaration_count, Vec::new()))
272                .1
273                .push((rel.to_owned(), block.line));
274        }
275    }
276
277    fn record_font_faces_and_layers(
278        &mut self,
279        analytics: &fallow_types::extract::CssAnalytics,
280        rel: &str,
281    ) {
282        for family in &analytics.referenced_font_families {
283            self.referenced_font_families.insert(family.clone());
284        }
285        for family in &analytics.defined_font_faces {
286            self.defined_font_faces.insert(family.clone());
287            self.font_face_definers
288                .entry(family.clone())
289                .or_insert_with(|| rel.to_owned());
290        }
291        for name in &analytics.populated_layers {
292            self.populated_layers.insert(name.clone());
293        }
294        for name in &analytics.declared_layers {
295            self.declared_layers.insert(name.clone());
296            self.layer_declarers
297                .entry(name.clone())
298                .or_insert_with(|| rel.to_owned());
299        }
300    }
301
302    fn record_raw_style_values(
303        &mut self,
304        analytics: &fallow_types::extract::CssAnalytics,
305        rel: &str,
306    ) {
307        for raw in &analytics.raw_style_values {
308            if self.raw_style_values.len() >= MAX_REPORTED_RAW_STYLE_VALUES {
309                break;
310            }
311            self.raw_style_values.push(fallow_output::RawStyleValue {
312                axis: raw.axis.clone(),
313                property: raw.property.clone(),
314                value: raw.value.clone(),
315                path: rel.to_owned(),
316                line: raw.line,
317                nearest_token: None,
318                actions: vec![fallow_output::CssCandidateAction::replace_raw_style_value(
319                    &raw.axis, &raw.value,
320                )],
321            });
322        }
323    }
324
325    /// Fold one stylesheet's Tailwind v4 `@theme` tokens, `@apply` body tokens,
326    /// and `@theme`-interior `var()` reads into the project-wide sets (the inputs
327    /// to the unused-theme-token candidate). `scan_theme_blocks` /
328    /// `extract_apply_tokens` fast-path out on sources with no `@theme` / `@apply`,
329    /// so this is near-free for non-Tailwind stylesheets.
330    fn record_theme(&mut self, source: &str, rel: &str) {
331        let scan = crate::css::scan_theme_blocks(source);
332        for token in scan.tokens {
333            self.theme_token_definers
334                .entry(token.name)
335                .or_insert_with(|| ThemeTokenDefinition {
336                    path: rel.to_owned(),
337                    line: token.line,
338                    value: token.value,
339                });
340        }
341        for (name, line) in scan.theme_var_reads {
342            self.theme_var_reads.insert(name.clone());
343            self.theme_var_reads_located
344                .push((name, rel.to_owned(), line));
345        }
346        self.apply_tokens
347            .extend(crate::css::extract_apply_tokens(source));
348        self.apply_uses_located.extend(
349            crate::css::extract_apply_tokens_located(source)
350                .into_iter()
351                .map(|(token, line)| (token, rel.to_owned(), line)),
352        );
353        self.css_var_reads_located.extend(
354            crate::css::extract_css_var_reads_located(source)
355                .into_iter()
356                .map(|(name, line)| (name, rel.to_owned(), line)),
357        );
358        if source.contains("@plugin") {
359            self.any_plugin_directive = true;
360        }
361    }
362
363    /// Group unused CSS at-rule entities: `@property` registrations never read
364    /// via `var()`, and cascade layers declared but never populated. Sets the
365    /// summary counts and returns the located list sorted by (kind, path, name).
366    fn group_unused_at_rules(
367        &self,
368        summary: &mut fallow_output::CssAnalyticsSummary,
369    ) -> Vec<fallow_output::UnusedAtRule> {
370        use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
371
372        let mut out: Vec<UnusedAtRule> = Vec::new();
373        for name in self
374            .registered_custom_props
375            .difference(&self.referenced_custom_props)
376        {
377            out.push(UnusedAtRule {
378                kind: UnusedAtRuleKind::PropertyRegistration,
379                name: name.clone(),
380                path: self
381                    .property_registrars
382                    .get(name)
383                    .cloned()
384                    .unwrap_or_default(),
385                actions: vec![CssCandidateAction::verify_unused_at_rule(
386                    UnusedAtRuleKind::PropertyRegistration,
387                    name,
388                )],
389            });
390        }
391        summary.unused_property_registrations = saturate_len(out.len());
392        let property_count = out.len();
393        for name in self.declared_layers.difference(&self.populated_layers) {
394            out.push(UnusedAtRule {
395                kind: UnusedAtRuleKind::Layer,
396                name: name.clone(),
397                path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
398                actions: vec![CssCandidateAction::verify_unused_at_rule(
399                    UnusedAtRuleKind::Layer,
400                    name,
401                )],
402            });
403        }
404        summary.unused_layers = saturate_len(out.len() - property_count);
405        out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
406        out
407    }
408
409    /// Fill the summary token counts and return the two located keyframe
410    /// candidate lists: defined-but-unused (`unreferenced`) and used-but-
411    /// undefined (`undefined`).
412    fn finalize(
413        &self,
414        summary: &mut fallow_output::CssAnalyticsSummary,
415    ) -> (
416        Vec<fallow_output::UnreferencedKeyframes>,
417        Vec<fallow_output::UndefinedKeyframes>,
418    ) {
419        use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
420
421        summary.unique_colors = saturate_len(self.colors.len());
422        summary.unique_font_sizes = saturate_len(self.font_sizes.len());
423        summary.unique_z_indexes = saturate_len(self.z_indexes.len());
424        summary.unique_box_shadows = saturate_len(self.box_shadows.len());
425        summary.unique_border_radii = saturate_len(self.border_radii.len());
426        summary.unique_line_heights = saturate_len(self.line_heights.len());
427        summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
428        summary.custom_properties_unreferenced = saturate_len(
429            self.defined_custom_props
430                .difference(&self.referenced_custom_props)
431                .count(),
432        );
433        // Count-only (per panel review): a var() referenced but defined in no
434        // stylesheet is dominated by JS-set design tokens, so locating these
435        // would be net-noise. The count is an architecture signal.
436        summary.custom_properties_undefined = saturate_len(
437            self.referenced_custom_props
438                .difference(&self.defined_custom_props)
439                .count(),
440        );
441        summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
442        summary.keyframes_unreferenced = saturate_len(
443            self.defined_keyframes
444                .difference(&self.referenced_keyframes)
445                .count(),
446        );
447        summary.keyframes_undefined = saturate_len(
448            self.referenced_keyframes
449                .difference(&self.defined_keyframes)
450                .count(),
451        );
452
453        // @keyframes are low-cardinality, so BOTH directions are located (not
454        // just counted): defined-but-unused, and used-but-defined-nowhere.
455        let unreferenced_keyframes = locate_keyframe_diff(
456            &self.defined_keyframes,
457            &self.referenced_keyframes,
458            &self.keyframes_definers,
459        )
460        .into_iter()
461        .map(|(name, path)| UnreferencedKeyframes {
462            actions: vec![CssCandidateAction::verify_keyframe(&name)],
463            name,
464            path,
465        })
466        .collect();
467        let undefined_keyframes = locate_keyframe_diff(
468            &self.referenced_keyframes,
469            &self.defined_keyframes,
470            &self.keyframe_referencers,
471        )
472        .into_iter()
473        .map(|(name, path)| UndefinedKeyframes {
474            actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
475            name,
476            path,
477        })
478        .collect();
479        (unreferenced_keyframes, undefined_keyframes)
480    }
481
482    /// `@font-face`-declared families referenced by no `font-family` anywhere in
483    /// the project: a dead web-font payload. Located at the declaring stylesheet,
484    /// set the summary count.
485    fn unused_font_faces(
486        &self,
487        summary: &mut fallow_output::CssAnalyticsSummary,
488    ) -> Vec<fallow_output::UnusedFontFace> {
489        use fallow_output::{CssCandidateAction, UnusedFontFace};
490        // CSS font-family names are case-insensitive (CSS Fonts Level 4 4.2.1),
491        // unlike `@keyframes` custom-ident names (case-sensitive, via
492        // `locate_keyframe_diff`), so match case-insensitively while keeping the
493        // declared casing for both display and the verify command.
494        let referenced_lower: rustc_hash::FxHashSet<String> = self
495            .referenced_font_families
496            .iter()
497            .map(|family| family.to_ascii_lowercase())
498            .collect();
499        let mut out: Vec<UnusedFontFace> = self
500            .defined_font_faces
501            .iter()
502            .filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
503            .map(|family| UnusedFontFace {
504                actions: vec![CssCandidateAction::verify_unused_font_face(family)],
505                path: self
506                    .font_face_definers
507                    .get(family)
508                    .cloned()
509                    .unwrap_or_default(),
510                family: family.clone(),
511            })
512            .collect();
513        out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
514        summary.unused_font_faces = saturate_len(out.len());
515        out
516    }
517
518    /// Group the distinct `font-size` values by length unit (`px`/`rem`/`em`/`%`/
519    /// `pt`/other), set the `font_size_units_used` count, and, when the project
520    /// mixes two or more units across enough distinct sizes, return a
521    /// consistency candidate (mixing `px` and `rem` for type works against
522    /// user-zoom accessibility). Advisory only, never gated.
523    fn font_size_unit_mix(
524        &self,
525        summary: &mut fallow_output::CssAnalyticsSummary,
526    ) -> Option<fallow_output::CssNotationConsistency> {
527        use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
528
529        let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
530        for value in &self.font_sizes {
531            if let Some(unit) = classify_font_size_unit(value) {
532                *counts.entry(unit).or_insert(0) += 1;
533            }
534        }
535        summary.font_size_units_used = saturate_len(counts.len());
536
537        // Conservative floor: at least two distinct units AND enough classified
538        // sizes that the project plainly has a type scale (so a tiny stylesheet
539        // with one px and one rem does not trip it). Smoke-tunable.
540        let total: u32 = counts.values().copied().sum();
541        if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
542            return None;
543        }
544        let mut notations: Vec<CssNotationCount> = counts
545            .into_iter()
546            .map(|(notation, count)| CssNotationCount {
547                notation: notation.to_owned(),
548                count,
549            })
550            .collect();
551        // Dominant unit first; tie-break on the unit name for deterministic output.
552        notations.sort_by(|a, b| {
553            b.count
554                .cmp(&a.count)
555                .then_with(|| a.notation.cmp(&b.notation))
556        });
557        // Safe: the floor guard above guarantees at least two notations.
558        let dominant = notations[0].notation.clone();
559        Some(CssNotationConsistency {
560            actions: vec![CssCandidateAction::standardize_notation(
561                "Font sizes",
562                &dominant,
563            )],
564            axis: "Font sizes".to_owned(),
565            notations,
566        })
567    }
568}
569
570/// Fewest distinct unit-classified `font-size` values before a unit-mix candidate
571/// is worth surfacing. Below this the project does not yet have a type scale, so
572/// a px/rem split is noise rather than an inconsistency.
573const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
574
575/// Classify a `font-size` value's length unit for the unit-consistency
576/// candidate. Returns `None` for function values (`clamp()` / `calc()` /
577/// `min()` / `max()` / `var()`) and bare keywords (`medium`, `larger`,
578/// `inherit`), which carry no single comparable unit. Unit names are lowercased;
579/// recognized type units map to a stable label, anything else to `"other"`.
580fn classify_font_size_unit(value: &str) -> Option<&'static str> {
581    let v = value.trim();
582    if v.is_empty() || v.contains('(') {
583        return None;
584    }
585    if let Some(stripped) = v.strip_suffix('%') {
586        // A bare `%` font-size is `<number>%`; reject anything else (defensive).
587        return stripped
588            .chars()
589            .all(|c| c.is_ascii_digit() || c == '.')
590            .then_some("%");
591    }
592    let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
593    let (number, unit) = v.split_at(unit_start);
594    // A dimension is `<number><unit>`; a leading non-numeric prefix means a
595    // keyword (e.g. `medium`), which has no unit.
596    if number.is_empty()
597        || !number
598            .chars()
599            .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
600    {
601        return None;
602    }
603    match unit.to_ascii_lowercase().as_str() {
604        "px" => Some("px"),
605        "rem" => Some("rem"),
606        "em" => Some("em"),
607        "pt" => Some("pt"),
608        _ => Some("other"),
609    }
610}
611
612/// Build the sorted `(name, path)` set difference `present - absent`, locating
613/// each surviving name via `locator` (empty path when absent). Sorted by
614/// `(path, name)` for deterministic output.
615fn locate_keyframe_diff(
616    present: &rustc_hash::FxHashSet<String>,
617    absent: &rustc_hash::FxHashSet<String>,
618    locator: &rustc_hash::FxHashMap<String, String>,
619) -> Vec<(String, String)> {
620    let mut out: Vec<(String, String)> = present
621        .difference(absent)
622        .map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
623        .collect();
624    out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
625    out
626}
627
628/// Saturating `usize -> u32` for token counts.
629fn saturate_len(len: usize) -> u32 {
630    u32::try_from(len).unwrap_or(u32::MAX)
631}
632
633/// `(first path, first line)` sort key for a duplicate block; occurrences are
634/// pre-sorted, so the first is the lexicographic minimum.
635fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
636    block
637        .occurrences
638        .first()
639        .map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
640}
641
642/// Scan the project's markup (`.jsx` / `.tsx` / `.html` / `.astro` / `.vue` /
643/// `.svelte` / `.md` / `.mdx`) for Tailwind arbitrary-value utility tokens,
644/// honoring the same
645/// ignore / changed / workspace filters as the CSS scan. Aggregates by token
646/// (total count + first location), sets the summary counts, and returns the
647/// located list sorted by use count descending.
648/// One eligible markup file for a class-token scan: the forward-slash relative
649/// path plus source, or `None` when the file is filtered out (extension, ignore
650/// set, changed-files, workspace scope) or unreadable.
651fn read_markup_scan_source(
652    file: &fallow_types::discover::DiscoveredFile,
653    ctx: HealthScanCtx<'_>,
654) -> Option<(String, String)> {
655    let HealthScanCtx {
656        config,
657        ignore_set,
658        changed_files,
659        output_changed_files: _,
660        ws_roots,
661    } = ctx;
662
663    let path = &file.path;
664    let extension = path.extension().and_then(|ext| ext.to_str());
665    if !extension.is_some_and(is_markup_source_extension) {
666        return None;
667    }
668    let relative = path.strip_prefix(&config.root).unwrap_or(path);
669    if ignore_set.is_match(relative) {
670        return None;
671    }
672    if let Some(changed) = changed_files
673        && !changed.contains(path)
674    {
675        return None;
676    }
677    if let Some(roots) = ws_roots
678        && !roots.iter().any(|root| path.starts_with(root))
679    {
680        return None;
681    }
682    let source = std::fs::read_to_string(path).ok()?;
683    let rel = relative.to_string_lossy().replace('\\', "/");
684    Some((rel, source))
685}
686
687fn scan_markup_tailwind_arbitrary_values(
688    files: &[fallow_types::discover::DiscoveredFile],
689    ctx: HealthScanCtx<'_>,
690    summary: &mut fallow_output::CssAnalyticsSummary,
691) -> Vec<fallow_output::TailwindArbitraryValue> {
692    let HealthScanCtx { config, .. } = ctx;
693
694    use fallow_output::TailwindArbitraryValue;
695
696    if !project_uses_tailwind(&config.root) {
697        return Vec::new();
698    }
699    // token -> (total count, first path, first line). First-seen wins for the
700    // location; files are path-sorted, so the first occurrence is deterministic.
701    let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
702        rustc_hash::FxHashMap::default();
703    let mut total_uses: u32 = 0;
704    for file in files {
705        let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
706            continue;
707        };
708        for arb in crate::css::scan_tailwind_arbitrary_values(&source) {
709            total_uses = total_uses.saturating_add(1);
710            let entry = agg
711                .entry(arb.value)
712                .or_insert_with(|| (0, rel.clone(), arb.line));
713            entry.0 = entry.0.saturating_add(1);
714        }
715    }
716
717    summary.tailwind_arbitrary_values = saturate_len(agg.len());
718    summary.tailwind_arbitrary_value_uses = total_uses;
719    let mut out: Vec<TailwindArbitraryValue> = agg
720        .into_iter()
721        .map(|(value, (count, path, line))| TailwindArbitraryValue {
722            actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
723                &value,
724            )],
725            value,
726            count,
727            path,
728            line,
729        })
730        .collect();
731    out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
732    out
733}
734
735fn record_css_analytics_summary(
736    summary: &mut fallow_output::CssAnalyticsSummary,
737    analytics: &fallow_types::extract::CssAnalytics,
738) {
739    summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
740    summary.total_declarations = summary
741        .total_declarations
742        .saturating_add(analytics.total_declarations);
743    summary.important_declarations = summary
744        .important_declarations
745        .saturating_add(analytics.important_declarations);
746    summary.empty_rules = summary
747        .empty_rules
748        .saturating_add(analytics.empty_rule_count);
749    summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
750    if analytics.notable_truncated {
751        summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
752    }
753}
754
755/// The per-file CSS walk accumulator: structural file reports, the project-wide
756/// token sets, scoped SFC unused-class findings, and the running summary.
757#[derive(Clone, Debug)]
758struct CssWalkAccum {
759    file_reports: Vec<fallow_output::CssFileAnalytics>,
760    summary: fallow_output::CssAnalyticsSummary,
761    scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
762    tokens: CssTokenSets,
763    scoring: CssGradeScoring,
764}
765
766#[derive(Clone, Debug, Default)]
767struct CssGradeScoring {
768    non_atomic_declarations: u32,
769    non_atomic_important_declarations: u32,
770    non_atomic_max_nesting_depth: u8,
771    atomic_declarations: u32,
772}
773
774impl CssGradeScoring {
775    fn add_non_atomic(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
776        self.non_atomic_declarations = self
777            .non_atomic_declarations
778            .saturating_add(analytics.total_declarations);
779        self.non_atomic_important_declarations = self
780            .non_atomic_important_declarations
781            .saturating_add(analytics.important_declarations);
782        self.non_atomic_max_nesting_depth = self
783            .non_atomic_max_nesting_depth
784            .max(analytics.max_nesting_depth);
785    }
786}
787
788/// The finalized whole-project token metrics (keyframes, duplicate blocks, unused
789/// at-rules, font-size unit mix, unused font faces) derived after the file walk.
790struct CssTokenMetrics {
791    unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
792    undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
793    duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
794    unused_at_rules: Vec<fallow_output::UnusedAtRule>,
795    font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
796    unused_font_faces: Vec<fallow_output::UnusedFontFace>,
797}
798
799/// CSS analytics plus internal-only inputs for the styling-health grade.
800pub(super) struct CssAnalyticsComputation {
801    pub(super) report: fallow_output::CssAnalyticsReport,
802    pub(super) scoring_inputs: super::styling_score::StylingScoringInputs,
803}
804
805/// Walk every in-scope stylesheet / SFC, accumulating structural metrics, the
806/// project token sets, and scoped SFC unused-class findings.
807fn walk_css_files(
808    files: &[fallow_types::discover::DiscoveredFile],
809    ctx: HealthScanCtx<'_>,
810) -> CssWalkAccum {
811    use fallow_output::{CssAnalyticsSummary, ScopedUnusedClasses};
812
813    let mut file_reports = Vec::new();
814    let mut summary = CssAnalyticsSummary::default();
815    let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
816    // Project-wide design-token + custom-property + @keyframes accumulator,
817    // unioned across every analyzed stylesheet (including ones with no notable
818    // rule, which are not listed individually), finalized after the walk.
819    let mut tokens = CssTokenSets::default();
820    let mut scoring = CssGradeScoring::default();
821    let css_in_js = project_uses_css_in_js(&ctx.config.root);
822
823    for file in files {
824        let Some((relative, kind)) = css_report_scan_target(file, ctx, css_in_js) else {
825            continue;
826        };
827        let Ok(source) = std::fs::read_to_string(&file.path) else {
828            continue;
829        };
830
831        if kind == CssScanKind::Sfc {
832            record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
833        }
834
835        let rel = relative.to_string_lossy().replace('\\', "/");
836        let mut file_had_sheet = false;
837        for item in css_report_scan_items(&source, &file.path, kind) {
838            file_had_sheet |= record_css_scan_item(
839                &item,
840                &rel,
841                &mut file_reports,
842                &mut summary,
843                &mut tokens,
844                &mut scoring,
845            );
846        }
847        if file_had_sheet {
848            summary.files_analyzed = summary.files_analyzed.saturating_add(1);
849        }
850    }
851
852    CssWalkAccum {
853        file_reports,
854        summary,
855        scoped_unused,
856        tokens,
857        scoring,
858    }
859}
860
861fn record_css_scan_item(
862    item: &CssScanItem<'_>,
863    rel: &str,
864    file_reports: &mut Vec<fallow_output::CssFileAnalytics>,
865    summary: &mut fallow_output::CssAnalyticsSummary,
866    tokens: &mut CssTokenSets,
867    scoring: &mut CssGradeScoring,
868) -> bool {
869    let Some(mut analytics) = crate::css::compute_css_analytics(&item.source) else {
870        return false;
871    };
872    record_css_analytics_summary(summary, &analytics);
873    tokens.record_theme(item.source.as_ref(), rel);
874
875    match item.policy {
876        GradePolicy::Atomic => {
877            analytics.declaration_blocks.clear();
878            analytics.raw_style_values.clear();
879            tokens.record(&analytics, rel);
880            scoring.atomic_declarations = scoring
881                .atomic_declarations
882                .saturating_add(analytics.total_declarations);
883        }
884        GradePolicy::Structural | GradePolicy::StructuralNoDedup => {
885            if item.policy == GradePolicy::StructuralNoDedup {
886                analytics.declaration_blocks.clear();
887            }
888            tokens.record(&analytics, rel);
889            scoring.add_non_atomic(&analytics);
890            if item.report_notable && !analytics.notable_rules.is_empty() {
891                file_reports.push(fallow_output::CssFileAnalytics {
892                    path: rel.to_owned(),
893                    analytics,
894                });
895            }
896        }
897    }
898
899    true
900}
901
902/// Credit Tailwind-markup-applied keyframes, then finalize the whole-project
903/// token metrics and prune unused `@font-face` families referenced elsewhere.
904fn finalize_css_token_metrics(
905    tokens: &mut CssTokenSets,
906    summary: &mut fallow_output::CssAnalyticsSummary,
907    files: &[fallow_types::discover::DiscoveredFile],
908    config: &ResolvedConfig,
909    ignore_set: &globset::GlobSet,
910) -> CssTokenMetrics {
911    // Credit @keyframes applied via Tailwind markup (`animate-[name_...]` /
912    // `animate-name`), not just CSS `animation:` declarations, before the
913    // unreferenced diff. Filtered to actually-defined keyframes so a stray
914    // `animate-*` suffix never manufactures a false `undefined_keyframes`.
915    for name in collect_markup_keyframe_references(files, config, ignore_set) {
916        if tokens.defined_keyframes.contains(&name) {
917            tokens.referenced_keyframes.insert(name);
918        }
919    }
920
921    let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
922    let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
923    let unused_at_rules = tokens.group_unused_at_rules(summary);
924    let font_size_unit_mix = tokens.font_size_unit_mix(summary);
925    let mut unused_font_faces = tokens.unused_font_faces(summary);
926    // The CSS-only set difference cannot see a font family applied from
927    // JavaScript / canvas (Excalidraw) or referenced from a `.scss`/`.sass`
928    // theme the parser never reads (reveal.js). Drop any candidate whose family
929    // name appears as a substring in ANY non-CSS source file, so only a font
930    // declared and used nowhere at all survives. (Real-world smoke.)
931    if !unused_font_faces.is_empty() {
932        let referenced =
933            font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
934        unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
935        summary.unused_font_faces = saturate_len(unused_font_faces.len());
936    }
937
938    CssTokenMetrics {
939        unreferenced_keyframes,
940        undefined_keyframes,
941        duplicate_declaration_blocks,
942        unused_at_rules,
943        font_size_unit_mix,
944        unused_font_faces,
945    }
946}
947
948#[cfg(test)]
949fn compute_css_analytics_report(
950    files: &[fallow_types::discover::DiscoveredFile],
951    modules: &[fallow_types::extract::ModuleInfo],
952    ctx: HealthScanCtx<'_>,
953) -> Option<CssAnalyticsComputation> {
954    compute_css_analytics_report_with_artifacts(files, modules, ctx, None)
955}
956
957pub(super) fn compute_css_analytics_report_with_artifacts(
958    files: &[fallow_types::discover::DiscoveredFile],
959    modules: &[fallow_types::extract::ModuleInfo],
960    ctx: HealthScanCtx<'_>,
961    styling_artifacts: Option<&StylingAnalysisArtifacts>,
962) -> Option<CssAnalyticsComputation> {
963    let HealthScanCtx {
964        config,
965        ignore_set,
966        changed_files,
967        output_changed_files,
968        ws_roots,
969    } = ctx;
970    let css_deep = output_changed_files.is_some();
971
972    // Collect CSS-in-JS token definers ONCE per run (parsing every candidate
973    // definer file from disk). Both the comparable-token candidate pass and the
974    // consumer blast-radius pass borrow this, instead of each recomputing it.
975    // `None` mirrors the old `!project_uses_css_in_js` short-circuit exactly.
976    let css_in_js_definers = project_uses_css_in_js(&config.root).then(|| {
977        let path_by_id: rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path> =
978            files.iter().map(|f| (f.id, f.path.as_path())).collect();
979        collect_css_in_js_definers(modules, &path_by_id, config)
980    });
981
982    let mut walk = css_report_walk(files, ctx, styling_artifacts);
983    let styling_token_candidates =
984        css_report_token_candidates(&walk.tokens, config, css_in_js_definers.as_ref());
985    annotate_raw_style_value_nearest_tokens(&mut walk.tokens, &styling_token_candidates);
986    let metrics = finalize_css_token_metrics(
987        &mut walk.tokens,
988        &mut walk.summary,
989        files,
990        config,
991        ignore_set,
992    );
993    let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
994        tokens: &walk.tokens,
995        files,
996        css_in_js_definers: css_in_js_definers.as_ref(),
997        config,
998        ignore_set,
999        changed_files,
1000        output_changed_files,
1001        css_deep,
1002        ws_roots,
1003        styling_artifacts,
1004        token_candidates: &styling_token_candidates,
1005        summary: &mut walk.summary,
1006    });
1007    let token_consumers = css_report_token_consumers(
1008        &TokenConsumersInput {
1009            tokens: &walk.tokens,
1010            files,
1011            config,
1012            ignore_set,
1013            changed_files,
1014            ws_roots,
1015        },
1016        modules,
1017        css_in_js_definers.as_ref(),
1018    );
1019    let scoring_inputs = css_report_scoring_inputs(&walk);
1020    let report = assemble_css_report(CssReportAssemblyInput {
1021        walk,
1022        metrics,
1023        candidates,
1024        token_consumers,
1025        config,
1026        output_changed_files,
1027    })?;
1028    Some(CssAnalyticsComputation {
1029        report,
1030        scoring_inputs,
1031    })
1032}
1033
1034fn css_report_walk(
1035    files: &[fallow_types::discover::DiscoveredFile],
1036    ctx: HealthScanCtx<'_>,
1037    styling_artifacts: Option<&StylingAnalysisArtifacts>,
1038) -> CssWalkAccum {
1039    let HealthScanCtx {
1040        changed_files,
1041        output_changed_files,
1042        ws_roots,
1043        ..
1044    } = ctx;
1045
1046    styling_artifacts
1047        .filter(|_| changed_files.is_none() && output_changed_files.is_none() && ws_roots.is_none())
1048        .map_or_else(
1049            || walk_css_files(files, ctx),
1050            |artifacts| artifacts.whole_scope_walk.clone(),
1051        )
1052}
1053
1054fn css_report_scoring_inputs(walk: &CssWalkAccum) -> super::styling_score::StylingScoringInputs {
1055    super::styling_score::StylingScoringInputs {
1056        theme_tokens_defined: saturate_len(walk.tokens.theme_token_definers.len()),
1057        non_atomic_declarations: walk.scoring.non_atomic_declarations,
1058        non_atomic_important_declarations: walk.scoring.non_atomic_important_declarations,
1059        non_atomic_max_nesting_depth: walk.scoring.non_atomic_max_nesting_depth,
1060        atomic_declarations: walk.scoring.atomic_declarations,
1061    }
1062}
1063
1064fn css_report_token_candidates(
1065    tokens: &CssTokenSets,
1066    config: &ResolvedConfig,
1067    css_in_js_definers: Option<&CssInJsDefiners>,
1068) -> Vec<ComparableThemeTokenCandidate> {
1069    let mut candidates = comparable_theme_token_candidates(tokens, config);
1070    candidates.extend(comparable_custom_property_token_candidates(tokens));
1071    candidates.extend(comparable_css_in_js_token_candidates(css_in_js_definers));
1072    candidates.extend(comparable_project_vocabulary_candidates(tokens));
1073    candidates.sort_by(|a, b| theme_token_sort_key(a).cmp(&theme_token_sort_key(b)));
1074    candidates
1075}
1076
1077fn css_report_token_consumers(
1078    input: &TokenConsumersInput<'_>,
1079    modules: &[fallow_types::extract::ModuleInfo],
1080    css_in_js_definers: Option<&CssInJsDefiners>,
1081) -> Vec<fallow_output::TokenConsumers> {
1082    let mut consumers = build_token_consumers(input);
1083    consumers.extend(build_css_in_js_token_consumers(
1084        input.files,
1085        modules,
1086        input.config,
1087        css_in_js_definers,
1088    ));
1089    consumers.sort_by(|a, b| {
1090        a.token
1091            .cmp(&b.token)
1092            .then_with(|| a.definition_path.cmp(&b.definition_path))
1093    });
1094    consumers
1095}
1096
1097/// Assemble the final CSS analytics report from the walk accumulator, finalized
1098/// token metrics, and markup candidates; returns `None` when nothing notable was
1099/// found (no analyzed files and every candidate list empty).
1100struct CssReportAssemblyInput<'a> {
1101    walk: CssWalkAccum,
1102    metrics: CssTokenMetrics,
1103    candidates: MarkupCssCandidates,
1104    token_consumers: Vec<fallow_output::TokenConsumers>,
1105    config: &'a ResolvedConfig,
1106    output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1107}
1108
1109fn assemble_css_report(
1110    input: CssReportAssemblyInput<'_>,
1111) -> Option<fallow_output::CssAnalyticsReport> {
1112    use fallow_output::CssAnalyticsReport;
1113
1114    let CssReportAssemblyInput {
1115        mut walk,
1116        mut metrics,
1117        mut candidates,
1118        mut token_consumers,
1119        config,
1120        output_changed_files,
1121    } = input;
1122
1123    if let Some(changed) = output_changed_files {
1124        retain_css_report_changed_scope(CssReportChangedScopeInput {
1125            walk: &mut walk,
1126            metrics: &mut metrics,
1127            candidates: &mut candidates,
1128            token_consumers: &mut token_consumers,
1129            config,
1130            changed,
1131        });
1132    }
1133
1134    if css_report_is_empty(&walk, &metrics, &candidates, &token_consumers) {
1135        return None;
1136    }
1137    let mut scoped_unused = walk.scoped_unused;
1138    scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
1139    let mut raw_style_values = walk.tokens.raw_style_values;
1140    sort_raw_style_values(&mut raw_style_values);
1141    walk.summary.raw_style_values = saturate_len(raw_style_values.len());
1142    Some(CssAnalyticsReport {
1143        files: walk.file_reports,
1144        summary: walk.summary,
1145        scoped_unused,
1146        unreferenced_keyframes: metrics.unreferenced_keyframes,
1147        undefined_keyframes: metrics.undefined_keyframes,
1148        duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
1149        cva_duplicate_variant_blocks: candidates.cva_duplicate_variant_blocks,
1150        cva_variant_token_drifts: candidates.cva_variant_token_drifts,
1151        tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
1152        raw_style_values,
1153        unused_at_rules: metrics.unused_at_rules,
1154        unresolved_class_references: candidates.unresolved_class_references,
1155        unreferenced_css_classes: candidates.unreferenced_css_classes,
1156        unused_font_faces: metrics.unused_font_faces,
1157        unused_theme_tokens: candidates.unused_theme_tokens,
1158        near_duplicate_theme_tokens: candidates.near_duplicate_theme_tokens,
1159        near_duplicate_css_in_js_tokens: candidates.near_duplicate_css_in_js_tokens,
1160        token_consumers,
1161        font_size_unit_mix: metrics.font_size_unit_mix,
1162    })
1163}
1164
1165fn css_report_is_empty(
1166    walk: &CssWalkAccum,
1167    metrics: &CssTokenMetrics,
1168    candidates: &MarkupCssCandidates,
1169    token_consumers: &[fallow_output::TokenConsumers],
1170) -> bool {
1171    walk.summary.files_analyzed == 0
1172        && walk.scoped_unused.is_empty()
1173        && candidates.tailwind_arbitrary_values.is_empty()
1174        && candidates.cva_duplicate_variant_blocks.is_empty()
1175        && candidates.cva_variant_token_drifts.is_empty()
1176        && candidates.unresolved_class_references.is_empty()
1177        && candidates.unreferenced_css_classes.is_empty()
1178        && metrics.unused_font_faces.is_empty()
1179        && candidates.unused_theme_tokens.is_empty()
1180        && candidates.near_duplicate_theme_tokens.is_empty()
1181        && candidates.near_duplicate_css_in_js_tokens.is_empty()
1182        && token_consumers.is_empty()
1183}
1184
1185fn sort_raw_style_values(values: &mut [fallow_output::RawStyleValue]) {
1186    values.sort_by(|a, b| {
1187        (&a.path, a.line, &a.axis, &a.property, &a.value).cmp(&(
1188            &b.path,
1189            b.line,
1190            &b.axis,
1191            &b.property,
1192            &b.value,
1193        ))
1194    });
1195}
1196
1197struct CssReportChangedScopeInput<'a> {
1198    walk: &'a mut CssWalkAccum,
1199    metrics: &'a mut CssTokenMetrics,
1200    candidates: &'a mut MarkupCssCandidates,
1201    token_consumers: &'a mut Vec<fallow_output::TokenConsumers>,
1202    config: &'a ResolvedConfig,
1203    changed: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
1204}
1205
1206fn retain_css_report_changed_scope(input: CssReportChangedScopeInput<'_>) {
1207    let CssReportChangedScopeInput {
1208        walk,
1209        metrics,
1210        candidates,
1211        token_consumers,
1212        config,
1213        changed,
1214    } = input;
1215    let in_scope = |path: &str| css_output_path_in_changed_scope(path, config, changed);
1216    walk.file_reports.retain(|file| in_scope(&file.path));
1217    walk.scoped_unused.retain(|item| in_scope(&item.path));
1218    retain_css_metrics_changed_scope(metrics, &in_scope);
1219    retain_markup_candidates_changed_scope(candidates, &in_scope);
1220    walk.tokens
1221        .raw_style_values
1222        .retain(|item| in_scope(&item.path));
1223    token_consumers.retain(|item| in_scope(&item.definition_path));
1224}
1225
1226fn retain_css_metrics_changed_scope(
1227    metrics: &mut CssTokenMetrics,
1228    in_scope: &impl Fn(&str) -> bool,
1229) {
1230    metrics
1231        .unreferenced_keyframes
1232        .retain(|item| in_scope(&item.path));
1233    metrics
1234        .undefined_keyframes
1235        .retain(|item| in_scope(&item.path));
1236    metrics.duplicate_declaration_blocks.retain_mut(|block| {
1237        let has_scoped_occurrence = block.occurrences.iter().any(|item| in_scope(&item.path));
1238        if has_scoped_occurrence {
1239            block.occurrences.sort_by(|a, b| {
1240                let a_out_of_scope = !in_scope(&a.path);
1241                let b_out_of_scope = !in_scope(&b.path);
1242                a_out_of_scope
1243                    .cmp(&b_out_of_scope)
1244                    .then_with(|| a.path.cmp(&b.path))
1245                    .then_with(|| a.line.cmp(&b.line))
1246            });
1247        }
1248        has_scoped_occurrence
1249    });
1250    metrics.unused_at_rules.retain(|item| in_scope(&item.path));
1251    metrics
1252        .unused_font_faces
1253        .retain(|item| in_scope(&item.path));
1254}
1255
1256fn retain_markup_candidates_changed_scope(
1257    candidates: &mut MarkupCssCandidates,
1258    in_scope: &impl Fn(&str) -> bool,
1259) {
1260    candidates
1261        .tailwind_arbitrary_values
1262        .retain(|item| in_scope(&item.path));
1263    candidates
1264        .cva_duplicate_variant_blocks
1265        .retain(|item| item.occurrences.iter().any(|occ| in_scope(&occ.path)));
1266    candidates
1267        .cva_variant_token_drifts
1268        .retain(|item| in_scope(&item.path));
1269    candidates
1270        .unresolved_class_references
1271        .retain(|item| in_scope(&item.path));
1272    candidates
1273        .unreferenced_css_classes
1274        .retain(|item| in_scope(&item.path));
1275    candidates
1276        .unused_theme_tokens
1277        .retain(|item| in_scope(&item.path));
1278    candidates
1279        .near_duplicate_theme_tokens
1280        .retain(|item| in_scope(&item.path));
1281    candidates
1282        .near_duplicate_css_in_js_tokens
1283        .retain(|item| in_scope(&item.path));
1284}
1285
1286fn css_output_path_in_changed_scope(
1287    path: &str,
1288    config: &ResolvedConfig,
1289    changed: &rustc_hash::FxHashSet<std::path::PathBuf>,
1290) -> bool {
1291    let relative = std::path::Path::new(path);
1292    let absolute = config.root.join(relative);
1293    changed.contains(relative) || changed.contains(&absolute)
1294}
1295
1296#[cfg(test)]
1297#[allow(
1298    clippy::unwrap_used,
1299    reason = "tests use unwrap to keep token-consumer assertions concise"
1300)]
1301mod token_consumer_tests;