Skip to main content

fallow_engine/health/
css_analytics.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
12const MAX_REPORTED_RAW_STYLE_VALUES: usize = 200;
13
14/// The per-run scan filters shared by every CSS and markup health scanner:
15/// resolved config, the ignore globset, the optional changed-file set, and
16/// the optional workspace roots.
17#[derive(Clone, Copy)]
18pub(super) struct HealthScanCtx<'a> {
19    pub(super) config: &'a ResolvedConfig,
20    pub(super) ignore_set: &'a globset::GlobSet,
21    pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
22    pub(super) output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
23    pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
24}
25
26/// Session-owned styling inputs that can be reused by health, audit, and future
27/// editor surfaces without rebuilding every source reference corpus.
28#[derive(Clone, Debug)]
29pub struct StylingAnalysisArtifacts {
30    reference_surface: CssReferenceSurface,
31    class_inventory: CssClassInventory,
32    whole_scope_walk: CssWalkAccum,
33}
34
35pub(super) fn build_styling_analysis_artifacts(
36    files: &[fallow_types::discover::DiscoveredFile],
37    config: &ResolvedConfig,
38) -> StylingAnalysisArtifacts {
39    let ignore_set = super::ignore::build_ignore_set(&config.health.ignore);
40    StylingAnalysisArtifacts {
41        reference_surface: css_reference_surface(files, config, &ignore_set),
42        class_inventory: css_class_inventory(files, config, &ignore_set),
43        whole_scope_walk: walk_css_files(
44            files,
45            HealthScanCtx {
46                config,
47                ignore_set: &ignore_set,
48                changed_files: None,
49                output_changed_files: None,
50                ws_roots: None,
51            },
52        ),
53    }
54}
55
56/// Compute structural CSS analytics, honoring the same ignore / changed-since /
57/// workspace filters as the rest of `fallow health`. Standard CSS is parsed for
58/// structural metrics; preprocessor sources are only used by candidate checks
59/// that can stay conservative without expanding Sass/Less semantics. Only
60/// stylesheets with a structurally notable rule are listed individually; the
61/// summary aggregates every analyzed stylesheet. Returns `None` when no
62/// stylesheet was analyzed.
63/// Project-wide CSS token accumulator: distinct design-token values plus the
64/// custom-property / `@keyframes` definition and reference sets, with the first
65/// stylesheet that defines/references each keyframe name so a candidate can be
66/// located. Populated per stylesheet during the discovery walk, then finalized
67/// into the summary counts and the two located keyframe candidate lists.
68#[derive(Clone, Default, Debug)]
69struct CssTokenSets {
70    colors: rustc_hash::FxHashSet<String>,
71    font_sizes: rustc_hash::FxHashSet<String>,
72    z_indexes: rustc_hash::FxHashSet<String>,
73    box_shadows: rustc_hash::FxHashSet<String>,
74    border_radii: rustc_hash::FxHashSet<String>,
75    line_heights: rustc_hash::FxHashSet<String>,
76    defined_custom_props: rustc_hash::FxHashSet<String>,
77    referenced_custom_props: rustc_hash::FxHashSet<String>,
78    defined_keyframes: rustc_hash::FxHashSet<String>,
79    referenced_keyframes: rustc_hash::FxHashSet<String>,
80    keyframes_definers: rustc_hash::FxHashMap<String, String>,
81    keyframe_referencers: rustc_hash::FxHashMap<String, String>,
82    /// Declaration-block fingerprint -> (declaration count, occurrences as
83    /// `(path, line)`), for cross-file duplicate-block detection.
84    declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
85    /// `@property` registrations + cascade-layer declarations / populations for
86    /// cross-file unused-at-rule detection, with the first defining file per name.
87    registered_custom_props: rustc_hash::FxHashSet<String>,
88    declared_layers: rustc_hash::FxHashSet<String>,
89    populated_layers: rustc_hash::FxHashSet<String>,
90    property_registrars: rustc_hash::FxHashMap<String, String>,
91    layer_declarers: rustc_hash::FxHashMap<String, String>,
92    /// `@font-face`-declared families + referenced font families for cross-file
93    /// dead-web-font detection, with the first declaring file per family.
94    defined_font_faces: rustc_hash::FxHashSet<String>,
95    referenced_font_families: rustc_hash::FxHashSet<String>,
96    font_face_definers: rustc_hash::FxHashMap<String, String>,
97    /// Tailwind v4 `@theme` tokens (custom-property name without `--`) -> first
98    /// definition, for token reachability and drift candidates.
99    theme_token_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
100    /// CSS custom properties with literal values, including non-`@theme`
101    /// variables, for raw-style nearest-token suggestions.
102    custom_property_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
103    /// Utility tokens referenced in `@apply` bodies across all CSS, so a theme
104    /// token whose utility is applied only in plain CSS is credited as used.
105    apply_tokens: rustc_hash::FxHashSet<String>,
106    /// Custom-property names (without `--`) read via `var()` inside `@theme`
107    /// interiors (lightningcss skips the unknown at-rule, so these are tracked
108    /// separately and never pollute the shared `referenced_custom_props` set
109    /// the `@property` / unreferenced-custom-property candidates diff against).
110    theme_var_reads: rustc_hash::FxHashSet<String>,
111    /// Located `@theme`-interior `var()` reads: `(name, path, line)` per read.
112    theme_var_reads_located: Vec<(String, String, u32)>,
113    /// Located regular-CSS `var()` reads: `(name, path, line)` per read.
114    css_var_reads_located: Vec<(String, String, u32)>,
115    /// Located class-shaped tokens inside `@apply` bodies: `(token, path, line)`.
116    apply_uses_located: Vec<(String, String, u32)>,
117    /// `true` when any analyzed stylesheet declares a Tailwind `@plugin`
118    /// directive: a plugin can consume theme tokens via `theme()` / `addUtilities`
119    /// invisibly to the markup / CSS / `var()` scan, so the unused-theme-token
120    /// candidate hard-abstains on plugin projects (the DI blind spot).
121    any_plugin_directive: bool,
122    /// Located raw CSS declaration values from authored structural stylesheets.
123    raw_style_values: Vec<fallow_output::RawStyleValue>,
124}
125
126#[derive(Clone, Debug)]
127struct ThemeTokenDefinition {
128    path: String,
129    line: u32,
130    value: String,
131}
132
133impl CssTokenSets {
134    /// Group declaration-block fingerprints seen in 2+ rules into located
135    /// duplicate-block candidates, set the summary counts, and sort by estimated
136    /// savings descending (then first occurrence path).
137    fn group_duplicate_blocks(
138        &self,
139        summary: &mut fallow_output::CssAnalyticsSummary,
140    ) -> Vec<fallow_output::CssDuplicateBlock> {
141        use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
142
143        let mut groups: Vec<CssDuplicateBlock> = self
144            .declaration_blocks
145            .values()
146            .filter(|(_, occurrences)| occurrences.len() >= 2)
147            .map(|(declaration_count, occurrences)| {
148                let occurrence_count = saturate_len(occurrences.len());
149                let estimated_savings = occurrence_count
150                    .saturating_sub(1)
151                    .saturating_mul(u32::from(*declaration_count));
152                let mut occ: Vec<CssBlockOccurrence> = occurrences
153                    .iter()
154                    .map(|(path, line)| CssBlockOccurrence {
155                        path: path.clone(),
156                        line: *line,
157                    })
158                    .collect();
159                occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
160                CssDuplicateBlock {
161                    declaration_count: *declaration_count,
162                    occurrence_count,
163                    estimated_savings,
164                    occurrences: occ,
165                    actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
166                }
167            })
168            .collect();
169        // Highest-savings groups first; tie-break on the first occurrence path for
170        // deterministic output.
171        groups.sort_by(|a, b| {
172            b.estimated_savings
173                .cmp(&a.estimated_savings)
174                .then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
175        });
176        summary.duplicate_declaration_blocks = saturate_len(groups.len());
177        summary.duplicate_declarations_total = groups
178            .iter()
179            .fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
180        groups
181    }
182
183    /// Fold one stylesheet's analytics into the project-wide token sets,
184    /// recording the first-defining file (`rel`) per located name.
185    fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
186        self.colors.extend(analytics.colors.iter().cloned());
187        self.font_sizes.extend(analytics.font_sizes.iter().cloned());
188        self.z_indexes.extend(analytics.z_indexes.iter().cloned());
189        self.box_shadows
190            .extend(analytics.box_shadows.iter().cloned());
191        self.border_radii
192            .extend(analytics.border_radii.iter().cloned());
193        self.line_heights
194            .extend(analytics.line_heights.iter().cloned());
195        self.defined_custom_props
196            .extend(analytics.defined_custom_properties.iter().cloned());
197        for token in &analytics.custom_property_definitions {
198            self.custom_property_definers
199                .entry(token.name.clone())
200                .or_insert_with(|| ThemeTokenDefinition {
201                    path: rel.to_owned(),
202                    line: token.line,
203                    value: token.value.clone(),
204                });
205        }
206        self.referenced_custom_props
207            .extend(analytics.referenced_custom_properties.iter().cloned());
208        for keyframes in &analytics.referenced_keyframes {
209            self.referenced_keyframes.insert(keyframes.clone());
210            self.keyframe_referencers
211                .entry(keyframes.clone())
212                .or_insert_with(|| rel.to_owned());
213        }
214        for keyframes in &analytics.defined_keyframes {
215            self.defined_keyframes.insert(keyframes.clone());
216            self.keyframes_definers
217                .entry(keyframes.clone())
218                .or_insert_with(|| rel.to_owned());
219        }
220        for block in &analytics.declaration_blocks {
221            self.declaration_blocks
222                .entry(block.fingerprint)
223                .or_insert_with(|| (block.declaration_count, Vec::new()))
224                .1
225                .push((rel.to_owned(), block.line));
226        }
227        for name in &analytics.registered_custom_properties {
228            self.registered_custom_props.insert(name.clone());
229            self.property_registrars
230                .entry(name.clone())
231                .or_insert_with(|| rel.to_owned());
232        }
233        for family in &analytics.referenced_font_families {
234            self.referenced_font_families.insert(family.clone());
235        }
236        for family in &analytics.defined_font_faces {
237            self.defined_font_faces.insert(family.clone());
238            self.font_face_definers
239                .entry(family.clone())
240                .or_insert_with(|| rel.to_owned());
241        }
242        for name in &analytics.populated_layers {
243            self.populated_layers.insert(name.clone());
244        }
245        for name in &analytics.declared_layers {
246            self.declared_layers.insert(name.clone());
247            self.layer_declarers
248                .entry(name.clone())
249                .or_insert_with(|| rel.to_owned());
250        }
251        for raw in &analytics.raw_style_values {
252            if self.raw_style_values.len() >= MAX_REPORTED_RAW_STYLE_VALUES {
253                break;
254            }
255            self.raw_style_values.push(fallow_output::RawStyleValue {
256                axis: raw.axis.clone(),
257                property: raw.property.clone(),
258                value: raw.value.clone(),
259                path: rel.to_owned(),
260                line: raw.line,
261                nearest_token: None,
262                actions: vec![fallow_output::CssCandidateAction::replace_raw_style_value(
263                    &raw.axis, &raw.value,
264                )],
265            });
266        }
267    }
268
269    /// Fold one stylesheet's Tailwind v4 `@theme` tokens, `@apply` body tokens,
270    /// and `@theme`-interior `var()` reads into the project-wide sets (the inputs
271    /// to the unused-theme-token candidate). `scan_theme_blocks` /
272    /// `extract_apply_tokens` fast-path out on sources with no `@theme` / `@apply`,
273    /// so this is near-free for non-Tailwind stylesheets.
274    fn record_theme(&mut self, source: &str, rel: &str) {
275        let scan = crate::css::scan_theme_blocks(source);
276        for token in scan.tokens {
277            self.theme_token_definers
278                .entry(token.name)
279                .or_insert_with(|| ThemeTokenDefinition {
280                    path: rel.to_owned(),
281                    line: token.line,
282                    value: token.value,
283                });
284        }
285        for (name, line) in scan.theme_var_reads {
286            self.theme_var_reads.insert(name.clone());
287            self.theme_var_reads_located
288                .push((name, rel.to_owned(), line));
289        }
290        self.apply_tokens
291            .extend(crate::css::extract_apply_tokens(source));
292        self.apply_uses_located.extend(
293            crate::css::extract_apply_tokens_located(source)
294                .into_iter()
295                .map(|(token, line)| (token, rel.to_owned(), line)),
296        );
297        self.css_var_reads_located.extend(
298            crate::css::extract_css_var_reads_located(source)
299                .into_iter()
300                .map(|(name, line)| (name, rel.to_owned(), line)),
301        );
302        if source.contains("@plugin") {
303            self.any_plugin_directive = true;
304        }
305    }
306
307    /// Group unused CSS at-rule entities: `@property` registrations never read
308    /// via `var()`, and cascade layers declared but never populated. Sets the
309    /// summary counts and returns the located list sorted by (kind, path, name).
310    fn group_unused_at_rules(
311        &self,
312        summary: &mut fallow_output::CssAnalyticsSummary,
313    ) -> Vec<fallow_output::UnusedAtRule> {
314        use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
315
316        let mut out: Vec<UnusedAtRule> = Vec::new();
317        for name in self
318            .registered_custom_props
319            .difference(&self.referenced_custom_props)
320        {
321            out.push(UnusedAtRule {
322                kind: UnusedAtRuleKind::PropertyRegistration,
323                name: name.clone(),
324                path: self
325                    .property_registrars
326                    .get(name)
327                    .cloned()
328                    .unwrap_or_default(),
329                actions: vec![CssCandidateAction::verify_unused_at_rule(
330                    UnusedAtRuleKind::PropertyRegistration,
331                    name,
332                )],
333            });
334        }
335        summary.unused_property_registrations = saturate_len(out.len());
336        let property_count = out.len();
337        for name in self.declared_layers.difference(&self.populated_layers) {
338            out.push(UnusedAtRule {
339                kind: UnusedAtRuleKind::Layer,
340                name: name.clone(),
341                path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
342                actions: vec![CssCandidateAction::verify_unused_at_rule(
343                    UnusedAtRuleKind::Layer,
344                    name,
345                )],
346            });
347        }
348        summary.unused_layers = saturate_len(out.len() - property_count);
349        out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
350        out
351    }
352
353    /// Fill the summary token counts and return the two located keyframe
354    /// candidate lists: defined-but-unused (`unreferenced`) and used-but-
355    /// undefined (`undefined`).
356    fn finalize(
357        &self,
358        summary: &mut fallow_output::CssAnalyticsSummary,
359    ) -> (
360        Vec<fallow_output::UnreferencedKeyframes>,
361        Vec<fallow_output::UndefinedKeyframes>,
362    ) {
363        use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
364
365        summary.unique_colors = saturate_len(self.colors.len());
366        summary.unique_font_sizes = saturate_len(self.font_sizes.len());
367        summary.unique_z_indexes = saturate_len(self.z_indexes.len());
368        summary.unique_box_shadows = saturate_len(self.box_shadows.len());
369        summary.unique_border_radii = saturate_len(self.border_radii.len());
370        summary.unique_line_heights = saturate_len(self.line_heights.len());
371        summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
372        summary.custom_properties_unreferenced = saturate_len(
373            self.defined_custom_props
374                .difference(&self.referenced_custom_props)
375                .count(),
376        );
377        // Count-only (per panel review): a var() referenced but defined in no
378        // stylesheet is dominated by JS-set design tokens, so locating these
379        // would be net-noise. The count is an architecture signal.
380        summary.custom_properties_undefined = saturate_len(
381            self.referenced_custom_props
382                .difference(&self.defined_custom_props)
383                .count(),
384        );
385        summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
386        summary.keyframes_unreferenced = saturate_len(
387            self.defined_keyframes
388                .difference(&self.referenced_keyframes)
389                .count(),
390        );
391        summary.keyframes_undefined = saturate_len(
392            self.referenced_keyframes
393                .difference(&self.defined_keyframes)
394                .count(),
395        );
396
397        // @keyframes are low-cardinality, so BOTH directions are located (not
398        // just counted): defined-but-unused, and used-but-defined-nowhere.
399        let unreferenced_keyframes = locate_keyframe_diff(
400            &self.defined_keyframes,
401            &self.referenced_keyframes,
402            &self.keyframes_definers,
403        )
404        .into_iter()
405        .map(|(name, path)| UnreferencedKeyframes {
406            actions: vec![CssCandidateAction::verify_keyframe(&name)],
407            name,
408            path,
409        })
410        .collect();
411        let undefined_keyframes = locate_keyframe_diff(
412            &self.referenced_keyframes,
413            &self.defined_keyframes,
414            &self.keyframe_referencers,
415        )
416        .into_iter()
417        .map(|(name, path)| UndefinedKeyframes {
418            actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
419            name,
420            path,
421        })
422        .collect();
423        (unreferenced_keyframes, undefined_keyframes)
424    }
425
426    /// `@font-face`-declared families referenced by no `font-family` anywhere in
427    /// the project: a dead web-font payload. Located at the declaring stylesheet,
428    /// set the summary count.
429    fn unused_font_faces(
430        &self,
431        summary: &mut fallow_output::CssAnalyticsSummary,
432    ) -> Vec<fallow_output::UnusedFontFace> {
433        use fallow_output::{CssCandidateAction, UnusedFontFace};
434        // CSS font-family names are case-insensitive (CSS Fonts Level 4 4.2.1),
435        // unlike `@keyframes` custom-ident names (case-sensitive, via
436        // `locate_keyframe_diff`), so match case-insensitively while keeping the
437        // declared casing for both display and the verify command.
438        let referenced_lower: rustc_hash::FxHashSet<String> = self
439            .referenced_font_families
440            .iter()
441            .map(|family| family.to_ascii_lowercase())
442            .collect();
443        let mut out: Vec<UnusedFontFace> = self
444            .defined_font_faces
445            .iter()
446            .filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
447            .map(|family| UnusedFontFace {
448                actions: vec![CssCandidateAction::verify_unused_font_face(family)],
449                path: self
450                    .font_face_definers
451                    .get(family)
452                    .cloned()
453                    .unwrap_or_default(),
454                family: family.clone(),
455            })
456            .collect();
457        out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
458        summary.unused_font_faces = saturate_len(out.len());
459        out
460    }
461
462    /// Group the distinct `font-size` values by length unit (`px`/`rem`/`em`/`%`/
463    /// `pt`/other), set the `font_size_units_used` count, and, when the project
464    /// mixes two or more units across enough distinct sizes, return a
465    /// consistency candidate (mixing `px` and `rem` for type works against
466    /// user-zoom accessibility). Advisory only, never gated.
467    fn font_size_unit_mix(
468        &self,
469        summary: &mut fallow_output::CssAnalyticsSummary,
470    ) -> Option<fallow_output::CssNotationConsistency> {
471        use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
472
473        let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
474        for value in &self.font_sizes {
475            if let Some(unit) = classify_font_size_unit(value) {
476                *counts.entry(unit).or_insert(0) += 1;
477            }
478        }
479        summary.font_size_units_used = saturate_len(counts.len());
480
481        // Conservative floor: at least two distinct units AND enough classified
482        // sizes that the project plainly has a type scale (so a tiny stylesheet
483        // with one px and one rem does not trip it). Smoke-tunable.
484        let total: u32 = counts.values().copied().sum();
485        if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
486            return None;
487        }
488        let mut notations: Vec<CssNotationCount> = counts
489            .into_iter()
490            .map(|(notation, count)| CssNotationCount {
491                notation: notation.to_owned(),
492                count,
493            })
494            .collect();
495        // Dominant unit first; tie-break on the unit name for deterministic output.
496        notations.sort_by(|a, b| {
497            b.count
498                .cmp(&a.count)
499                .then_with(|| a.notation.cmp(&b.notation))
500        });
501        // Safe: the floor guard above guarantees at least two notations.
502        let dominant = notations[0].notation.clone();
503        Some(CssNotationConsistency {
504            actions: vec![CssCandidateAction::standardize_notation(
505                "Font sizes",
506                &dominant,
507            )],
508            axis: "Font sizes".to_owned(),
509            notations,
510        })
511    }
512}
513
514/// Fewest distinct unit-classified `font-size` values before a unit-mix candidate
515/// is worth surfacing. Below this the project does not yet have a type scale, so
516/// a px/rem split is noise rather than an inconsistency.
517const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
518
519/// Classify a `font-size` value's length unit for the unit-consistency
520/// candidate. Returns `None` for function values (`clamp()` / `calc()` /
521/// `min()` / `max()` / `var()`) and bare keywords (`medium`, `larger`,
522/// `inherit`), which carry no single comparable unit. Unit names are lowercased;
523/// recognized type units map to a stable label, anything else to `"other"`.
524fn classify_font_size_unit(value: &str) -> Option<&'static str> {
525    let v = value.trim();
526    if v.is_empty() || v.contains('(') {
527        return None;
528    }
529    if let Some(stripped) = v.strip_suffix('%') {
530        // A bare `%` font-size is `<number>%`; reject anything else (defensive).
531        return stripped
532            .chars()
533            .all(|c| c.is_ascii_digit() || c == '.')
534            .then_some("%");
535    }
536    let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
537    let (number, unit) = v.split_at(unit_start);
538    // A dimension is `<number><unit>`; a leading non-numeric prefix means a
539    // keyword (e.g. `medium`), which has no unit.
540    if number.is_empty()
541        || !number
542            .chars()
543            .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
544    {
545        return None;
546    }
547    match unit.to_ascii_lowercase().as_str() {
548        "px" => Some("px"),
549        "rem" => Some("rem"),
550        "em" => Some("em"),
551        "pt" => Some("pt"),
552        _ => Some("other"),
553    }
554}
555
556/// Build the sorted `(name, path)` set difference `present - absent`, locating
557/// each surviving name via `locator` (empty path when absent). Sorted by
558/// `(path, name)` for deterministic output.
559fn locate_keyframe_diff(
560    present: &rustc_hash::FxHashSet<String>,
561    absent: &rustc_hash::FxHashSet<String>,
562    locator: &rustc_hash::FxHashMap<String, String>,
563) -> Vec<(String, String)> {
564    let mut out: Vec<(String, String)> = present
565        .difference(absent)
566        .map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
567        .collect();
568    out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
569    out
570}
571
572/// Saturating `usize -> u32` for token counts.
573fn saturate_len(len: usize) -> u32 {
574    u32::try_from(len).unwrap_or(u32::MAX)
575}
576
577/// `(first path, first line)` sort key for a duplicate block; occurrences are
578/// pre-sorted, so the first is the lexicographic minimum.
579fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
580    block
581        .occurrences
582        .first()
583        .map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
584}
585
586/// Scan the project's markup (`.jsx` / `.tsx` / `.html` / `.astro` / `.vue` /
587/// `.svelte` / `.md` / `.mdx`) for Tailwind arbitrary-value utility tokens,
588/// honoring the same
589/// ignore / changed / workspace filters as the CSS scan. Aggregates by token
590/// (total count + first location), sets the summary counts, and returns the
591/// located list sorted by use count descending.
592/// One eligible markup file for a class-token scan: the forward-slash relative
593/// path plus source, or `None` when the file is filtered out (extension, ignore
594/// set, changed-files, workspace scope) or unreadable.
595fn read_markup_scan_source(
596    file: &fallow_types::discover::DiscoveredFile,
597    ctx: HealthScanCtx<'_>,
598) -> Option<(String, String)> {
599    let HealthScanCtx {
600        config,
601        ignore_set,
602        changed_files,
603        output_changed_files: _,
604        ws_roots,
605    } = ctx;
606
607    let path = &file.path;
608    let extension = path.extension().and_then(|ext| ext.to_str());
609    if !extension.is_some_and(is_markup_source_extension) {
610        return None;
611    }
612    let relative = path.strip_prefix(&config.root).unwrap_or(path);
613    if ignore_set.is_match(relative) {
614        return None;
615    }
616    if let Some(changed) = changed_files
617        && !changed.contains(path)
618    {
619        return None;
620    }
621    if let Some(roots) = ws_roots
622        && !roots.iter().any(|root| path.starts_with(root))
623    {
624        return None;
625    }
626    let source = std::fs::read_to_string(path).ok()?;
627    let rel = relative.to_string_lossy().replace('\\', "/");
628    Some((rel, source))
629}
630
631fn scan_markup_tailwind_arbitrary_values(
632    files: &[fallow_types::discover::DiscoveredFile],
633    ctx: HealthScanCtx<'_>,
634    summary: &mut fallow_output::CssAnalyticsSummary,
635) -> Vec<fallow_output::TailwindArbitraryValue> {
636    let HealthScanCtx { config, .. } = ctx;
637
638    use fallow_output::TailwindArbitraryValue;
639
640    if !project_uses_tailwind(&config.root) {
641        return Vec::new();
642    }
643    // token -> (total count, first path, first line). First-seen wins for the
644    // location; files are path-sorted, so the first occurrence is deterministic.
645    let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
646        rustc_hash::FxHashMap::default();
647    let mut total_uses: u32 = 0;
648    for file in files {
649        let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
650            continue;
651        };
652        for arb in crate::css::scan_tailwind_arbitrary_values(&source) {
653            total_uses = total_uses.saturating_add(1);
654            let entry = agg
655                .entry(arb.value)
656                .or_insert_with(|| (0, rel.clone(), arb.line));
657            entry.0 = entry.0.saturating_add(1);
658        }
659    }
660
661    summary.tailwind_arbitrary_values = saturate_len(agg.len());
662    summary.tailwind_arbitrary_value_uses = total_uses;
663    let mut out: Vec<TailwindArbitraryValue> = agg
664        .into_iter()
665        .map(|(value, (count, path, line))| TailwindArbitraryValue {
666            actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
667                &value,
668            )],
669            value,
670            count,
671            path,
672            line,
673        })
674        .collect();
675    out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
676    out
677}
678
679fn scan_cva_duplicate_variant_blocks(
680    files: &[fallow_types::discover::DiscoveredFile],
681    ctx: HealthScanCtx<'_>,
682) -> Vec<fallow_output::CvaDuplicateVariantBlock> {
683    let mut blocks: rustc_hash::FxHashMap<String, Vec<fallow_output::CssBlockOccurrence>> =
684        rustc_hash::FxHashMap::default();
685    for file in files {
686        let Some((rel, source)) = read_js_style_scan_source(file, ctx) else {
687            continue;
688        };
689        if !source_contains_cva_variants(&source) {
690            continue;
691        }
692        for (value, line) in collect_cva_class_blocks(&source) {
693            blocks
694                .entry(value)
695                .or_default()
696                .push(fallow_output::CssBlockOccurrence {
697                    path: rel.clone(),
698                    line,
699                });
700        }
701    }
702    let mut out: Vec<_> = blocks
703        .into_iter()
704        .filter_map(|(value, mut occurrences)| {
705            if occurrences.len() < 2 {
706                return None;
707            }
708            occurrences.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
709            let occurrence_count = saturate_len(occurrences.len());
710            Some(fallow_output::CvaDuplicateVariantBlock {
711                value,
712                occurrence_count,
713                occurrences,
714                actions: vec![fallow_output::CssCandidateAction::consolidate_block(
715                    occurrence_count,
716                )],
717            })
718        })
719        .collect();
720    out.sort_by(|a, b| {
721        b.occurrence_count
722            .cmp(&a.occurrence_count)
723            .then_with(|| {
724                let a_key = a
725                    .occurrences
726                    .first()
727                    .map_or(("", 0), |occ| (occ.path.as_str(), occ.line));
728                let b_key = b
729                    .occurrences
730                    .first()
731                    .map_or(("", 0), |occ| (occ.path.as_str(), occ.line));
732                a_key.cmp(&b_key)
733            })
734            .then_with(|| a.value.cmp(&b.value))
735    });
736    out
737}
738
739fn scan_cva_variant_token_drifts(
740    files: &[fallow_types::discover::DiscoveredFile],
741    ctx: HealthScanCtx<'_>,
742    token_candidates: &[ComparableThemeTokenCandidate],
743) -> Vec<fallow_output::CvaVariantTokenDrift> {
744    if token_candidates.is_empty() {
745        return Vec::new();
746    }
747    let mut out = Vec::new();
748    let mut seen: rustc_hash::FxHashSet<(String, u32, String, String)> =
749        rustc_hash::FxHashSet::default();
750    for file in files {
751        let Some((rel, source)) = read_js_style_scan_source(file, ctx) else {
752            continue;
753        };
754        if !source_contains_cva_variants(&source) {
755            continue;
756        }
757        for (variant_classes, line) in collect_cva_class_blocks(&source) {
758            for arbitrary in crate::css::scan_tailwind_arbitrary_values(&variant_classes) {
759                let Some((namespace, value, metric)) = cva_arbitrary_value_metric(&arbitrary.value)
760                else {
761                    continue;
762                };
763                let Some((nearest, distance)) =
764                    nearest_styling_token(namespace, &metric, token_candidates)
765                else {
766                    continue;
767                };
768                let key = (
769                    rel.clone(),
770                    line,
771                    arbitrary.value.clone(),
772                    nearest.token.clone(),
773                );
774                if !seen.insert(key) {
775                    continue;
776                }
777                out.push(fallow_output::CvaVariantTokenDrift {
778                    class_token: arbitrary.value.clone(),
779                    value: value.clone(),
780                    variant_classes: variant_classes.clone(),
781                    path: rel.clone(),
782                    line,
783                    nearest_token: fallow_output::NearestStylingToken {
784                        name: nearest.token.clone(),
785                        value: nearest.value.clone(),
786                        path: nearest.path.clone(),
787                        line: nearest.line,
788                        distance: round_distance(distance),
789                    },
790                    actions: vec![
791                        fallow_output::CssCandidateAction::replace_cva_variant_arbitrary_value(
792                            &arbitrary.value,
793                            &nearest.token,
794                        ),
795                    ],
796                });
797            }
798        }
799    }
800    out.sort_by(|a, b| {
801        a.path
802            .cmp(&b.path)
803            .then_with(|| a.line.cmp(&b.line))
804            .then_with(|| a.class_token.cmp(&b.class_token))
805            .then_with(|| a.nearest_token.name.cmp(&b.nearest_token.name))
806    });
807    out
808}
809
810fn cva_arbitrary_value_metric(
811    class_token: &str,
812) -> Option<(&'static str, String, ThemeTokenMetric)> {
813    let marker = "-[";
814    let start = class_token.find(marker)?;
815    let value_start = start + marker.len();
816    let raw = class_token.get(value_start..class_token.len().checked_sub(1)?)?;
817    let value = raw.replace('_', " ");
818    let prefix = class_token.get(..start)?;
819    let namespace = match prefix {
820        "bg" | "border" | "fill" | "stroke" | "ring" | "outline" | "decoration" | "accent"
821        | "caret" | "from" | "via" | "to" => "color",
822        "text" if parse_theme_token_metric("color", &value).is_some() => "color",
823        "text" => "text",
824        "rounded" => "radius",
825        "shadow" => "shadow",
826        _ if prefix.starts_with("rounded-") => "radius",
827        _ if prefix.starts_with("shadow-") => "shadow",
828        _ => return None,
829    };
830    let metric = parse_theme_token_metric(namespace, &value)?;
831    Some((namespace, value, metric))
832}
833
834fn nearest_styling_token<'a>(
835    namespace: &str,
836    metric: &ThemeTokenMetric,
837    candidates: &'a [ComparableThemeTokenCandidate],
838) -> Option<(&'a ComparableThemeTokenCandidate, f64)> {
839    candidates
840        .iter()
841        .filter(|candidate| candidate.namespace == namespace)
842        .filter_map(|candidate| {
843            let distance = metric.distance(&candidate.metric)?;
844            (distance <= metric.threshold()).then_some((candidate, distance))
845        })
846        .min_by(|(left, left_distance), (right, right_distance)| {
847            left_distance
848                .total_cmp(right_distance)
849                .then_with(|| theme_token_sort_key(left).cmp(&theme_token_sort_key(right)))
850        })
851}
852
853fn read_js_style_scan_source(
854    file: &fallow_types::discover::DiscoveredFile,
855    ctx: HealthScanCtx<'_>,
856) -> Option<(String, String)> {
857    let HealthScanCtx {
858        config,
859        ignore_set,
860        changed_files,
861        output_changed_files: _,
862        ws_roots,
863    } = ctx;
864    let path = &file.path;
865    let extension = path.extension().and_then(|ext| ext.to_str());
866    if !matches!(extension, Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs")) {
867        return None;
868    }
869    if path
870        .file_name()
871        .and_then(|name| name.to_str())
872        .is_some_and(|name| name.ends_with(".d.ts"))
873    {
874        return None;
875    }
876    let path_text = path.to_string_lossy();
877    if path_text.contains("__tests__")
878        || path_text.contains("/test/")
879        || path_text.contains("/tests/")
880        || path_text.contains(".test.")
881        || path_text.contains(".spec.")
882    {
883        return None;
884    }
885    let relative = path.strip_prefix(&config.root).unwrap_or(path);
886    if ignore_set.is_match(relative) {
887        return None;
888    }
889    if let Some(changed) = changed_files
890        && !changed.contains(path)
891    {
892        return None;
893    }
894    if let Some(roots) = ws_roots
895        && !roots.iter().any(|root| path.starts_with(root))
896    {
897        return None;
898    }
899    let source = std::fs::read_to_string(path).ok()?;
900    let rel = relative.to_string_lossy().replace('\\', "/");
901    Some((rel, source))
902}
903
904fn source_contains_cva_variants(source: &str) -> bool {
905    source.contains("cva(")
906        && source.contains("variants")
907        && (source.contains("class-variance-authority") || source.contains("styled-system"))
908}
909
910fn collect_cva_class_blocks(source: &str) -> Vec<(String, u32)> {
911    let mut out = Vec::new();
912    let mut search = 0usize;
913    while let Some(rel) = source[search..].find("cva(") {
914        let start = search + rel;
915        search = start + 4;
916        if start > 0 && is_identifier_byte(source.as_bytes()[start - 1]) {
917            continue;
918        }
919        let Some(end) = scan_call_end(source, start + 3) else {
920            continue;
921        };
922        let base_line = source[..start].bytes().filter(|b| *b == b'\n').count() as u32 + 1;
923        collect_quoted_cva_class_blocks(&source[start..end], base_line, &mut out);
924    }
925    out
926}
927
928fn is_identifier_byte(b: u8) -> bool {
929    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
930}
931
932fn scan_call_end(source: &str, open_paren: usize) -> Option<usize> {
933    let bytes = source.as_bytes();
934    let mut i = open_paren;
935    let mut depth = 0usize;
936    let mut quote: Option<u8> = None;
937    let mut escaped = false;
938    while i < bytes.len() {
939        let b = bytes[i];
940        if let Some(q) = quote {
941            if escaped {
942                escaped = false;
943            } else if b == b'\\' {
944                escaped = true;
945            } else if b == q {
946                quote = None;
947            }
948            i += 1;
949            continue;
950        }
951        if matches!(b, b'\'' | b'"' | b'`') {
952            quote = Some(b);
953            i += 1;
954            continue;
955        }
956        if b == b'(' {
957            depth += 1;
958        } else if b == b')' {
959            depth = depth.checked_sub(1)?;
960            if depth == 0 {
961                return Some(i + 1);
962            }
963        }
964        i += 1;
965    }
966    None
967}
968
969fn collect_quoted_cva_class_blocks(source: &str, base_line: u32, out: &mut Vec<(String, u32)>) {
970    let bytes = source.as_bytes();
971    let mut i = 0;
972    let mut line = base_line;
973    while i < bytes.len() {
974        let b = bytes[i];
975        if b == b'\n' {
976            line = line.saturating_add(1);
977            i += 1;
978            continue;
979        }
980        if !matches!(b, b'\'' | b'"' | b'`') {
981            i += 1;
982            continue;
983        }
984        let quote = b;
985        let start_line = line;
986        i += 1;
987        let start = i;
988        let mut escaped = false;
989        while i < bytes.len() {
990            let c = bytes[i];
991            if c == b'\n' {
992                line = line.saturating_add(1);
993            }
994            if escaped {
995                escaped = false;
996                i += 1;
997                continue;
998            }
999            if c == b'\\' {
1000                escaped = true;
1001                i += 1;
1002                continue;
1003            }
1004            if c == quote {
1005                if let Some(block) = normalize_cva_class_block(&source[start..i]) {
1006                    out.push((block, start_line));
1007                }
1008                i += 1;
1009                break;
1010            }
1011            i += 1;
1012        }
1013    }
1014}
1015
1016fn normalize_cva_class_block(value: &str) -> Option<String> {
1017    let tokens: Vec<_> = value.split_whitespace().collect();
1018    if tokens.len() < 3 {
1019        return None;
1020    }
1021    let class_like = tokens
1022        .iter()
1023        .filter(|token| {
1024            token.contains('-')
1025                || token.contains(':')
1026                || token.contains('[')
1027                || token.contains('/')
1028                || matches!(
1029                    **token,
1030                    "flex" | "grid" | "block" | "inline-flex" | "hidden"
1031                )
1032        })
1033        .count();
1034    (class_like >= 2).then(|| tokens.join(" "))
1035}
1036
1037/// True for a byte that can appear inside a Tailwind class token (used to anchor
1038/// the `animate-` prefix at a token boundary so `xanimate-` does not match).
1039fn is_tailwind_class_byte(b: u8) -> bool {
1040    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1041}
1042
1043/// Extract `@keyframes` names applied via Tailwind from one source string: the
1044/// custom-ident after `animate-[<name>_...]` (arbitrary value, up to the first
1045/// `_`/`]`) and after a bare `animate-<name>` utility. The `animate-` prefix must
1046/// sit at a token boundary. Names are collected raw; the caller filters them to
1047/// actually-defined keyframes.
1048fn collect_animate_keyframe_names(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1049    let bytes = source.as_bytes();
1050    const PREFIX: &str = "animate-";
1051    let mut search = 0;
1052    while let Some(rel) = source[search..].find(PREFIX) {
1053        let start = search + rel;
1054        search = start + PREFIX.len();
1055        // The prefix must start at a token boundary (`hover:animate-x` is fine,
1056        // `myanimate-x` is not).
1057        if start > 0 && is_tailwind_class_byte(bytes[start - 1]) {
1058            continue;
1059        }
1060        let after = start + PREFIX.len();
1061        if after >= bytes.len() {
1062            continue;
1063        }
1064        if bytes[after] == b'[' {
1065            // Arbitrary value: `animate-[badge-pop_0.5s_...]` -> `badge-pop`.
1066            let name_start = after + 1;
1067            let mut j = name_start;
1068            while j < bytes.len() {
1069                let c = bytes[j];
1070                if c == b'-' || c.is_ascii_alphanumeric() {
1071                    j += 1;
1072                } else {
1073                    break;
1074                }
1075            }
1076            if j > name_start {
1077                out.insert(source[name_start..j].to_owned());
1078            }
1079        } else {
1080            // Named utility: `animate-bar-fill` -> `bar-fill`.
1081            let mut j = after;
1082            while j < bytes.len() {
1083                let c = bytes[j];
1084                if c == b'-' || c.is_ascii_lowercase() || c.is_ascii_digit() {
1085                    j += 1;
1086                } else {
1087                    break;
1088                }
1089            }
1090            let name = source[after..j].trim_end_matches('-');
1091            if !name.is_empty() {
1092                out.insert(name.to_owned());
1093            }
1094        }
1095    }
1096}
1097
1098/// Collect `@keyframes` names applied via Tailwind markup utilities
1099/// (`animate-[name_...]` / `animate-name`) across the project's markup and JS,
1100/// so a keyframe used only that way (never via a CSS `animation:` declaration)
1101/// is not wrongly flagged `unreferenced`. Not gated on the Tailwind dependency:
1102/// the `animate-[...]` / `animate-<name>` shapes are distinctive, the caller
1103/// filters the result to actually-defined keyframes, and a project can apply
1104/// Tailwind utilities without declaring the npm dep at the scanned root
1105/// (CDN / PostCSS / monorepo subpackage).
1106fn collect_markup_keyframe_references(
1107    files: &[fallow_types::discover::DiscoveredFile],
1108    config: &ResolvedConfig,
1109    ignore_set: &globset::GlobSet,
1110) -> rustc_hash::FxHashSet<String> {
1111    let mut out: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1112    for file in files {
1113        let path = &file.path;
1114        let extension = path.extension().and_then(|ext| ext.to_str());
1115        if !matches!(
1116            extension,
1117            Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte" | "js" | "ts" | "mjs" | "cjs")
1118        ) {
1119            continue;
1120        }
1121        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1122        if ignore_set.is_match(relative) {
1123            continue;
1124        }
1125        if let Ok(source) = std::fs::read_to_string(path) {
1126            collect_animate_keyframe_names(&source, &mut out);
1127            // Also a keyframe named in a JS inline-style `animation:` /
1128            // `animationName:` string (`animation: 'progress-indeterminate 1.5s'`)
1129            // appears as a dashed token in a quoted string; the caller filters
1130            // these to actually-defined keyframes, so an unrelated dashed token
1131            // can never manufacture a reference. `require_dash: false` so a
1132            // single-word keyframe name (`spin`, `jsanim`) is credited too.
1133            collect_quoted_class_tokens(&source, &mut out, false);
1134        }
1135    }
1136    out
1137}
1138
1139/// Shortest authored CSS class that can be a credible typo target. Below this a
1140/// one-edit near miss is too likely to be a coincidental collision between two
1141/// short real words (`catch` vs `match`, `list` vs `last`) rather than a typo.
1142/// Real component-class typos are compound / hyphenated and comfortably longer.
1143/// (Real-world smoke on Svelte: `catch` vs `match` in test fixtures.)
1144const MIN_DEFINED_CLASS_LEN: usize = 6;
1145/// Shortest markup token worth typo-checking, for the same reason. One below the
1146/// defined floor, since a one-edit pair differs in length by at most one.
1147const MIN_TOKEN_LEN: usize = 5;
1148
1149/// Count plain-CSS vs preprocessor (`.scss`/`.sass`/`.less`) stylesheet files in
1150/// the project (ignore-filtered). Used to abstain from class-typo detection when
1151/// preprocessors dominate, because the parser cannot expand their loops/mixins,
1152/// so the defined-class set is unreliable.
1153fn count_stylesheet_kinds(
1154    files: &[fallow_types::discover::DiscoveredFile],
1155    config: &ResolvedConfig,
1156    ignore_set: &globset::GlobSet,
1157) -> (usize, usize) {
1158    let mut css = 0usize;
1159    let mut preprocessor = 0usize;
1160    for file in files {
1161        let path = &file.path;
1162        let kind = match path.extension().and_then(|ext| ext.to_str()) {
1163            Some("css") => &mut css,
1164            Some("scss" | "sass" | "less") => &mut preprocessor,
1165            _ => continue,
1166        };
1167        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1168        if ignore_set.is_match(relative) {
1169            continue;
1170        }
1171        *kind += 1;
1172    }
1173    (css, preprocessor)
1174}
1175
1176/// Collect every authored CSS class name defined anywhere in the project (plain
1177/// and module `.css`/`.scss`, plus Astro/SFC `<style>` blocks of any scoping). The set
1178/// is the typo-suggestion target for [`scan_unresolved_class_references`], so it
1179/// is NOT narrowed by `changed_files` / `ws_roots`: a class defined in an
1180/// unchanged file must still count as defined, or a markup token referencing it
1181/// would false-positive as unresolved. Only the ignore filter applies.
1182fn collect_defined_css_classes(
1183    files: &[fallow_types::discover::DiscoveredFile],
1184    config: &ResolvedConfig,
1185    ignore_set: &globset::GlobSet,
1186) -> rustc_hash::FxHashSet<String> {
1187    use fallow_types::extract::ExportName;
1188    let mut defined: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1189    for file in files {
1190        let path = &file.path;
1191        let extension = path.extension().and_then(|ext| ext.to_str());
1192        let is_preprocessor = matches!(extension, Some("scss" | "sass" | "less"));
1193        let is_css = extension == Some("css") || is_preprocessor;
1194        let has_style_blocks = matches!(extension, Some("astro" | "vue" | "svelte"));
1195        if !is_css && !has_style_blocks {
1196            continue;
1197        }
1198        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1199        if ignore_set.is_match(relative) {
1200            continue;
1201        }
1202        let Ok(source) = std::fs::read_to_string(path) else {
1203            continue;
1204        };
1205        if has_style_blocks {
1206            for style in crate::css::extract_sfc_styles(&source) {
1207                let is_style_scss = style
1208                    .lang
1209                    .as_deref()
1210                    .is_some_and(|lang| matches!(lang, "scss" | "sass"));
1211                for export in crate::css::extract_css_module_exports(&style.body, is_style_scss) {
1212                    if let ExportName::Named(name) = export.name {
1213                        defined.insert(name);
1214                    }
1215                }
1216            }
1217            continue;
1218        }
1219        for export in crate::css::extract_css_module_exports(&source, is_preprocessor) {
1220            if let ExportName::Named(name) = export.name {
1221                defined.insert(name);
1222            }
1223        }
1224    }
1225    defined
1226}
1227
1228/// Find the best one-edit typo suggestion for a markup token among the defined
1229/// classes, using a length-bucketed index so only classes of length `len-1`,
1230/// `len`, `len+1` are compared. Returns the lexicographically smallest defined
1231/// class at edit distance one (deterministic), or `None`.
1232fn best_class_suggestion<'a>(
1233    token: &str,
1234    by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1235) -> Option<&'a str> {
1236    let len = token.len();
1237    let mut best: Option<&str> = None;
1238    for candidate_len in [len.wrapping_sub(1), len, len + 1] {
1239        let Some(bucket) = by_len.get(&candidate_len) else {
1240            continue;
1241        };
1242        for &defined in bucket {
1243            if defined.len() < MIN_DEFINED_CLASS_LEN {
1244                continue;
1245            }
1246            if crate::css::is_typo_edit(token, defined)
1247                && best.is_none_or(|current| defined < current)
1248            {
1249                best = Some(defined);
1250            }
1251        }
1252    }
1253    best
1254}
1255
1256/// True when a markup class token is Tailwind-flavored (a variant prefix `:`,
1257/// an opacity `/`, or an arbitrary-value bracket), so it is not an authored CSS
1258/// class and never a typo candidate.
1259fn is_tailwind_shaped(token: &str) -> bool {
1260    token.contains([':', '/', '[', ']'])
1261}
1262
1263/// Length-bucketed index over the typo-target classes for O(1)-ish near-miss.
1264/// Drops names ending in `-` / `_`: those are SCSS interpolation artifacts
1265/// (`.display-#{$i}` parsed by lightningcss as a partial `display-`), never a
1266/// real typo target.
1267fn build_typo_target_index(
1268    defined: &rustc_hash::FxHashSet<String>,
1269) -> rustc_hash::FxHashMap<usize, Vec<&str>> {
1270    let mut by_len: rustc_hash::FxHashMap<usize, Vec<&str>> = rustc_hash::FxHashMap::default();
1271    for class in defined {
1272        if class.len() >= MIN_DEFINED_CLASS_LEN && !class.ends_with('-') && !class.ends_with('_') {
1273            by_len.entry(class.len()).or_default().push(class.as_str());
1274        }
1275    }
1276    by_len
1277}
1278
1279/// Collect the likely-typo class references in one markup source into `out`,
1280/// deduping by `(rel, line, value)` via `seen`.
1281fn collect_unresolved_class_refs_in_file<'a>(
1282    source: &str,
1283    rel: &str,
1284    defined: &rustc_hash::FxHashSet<String>,
1285    by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1286    seen: &mut rustc_hash::FxHashSet<(String, u32, String)>,
1287    out: &mut Vec<fallow_output::UnresolvedClassReference>,
1288) {
1289    use fallow_output::{CssCandidateAction, UnresolvedClassReference};
1290    for token in crate::css::scan_markup_class_tokens(source).static_tokens {
1291        if token.value.len() < MIN_TOKEN_LEN
1292            || is_tailwind_shaped(&token.value)
1293            || defined.contains(&token.value)
1294        {
1295            continue;
1296        }
1297        let Some(suggestion) = best_class_suggestion(&token.value, by_len) else {
1298            continue;
1299        };
1300        let key = (rel.to_owned(), token.line, token.value.clone());
1301        if !seen.insert(key) {
1302            continue;
1303        }
1304        out.push(UnresolvedClassReference {
1305            actions: vec![CssCandidateAction::verify_unresolved_class(
1306                &token.value,
1307                suggestion,
1308            )],
1309            class: token.value,
1310            suggestion: suggestion.to_owned(),
1311            path: rel.to_owned(),
1312            line: token.line,
1313        });
1314    }
1315}
1316
1317/// Scan markup for static `class` / `className` tokens that match no defined CSS
1318/// class but are one edit from a defined class (a likely typo / stale rename).
1319/// The defined set is the full project; markup honors the ignore / changed /
1320/// workspace filters (a typo is local). Near-zero false-positive by the near-miss
1321/// restriction: Tailwind utilities and third-party classes are not one edit from
1322/// an authored class. Candidates, never gated.
1323fn scan_unresolved_class_references(
1324    files: &[fallow_types::discover::DiscoveredFile],
1325    ctx: HealthScanCtx<'_>,
1326    summary: &mut fallow_output::CssAnalyticsSummary,
1327) -> Vec<fallow_output::UnresolvedClassReference> {
1328    let HealthScanCtx {
1329        config, ignore_set, ..
1330    } = ctx;
1331
1332    use fallow_output::UnresolvedClassReference;
1333
1334    // Abstain on preprocessor-dominant projects. lightningcss parses `.scss` /
1335    // `.sass` / `.less` source textually but cannot expand loops / mixins, so a
1336    // generated class (`.bg-#{$color}`, `.col-#{$i}`) is invisible to the defined
1337    // set. On a SCSS framework like Bootstrap that makes a real, used class
1338    // (`bg-white`) look unresolved and false-positive as a typo of a parsed
1339    // sibling. When preprocessor stylesheets outnumber plain CSS, the defined set
1340    // is too incomplete to trust, so emit nothing (real-world smoke: Bootstrap).
1341    let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1342    summary.preprocessor_stylesheets = saturate_len(preprocessor_files);
1343    if preprocessor_files > css_files {
1344        summary.preprocessor_reachability_abstained = true;
1345        return Vec::new();
1346    }
1347
1348    let defined = collect_defined_css_classes(files, config, ignore_set);
1349    if defined.is_empty() {
1350        return Vec::new();
1351    }
1352    let by_len = build_typo_target_index(&defined);
1353
1354    let mut out: Vec<UnresolvedClassReference> = Vec::new();
1355    let mut seen: rustc_hash::FxHashSet<(String, u32, String)> = rustc_hash::FxHashSet::default();
1356    for file in files {
1357        let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
1358            continue;
1359        };
1360        collect_unresolved_class_refs_in_file(
1361            &source, &rel, &defined, &by_len, &mut seen, &mut out,
1362        );
1363    }
1364
1365    out.sort_by(|a, b| {
1366        a.path
1367            .cmp(&b.path)
1368            .then_with(|| a.line.cmp(&b.line))
1369            .then_with(|| a.class.cmp(&b.class))
1370    });
1371    summary.unresolved_class_references = saturate_len(out.len());
1372    out
1373}
1374
1375/// Blank every `@font-face { ... }` block in a (lowercased) source so a declared
1376/// family's own `font-family:` inside its definition does not self-credit when
1377/// the source is scanned for OTHER references to that family. The `@font-face`,
1378/// `{`, and `}` boundaries are ASCII, so replacing the whole block range with
1379/// spaces preserves UTF-8 validity (any multi-byte family name inside the block
1380/// is fully within the replaced range).
1381fn mask_font_face_blocks(lower_source: &str) -> String {
1382    if !lower_source.contains("@font-face") {
1383        return lower_source.to_owned();
1384    }
1385    let mut bytes = lower_source.as_bytes().to_vec();
1386    let sb = lower_source.as_bytes();
1387    let mut search = 0;
1388    while let Some(rel) = lower_source[search..].find("@font-face") {
1389        let start = search + rel;
1390        let Some(brace_rel) = lower_source[start..].find('{') else {
1391            break;
1392        };
1393        let mut depth = 0usize;
1394        let mut j = start + brace_rel;
1395        while j < sb.len() {
1396            match sb[j] {
1397                b'{' => depth += 1,
1398                b'}' => {
1399                    depth -= 1;
1400                    if depth == 0 {
1401                        break;
1402                    }
1403                }
1404                _ => {}
1405            }
1406            j += 1;
1407        }
1408        let end = (j + 1).min(bytes.len());
1409        for b in &mut bytes[start..end] {
1410            *b = b' ';
1411        }
1412        search = end;
1413    }
1414    String::from_utf8(bytes).unwrap_or_else(|_| lower_source.to_owned())
1415}
1416
1417/// Of the candidate unused `@font-face` families, the subset whose name appears
1418/// as a substring in some other source file (`.css`/`.scss`/`.sass`/`.less`,
1419/// JS/TS, or markup), OUTSIDE its own `@font-face` block. Such a family is
1420/// applied somewhere the structural `font-family` reference set cannot see (a
1421/// Tailwind v4 `--font-*` theme token in a `@theme` block lightningcss skips, a
1422/// `.scss` theme, a canvas/JS `fontFamily` assignment, an inline style), so it
1423/// is NOT dead.
1424fn font_families_referenced_in_source(
1425    candidates: &[fallow_output::UnusedFontFace],
1426    files: &[fallow_types::discover::DiscoveredFile],
1427    config: &ResolvedConfig,
1428    ignore_set: &globset::GlobSet,
1429) -> rustc_hash::FxHashSet<String> {
1430    // `(original-case family, lowercase family)`; the lowercase form drives the
1431    // substring test because CSS font-family names are case-insensitive, while the
1432    // original case is what gets returned for the caller's retain.
1433    let mut pending: Vec<(String, String)> = candidates
1434        .iter()
1435        .map(|c| (c.family.clone(), c.family.to_ascii_lowercase()))
1436        .collect();
1437    let mut found: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1438    for file in files {
1439        if pending.is_empty() {
1440            break;
1441        }
1442        let path = &file.path;
1443        let extension = path.extension().and_then(|ext| ext.to_str());
1444        if !matches!(
1445            extension,
1446            Some(
1447                "css"
1448                    | "scss"
1449                    | "sass"
1450                    | "less"
1451                    | "js"
1452                    | "jsx"
1453                    | "ts"
1454                    | "tsx"
1455                    | "mjs"
1456                    | "cjs"
1457                    | "vue"
1458                    | "svelte"
1459                    | "astro"
1460                    | "html"
1461                    | "mdx"
1462            )
1463        ) {
1464            continue;
1465        }
1466        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1467        if ignore_set.is_match(relative) {
1468            continue;
1469        }
1470        let Ok(source) = std::fs::read_to_string(path) else {
1471            continue;
1472        };
1473        // `.css` is scanned too: a family can be referenced via a custom-property
1474        // value (a Tailwind v4 `--font-*` theme token, which lives inside a
1475        // `@theme` block that lightningcss skips, so the structural reference set
1476        // never sees it). The family's OWN `@font-face` definition is masked so it
1477        // does not self-credit (every declared family appears in its own block).
1478        let source_lower = mask_font_face_blocks(&source.to_ascii_lowercase());
1479        pending.retain(|(family, family_lower)| {
1480            if source_lower.contains(family_lower.as_str()) {
1481                found.insert(family.clone());
1482                false
1483            } else {
1484                true
1485            }
1486        });
1487    }
1488    found
1489}
1490
1491/// Shortest global class worth reporting as unreferenced. Shorter names are
1492/// substring-prone (their literal appears inside many longer strings, so the
1493/// substring reference check already keeps them safe) and low-signal.
1494const MIN_UNREF_CLASS_LEN: usize = 5;
1495
1496/// Extract class-shaped tokens from quoted string literals (`'...'` / `"..."` /
1497/// `` `...` ``) in a source string and add them to `out`, crediting a name
1498/// applied outside a `class=` / `className=` attribute (a config-object
1499/// `className: 'leveret-toast'`, a helper `return "x-y"`, a JS inline-style
1500/// `animation: 'progress-indeterminate 1s'`).
1501///
1502/// `require_dash` controls strictness. For CLASS crediting it is `true`: only
1503/// compound (dash-bearing) tokens are taken, so a generic single word never
1504/// coincidentally credits a class and breaks the whole-sheet abstain that
1505/// protects classes used in a surface fallow cannot read (Phoenix `.heex`). For
1506/// KEYFRAME crediting it is `false` (the caller filters to actually-defined
1507/// keyframes, so over-extraction is inert), letting a single-word keyframe name
1508/// (`spin`, `jsanim`) be credited from a JS `animation:` string too.
1509fn collect_quoted_class_tokens(
1510    source: &str,
1511    out: &mut rustc_hash::FxHashSet<String>,
1512    require_dash: bool,
1513) {
1514    let bytes = source.as_bytes();
1515    let mut i = 0;
1516    while i < bytes.len() {
1517        let quote = bytes[i];
1518        if quote == b'"' || quote == b'\'' || quote == b'`' {
1519            let start = i + 1;
1520            let mut j = start;
1521            while j < bytes.len() && bytes[j] != quote {
1522                j += 1;
1523            }
1524            if let Some(content) = source.get(start..j) {
1525                for token in content
1526                    .split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
1527                {
1528                    let shaped = token.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
1529                        && !token.ends_with('-')
1530                        && (if require_dash {
1531                            token.contains('-')
1532                        } else {
1533                            token.len() >= 3
1534                        });
1535                    if shaped {
1536                        out.insert(token.to_owned());
1537                    }
1538                }
1539            }
1540            i = j + 1;
1541        } else {
1542            i += 1;
1543        }
1544    }
1545}
1546
1547/// Class names wrapped in a CSS Modules `:global(...)` selector. Such a class is
1548/// applied by code OUTSIDE this stylesheet, most often a third-party library's
1549/// runtime DOM that the module styles via an escape hatch (an antd
1550/// `.validatiemeldingenModal :global(.ant-modal-header)` override). The project's
1551/// own markup never writes that class, so it can never be credited and would
1552/// always surface as a (false) unreferenced-class candidate. `:global` is the
1553/// author's explicit "not locally scoped, applied elsewhere" marker, so excluding
1554/// these from the candidate set is semantically correct, not a heuristic guess.
1555fn collect_global_scoped_classes(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1556    let bytes = source.as_bytes();
1557    let mut i = 0;
1558    while let Some(rel) = source[i..].find(":global(") {
1559        let open = i + rel + ":global(".len();
1560        // Balance parentheses so a `:global(:is(.a, .b))` still closes correctly.
1561        let mut depth = 1usize;
1562        let mut j = open;
1563        while j < bytes.len() && depth > 0 {
1564            match bytes[j] {
1565                b'(' => depth += 1,
1566                b')' => depth -= 1,
1567                _ => {}
1568            }
1569            j += 1;
1570        }
1571        let inner_end = j.saturating_sub(1).max(open);
1572        if let Some(inner) = source.get(open..inner_end) {
1573            extract_dotted_class_names(inner, out);
1574        }
1575        i = j.max(open + 1);
1576    }
1577}
1578
1579/// Push every `.class` token in a CSS selector fragment (the bare name, no dot)
1580/// into `out`. A class name is a dot followed by `[A-Za-z_-]` then any run of
1581/// `[A-Za-z0-9_-]`.
1582fn extract_dotted_class_names(selector: &str, out: &mut rustc_hash::FxHashSet<String>) {
1583    let bytes = selector.as_bytes();
1584    let mut i = 0;
1585    while i < bytes.len() {
1586        if bytes[i] == b'.' {
1587            let start = i + 1;
1588            if start < bytes.len()
1589                && (bytes[start].is_ascii_alphabetic() || matches!(bytes[start], b'_' | b'-'))
1590            {
1591                let mut j = start;
1592                while j < bytes.len()
1593                    && (bytes[j].is_ascii_alphanumeric() || matches!(bytes[j], b'_' | b'-'))
1594                {
1595                    j += 1;
1596                }
1597                if let Some(name) = selector.get(start..j) {
1598                    out.insert(name.to_owned());
1599                }
1600                i = j;
1601                continue;
1602            }
1603        }
1604        i += 1;
1605    }
1606}
1607
1608/// Per-stylesheet located class definitions from STANDALONE `.css`/`.scss`/
1609/// `.sass`/`.less` files (not SFC `<style>` blocks, which are component-scoped
1610/// and covered by the scoped-unused check). Returns `(rel_path, [(class, 1-based
1611/// line)])`, each class deduped to its first definition. The defined surface for
1612/// the unreferenced-global-class candidate. Classes wrapped in `:global(...)`
1613/// are dropped: they target externally-applied DOM and are never authored in
1614/// markup.
1615fn collect_defined_css_classes_located(
1616    files: &[fallow_types::discover::DiscoveredFile],
1617    config: &ResolvedConfig,
1618    ignore_set: &globset::GlobSet,
1619) -> Vec<(String, Vec<(String, u32)>)> {
1620    use fallow_types::extract::ExportName;
1621    let mut out: Vec<(String, Vec<(String, u32)>)> = Vec::new();
1622    for file in files {
1623        let path = &file.path;
1624        let extension = path.extension().and_then(|ext| ext.to_str());
1625        let is_preprocessor = matches!(extension, Some("scss" | "sass" | "less"));
1626        if extension != Some("css") && !is_preprocessor {
1627            continue;
1628        }
1629        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1630        if ignore_set.is_match(relative) {
1631            continue;
1632        }
1633        let Ok(source) = std::fs::read_to_string(path) else {
1634            continue;
1635        };
1636        let mut global_scoped: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1637        collect_global_scoped_classes(&source, &mut global_scoped);
1638        let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1639        let mut classes: Vec<(String, u32)> = Vec::new();
1640        for export in crate::css::extract_css_module_exports(&source, is_preprocessor) {
1641            let ExportName::Named(name) = export.name else {
1642                continue;
1643            };
1644            // A `:global(.foo)` override targets DOM applied outside this module
1645            // (a third-party library's runtime markup), so it is never authored in
1646            // project markup and must not be an unreferenced-class candidate.
1647            if global_scoped.contains(&name) {
1648                continue;
1649            }
1650            if !seen.insert(name.clone()) {
1651                continue;
1652            }
1653            let start = export.span.start as usize;
1654            let line = 1 + source
1655                .get(..start)
1656                .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
1657            classes.push((name, u32::try_from(line).unwrap_or(u32::MAX)));
1658        }
1659        if !classes.is_empty() {
1660            out.push((relative.to_string_lossy().replace('\\', "/"), classes));
1661        }
1662    }
1663    out
1664}
1665
1666#[derive(Clone, Debug)]
1667struct CssClassInventory {
1668    css_files: usize,
1669    preprocessor_files: usize,
1670    defined_classes: Vec<(String, Vec<(String, u32)>)>,
1671}
1672
1673fn css_class_inventory(
1674    files: &[fallow_types::discover::DiscoveredFile],
1675    config: &ResolvedConfig,
1676    ignore_set: &globset::GlobSet,
1677) -> CssClassInventory {
1678    let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1679    CssClassInventory {
1680        css_files,
1681        preprocessor_files,
1682        defined_classes: collect_defined_css_classes_located(files, config, ignore_set),
1683    }
1684}
1685
1686/// Scan for global CSS classes referenced by NO in-project markup (the CSS
1687/// analogue of an unused export). Heavily gated to stay near-zero-false-positive:
1688///
1689/// - **Partial scope** (`changed_files` / `ws_roots`): abstain. A partial markup
1690///   view cannot prove a global class dead.
1691/// - **Preprocessor-dominant** (`.scss`/`.sass`/`.less` outnumber plain `.css`):
1692///   abstain. The parser cannot expand loops/mixins, so the markup-vs-CSS join
1693///   is unreliable.
1694/// - **Published surface**: a stylesheet reachable from `package.json` entries,
1695///   or whose classes are referenced by zero in-project markup (a design system
1696///   consumed elsewhere), abstains entirely.
1697/// - **Reference test** (panel gate 1): a class is referenced if it is a whole
1698///   static markup token OR a substring of any dynamic-class source, so a class
1699///   assembled from a `${...}` / `clsx(...)` fragment is never flagged.
1700fn scan_unreferenced_css_classes(
1701    files: &[fallow_types::discover::DiscoveredFile],
1702    ctx: HealthScanCtx<'_>,
1703    summary: &mut fallow_output::CssAnalyticsSummary,
1704    reference_surface: Option<&CssReferenceSurface>,
1705    class_inventory: Option<&CssClassInventory>,
1706) -> Vec<fallow_output::UnreferencedCssClass> {
1707    let HealthScanCtx {
1708        config,
1709        ignore_set,
1710        changed_files,
1711        output_changed_files: _,
1712        ws_roots,
1713    } = ctx;
1714
1715    use fallow_output::UnreferencedCssClass;
1716
1717    // Partial scope cannot prove a global class dead.
1718    if changed_files.is_some() || ws_roots.is_some() {
1719        return Vec::new();
1720    }
1721    // Preprocessor-dominant projects have an unreliable defined/used join.
1722    let fallback_class_inventory;
1723    let class_inventory = if let Some(inventory) = class_inventory {
1724        inventory
1725    } else {
1726        fallback_class_inventory = css_class_inventory(files, config, ignore_set);
1727        &fallback_class_inventory
1728    };
1729    let css_files = class_inventory.css_files;
1730    let preprocessor_files = class_inventory.preprocessor_files;
1731    if preprocessor_files > css_files {
1732        return Vec::new();
1733    }
1734
1735    let fallback_reference_surface;
1736    let reference_surface = if let Some(surface) = reference_surface {
1737        surface
1738    } else {
1739        fallback_reference_surface = css_reference_surface(files, config, ignore_set);
1740        &fallback_reference_surface
1741    };
1742
1743    let published = published_css_paths(config);
1744    let dependency_prefixes = dependency_class_prefixes(config);
1745
1746    let mut out: Vec<UnreferencedCssClass> = Vec::new();
1747    for (rel, classes) in &class_inventory.defined_classes {
1748        push_unreferenced_css_class_candidates(
1749            &mut out,
1750            rel,
1751            classes.clone(),
1752            &published,
1753            &dependency_prefixes,
1754            reference_surface,
1755        );
1756    }
1757
1758    out.sort_by(|a, b| {
1759        a.path
1760            .cmp(&b.path)
1761            .then_with(|| a.line.cmp(&b.line))
1762            .then_with(|| a.class.cmp(&b.class))
1763    });
1764    summary.unreferenced_css_classes = saturate_len(out.len());
1765    out
1766}
1767
1768#[derive(Clone, Debug)]
1769struct CssReferenceSurface {
1770    static_tokens: rustc_hash::FxHashSet<String>,
1771    dynamic_corpus: String,
1772    source_corpus: String,
1773    dynamic_interpolants: rustc_hash::FxHashSet<String>,
1774}
1775
1776impl CssReferenceSurface {
1777    fn references(&self, class: &str) -> bool {
1778        self.static_tokens.contains(class)
1779            || class_name_occurrences(&self.dynamic_corpus, class)
1780                .next()
1781                .is_some()
1782            || self.css_module_property_referenced(class)
1783            || self.dynamic_prefix_referenced(class)
1784            || self.dynamic_literal_referenced(class)
1785    }
1786
1787    fn css_module_property_referenced(&self, class: &str) -> bool {
1788        let Some(alias) = css_module_property_alias(class) else {
1789            return false;
1790        };
1791        self.source_corpus.contains(&format!(".{alias}"))
1792            || self.source_corpus.contains(&format!("['{alias}']"))
1793            || self.source_corpus.contains(&format!("[\"{alias}\"]"))
1794    }
1795
1796    fn dynamic_prefix_referenced(&self, class: &str) -> bool {
1797        let Some(dash) = class.rfind('-') else {
1798            return false;
1799        };
1800        let head = &class[..=dash];
1801        const INTERP_MARKERS: [&str; 6] = ["${", "' +", "'+", "\" +", "\"+", "` +"];
1802        INTERP_MARKERS
1803            .iter()
1804            .any(|marker| self.dynamic_corpus.contains(&format!("{head}{marker}")))
1805    }
1806
1807    fn dynamic_literal_referenced(&self, class: &str) -> bool {
1808        if !is_plain_dynamic_class_value(class) || self.dynamic_interpolants.is_empty() {
1809            return false;
1810        }
1811        class_literal_occurrences(&self.source_corpus, class).any(|offset| {
1812            let start = offset.saturating_sub(120);
1813            let end = self.source_corpus.len().min(offset + class.len() + 120);
1814            let Some(window) = self.source_corpus.get(start..end) else {
1815                return false;
1816            };
1817            let window = window.to_ascii_lowercase();
1818            self.dynamic_interpolants
1819                .iter()
1820                .any(|name| window.contains(&name.to_ascii_lowercase()))
1821        })
1822    }
1823}
1824
1825fn css_module_property_alias(class: &str) -> Option<String> {
1826    if !class.contains('-') {
1827        return None;
1828    }
1829    let mut alias = String::with_capacity(class.len());
1830    let mut uppercase_next = false;
1831    for c in class.chars() {
1832        if c == '-' {
1833            uppercase_next = true;
1834            continue;
1835        }
1836        if uppercase_next {
1837            alias.extend(c.to_uppercase());
1838            uppercase_next = false;
1839        } else {
1840            alias.push(c);
1841        }
1842    }
1843    (alias != class && is_valid_js_property_ident(&alias)).then_some(alias)
1844}
1845
1846fn is_valid_js_property_ident(value: &str) -> bool {
1847    let mut chars = value.chars();
1848    let Some(first) = chars.next() else {
1849        return false;
1850    };
1851    (first == '_' || first == '$' || first.is_ascii_alphabetic())
1852        && chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
1853}
1854
1855fn is_plain_dynamic_class_value(class: &str) -> bool {
1856    class.len() >= MIN_UNREF_CLASS_LEN
1857        && class
1858            .bytes()
1859            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1860}
1861
1862fn class_literal_occurrences<'a>(
1863    source: &'a str,
1864    class: &'a str,
1865) -> impl Iterator<Item = usize> + 'a {
1866    source.match_indices(class).filter_map(move |(offset, _)| {
1867        let before = source.as_bytes().get(offset.wrapping_sub(1)).copied();
1868        let after = source.as_bytes().get(offset + class.len()).copied();
1869        match (before, after) {
1870            (Some(b'\''), Some(b'\'' | b',' | b';' | b')' | b']' | b'}'))
1871            | (Some(b'"'), Some(b'"' | b',' | b';' | b')' | b']' | b'}'))
1872            | (Some(b'`'), Some(b'`' | b',' | b';' | b')' | b']' | b'}')) => Some(offset),
1873            _ => None,
1874        }
1875    })
1876}
1877
1878fn class_name_occurrences<'a>(source: &'a str, class: &'a str) -> impl Iterator<Item = usize> + 'a {
1879    source.match_indices(class).filter_map(move |(offset, _)| {
1880        let before = source.as_bytes().get(offset.wrapping_sub(1)).copied();
1881        let after = source.as_bytes().get(offset + class.len()).copied();
1882        if before.is_some_and(is_class_name_byte) || after.is_some_and(is_class_name_byte) {
1883            None
1884        } else {
1885            Some(offset)
1886        }
1887    })
1888}
1889
1890fn is_class_name_byte(byte: u8) -> bool {
1891    byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
1892}
1893
1894fn collect_dynamic_class_interpolants(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1895    let bytes = source.as_bytes();
1896    let mut i = 0usize;
1897    while let Some(rel) = source.get(i..).and_then(|tail| tail.find("${")) {
1898        let start = i + rel + 2;
1899        let mut name_start = start;
1900        while bytes
1901            .get(name_start)
1902            .is_some_and(|b| b.is_ascii_whitespace())
1903        {
1904            name_start += 1;
1905        }
1906        let Some(first) = bytes.get(name_start).copied() else {
1907            break;
1908        };
1909        if !is_js_identifier_start(first) {
1910            i = start;
1911            continue;
1912        }
1913        let mut name_end = name_start + 1;
1914        while bytes
1915            .get(name_end)
1916            .is_some_and(|b| is_js_identifier_continue(*b))
1917        {
1918            name_end += 1;
1919        }
1920        let mut cursor = name_end;
1921        while bytes.get(cursor).is_some_and(|b| b.is_ascii_whitespace()) {
1922            cursor += 1;
1923        }
1924        if bytes.get(cursor) == Some(&b'}') {
1925            out.insert(source[name_start..name_end].to_owned());
1926        }
1927        i = cursor.saturating_add(1);
1928    }
1929}
1930
1931fn is_js_identifier_start(byte: u8) -> bool {
1932    byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
1933}
1934
1935fn is_js_identifier_continue(byte: u8) -> bool {
1936    is_js_identifier_start(byte) || byte.is_ascii_digit()
1937}
1938
1939fn css_reference_surface(
1940    files: &[fallow_types::discover::DiscoveredFile],
1941    config: &ResolvedConfig,
1942    ignore_set: &globset::GlobSet,
1943) -> CssReferenceSurface {
1944    let mut surface = CssReferenceSurface {
1945        static_tokens: rustc_hash::FxHashSet::default(),
1946        dynamic_corpus: String::new(),
1947        source_corpus: String::new(),
1948        dynamic_interpolants: rustc_hash::FxHashSet::default(),
1949    };
1950    for file in files {
1951        collect_css_reference_surface_file(&mut surface, file, config, ignore_set);
1952    }
1953    collect_markdown_reference_surface_files(&mut surface, config, ignore_set);
1954    surface
1955}
1956
1957fn collect_css_reference_surface_file(
1958    surface: &mut CssReferenceSurface,
1959    file: &fallow_types::discover::DiscoveredFile,
1960    config: &ResolvedConfig,
1961    ignore_set: &globset::GlobSet,
1962) {
1963    let path = &file.path;
1964    let extension = path.extension().and_then(|ext| ext.to_str());
1965    if !matches!(extension, Some("js" | "ts" | "mjs" | "cjs"))
1966        && !extension.is_some_and(is_markup_source_extension)
1967    {
1968        return;
1969    }
1970    let relative = path.strip_prefix(&config.root).unwrap_or(path);
1971    if ignore_set.is_match(relative) {
1972        return;
1973    }
1974    let Ok(source) = std::fs::read_to_string(path) else {
1975        return;
1976    };
1977    surface.source_corpus.push_str(&source);
1978    surface.source_corpus.push('\n');
1979    let is_markup_surface = extension.is_some_and(is_markup_source_extension);
1980    if !is_markup_surface {
1981        return;
1982    }
1983    let scan = crate::css::scan_markup_class_tokens(&source);
1984    for token in scan.static_tokens {
1985        surface.static_tokens.insert(token.value);
1986    }
1987    collect_quoted_class_tokens(&source, &mut surface.static_tokens, true);
1988    if scan.has_dynamic {
1989        collect_dynamic_class_interpolants(&source, &mut surface.dynamic_interpolants);
1990        surface.dynamic_corpus.push_str(&source);
1991        surface.dynamic_corpus.push('\n');
1992    }
1993}
1994
1995fn collect_markdown_reference_surface_files(
1996    surface: &mut CssReferenceSurface,
1997    config: &ResolvedConfig,
1998    ignore_set: &globset::GlobSet,
1999) {
2000    collect_markdown_reference_surface_dir(surface, &config.root, config, ignore_set);
2001}
2002
2003fn collect_markdown_reference_surface_dir(
2004    surface: &mut CssReferenceSurface,
2005    dir: &std::path::Path,
2006    config: &ResolvedConfig,
2007    ignore_set: &globset::GlobSet,
2008) {
2009    let Ok(entries) = std::fs::read_dir(dir) else {
2010        return;
2011    };
2012    for entry in entries.flatten() {
2013        let path = entry.path();
2014        let relative = path.strip_prefix(&config.root).unwrap_or(&path);
2015        if ignore_set.is_match(relative) || is_skipped_markdown_reference_path(relative) {
2016            continue;
2017        }
2018        let Ok(file_type) = entry.file_type() else {
2019            continue;
2020        };
2021        if file_type.is_dir() {
2022            collect_markdown_reference_surface_dir(surface, &path, config, ignore_set);
2023            continue;
2024        }
2025        let extension = path.extension().and_then(|ext| ext.to_str());
2026        if !matches!(extension, Some("md" | "mdx")) {
2027            continue;
2028        }
2029        let Ok(source) = std::fs::read_to_string(&path) else {
2030            continue;
2031        };
2032        surface.source_corpus.push_str(&source);
2033        surface.source_corpus.push('\n');
2034        let scan = crate::css::scan_markup_class_tokens(&source);
2035        for token in scan.static_tokens {
2036            surface.static_tokens.insert(token.value);
2037        }
2038        collect_quoted_class_tokens(&source, &mut surface.static_tokens, true);
2039        if scan.has_dynamic {
2040            collect_dynamic_class_interpolants(&source, &mut surface.dynamic_interpolants);
2041            surface.dynamic_corpus.push_str(&source);
2042            surface.dynamic_corpus.push('\n');
2043        }
2044    }
2045}
2046
2047fn is_skipped_markdown_reference_path(relative: &std::path::Path) -> bool {
2048    relative.components().any(|component| {
2049        let std::path::Component::Normal(name) = component else {
2050            return false;
2051        };
2052        matches!(
2053            name.to_str(),
2054            Some(
2055                "node_modules"
2056                    | ".git"
2057                    | ".next"
2058                    | ".nuxt"
2059                    | ".svelte-kit"
2060                    | "dist"
2061                    | "build"
2062                    | "target"
2063                    | "coverage"
2064                    | ".turbo"
2065                    | ".cache"
2066            )
2067        )
2068    })
2069}
2070
2071fn is_markup_source_extension(extension: &str) -> bool {
2072    matches!(
2073        extension,
2074        "jsx" | "tsx" | "html" | "astro" | "vue" | "svelte" | "md" | "mdx"
2075    )
2076}
2077
2078fn push_unreferenced_css_class_candidates(
2079    out: &mut Vec<fallow_output::UnreferencedCssClass>,
2080    rel: &str,
2081    classes: Vec<(String, u32)>,
2082    published: &rustc_hash::FxHashSet<String>,
2083    dependency_prefixes: &rustc_hash::FxHashSet<String>,
2084    reference_surface: &CssReferenceSurface,
2085) {
2086    use fallow_output::{CssCandidateAction, UnreferencedCssClass};
2087
2088    if published.contains(rel)
2089        || !classes
2090            .iter()
2091            .any(|(class, _)| reference_surface.references(class))
2092    {
2093        return;
2094    }
2095    for (class, line) in classes {
2096        if class.len() >= MIN_UNREF_CLASS_LEN
2097            && !reference_surface.references(&class)
2098            && !class_matches_dependency_prefix(&class, dependency_prefixes)
2099        {
2100            out.push(UnreferencedCssClass {
2101                actions: vec![CssCandidateAction::verify_unreferenced_class(&class)],
2102                class,
2103                path: rel.to_string(),
2104                line,
2105            });
2106        }
2107    }
2108}
2109
2110/// Source-file extensions scanned for Tailwind utility-class-shaped tokens when
2111/// crediting `@theme` token usage. Mirrors the font-family source scan (markup,
2112/// JS/TS className strings / `clsx` args / CSS-in-JS, preprocessor stylesheets)
2113/// but deliberately EXCLUDES plain `.css`, which would re-read the `@theme`
2114/// DEFINITION and self-credit every token.
2115const THEME_USAGE_SOURCE_EXTS: &[&str] = &[
2116    "scss", "sass", "less", "js", "jsx", "ts", "tsx", "mjs", "cjs", "vue", "svelte", "astro",
2117    "html", "mdx",
2118];
2119
2120/// Collect every Tailwind-utility-shaped token from `source` into `out`: a
2121/// maximal run of `[a-z0-9-]` that, with leading/trailing `-` trimmed, still
2122/// contains a `-` and starts with a lowercase letter. Captures `bg-brand`,
2123/// `rounded-card`, `text-2xl`, and the `color-brand` core of a
2124/// `var(--color-brand)` / `[--color-brand]` reference. Deliberately captures the
2125/// dashed SHAPE, never a bare word, so a dictionary-word theme name
2126/// (`brand`/`card`/`muted`) is credited only by a real `-<name>` utility suffix,
2127/// not by the word appearing anywhere in source.
2128fn collect_class_shaped_tokens(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
2129    let bytes = source.as_bytes();
2130    let mut i = 0;
2131    while i < bytes.len() {
2132        let b = bytes[i];
2133        if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
2134            let start = i;
2135            while i < bytes.len() {
2136                let c = bytes[i];
2137                if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
2138                    i += 1;
2139                } else {
2140                    break;
2141                }
2142            }
2143            let tok = source[start..i].trim_matches('-');
2144            if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
2145                out.insert(tok.to_owned());
2146            }
2147        } else {
2148            i += 1;
2149        }
2150    }
2151}
2152
2153/// Location-aware sibling of [`collect_class_shaped_tokens`]: appends every
2154/// Tailwind-utility-shaped token in `source` to `out` as `(token, rel, line)`.
2155fn collect_class_shaped_tokens_located(
2156    source: &str,
2157    rel: &str,
2158    out: &mut Vec<(String, String, u32)>,
2159) {
2160    let bytes = source.as_bytes();
2161    let mut i = 0;
2162    while i < bytes.len() {
2163        let b = bytes[i];
2164        if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
2165            let start = i;
2166            while i < bytes.len() {
2167                let c = bytes[i];
2168                if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
2169                    i += 1;
2170                } else {
2171                    break;
2172                }
2173            }
2174            let tok = source[start..i].trim_matches('-');
2175            if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
2176                out.push((
2177                    tok.to_owned(),
2178                    rel.to_owned(),
2179                    line_at_offset(source, start),
2180                ));
2181            }
2182        } else {
2183            i += 1;
2184        }
2185    }
2186}
2187
2188fn line_at_offset(source: &str, offset: usize) -> u32 {
2189    let count = source
2190        .get(..offset)
2191        .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
2192    u32::try_from(1 + count).unwrap_or(u32::MAX)
2193}
2194
2195/// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`) defined
2196/// in a stylesheet but used by no generated utility, `var()` read, `@apply`, or
2197/// arbitrary value anywhere in the project: dead design tokens (the
2198/// `unused-export` of the token era). Heavily gated to stay near-zero-false-
2199/// positive (panel BLOCKs):
2200///
2201/// - **Partial scope** (`changed_files` / `ws_roots`): abstain. A partial view
2202///   cannot prove a token dead.
2203/// - **v4 gate**: emit only when the project declares a `tailwindcss` dependency
2204///   AND at least one `@theme` token was found.
2205/// - **Tailwind plugin** (`@plugin` / config `plugins[]`): abstain. A plugin can
2206///   consume tokens invisibly to the scan (the DI blind spot).
2207/// - **Published library**: a token defined in a stylesheet that is a published
2208///   package surface is a public design-token API consumed downstream; skip it.
2209/// - **Variant namespaces** (`--breakpoint-*` / `--container-*`): excluded from
2210///   candidacy in this version. Crediting their `<name>:` / `@<name>:` variant
2211///   usage robustly needs a dedicated variant parser; a follow-up can add it.
2212///   (Acceptance criterion 7: excluded when the variant scan is not built.)
2213///
2214/// The usage test is false-negative-leaning by design: every check CREDITS usage,
2215/// so a genuinely-dead token is missed before a live one is flagged.
2216struct UnusedThemeTokenScanInput<'a> {
2217    tokens: &'a CssTokenSets,
2218    files: &'a [fallow_types::discover::DiscoveredFile],
2219    config: &'a ResolvedConfig,
2220    ignore_set: &'a globset::GlobSet,
2221    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2222    output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2223    ws_roots: Option<&'a [std::path::PathBuf]>,
2224    summary: &'a mut fallow_output::CssAnalyticsSummary,
2225}
2226
2227/// A classified `@theme` token candidate (namespace + name + definition site)
2228/// surviving the variant / published-library / unknown-namespace filters.
2229struct ThemeTokenCandidate {
2230    token: String,
2231    namespace: String,
2232    name: String,
2233    value: String,
2234    path: String,
2235    line: u32,
2236}
2237
2238/// Classify the project's `@theme` token definers, dropping variant namespaces,
2239/// published-library stylesheets, and anything outside a known namespace.
2240fn classify_theme_token_candidates(
2241    input: &UnusedThemeTokenScanInput<'_>,
2242) -> Vec<ThemeTokenCandidate> {
2243    classify_theme_token_candidates_from_tokens(input.tokens, input.config)
2244}
2245
2246fn classify_theme_token_candidates_from_tokens(
2247    tokens: &CssTokenSets,
2248    config: &ResolvedConfig,
2249) -> Vec<ThemeTokenCandidate> {
2250    let published = published_css_paths(config);
2251    let mut candidates: Vec<ThemeTokenCandidate> = Vec::new();
2252    for (raw, definition) in &tokens.theme_token_definers {
2253        if published.contains(&definition.path) {
2254            continue;
2255        }
2256        let Some(classified) = tailwind_theme::classify(raw) else {
2257            continue;
2258        };
2259        if classified.is_variant {
2260            continue;
2261        }
2262        candidates.push(ThemeTokenCandidate {
2263            token: format!("--{raw}"),
2264            namespace: classified.namespace,
2265            name: classified.name,
2266            value: definition.value.clone(),
2267            path: definition.path.clone(),
2268            line: definition.line,
2269        });
2270    }
2271    candidates
2272}
2273
2274/// Build the utility-shaped usage surface: every class-shaped token from `@apply`
2275/// bodies plus non-CSS source (markup class attributes, `clsx` args, CSS-in-JS).
2276fn collect_theme_usage_tokens(
2277    input: &UnusedThemeTokenScanInput<'_>,
2278) -> rustc_hash::FxHashSet<String> {
2279    let mut utility_tokens: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
2280    for apply in &input.tokens.apply_tokens {
2281        collect_class_shaped_tokens(apply, &mut utility_tokens);
2282    }
2283    for file in input.files {
2284        let path = &file.path;
2285        let extension = path.extension().and_then(|ext| ext.to_str());
2286        if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
2287            continue;
2288        }
2289        let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
2290        if input.ignore_set.is_match(relative) {
2291            continue;
2292        }
2293        if let Ok(source) = std::fs::read_to_string(path) {
2294            collect_class_shaped_tokens(&source, &mut utility_tokens);
2295        }
2296    }
2297    utility_tokens
2298}
2299
2300/// The `var()` read surface: CSS-side `@theme` reads plus referenced custom
2301/// properties (leading dashes trimmed to the property key form).
2302fn collect_theme_var_reads(tokens: &CssTokenSets) -> rustc_hash::FxHashSet<String> {
2303    let mut var_reads: rustc_hash::FxHashSet<String> = tokens.theme_var_reads.clone();
2304    for referenced in &tokens.referenced_custom_props {
2305        var_reads.insert(referenced.trim_start_matches('-').to_owned());
2306    }
2307    var_reads
2308}
2309
2310fn scan_unused_theme_tokens(
2311    input: &mut UnusedThemeTokenScanInput<'_>,
2312) -> Vec<fallow_output::UnusedThemeToken> {
2313    use fallow_output::{CssCandidateAction, UnusedThemeToken};
2314
2315    // Partial scope cannot prove a token dead.
2316    if input.changed_files.is_some() || input.ws_roots.is_some() {
2317        return Vec::new();
2318    }
2319    // v4 gate: a Tailwind dependency AND at least one @theme token present.
2320    if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
2321        return Vec::new();
2322    }
2323    // Tailwind-plugin abstain (DI blind spot).
2324    if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
2325        return Vec::new();
2326    }
2327
2328    let candidates = classify_theme_token_candidates(input);
2329    if candidates.is_empty() {
2330        input.summary.unused_theme_tokens = 0;
2331        return Vec::new();
2332    }
2333
2334    let utility_tokens = collect_theme_usage_tokens(input);
2335    let var_reads = collect_theme_var_reads(input.tokens);
2336
2337    let mut out: Vec<UnusedThemeToken> = Vec::new();
2338    for candidate in candidates {
2339        let dash_name = format!("-{}", candidate.name);
2340        // The token's own custom-property key, used by the var() read test.
2341        let raw = candidate.token.trim_start_matches('-');
2342        let used = var_reads.contains(raw)
2343            || utility_tokens
2344                .iter()
2345                .any(|t| t.len() > dash_name.len() && t.ends_with(&dash_name));
2346        if used {
2347            continue;
2348        }
2349        out.push(UnusedThemeToken {
2350            actions: vec![CssCandidateAction::verify_unused_theme_token(
2351                &candidate.token,
2352                &candidate.namespace,
2353                &candidate.name,
2354            )],
2355            token: candidate.token,
2356            namespace: candidate.namespace,
2357            path: candidate.path,
2358            line: candidate.line,
2359        });
2360    }
2361    out.sort_by(|a, b| {
2362        a.path
2363            .cmp(&b.path)
2364            .then_with(|| a.line.cmp(&b.line))
2365            .then_with(|| a.token.cmp(&b.token))
2366    });
2367    input.summary.unused_theme_tokens = saturate_len(out.len());
2368    out
2369}
2370
2371const NEAR_DUPLICATE_COLOR_DISTANCE: f64 = 2.0;
2372const NEAR_DUPLICATE_LENGTH_DISTANCE_PX: f64 = 0.5;
2373const NEAR_DUPLICATE_DURATION_DISTANCE_MS: f64 = 10.0;
2374const NEAR_DUPLICATE_SHADOW_DISTANCE_PX: f64 = 1.0;
2375
2376#[derive(Clone, Debug)]
2377struct ComparableThemeTokenCandidate {
2378    token: String,
2379    namespace: String,
2380    name: String,
2381    value: String,
2382    path: String,
2383    line: u32,
2384    metric: ThemeTokenMetric,
2385    origin: ComparableTokenOrigin,
2386}
2387
2388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2389enum ComparableTokenOrigin {
2390    Explicit,
2391    ProjectVocabulary,
2392}
2393
2394impl ComparableTokenOrigin {
2395    fn priority(self) -> u8 {
2396        match self {
2397            Self::Explicit => 0,
2398            Self::ProjectVocabulary => 1,
2399        }
2400    }
2401}
2402
2403#[derive(Clone, Debug)]
2404enum ThemeTokenMetric {
2405    Color(OklabColor),
2406    LengthPx(f64),
2407    DurationMs(f64),
2408    ShadowPx(Vec<f64>),
2409}
2410
2411impl ThemeTokenMetric {
2412    fn distance(&self, other: &Self) -> Option<f64> {
2413        match (self, other) {
2414            (Self::Color(left), Self::Color(right)) => Some(oklab_distance(*left, *right)),
2415            (Self::LengthPx(left), Self::LengthPx(right))
2416            | (Self::DurationMs(left), Self::DurationMs(right)) => Some((left - right).abs()),
2417            (Self::ShadowPx(left), Self::ShadowPx(right)) if left.len() == right.len() => Some(
2418                left.iter()
2419                    .zip(right)
2420                    .map(|(l, r)| {
2421                        let delta = l - r;
2422                        delta * delta
2423                    })
2424                    .sum::<f64>()
2425                    .sqrt(),
2426            ),
2427            _ => None,
2428        }
2429    }
2430
2431    fn threshold(&self) -> f64 {
2432        match self {
2433            Self::Color(_) => NEAR_DUPLICATE_COLOR_DISTANCE,
2434            Self::LengthPx(_) => NEAR_DUPLICATE_LENGTH_DISTANCE_PX,
2435            Self::DurationMs(_) => NEAR_DUPLICATE_DURATION_DISTANCE_MS,
2436            Self::ShadowPx(_) => NEAR_DUPLICATE_SHADOW_DISTANCE_PX,
2437        }
2438    }
2439}
2440
2441#[derive(Clone, Copy, Debug)]
2442struct OklabColor {
2443    l: f64,
2444    a: f64,
2445    b: f64,
2446}
2447
2448fn scan_near_duplicate_theme_tokens(
2449    input: &mut UnusedThemeTokenScanInput<'_>,
2450) -> Vec<fallow_output::NearDuplicateThemeToken> {
2451    use fallow_output::{CssCandidateAction, NearDuplicateThemeToken, NearestStylingToken};
2452
2453    if input.changed_files.is_some() || input.ws_roots.is_some() {
2454        return Vec::new();
2455    }
2456    if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
2457        return Vec::new();
2458    }
2459    if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
2460        return Vec::new();
2461    }
2462
2463    let mut candidates = comparable_theme_token_candidates(input.tokens, input.config);
2464    candidates.sort_by(|a, b| theme_token_sort_key(a).cmp(&theme_token_sort_key(b)));
2465    if candidates.len() < 2 {
2466        return Vec::new();
2467    }
2468
2469    let mut out = Vec::new();
2470    let changed = input.output_changed_files;
2471    for candidate in &candidates {
2472        if let Some(changed) = changed
2473            && !css_output_path_in_changed_scope(&candidate.path, input.config, changed)
2474        {
2475            continue;
2476        }
2477        let nearest = find_nearest_duplicate_theme_token(candidate, &candidates, changed.is_some());
2478
2479        let Some((nearest, distance)) = nearest else {
2480            continue;
2481        };
2482        let distance = round_distance(distance);
2483        let nearest_token = NearestStylingToken {
2484            name: nearest.token.clone(),
2485            value: nearest.value.clone(),
2486            path: nearest.path.clone(),
2487            line: nearest.line,
2488            distance,
2489        };
2490        out.push(NearDuplicateThemeToken {
2491            token: candidate.token.clone(),
2492            value: candidate.value.clone(),
2493            path: candidate.path.clone(),
2494            line: candidate.line,
2495            actions: vec![CssCandidateAction::replace_near_duplicate_token(
2496                &candidate.token,
2497                &nearest.token,
2498            )],
2499            nearest_token,
2500        });
2501    }
2502    out.sort_by(|a, b| {
2503        a.path
2504            .cmp(&b.path)
2505            .then_with(|| a.line.cmp(&b.line))
2506            .then_with(|| a.token.cmp(&b.token))
2507    });
2508    input.summary.near_duplicate_theme_tokens = saturate_len(out.len());
2509    out
2510}
2511
2512fn annotate_raw_style_value_nearest_tokens(
2513    tokens: &mut CssTokenSets,
2514    candidates: &[ComparableThemeTokenCandidate],
2515) {
2516    if tokens.raw_style_values.is_empty() || candidates.is_empty() {
2517        return;
2518    }
2519    let raw_value_counts = raw_style_value_counts(&tokens.raw_style_values);
2520    for raw in &mut tokens.raw_style_values {
2521        let Some(namespace) = raw_style_token_namespace(&raw.axis) else {
2522            continue;
2523        };
2524        let Some(metric) = parse_theme_token_metric(namespace, &raw.value) else {
2525            continue;
2526        };
2527        let raw_value = normalize_theme_token_value(&raw.value);
2528        if namespace == "color" && color_value_has_alpha(&raw_value) {
2529            continue;
2530        }
2531        let raw_key = (namespace.to_string(), raw_value.clone());
2532        let raw_value_is_repeated = raw_value_counts.get(&raw_key).copied().unwrap_or(0) > 1;
2533        let nearest = candidates
2534            .iter()
2535            .filter(|candidate| candidate.namespace == namespace)
2536            .filter_map(|candidate| {
2537                if candidate.origin == ComparableTokenOrigin::ProjectVocabulary
2538                    && (raw_value == candidate.value || raw_value_is_repeated)
2539                {
2540                    return None;
2541                }
2542                let distance = metric.distance(&candidate.metric)?;
2543                (distance <= metric.threshold()).then_some((candidate, round_distance(distance)))
2544            })
2545            .min_by(|(left, left_distance), (right, right_distance)| {
2546                left_distance
2547                    .total_cmp(right_distance)
2548                    .then_with(|| left.origin.priority().cmp(&right.origin.priority()))
2549                    .then_with(|| theme_token_sort_key(left).cmp(&theme_token_sort_key(right)))
2550            });
2551        if let Some((nearest, distance)) = nearest {
2552            raw.nearest_token = Some(fallow_output::NearestStylingToken {
2553                name: nearest.token.clone(),
2554                value: nearest.value.clone(),
2555                path: nearest.path.clone(),
2556                line: nearest.line,
2557                distance,
2558            });
2559        }
2560    }
2561}
2562
2563fn raw_style_value_counts(
2564    raw_values: &[fallow_output::RawStyleValue],
2565) -> rustc_hash::FxHashMap<(String, String), u32> {
2566    let mut counts = rustc_hash::FxHashMap::default();
2567    for raw in raw_values {
2568        let Some(namespace) = raw_style_token_namespace(&raw.axis) else {
2569            continue;
2570        };
2571        *counts
2572            .entry((
2573                namespace.to_string(),
2574                normalize_theme_token_value(&raw.value),
2575            ))
2576            .or_insert(0) += 1;
2577    }
2578    counts
2579}
2580
2581fn comparable_css_in_js_token_candidates(
2582    files: &[fallow_types::discover::DiscoveredFile],
2583    modules: &[fallow_types::extract::ModuleInfo],
2584    config: &ResolvedConfig,
2585) -> Vec<ComparableThemeTokenCandidate> {
2586    if !project_uses_css_in_js(&config.root) {
2587        return Vec::new();
2588    }
2589    let path_by_id: rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path> =
2590        files.iter().map(|f| (f.id, f.path.as_path())).collect();
2591    let definers = collect_css_in_js_definers(modules, &path_by_id, config);
2592    let mut candidates = Vec::new();
2593    for definer in definers.entries {
2594        for leaf in definer.leaves {
2595            let Some(value) = leaf.value else {
2596                continue;
2597            };
2598            let Some(namespace) = css_in_js_token_namespace(definer.origin, &leaf.path) else {
2599                continue;
2600            };
2601            let Some(metric) = parse_theme_token_metric(namespace, &value) else {
2602                continue;
2603            };
2604            candidates.push(ComparableThemeTokenCandidate {
2605                token: format!("{}.{}", definer.binding, leaf.path),
2606                namespace: namespace.to_string(),
2607                name: leaf.path,
2608                value: normalize_theme_token_value(&value),
2609                path: definer.rel_path.clone(),
2610                line: leaf.def_line,
2611                metric,
2612                origin: ComparableTokenOrigin::Explicit,
2613            });
2614        }
2615    }
2616    candidates
2617}
2618
2619fn css_in_js_token_namespace(
2620    origin: fallow_extract::CssInJsTokenOrigin,
2621    path: &str,
2622) -> Option<&'static str> {
2623    let first = path.split('.').next().unwrap_or(path);
2624    let normalized = first.to_ascii_lowercase();
2625    match origin {
2626        fallow_extract::CssInJsTokenOrigin::Panda => match normalized.as_str() {
2627            "colors" | "color" => Some("color"),
2628            "fontsizes" | "font-sizes" | "text" => Some("text"),
2629            "radii" | "radius" | "radiitokens" | "border-radii" => Some("radius"),
2630            "shadows" | "shadow" => Some("shadow"),
2631            _ => None,
2632        },
2633        _ => match normalized.as_str() {
2634            "color" | "colors" | "palette" => Some("color"),
2635            "fontsize" | "fontsizes" | "font-size" | "text" => Some("text"),
2636            "radius" | "radii" | "borderradius" | "border-radius" => Some("radius"),
2637            "shadow" | "shadows" | "boxshadow" | "box-shadow" => Some("shadow"),
2638            _ => None,
2639        },
2640    }
2641}
2642
2643fn raw_style_token_namespace(axis: &str) -> Option<&'static str> {
2644    match axis {
2645        "color" => Some("color"),
2646        "font-size" => Some("text"),
2647        "radius" => Some("radius"),
2648        "shadow" => Some("shadow"),
2649        _ => None,
2650    }
2651}
2652
2653fn comparable_custom_property_token_candidates(
2654    tokens: &CssTokenSets,
2655) -> Vec<ComparableThemeTokenCandidate> {
2656    tokens
2657        .custom_property_definers
2658        .iter()
2659        .filter_map(|(token, definition)| {
2660            let namespace = custom_property_token_namespace(token)?;
2661            let metric = parse_theme_token_metric(namespace, &definition.value)?;
2662            Some(ComparableThemeTokenCandidate {
2663                token: token.clone(),
2664                namespace: namespace.to_string(),
2665                name: token.trim_start_matches('-').to_owned(),
2666                value: normalize_theme_token_value(&definition.value),
2667                path: definition.path.clone(),
2668                line: definition.line,
2669                metric,
2670                origin: ComparableTokenOrigin::Explicit,
2671            })
2672        })
2673        .collect()
2674}
2675
2676fn comparable_project_vocabulary_candidates(
2677    tokens: &CssTokenSets,
2678) -> Vec<ComparableThemeTokenCandidate> {
2679    let mut groups: rustc_hash::FxHashMap<(String, String), ProjectVocabularyValue> =
2680        rustc_hash::FxHashMap::default();
2681    for raw in &tokens.raw_style_values {
2682        let Some(namespace) = raw_style_token_namespace(&raw.axis) else {
2683            continue;
2684        };
2685        let value = normalize_theme_token_value(&raw.value);
2686        if namespace == "color" && color_value_has_alpha(&value) {
2687            continue;
2688        }
2689        let Some(metric) = parse_theme_token_metric(namespace, &value) else {
2690            continue;
2691        };
2692        let key = (namespace.to_string(), value.clone());
2693        let entry = groups.entry(key).or_insert_with(|| ProjectVocabularyValue {
2694            namespace: namespace.to_string(),
2695            value,
2696            path: raw.path.clone(),
2697            line: raw.line,
2698            count: 0,
2699            metric,
2700        });
2701        entry.count += 1;
2702        if (raw.path.as_str(), raw.line) < (entry.path.as_str(), entry.line) {
2703            entry.path.clone_from(&raw.path);
2704            entry.line = raw.line;
2705        }
2706    }
2707
2708    let mut candidates: Vec<ComparableThemeTokenCandidate> = groups
2709        .into_values()
2710        .filter(|value| value.count >= 2)
2711        .map(|value| ComparableThemeTokenCandidate {
2712            token: project_vocabulary_token_name(&value.namespace, &value.value),
2713            namespace: value.namespace.clone(),
2714            name: value.value.clone(),
2715            value: value.value,
2716            path: value.path,
2717            line: value.line,
2718            metric: value.metric,
2719            origin: ComparableTokenOrigin::ProjectVocabulary,
2720        })
2721        .collect();
2722    candidates.sort_by(|a, b| theme_token_sort_key(a).cmp(&theme_token_sort_key(b)));
2723    candidates
2724}
2725
2726#[derive(Clone, Debug)]
2727struct ProjectVocabularyValue {
2728    namespace: String,
2729    value: String,
2730    path: String,
2731    line: u32,
2732    count: u32,
2733    metric: ThemeTokenMetric,
2734}
2735
2736fn project_vocabulary_token_name(namespace: &str, value: &str) -> String {
2737    let stable_value = value.split_whitespace().collect::<Vec<_>>().join("_");
2738    format!("project-vocabulary.{namespace}.{stable_value}")
2739}
2740
2741fn color_value_has_alpha(value: &str) -> bool {
2742    let trimmed = value.trim();
2743    let Some(hex) = trimmed.strip_prefix('#') else {
2744        return false;
2745    };
2746    matches!(hex.len(), 4 | 8)
2747}
2748
2749fn custom_property_token_namespace(token: &str) -> Option<&'static str> {
2750    let key = token.trim_start_matches('-');
2751    if key.starts_with("color-") {
2752        Some("color")
2753    } else if key.starts_with("text-") || key.starts_with("font-size-") {
2754        Some("text")
2755    } else if key.starts_with("radius-") || key.starts_with("border-radius-") {
2756        Some("radius")
2757    } else if key.starts_with("shadow-") || key.starts_with("box-shadow-") {
2758        Some("shadow")
2759    } else {
2760        None
2761    }
2762}
2763
2764fn comparable_theme_token_candidates(
2765    tokens: &CssTokenSets,
2766    config: &ResolvedConfig,
2767) -> Vec<ComparableThemeTokenCandidate> {
2768    classify_theme_token_candidates_from_tokens(tokens, config)
2769        .into_iter()
2770        .filter_map(|candidate| {
2771            let metric = parse_theme_token_metric(&candidate.namespace, &candidate.value)?;
2772            Some(ComparableThemeTokenCandidate {
2773                token: candidate.token,
2774                namespace: candidate.namespace,
2775                name: candidate.name,
2776                value: normalize_theme_token_value(&candidate.value),
2777                path: candidate.path,
2778                line: candidate.line,
2779                metric,
2780                origin: ComparableTokenOrigin::Explicit,
2781            })
2782        })
2783        .collect()
2784}
2785
2786fn find_nearest_duplicate_theme_token<'a>(
2787    candidate: &'a ComparableThemeTokenCandidate,
2788    candidates: &'a [ComparableThemeTokenCandidate],
2789    include_later_tokens: bool,
2790) -> Option<(&'a ComparableThemeTokenCandidate, f64)> {
2791    candidates
2792        .iter()
2793        .filter(|other| other.token != candidate.token)
2794        .filter(|other| other.namespace == candidate.namespace)
2795        .filter(|other| {
2796            include_later_tokens || theme_token_sort_key(other) < theme_token_sort_key(candidate)
2797        })
2798        .filter(|other| {
2799            !theme_token_names_are_deliberate_pair(
2800                &candidate.namespace,
2801                &candidate.name,
2802                &other.name,
2803            )
2804        })
2805        .filter_map(|other| {
2806            let distance = candidate.metric.distance(&other.metric)?;
2807            if distance > 0.0 && distance <= candidate.metric.threshold() {
2808                Some((other, distance))
2809            } else {
2810                None
2811            }
2812        })
2813        .min_by(
2814            |(left_candidate, left_distance), (right_candidate, right_distance)| {
2815                left_distance
2816                    .partial_cmp(right_distance)
2817                    .unwrap_or(std::cmp::Ordering::Equal)
2818                    .then_with(|| {
2819                        theme_token_sort_key(left_candidate)
2820                            .cmp(&theme_token_sort_key(right_candidate))
2821                    })
2822            },
2823        )
2824}
2825
2826fn theme_token_sort_key(candidate: &ComparableThemeTokenCandidate) -> (&str, u32, &str) {
2827    (&candidate.path, candidate.line, &candidate.token)
2828}
2829
2830fn normalize_theme_token_value(value: &str) -> String {
2831    value.split_whitespace().collect::<Vec<_>>().join(" ")
2832}
2833
2834fn parse_theme_token_metric(namespace: &str, value: &str) -> Option<ThemeTokenMetric> {
2835    match namespace {
2836        "color" => fallow_extract::parse_css_color_rgb(value)
2837            .map(rgb_to_oklab)
2838            .map(ThemeTokenMetric::Color),
2839        "spacing" | "radius" | "text" => parse_length_px(value).map(ThemeTokenMetric::LengthPx),
2840        "duration" => parse_duration_ms(value).map(ThemeTokenMetric::DurationMs),
2841        "shadow" => parse_shadow_lengths_px(value).map(ThemeTokenMetric::ShadowPx),
2842        _ => None,
2843    }
2844}
2845
2846fn parse_length_px(value: &str) -> Option<f64> {
2847    let (number, unit) = parse_number_with_unit(value.trim())?;
2848    match unit {
2849        "" if number == 0.0 => Some(0.0),
2850        "px" => Some(number),
2851        "rem" | "em" => Some(number * 16.0),
2852        _ => None,
2853    }
2854}
2855
2856fn parse_duration_ms(value: &str) -> Option<f64> {
2857    let (number, unit) = parse_number_with_unit(value.trim())?;
2858    match unit {
2859        "ms" => Some(number),
2860        "s" => Some(number * 1000.0),
2861        _ => None,
2862    }
2863}
2864
2865fn parse_shadow_lengths_px(value: &str) -> Option<Vec<f64>> {
2866    if value.contains(',') {
2867        return None;
2868    }
2869    let mut lengths = Vec::new();
2870    for part in value.split_whitespace() {
2871        let Some(length) = parse_length_px(part) else {
2872            break;
2873        };
2874        lengths.push(length);
2875    }
2876    if (2..=4).contains(&lengths.len()) {
2877        Some(lengths)
2878    } else {
2879        None
2880    }
2881}
2882
2883fn parse_number_with_unit(value: &str) -> Option<(f64, &str)> {
2884    let split = value
2885        .char_indices()
2886        .find(|(idx, c)| *idx > 0 && !matches!(c, '0'..='9' | '.' | '+' | '-'))
2887        .map_or(value.len(), |(idx, _)| idx);
2888    let number = value[..split].parse::<f64>().ok()?;
2889    let unit = &value[split..];
2890    if number.is_finite() {
2891        Some((number, unit))
2892    } else {
2893        None
2894    }
2895}
2896
2897#[expect(
2898    clippy::suboptimal_flops,
2899    reason = "OKLab conversion mirrors the reference matrix; mul_add obscures the coefficients."
2900)]
2901fn rgb_to_oklab((red, green, blue): (f64, f64, f64)) -> OklabColor {
2902    let linear_red = srgb_to_linear(red / 255.0);
2903    let linear_green = srgb_to_linear(green / 255.0);
2904    let linear_blue = srgb_to_linear(blue / 255.0);
2905    let long_cone = 0.412_221_470_8 * linear_red
2906        + 0.536_332_536_3 * linear_green
2907        + 0.051_445_992_9 * linear_blue;
2908    let medium_cone = 0.211_903_498_2 * linear_red
2909        + 0.680_699_545_1 * linear_green
2910        + 0.107_396_956_6 * linear_blue;
2911    let short_cone = 0.088_302_461_9 * linear_red
2912        + 0.281_718_837_6 * linear_green
2913        + 0.629_978_700_5 * linear_blue;
2914    let long_cone = long_cone.cbrt();
2915    let medium_cone = medium_cone.cbrt();
2916    let short_cone = short_cone.cbrt();
2917    OklabColor {
2918        l: 0.210_454_255_3 * long_cone + 0.793_617_785_0 * medium_cone
2919            - 0.004_072_046_8 * short_cone,
2920        a: 1.977_998_495_1 * long_cone - 2.428_592_205_0 * medium_cone
2921            + 0.450_593_709_9 * short_cone,
2922        b: 0.025_904_037_1 * long_cone + 0.782_771_766_2 * medium_cone
2923            - 0.808_675_766_0 * short_cone,
2924    }
2925}
2926
2927fn srgb_to_linear(channel: f64) -> f64 {
2928    if channel <= 0.04045 {
2929        channel / 12.92
2930    } else {
2931        ((channel + 0.055) / 1.055).powf(2.4)
2932    }
2933}
2934
2935#[expect(
2936    clippy::suboptimal_flops,
2937    reason = "Distance formula is clearer in expanded Euclidean form."
2938)]
2939fn oklab_distance(left: OklabColor, right: OklabColor) -> f64 {
2940    let l = left.l - right.l;
2941    let a = left.a - right.a;
2942    let b = left.b - right.b;
2943    ((l * l + a * a + b * b).sqrt()) * 100.0
2944}
2945
2946fn round_distance(distance: f64) -> f64 {
2947    (distance * 100.0).round() / 100.0
2948}
2949
2950fn theme_token_names_are_deliberate_pair(namespace: &str, left: &str, right: &str) -> bool {
2951    if namespace == "color" && color_token_name_is_semantic_ui_role(left, right) {
2952        return true;
2953    }
2954    if let (Some((left_base, _)), Some((right_base, _))) =
2955        (split_numeric_suffix(left), split_numeric_suffix(right))
2956        && left_base == right_base
2957    {
2958        return true;
2959    }
2960    let state_suffixes = [
2961        "-hover",
2962        "-active",
2963        "-focus",
2964        "-disabled",
2965        "-pressed",
2966        "-selected",
2967    ];
2968    state_suffixes.iter().any(|suffix| {
2969        left.strip_suffix(suffix) == Some(right) || right.strip_suffix(suffix) == Some(left)
2970    })
2971}
2972
2973fn color_token_name_is_semantic_ui_role(left: &str, right: &str) -> bool {
2974    const ROLES: &[&str] = &[
2975        "accent",
2976        "accent-foreground",
2977        "background",
2978        "border",
2979        "card",
2980        "card-foreground",
2981        "destructive",
2982        "destructive-foreground",
2983        "foreground",
2984        "input",
2985        "muted",
2986        "muted-foreground",
2987        "popover",
2988        "popover-foreground",
2989        "primary",
2990        "primary-foreground",
2991        "ring",
2992        "secondary",
2993        "secondary-foreground",
2994    ];
2995    ROLES.contains(&left) || ROLES.contains(&right)
2996}
2997
2998fn split_numeric_suffix(name: &str) -> Option<(&str, &str)> {
2999    let split = name
3000        .char_indices()
3001        .rev()
3002        .find(|(_, c)| !c.is_ascii_digit())
3003        .map(|(idx, c)| idx + c.len_utf8())?;
3004    if split == name.len() {
3005        return None;
3006    }
3007    Some((&name[..split], &name[split..]))
3008}
3009
3010/// Input for the location-aware reverse index of Tailwind v4 `@theme` token
3011/// consumers. The index is descriptive only and sets no summary count.
3012struct TokenConsumersInput<'a> {
3013    tokens: &'a CssTokenSets,
3014    files: &'a [fallow_types::discover::DiscoveredFile],
3015    config: &'a ResolvedConfig,
3016    ignore_set: &'a globset::GlobSet,
3017    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3018    ws_roots: Option<&'a [std::path::PathBuf]>,
3019}
3020
3021fn collect_located_utility_consumers(
3022    input: &TokenConsumersInput<'_>,
3023) -> Vec<(String, String, u32)> {
3024    let mut located: Vec<(String, String, u32)> = Vec::new();
3025    for file in input.files {
3026        let path = &file.path;
3027        let extension = path.extension().and_then(|ext| ext.to_str());
3028        if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
3029            continue;
3030        }
3031        let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
3032        if input.ignore_set.is_match(relative) {
3033            continue;
3034        }
3035        let rel = relative.to_string_lossy().replace('\\', "/");
3036        if let Ok(source) = std::fs::read_to_string(path) {
3037            collect_class_shaped_tokens_located(&source, &rel, &mut located);
3038        }
3039    }
3040    located
3041}
3042
3043fn build_token_consumers(input: &TokenConsumersInput<'_>) -> Vec<fallow_output::TokenConsumers> {
3044    use fallow_output::{
3045        ConsumerKind, TOKEN_CONSUMER_SAMPLE_CAP, TokenConsumerLocation, TokenConsumers,
3046    };
3047
3048    if input.changed_files.is_some() || input.ws_roots.is_some() {
3049        return Vec::new();
3050    }
3051    if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
3052        return Vec::new();
3053    }
3054    if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
3055        return Vec::new();
3056    }
3057
3058    let mut summary = fallow_output::CssAnalyticsSummary::default();
3059    let candidates = classify_theme_token_candidates(&UnusedThemeTokenScanInput {
3060        tokens: input.tokens,
3061        files: input.files,
3062        config: input.config,
3063        ignore_set: input.ignore_set,
3064        changed_files: input.changed_files,
3065        output_changed_files: None,
3066        ws_roots: input.ws_roots,
3067        summary: &mut summary,
3068    });
3069    if candidates.is_empty() {
3070        return Vec::new();
3071    }
3072
3073    let utility_located = collect_located_utility_consumers(input);
3074
3075    let mut out: Vec<TokenConsumers> = candidates
3076        .into_iter()
3077        .map(|candidate| {
3078            let dash_name = format!("-{}", candidate.name);
3079            let raw = candidate.token.trim_start_matches('-').to_owned();
3080            let mut consumers: Vec<TokenConsumerLocation> = Vec::new();
3081
3082            for (name, path, line) in &input.tokens.theme_var_reads_located {
3083                if *name == raw {
3084                    consumers.push(TokenConsumerLocation {
3085                        path: path.clone(),
3086                        line: *line,
3087                        kind: ConsumerKind::ThemeVar,
3088                    });
3089                }
3090            }
3091            for (name, path, line) in &input.tokens.css_var_reads_located {
3092                if *name == raw {
3093                    consumers.push(TokenConsumerLocation {
3094                        path: path.clone(),
3095                        line: *line,
3096                        kind: ConsumerKind::CssVar,
3097                    });
3098                }
3099            }
3100            for (token, path, line) in &input.tokens.apply_uses_located {
3101                if token.len() > dash_name.len() && token.ends_with(&dash_name) {
3102                    consumers.push(TokenConsumerLocation {
3103                        path: path.clone(),
3104                        line: *line,
3105                        kind: ConsumerKind::Apply,
3106                    });
3107                }
3108            }
3109            for (token, path, line) in &utility_located {
3110                if token.len() > dash_name.len() && token.ends_with(&dash_name) {
3111                    consumers.push(TokenConsumerLocation {
3112                        path: path.clone(),
3113                        line: *line,
3114                        kind: ConsumerKind::Utility,
3115                    });
3116                }
3117            }
3118
3119            consumers.sort_by(|a, b| {
3120                a.path
3121                    .cmp(&b.path)
3122                    .then_with(|| a.line.cmp(&b.line))
3123                    .then_with(|| consumer_kind_rank(a.kind).cmp(&consumer_kind_rank(b.kind)))
3124            });
3125            let consumer_count = saturate_len(consumers.len());
3126            consumers.truncate(TOKEN_CONSUMER_SAMPLE_CAP);
3127
3128            TokenConsumers {
3129                token: candidate.token,
3130                namespace: candidate.namespace,
3131                definition_path: candidate.path,
3132                definition_line: candidate.line,
3133                consumer_count,
3134                consumers,
3135            }
3136        })
3137        .collect();
3138
3139    out.sort_by(|a, b| a.token.cmp(&b.token));
3140    out
3141}
3142
3143/// A CSS-in-JS token-definition site discovered during the definer pass: the
3144/// root-relative definition file, the access binding consumers read through, and
3145/// its flattened leaf tokens.
3146struct CssInJsDefiner {
3147    rel_path: String,
3148    binding: String,
3149    origin: fallow_extract::CssInJsTokenOrigin,
3150    leaves: Vec<fallow_extract::CssInJsToken>,
3151}
3152
3153/// The definer-pass result: every `(file, binding)` token-definition site plus the
3154/// lookups the consumer pass keys on (normalized definer path + binding -> entry
3155/// index, and the set of normalized definer paths for relative-import resolution).
3156struct CssInJsDefiners {
3157    entries: Vec<CssInJsDefiner>,
3158    index: rustc_hash::FxHashMap<(std::path::PathBuf, String), usize>,
3159    paths: rustc_hash::FxHashSet<std::path::PathBuf>,
3160}
3161
3162type CssInJsConsumerKey = (usize, String);
3163type CssInJsConsumerHit = (String, u32, fallow_output::ConsumerKind);
3164type CssInJsConsumerHits =
3165    rustc_hash::FxHashMap<CssInJsConsumerKey, rustc_hash::FxHashSet<CssInJsConsumerHit>>;
3166type CssInJsImportKey = (fallow_types::discover::FileId, String, String, String);
3167type ResolvedCssInJsImportTargets =
3168    rustc_hash::FxHashMap<CssInJsImportKey, fallow_types::discover::FileId>;
3169
3170/// Whether a specifier names a CSS-in-JS token-DEFINITION library. `@vanilla-extract/recipes`
3171/// is excluded: it exports no token-definition function (`createTheme` family lives
3172/// in `@vanilla-extract/css`), so it is not a definer-pass pre-filter source.
3173fn is_css_in_js_token_lib(specifier: &str) -> bool {
3174    matches!(
3175        specifier,
3176        "@stylexjs/stylex" | "@vanilla-extract/css" | "@pandacss/dev"
3177    )
3178}
3179
3180/// A cheap source pre-filter: only re-parse a token-lib-importing file as a
3181/// potential definer if its source mentions a token-definition function, so a
3182/// StyleX file that only calls `stylex.create` (no `defineVars`) is not parsed.
3183fn source_mentions_token_definer(source: &str) -> bool {
3184    source.contains("defineVars")
3185        || source.contains("createThemeContract")
3186        || source.contains("createGlobalTheme")
3187        || source.contains("createTheme")
3188        || source.contains("defineTokens")
3189        || source.contains("defineConfig")
3190}
3191
3192fn source_mentions_theme_definer(source: &str) -> bool {
3193    source.contains("theme") || source.contains("Theme")
3194}
3195
3196fn is_theme_provider_source(specifier: &str) -> bool {
3197    matches!(specifier, "styled-components" | "@emotion/react")
3198}
3199
3200fn project_imports_theme_provider(modules: &[fallow_types::extract::ModuleInfo]) -> bool {
3201    use fallow_types::extract::ImportedName;
3202
3203    modules.iter().any(|module| {
3204        module.imports.iter().any(|import| {
3205            !import.is_type_only
3206                && is_theme_provider_source(&import.source)
3207                && matches!(&import.imported_name, ImportedName::Named(name) if name == "ThemeProvider")
3208        })
3209    })
3210}
3211
3212/// Whether an import specifier is a relative path. The shared graph resolver
3213/// handles tsconfig aliases and workspace packages first; this light resolver is
3214/// the zero-FP local fallback for cases where a graph edge was not available.
3215fn is_relative_specifier(specifier: &str) -> bool {
3216    specifier.starts_with('.')
3217}
3218
3219fn is_panda_generated_specifier(specifier: &str) -> bool {
3220    specifier
3221        .split(['/', '\\'])
3222        .any(|segment| segment == "styled-system")
3223}
3224
3225fn is_panda_style_function(name: &str) -> bool {
3226    matches!(name, "css" | "cva" | "sva" | "recipe" | "styled")
3227}
3228
3229/// Lexically normalize a path (resolve `.` / `..` without touching the
3230/// filesystem), so a consumer-relative join compares equal to a definer's
3231/// discovered absolute path regardless of `./` / `../` segments.
3232fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
3233    let mut out = std::path::PathBuf::new();
3234    for comp in path.components() {
3235        match comp {
3236            std::path::Component::CurDir => {}
3237            std::path::Component::ParentDir => {
3238                out.pop();
3239            }
3240            other => out.push(other.as_os_str()),
3241        }
3242    }
3243    out
3244}
3245
3246/// Resolve a relative import specifier from a consuming file to a known definer
3247/// path (extension + `/index` candidates, lexically normalized). Returns the
3248/// matched, normalized definer path or `None`. Zero-FP for relative imports: a
3249/// specifier that resolves to a non-definer path yields `None`, so an unrelated
3250/// `import { vars } from './other'` is never matched against a design-token `vars`.
3251fn resolve_relative_specifier(
3252    consumer_abs: &std::path::Path,
3253    specifier: &str,
3254    definer_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
3255) -> Option<std::path::PathBuf> {
3256    const EXTS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"];
3257    let base = lexical_normalize(&consumer_abs.parent()?.join(specifier));
3258    // 1. Exact (specifier already carried a resolvable filename).
3259    if definer_paths.contains(&base) {
3260        return Some(base);
3261    }
3262    // 2. `<base>.<ext>` (`./tokens` -> `./tokens.ts`; `./theme.css` -> `./theme.css.ts`).
3263    for ext in EXTS {
3264        let mut candidate = base.clone().into_os_string();
3265        candidate.push(".");
3266        candidate.push(ext);
3267        let candidate = std::path::PathBuf::from(candidate);
3268        if definer_paths.contains(&candidate) {
3269            return Some(candidate);
3270        }
3271    }
3272    // 3. `<base>/index.<ext>`.
3273    for ext in EXTS {
3274        let candidate = base.join(format!("index.{ext}"));
3275        if definer_paths.contains(&candidate) {
3276            return Some(candidate);
3277        }
3278    }
3279    None
3280}
3281
3282fn css_in_js_import_key(
3283    file_id: fallow_types::discover::FileId,
3284    import: &fallow_types::extract::ImportInfo,
3285) -> Option<CssInJsImportKey> {
3286    let fallow_types::extract::ImportedName::Named(imported_name) = &import.imported_name else {
3287        return None;
3288    };
3289    Some((
3290        file_id,
3291        import.source.clone(),
3292        imported_name.clone(),
3293        import.local_name.clone(),
3294    ))
3295}
3296
3297fn resolve_css_in_js_import_targets(
3298    files: &[fallow_types::discover::DiscoveredFile],
3299    modules: &[fallow_types::extract::ModuleInfo],
3300    config: &ResolvedConfig,
3301) -> ResolvedCssInJsImportTargets {
3302    let workspaces = fallow_config::discover_workspaces(&config.root);
3303    let active_plugins: Vec<String> = Vec::new();
3304    let path_aliases: Vec<(String, String)> = Vec::new();
3305    let auto_imports: Vec<fallow_config::AutoImportRule> = Vec::new();
3306    let scss_include_paths: Vec<std::path::PathBuf> = Vec::new();
3307    let static_dir_mappings: Vec<(std::path::PathBuf, String)> = Vec::new();
3308    let input = fallow_graph::resolve::ResolveAllImportsInput {
3309        modules,
3310        files,
3311        workspaces: &workspaces,
3312        active_plugins: &active_plugins,
3313        path_aliases: &path_aliases,
3314        auto_imports: &auto_imports,
3315        scss_include_paths: &scss_include_paths,
3316        static_dir_mappings: &static_dir_mappings,
3317        root: &config.root,
3318        extra_conditions: &config.resolve.conditions,
3319    };
3320    let mut targets = ResolvedCssInJsImportTargets::default();
3321    for resolved in fallow_graph::resolve::resolve_all_imports(&input) {
3322        for import in resolved.resolved_imports {
3323            let Some(file_id) = import.target.internal_file_id() else {
3324                continue;
3325            };
3326            let Some(key) = css_in_js_import_key(resolved.file_id, &import.info) else {
3327                continue;
3328            };
3329            targets.insert(key, file_id);
3330        }
3331    }
3332    targets
3333}
3334
3335fn resolve_css_in_js_definer_import(
3336    consumer_file_id: fallow_types::discover::FileId,
3337    consumer_abs: &std::path::Path,
3338    import: &fallow_types::extract::ImportInfo,
3339    definers: &CssInJsDefiners,
3340    path_by_id: &rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path>,
3341    resolved_targets: &ResolvedCssInJsImportTargets,
3342) -> Option<usize> {
3343    let fallow_types::extract::ImportedName::Named(imported_name) = &import.imported_name else {
3344        return None;
3345    };
3346    if let Some(key) = css_in_js_import_key(consumer_file_id, import)
3347        && let Some(target_id) = resolved_targets.get(&key)
3348        && let Some(target_abs) = path_by_id.get(target_id)
3349    {
3350        let resolved = lexical_normalize(target_abs);
3351        if let Some(&idx) = definers.index.get(&(resolved, imported_name.clone())) {
3352            return Some(idx);
3353        }
3354    }
3355    if !is_relative_specifier(&import.source) {
3356        return None;
3357    }
3358    let resolved = resolve_relative_specifier(consumer_abs, &import.source, &definers.paths)?;
3359    definers
3360        .index
3361        .get(&(resolved, imported_name.clone()))
3362        .copied()
3363}
3364
3365/// Definer pass: re-parse every token-lib-importing file that mentions a
3366/// token-definition function, collecting each `(file, binding)` token-definition
3367/// site plus the lookup structures the consumer pass needs.
3368fn collect_css_in_js_definers(
3369    modules: &[fallow_types::extract::ModuleInfo],
3370    path_by_id: &rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path>,
3371    config: &ResolvedConfig,
3372) -> CssInJsDefiners {
3373    let mut definers: Vec<CssInJsDefiner> = Vec::new();
3374    let mut definer_index: rustc_hash::FxHashMap<(std::path::PathBuf, String), usize> =
3375        rustc_hash::FxHashMap::default();
3376    let mut definer_paths: rustc_hash::FxHashSet<std::path::PathBuf> =
3377        rustc_hash::FxHashSet::default();
3378    let has_theme_provider = project_imports_theme_provider(modules);
3379
3380    for module in modules {
3381        let imports_token_lib = module
3382            .imports
3383            .iter()
3384            .any(|i| !i.is_type_only && is_css_in_js_token_lib(&i.source));
3385        let Some(abs) = path_by_id.get(&module.file_id).copied() else {
3386            continue;
3387        };
3388        let Ok(source) = std::fs::read_to_string(abs) else {
3389            continue;
3390        };
3391        let mut defs = Vec::new();
3392        if imports_token_lib && source_mentions_token_definer(&source) {
3393            defs.extend(fallow_extract::css_in_js_token_defs(&source, abs));
3394        }
3395        if has_theme_provider && source_mentions_theme_definer(&source) {
3396            defs.extend(fallow_extract::css_in_js_theme_token_defs(&source, abs));
3397        }
3398        if defs.is_empty() {
3399            continue;
3400        }
3401        let Some(rel) = relative_to_root(abs, &config.root) else {
3402            continue;
3403        };
3404        let norm = lexical_normalize(abs);
3405        for def in defs {
3406            let idx = definers.len();
3407            definer_index.insert((norm.clone(), def.binding.clone()), idx);
3408            definer_paths.insert(norm.clone());
3409            definers.push(CssInJsDefiner {
3410                rel_path: rel.clone(),
3411                binding: def.binding,
3412                origin: def.origin,
3413                leaves: def.tokens,
3414            });
3415        }
3416    }
3417    CssInJsDefiners {
3418        entries: definers,
3419        index: definer_index,
3420        paths: definer_paths,
3421    }
3422}
3423
3424/// Consumer pass: for each file whose named imports resolve to a definer binding
3425/// through the shared graph resolver or local relative fallback, re-parse it and
3426/// collect located member-access reads, deduped by `(consumer file, line)` per
3427/// `(definer, leaf token path)`.
3428fn collect_css_in_js_consumers(
3429    modules: &[fallow_types::extract::ModuleInfo],
3430    path_by_id: &rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path>,
3431    config: &ResolvedConfig,
3432    definers: &CssInJsDefiners,
3433    resolved_targets: &ResolvedCssInJsImportTargets,
3434) -> CssInJsConsumerHits {
3435    use fallow_output::ConsumerKind;
3436    use fallow_types::extract::ImportedName;
3437    let mut hits: CssInJsConsumerHits = rustc_hash::FxHashMap::default();
3438    let has_theme_definers = definers
3439        .entries
3440        .iter()
3441        .any(|definer| definer.origin == fallow_extract::CssInJsTokenOrigin::Theme);
3442
3443    for module in modules {
3444        let Some(consumer_abs) = path_by_id.get(&module.file_id).copied() else {
3445            continue;
3446        };
3447        // (definer index, local alias the file imported the binding under).
3448        let mut matches: Vec<(usize, &str)> = Vec::new();
3449        for import in &module.imports {
3450            if import.is_type_only {
3451                continue;
3452            }
3453            if !matches!(&import.imported_name, ImportedName::Named(_)) {
3454                continue;
3455            }
3456            if let Some(idx) = resolve_css_in_js_definer_import(
3457                module.file_id,
3458                consumer_abs,
3459                import,
3460                definers,
3461                path_by_id,
3462                resolved_targets,
3463            ) {
3464                matches.push((idx, import.local_name.as_str()));
3465            }
3466        }
3467        let has_panda_generated_alias = module.imports.iter().any(|import| {
3468            !import.is_type_only
3469                && is_panda_generated_specifier(&import.source)
3470                && matches!(&import.imported_name, ImportedName::Named(name) if name == "token" || is_panda_style_function(name))
3471        });
3472        if matches.is_empty() && !has_panda_generated_alias && !has_theme_definers {
3473            continue;
3474        }
3475        let Ok(source) = std::fs::read_to_string(consumer_abs) else {
3476            continue;
3477        };
3478        let Some(consumer_rel) = relative_to_root(consumer_abs, &config.root) else {
3479            continue;
3480        };
3481        for (idx, alias) in matches {
3482            let leaf_set: rustc_hash::FxHashSet<String> = definers.entries[idx]
3483                .leaves
3484                .iter()
3485                .map(|t| t.path.clone())
3486                .collect();
3487            for hit in
3488                fallow_extract::css_in_js_token_consumers(&source, consumer_abs, alias, &leaf_set)
3489            {
3490                hits.entry((idx, hit.token_path)).or_default().insert((
3491                    consumer_rel.clone(),
3492                    hit.line,
3493                    ConsumerKind::JsMember,
3494                ));
3495            }
3496        }
3497        collect_panda_token_call_consumers(
3498            module,
3499            consumer_abs,
3500            &source,
3501            &consumer_rel,
3502            definers,
3503            &mut hits,
3504        );
3505        collect_theme_member_consumers(&source, consumer_abs, &consumer_rel, definers, &mut hits);
3506    }
3507    hits
3508}
3509
3510fn collect_theme_member_consumers(
3511    source: &str,
3512    consumer_abs: &std::path::Path,
3513    consumer_rel: &str,
3514    definers: &CssInJsDefiners,
3515    hits: &mut CssInJsConsumerHits,
3516) {
3517    use fallow_output::ConsumerKind;
3518
3519    for (idx, definer) in definers.entries.iter().enumerate() {
3520        if definer.origin != fallow_extract::CssInJsTokenOrigin::Theme {
3521            continue;
3522        }
3523        let leaf_set: rustc_hash::FxHashSet<String> =
3524            definer.leaves.iter().map(|t| t.path.clone()).collect();
3525        for hit in fallow_extract::css_in_js_theme_consumers(source, consumer_abs, &leaf_set) {
3526            hits.entry((idx, hit.token_path)).or_default().insert((
3527                consumer_rel.to_owned(),
3528                hit.line,
3529                ConsumerKind::JsMember,
3530            ));
3531        }
3532    }
3533}
3534
3535fn collect_panda_token_call_consumers(
3536    module: &fallow_types::extract::ModuleInfo,
3537    consumer_abs: &std::path::Path,
3538    source: &str,
3539    consumer_rel: &str,
3540    definers: &CssInJsDefiners,
3541    hits: &mut CssInJsConsumerHits,
3542) {
3543    use fallow_output::ConsumerKind;
3544    use fallow_types::extract::ImportedName;
3545
3546    let token_aliases: Vec<&str> = module
3547        .imports
3548        .iter()
3549        .filter(|import| {
3550            !import.is_type_only
3551                && is_panda_generated_specifier(&import.source)
3552                && matches!(&import.imported_name, ImportedName::Named(name) if name == "token")
3553        })
3554        .map(|import| import.local_name.as_str())
3555        .collect();
3556    let style_aliases: rustc_hash::FxHashSet<String> = module
3557        .imports
3558        .iter()
3559        .filter(|import| {
3560            !import.is_type_only
3561                && is_panda_generated_specifier(&import.source)
3562                && matches!(&import.imported_name, ImportedName::Named(name) if is_panda_style_function(name))
3563        })
3564        .map(|import| import.local_name.clone())
3565        .collect();
3566    if token_aliases.is_empty() && style_aliases.is_empty() {
3567        return;
3568    }
3569    for (idx, definer) in definers.entries.iter().enumerate() {
3570        if definer.origin != fallow_extract::CssInJsTokenOrigin::Panda {
3571            continue;
3572        }
3573        let leaf_set: rustc_hash::FxHashSet<String> =
3574            definer.leaves.iter().map(|t| t.path.clone()).collect();
3575        for alias in &token_aliases {
3576            for hit in
3577                fallow_extract::panda_token_call_consumers(source, consumer_abs, alias, &leaf_set)
3578            {
3579                hits.entry((idx, hit.token_path)).or_default().insert((
3580                    consumer_rel.to_owned(),
3581                    hit.line,
3582                    ConsumerKind::JsCall,
3583                ));
3584            }
3585        }
3586        for hit in fallow_extract::panda_style_value_consumers(
3587            source,
3588            consumer_abs,
3589            &style_aliases,
3590            &leaf_set,
3591        ) {
3592            hits.entry((idx, hit.token_path)).or_default().insert((
3593                consumer_rel.to_owned(),
3594                hit.line,
3595                ConsumerKind::JsCall,
3596            ));
3597        }
3598    }
3599}
3600
3601/// Build the CSS-in-JS design-token blast-radius: StyleX `defineVars`,
3602/// vanilla-extract `createTheme`-family, PandaCSS `defineTokens`, and
3603/// styled-components / Emotion theme objects. Uses resolved import edges for
3604/// relative imports, tsconfig aliases, and workspace packages, then falls back to
3605/// the light relative resolver for zero-FP local cases.
3606fn build_css_in_js_token_consumers(
3607    files: &[fallow_types::discover::DiscoveredFile],
3608    modules: &[fallow_types::extract::ModuleInfo],
3609    config: &ResolvedConfig,
3610) -> Vec<fallow_output::TokenConsumers> {
3611    use fallow_output::{TOKEN_CONSUMER_SAMPLE_CAP, TokenConsumerLocation, TokenConsumers};
3612
3613    if !project_uses_css_in_js(&config.root) {
3614        return Vec::new();
3615    }
3616    let path_by_id: rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path> =
3617        files.iter().map(|f| (f.id, f.path.as_path())).collect();
3618
3619    let definers = collect_css_in_js_definers(modules, &path_by_id, config);
3620    if definers.entries.is_empty() {
3621        return Vec::new();
3622    }
3623    let resolved_targets = resolve_css_in_js_import_targets(files, modules, config);
3624    let hits =
3625        collect_css_in_js_consumers(modules, &path_by_id, config, &definers, &resolved_targets);
3626
3627    let mut out: Vec<TokenConsumers> = Vec::new();
3628    for (idx, definer) in definers.entries.iter().enumerate() {
3629        for leaf in &definer.leaves {
3630            let mut consumers: Vec<TokenConsumerLocation> = hits
3631                .get(&(idx, leaf.path.clone()))
3632                .map(|set| {
3633                    set.iter()
3634                        .map(|(path, line, kind)| TokenConsumerLocation {
3635                            path: path.clone(),
3636                            line: *line,
3637                            kind: *kind,
3638                        })
3639                        .collect()
3640                })
3641                .unwrap_or_default();
3642            consumers.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
3643            let consumer_count = saturate_len(consumers.len());
3644            consumers.truncate(TOKEN_CONSUMER_SAMPLE_CAP);
3645            out.push(TokenConsumers {
3646                token: format!("{}.{}", definer.binding, leaf.path),
3647                namespace: definer.binding.clone(),
3648                definition_path: definer.rel_path.clone(),
3649                definition_line: leaf.def_line,
3650                consumer_count,
3651                consumers,
3652            });
3653        }
3654    }
3655    // Deterministic order among the CSS-in-JS entries. The caller
3656    // (`compute_css_analytics_report`) applies a final sort over the COMBINED
3657    // Tailwind + CSS-in-JS list, so the emitted `token_consumers` is globally
3658    // ordered by `(token, definition_path)`.
3659    out.sort_by(|a, b| {
3660        a.token
3661            .cmp(&b.token)
3662            .then_with(|| a.definition_path.cmp(&b.definition_path))
3663    });
3664    out
3665}
3666
3667fn consumer_kind_rank(kind: fallow_output::ConsumerKind) -> u8 {
3668    use fallow_output::ConsumerKind;
3669    match kind {
3670        ConsumerKind::ThemeVar => 0,
3671        ConsumerKind::CssVar => 1,
3672        ConsumerKind::Utility => 2,
3673        ConsumerKind::Apply => 3,
3674        ConsumerKind::JsMember => 4,
3675        ConsumerKind::JsCall => 5,
3676    }
3677}
3678
3679/// The markup / source-derived CSS candidate lists, gathered in one pass-set so
3680/// the orchestrator stays a thin assembler.
3681struct MarkupCssCandidates {
3682    tailwind_arbitrary_values: Vec<fallow_output::TailwindArbitraryValue>,
3683    cva_duplicate_variant_blocks: Vec<fallow_output::CvaDuplicateVariantBlock>,
3684    cva_variant_token_drifts: Vec<fallow_output::CvaVariantTokenDrift>,
3685    unresolved_class_references: Vec<fallow_output::UnresolvedClassReference>,
3686    unreferenced_css_classes: Vec<fallow_output::UnreferencedCssClass>,
3687    unused_theme_tokens: Vec<fallow_output::UnusedThemeToken>,
3688    near_duplicate_theme_tokens: Vec<fallow_output::NearDuplicateThemeToken>,
3689}
3690
3691/// Run the markup / source-scanning CSS candidates (Tailwind arbitrary values,
3692/// likely class typos, unreferenced global classes, unused `@theme` tokens),
3693/// each honoring the same ignore / changed / workspace filters and setting its
3694/// own summary counts.
3695struct MarkupCssCandidateInput<'a> {
3696    tokens: &'a CssTokenSets,
3697    files: &'a [fallow_types::discover::DiscoveredFile],
3698    config: &'a ResolvedConfig,
3699    ignore_set: &'a globset::GlobSet,
3700    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3701    output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3702    css_deep: bool,
3703    ws_roots: Option<&'a [std::path::PathBuf]>,
3704    styling_artifacts: Option<&'a StylingAnalysisArtifacts>,
3705    token_candidates: &'a [ComparableThemeTokenCandidate],
3706    summary: &'a mut fallow_output::CssAnalyticsSummary,
3707}
3708
3709fn scan_markup_css_candidates(input: &mut MarkupCssCandidateInput<'_>) -> MarkupCssCandidates {
3710    MarkupCssCandidates {
3711        // Markup arbitrary-value scan (gated on the project using Tailwind).
3712        tailwind_arbitrary_values: scan_markup_tailwind_arbitrary_values(
3713            input.files,
3714            HealthScanCtx {
3715                config: input.config,
3716                ignore_set: input.ignore_set,
3717                changed_files: input.changed_files,
3718                output_changed_files: None,
3719                ws_roots: input.ws_roots,
3720            },
3721            input.summary,
3722        ),
3723        cva_duplicate_variant_blocks: scan_cva_duplicate_variant_blocks(
3724            input.files,
3725            HealthScanCtx {
3726                config: input.config,
3727                ignore_set: input.ignore_set,
3728                changed_files: input.changed_files,
3729                output_changed_files: None,
3730                ws_roots: input.ws_roots,
3731            },
3732        ),
3733        cva_variant_token_drifts: scan_cva_variant_token_drifts(
3734            input.files,
3735            HealthScanCtx {
3736                config: input.config,
3737                ignore_set: input.ignore_set,
3738                changed_files: input.changed_files,
3739                output_changed_files: None,
3740                ws_roots: input.ws_roots,
3741            },
3742            input.token_candidates,
3743        ),
3744        // Static markup class tokens one edit from a defined class (likely typos).
3745        unresolved_class_references: scan_unresolved_class_references(
3746            input.files,
3747            HealthScanCtx {
3748                config: input.config,
3749                ignore_set: input.ignore_set,
3750                changed_files: input.changed_files,
3751                output_changed_files: None,
3752                ws_roots: input.ws_roots,
3753            },
3754            input.summary,
3755        ),
3756        // Global classes referenced by no in-project markup (heavily gated).
3757        unreferenced_css_classes: scan_unreferenced_css_classes(
3758            input.files,
3759            HealthScanCtx {
3760                config: input.config,
3761                ignore_set: input.ignore_set,
3762                changed_files: input.changed_files,
3763                output_changed_files: None,
3764                ws_roots: input.ws_roots,
3765            },
3766            input.summary,
3767            input
3768                .styling_artifacts
3769                .map(|artifacts| &artifacts.reference_surface),
3770            input
3771                .styling_artifacts
3772                .map(|artifacts| &artifacts.class_inventory),
3773        ),
3774        // Tailwind v4 @theme design tokens used by no utility / var() / @apply
3775        // anywhere (heavily gated: v4 + non-plugin + non-published + whole-scope).
3776        unused_theme_tokens: scan_unused_theme_tokens(&mut UnusedThemeTokenScanInput {
3777            tokens: input.tokens,
3778            files: input.files,
3779            config: input.config,
3780            ignore_set: input.ignore_set,
3781            changed_files: input.changed_files,
3782            output_changed_files: input.output_changed_files,
3783            ws_roots: input.ws_roots,
3784            summary: input.summary,
3785        }),
3786        // Perceptually-close Tailwind v4 color tokens, whole-scope only.
3787        near_duplicate_theme_tokens: if input.css_deep {
3788            scan_near_duplicate_theme_tokens(&mut UnusedThemeTokenScanInput {
3789                tokens: input.tokens,
3790                files: input.files,
3791                config: input.config,
3792                ignore_set: input.ignore_set,
3793                changed_files: input.changed_files,
3794                output_changed_files: input.output_changed_files,
3795                ws_roots: input.ws_roots,
3796                summary: input.summary,
3797            })
3798        } else {
3799            Vec::new()
3800        },
3801    }
3802}
3803
3804fn project_uses_css_in_js(root: &std::path::Path) -> bool {
3805    const CSS_IN_JS_DEPS: &[&str] = &[
3806        "styled-components",
3807        "@emotion/styled",
3808        "@emotion/react",
3809        "@emotion/css",
3810        "@linaria/core",
3811        "@linaria/react",
3812        "@vanilla-extract/css",
3813        "@pandacss/dev",
3814        "@stylexjs/stylex",
3815    ];
3816    let Ok(text) = std::fs::read_to_string(root.join("package.json")) else {
3817        return false;
3818    };
3819    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
3820        return false;
3821    };
3822    ["dependencies", "devDependencies", "peerDependencies"]
3823        .iter()
3824        .any(|key| {
3825            json.get(key)
3826                .and_then(serde_json::Value::as_object)
3827                .is_some_and(|deps| deps.keys().any(|k| CSS_IN_JS_DEPS.contains(&k.as_str())))
3828        })
3829}
3830
3831#[derive(Clone, Copy, PartialEq, Eq)]
3832enum CssScanKind {
3833    Css,
3834    Preprocessor,
3835    Sfc,
3836    CssInJs,
3837}
3838
3839fn css_report_scan_target<'a>(
3840    file: &'a fallow_types::discover::DiscoveredFile,
3841    ctx: HealthScanCtx<'_>,
3842    css_in_js: bool,
3843) -> Option<(&'a std::path::Path, CssScanKind)> {
3844    let HealthScanCtx {
3845        config,
3846        ignore_set,
3847        changed_files,
3848        output_changed_files: _,
3849        ws_roots,
3850    } = ctx;
3851
3852    let path = &file.path;
3853    let extension = path.extension().and_then(|ext| ext.to_str());
3854    let kind = match extension {
3855        Some("css") => CssScanKind::Css,
3856        Some("scss" | "sass" | "less") => CssScanKind::Preprocessor,
3857        Some("vue") | Some("svelte") => CssScanKind::Sfc,
3858        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts") if css_in_js => {
3859            CssScanKind::CssInJs
3860        }
3861        _ => return None,
3862    };
3863
3864    let relative = path.strip_prefix(&config.root).unwrap_or(path);
3865    if ignore_set.is_match(relative) {
3866        return None;
3867    }
3868    if let Some(changed) = changed_files
3869        && !changed.contains(path)
3870    {
3871        return None;
3872    }
3873    if let Some(roots) = ws_roots
3874        && !roots.iter().any(|root| path.starts_with(root))
3875    {
3876        return None;
3877    }
3878    Some((relative, kind))
3879}
3880
3881fn record_scoped_unused_classes(
3882    source: &str,
3883    relative: &std::path::Path,
3884    summary: &mut fallow_output::CssAnalyticsSummary,
3885    scoped_unused: &mut Vec<fallow_output::ScopedUnusedClasses>,
3886) {
3887    let classes = crate::css::scoped_unused_classes(source);
3888    if classes.is_empty() {
3889        return;
3890    }
3891
3892    summary.scoped_unused_classes = summary
3893        .scoped_unused_classes
3894        .saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX));
3895    scoped_unused.push(fallow_output::ScopedUnusedClasses {
3896        path: relative.to_string_lossy().replace('\\', "/"),
3897        classes,
3898        actions: vec![fallow_output::CssCandidateAction::verify_scoped_classes()],
3899    });
3900}
3901
3902#[derive(Clone, Copy, PartialEq, Eq)]
3903enum GradePolicy {
3904    Structural,
3905    StructuralNoDedup,
3906    Atomic,
3907}
3908
3909struct CssScanItem<'a> {
3910    source: std::borrow::Cow<'a, str>,
3911    policy: GradePolicy,
3912    report_notable: bool,
3913}
3914
3915fn css_report_scan_items<'a>(
3916    source: &'a str,
3917    path: &std::path::Path,
3918    kind: CssScanKind,
3919) -> Vec<CssScanItem<'a>> {
3920    use std::borrow::Cow;
3921    match kind {
3922        CssScanKind::Css => vec![CssScanItem {
3923            source: Cow::Borrowed(source),
3924            policy: GradePolicy::Structural,
3925            report_notable: true,
3926        }],
3927        CssScanKind::Preprocessor => preprocessor_virtual_stylesheet(source)
3928            .map(|virtual_css| {
3929                vec![CssScanItem {
3930                    source: Cow::Owned(virtual_css),
3931                    policy: GradePolicy::Structural,
3932                    report_notable: true,
3933                }]
3934            })
3935            .unwrap_or_default(),
3936        CssScanKind::Sfc => {
3937            let mut items = Vec::new();
3938            if let Some(virtual_css) = crate::css::sfc_virtual_stylesheet(source) {
3939                items.push(CssScanItem {
3940                    source: Cow::Owned(virtual_css),
3941                    policy: GradePolicy::Structural,
3942                    report_notable: true,
3943                });
3944            }
3945            if let Some(preprocessor_source) =
3946                crate::css::sfc_preprocessor_virtual_stylesheet(source)
3947                && let Some(virtual_css) = preprocessor_virtual_stylesheet(&preprocessor_source)
3948            {
3949                items.push(CssScanItem {
3950                    source: Cow::Owned(virtual_css),
3951                    policy: GradePolicy::Structural,
3952                    report_notable: true,
3953                });
3954            }
3955            items
3956        }
3957        CssScanKind::CssInJs => {
3958            let mut items = Vec::new();
3959            if let Some(virtual_css) = crate::css::css_in_js_virtual_stylesheet(source) {
3960                items.push(CssScanItem {
3961                    source: Cow::Owned(virtual_css),
3962                    policy: GradePolicy::Structural,
3963                    report_notable: true,
3964                });
3965            }
3966            let sheets = crate::css::css_in_js_object_sheets(source, path);
3967            if let Some(structural) = sheets.structural {
3968                items.push(CssScanItem {
3969                    source: Cow::Owned(structural),
3970                    policy: GradePolicy::Structural,
3971                    report_notable: false,
3972                });
3973            }
3974            if let Some(partial) = sheets.structural_partial {
3975                items.push(CssScanItem {
3976                    source: Cow::Owned(partial),
3977                    policy: GradePolicy::StructuralNoDedup,
3978                    report_notable: false,
3979                });
3980            }
3981            if let Some(atomic) = sheets.atomic {
3982                items.push(CssScanItem {
3983                    source: Cow::Owned(atomic),
3984                    policy: GradePolicy::Atomic,
3985                    report_notable: false,
3986                });
3987            }
3988            items
3989        }
3990    }
3991}
3992
3993fn preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
3994    let clean = strip_preprocessor_comments(source);
3995    let output = render_preprocessor_children(&clean, 0, clean.len(), 0);
3996    (!output.trim().is_empty()).then_some(output)
3997}
3998
3999fn strip_preprocessor_comments(source: &str) -> String {
4000    let mut out = String::with_capacity(source.len());
4001    let bytes = source.as_bytes();
4002    let mut cursor = 0;
4003    let mut i = 0;
4004    while i < bytes.len() {
4005        if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'/') {
4006            out.push_str(&source[cursor..i]);
4007            out.push_str("  ");
4008            i += 2;
4009            while i < bytes.len() && bytes[i] != b'\n' {
4010                out.push(' ');
4011                i += 1;
4012            }
4013            cursor = i;
4014            continue;
4015        }
4016        i += 1;
4017    }
4018    out.push_str(&source[cursor..]);
4019    out
4020}
4021
4022fn render_preprocessor_children(source: &str, start: usize, end: usize, indent: usize) -> String {
4023    let bytes = source.as_bytes();
4024    let mut output = String::new();
4025    let mut statement_start = start;
4026    let mut i = start;
4027    while i < end {
4028        if bytes[i] == b'{' {
4029            let prelude = source[statement_start..i].trim();
4030            let Some(close) = find_matching_brace(source, i, end) else {
4031                return output;
4032            };
4033            if let Some(block) = render_preprocessor_block(source, prelude, i + 1, close, indent) {
4034                output.push_str(&block);
4035            }
4036            i = close + 1;
4037            statement_start = i;
4038        } else if bytes[i] == b';' {
4039            i += 1;
4040            statement_start = i;
4041        } else {
4042            i += 1;
4043        }
4044    }
4045    output
4046}
4047
4048fn render_preprocessor_block(
4049    source: &str,
4050    prelude: &str,
4051    body_start: usize,
4052    body_end: usize,
4053    indent: usize,
4054) -> Option<String> {
4055    let prelude = prelude.trim();
4056    if prelude.is_empty()
4057        || prelude.contains("#{")
4058        || prelude.starts_with("@mixin")
4059        || prelude.starts_with("@function")
4060        || prelude.starts_with("@for")
4061        || prelude.starts_with("@each")
4062        || prelude.starts_with("@if")
4063        || prelude.starts_with("@else")
4064        || prelude.starts_with("@while")
4065    {
4066        return None;
4067    }
4068    if prelude.starts_with("@media")
4069        || prelude.starts_with("@supports")
4070        || prelude.starts_with("@container")
4071        || prelude.starts_with("@layer")
4072    {
4073        let body = render_preprocessor_children(source, body_start, body_end, indent + 1);
4074        if body.trim().is_empty() {
4075            return None;
4076        }
4077        let mut output = String::new();
4078        push_indent(&mut output, indent);
4079        output.push_str(prelude);
4080        output.push_str(" {\n");
4081        output.push_str(&body);
4082        push_indent(&mut output, indent);
4083        output.push_str("}\n");
4084        return Some(output);
4085    }
4086    if prelude.starts_with('@') || prelude.ends_with(':') {
4087        return None;
4088    }
4089
4090    let selectors = clean_preprocessor_selector_list(prelude)?;
4091    let (declarations, children) =
4092        render_preprocessor_body(source, body_start, body_end, indent + 1);
4093    if declarations.is_empty() && children.trim().is_empty() {
4094        return None;
4095    }
4096    let mut output = String::new();
4097    push_indent(&mut output, indent);
4098    output.push_str(&selectors);
4099    output.push_str(" {\n");
4100    for declaration in declarations {
4101        push_indent(&mut output, indent + 1);
4102        output.push_str(&declaration);
4103        output.push('\n');
4104    }
4105    output.push_str(&children);
4106    push_indent(&mut output, indent);
4107    output.push_str("}\n");
4108    Some(output)
4109}
4110
4111fn render_preprocessor_body(
4112    source: &str,
4113    body_start: usize,
4114    body_end: usize,
4115    indent: usize,
4116) -> (Vec<String>, String) {
4117    let bytes = source.as_bytes();
4118    let mut declarations = Vec::new();
4119    let mut children = String::new();
4120    let mut statement_start = body_start;
4121    let mut i = body_start;
4122    while i < body_end {
4123        match bytes[i] {
4124            b'{' => {
4125                let prelude = source[statement_start..i].trim();
4126                let Some(close) = find_matching_brace(source, i, body_end) else {
4127                    break;
4128                };
4129                if let Some(block) =
4130                    render_preprocessor_block(source, prelude, i + 1, close, indent)
4131                {
4132                    children.push_str(&block);
4133                }
4134                i = close + 1;
4135                statement_start = i;
4136            }
4137            b';' => {
4138                let statement = source[statement_start..=i].trim();
4139                if let Some(declaration) = normalize_preprocessor_declaration(statement) {
4140                    declarations.push(declaration);
4141                }
4142                i += 1;
4143                statement_start = i;
4144            }
4145            _ => i += 1,
4146        }
4147    }
4148    (declarations, children)
4149}
4150
4151fn clean_preprocessor_selector_list(prelude: &str) -> Option<String> {
4152    let children: Vec<&str> = prelude
4153        .split(',')
4154        .map(str::trim)
4155        .filter(|selector| {
4156            !selector.is_empty()
4157                && !selector.contains("#{")
4158                && !selector.starts_with('@')
4159                && !selector.ends_with(':')
4160        })
4161        .collect();
4162    if children.is_empty() {
4163        None
4164    } else {
4165        Some(children.join(", "))
4166    }
4167}
4168
4169fn normalize_preprocessor_declaration(statement: &str) -> Option<String> {
4170    let statement = statement.trim().trim_end_matches(';').trim();
4171    if statement.is_empty()
4172        || statement.starts_with('$')
4173        || statement.starts_with("@include")
4174        || statement.starts_with("@extend")
4175        || statement.starts_with("@debug")
4176        || statement.starts_with("@warn")
4177        || statement.starts_with("@error")
4178        || statement.contains("#{")
4179    {
4180        return None;
4181    }
4182    let (property, value) = statement.split_once(':')?;
4183    let property = property.trim();
4184    let value = value.trim();
4185    if property.is_empty() || value.is_empty() || property.starts_with('@') {
4186        return None;
4187    }
4188    Some(format!(
4189        "{property}: {};",
4190        normalize_preprocessor_value(value)
4191    ))
4192}
4193
4194fn normalize_preprocessor_value(value: &str) -> String {
4195    let mut out = String::with_capacity(value.len());
4196    let bytes = value.as_bytes();
4197    let mut cursor = 0;
4198    let mut i = 0;
4199    while i < bytes.len() {
4200        if (bytes[i] == b'$' || bytes[i] == b'@') && is_preprocessor_ident_start(bytes.get(i + 1)) {
4201            out.push_str(&value[cursor..i]);
4202            out.push_str("var(--fallow-preprocessor-var)");
4203            i += 2;
4204            while i < bytes.len() && is_preprocessor_ident_continue(bytes[i]) {
4205                i += 1;
4206            }
4207            cursor = i;
4208        } else {
4209            i += 1;
4210        }
4211    }
4212    out.push_str(&value[cursor..]);
4213    out
4214}
4215
4216fn is_preprocessor_ident_start(byte: Option<&u8>) -> bool {
4217    byte.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_' || *b == b'-')
4218}
4219
4220fn is_preprocessor_ident_continue(byte: u8) -> bool {
4221    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
4222}
4223
4224fn push_indent(output: &mut String, indent: usize) {
4225    for _ in 0..indent {
4226        output.push_str("  ");
4227    }
4228}
4229
4230fn find_matching_brace(source: &str, open: usize, limit: usize) -> Option<usize> {
4231    let bytes = source.as_bytes();
4232    let mut depth = 0usize;
4233    let mut i = open;
4234    while i < limit {
4235        match bytes[i] {
4236            b'{' => depth += 1,
4237            b'}' => {
4238                depth = depth.saturating_sub(1);
4239                if depth == 0 {
4240                    return Some(i);
4241                }
4242            }
4243            _ => {}
4244        }
4245        i += 1;
4246    }
4247    None
4248}
4249
4250fn record_css_analytics_summary(
4251    summary: &mut fallow_output::CssAnalyticsSummary,
4252    analytics: &fallow_types::extract::CssAnalytics,
4253) {
4254    summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
4255    summary.total_declarations = summary
4256        .total_declarations
4257        .saturating_add(analytics.total_declarations);
4258    summary.important_declarations = summary
4259        .important_declarations
4260        .saturating_add(analytics.important_declarations);
4261    summary.empty_rules = summary
4262        .empty_rules
4263        .saturating_add(analytics.empty_rule_count);
4264    summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
4265    if analytics.notable_truncated {
4266        summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
4267    }
4268}
4269
4270/// The per-file CSS walk accumulator: structural file reports, the project-wide
4271/// token sets, scoped SFC unused-class findings, and the running summary.
4272#[derive(Clone, Debug)]
4273struct CssWalkAccum {
4274    file_reports: Vec<fallow_output::CssFileAnalytics>,
4275    summary: fallow_output::CssAnalyticsSummary,
4276    scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
4277    tokens: CssTokenSets,
4278    scoring: CssGradeScoring,
4279}
4280
4281#[derive(Clone, Debug, Default)]
4282struct CssGradeScoring {
4283    non_atomic_declarations: u32,
4284    non_atomic_important_declarations: u32,
4285    non_atomic_max_nesting_depth: u8,
4286    atomic_declarations: u32,
4287}
4288
4289impl CssGradeScoring {
4290    fn add_non_atomic(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
4291        self.non_atomic_declarations = self
4292            .non_atomic_declarations
4293            .saturating_add(analytics.total_declarations);
4294        self.non_atomic_important_declarations = self
4295            .non_atomic_important_declarations
4296            .saturating_add(analytics.important_declarations);
4297        self.non_atomic_max_nesting_depth = self
4298            .non_atomic_max_nesting_depth
4299            .max(analytics.max_nesting_depth);
4300    }
4301}
4302
4303/// The finalized whole-project token metrics (keyframes, duplicate blocks, unused
4304/// at-rules, font-size unit mix, unused font faces) derived after the file walk.
4305struct CssTokenMetrics {
4306    unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
4307    undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
4308    duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
4309    unused_at_rules: Vec<fallow_output::UnusedAtRule>,
4310    font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
4311    unused_font_faces: Vec<fallow_output::UnusedFontFace>,
4312}
4313
4314/// CSS analytics plus internal-only inputs for the styling-health grade.
4315pub(super) struct CssAnalyticsComputation {
4316    pub(super) report: fallow_output::CssAnalyticsReport,
4317    pub(super) scoring_inputs: super::styling_score::StylingScoringInputs,
4318}
4319
4320/// Walk every in-scope stylesheet / SFC, accumulating structural metrics, the
4321/// project token sets, and scoped SFC unused-class findings.
4322fn walk_css_files(
4323    files: &[fallow_types::discover::DiscoveredFile],
4324    ctx: HealthScanCtx<'_>,
4325) -> CssWalkAccum {
4326    use fallow_output::{CssAnalyticsSummary, CssFileAnalytics, ScopedUnusedClasses};
4327
4328    let mut file_reports = Vec::new();
4329    let mut summary = CssAnalyticsSummary::default();
4330    let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
4331    // Project-wide design-token + custom-property + @keyframes accumulator,
4332    // unioned across every analyzed stylesheet (including ones with no notable
4333    // rule, which are not listed individually), finalized after the walk.
4334    let mut tokens = CssTokenSets::default();
4335    let mut scoring = CssGradeScoring::default();
4336    let css_in_js = project_uses_css_in_js(&ctx.config.root);
4337
4338    for file in files {
4339        let Some((relative, kind)) = css_report_scan_target(file, ctx, css_in_js) else {
4340            continue;
4341        };
4342        let Ok(source) = std::fs::read_to_string(&file.path) else {
4343            continue;
4344        };
4345
4346        if kind == CssScanKind::Sfc {
4347            record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
4348        }
4349
4350        let rel = relative.to_string_lossy().replace('\\', "/");
4351        let mut file_had_sheet = false;
4352        for item in css_report_scan_items(&source, &file.path, kind) {
4353            let Some(mut analytics) = crate::css::compute_css_analytics(&item.source) else {
4354                continue;
4355            };
4356            file_had_sheet = true;
4357            record_css_analytics_summary(&mut summary, &analytics);
4358            tokens.record_theme(item.source.as_ref(), &rel);
4359
4360            match item.policy {
4361                GradePolicy::Atomic => {
4362                    analytics.declaration_blocks.clear();
4363                    analytics.raw_style_values.clear();
4364                    tokens.record(&analytics, &rel);
4365                    scoring.atomic_declarations = scoring
4366                        .atomic_declarations
4367                        .saturating_add(analytics.total_declarations);
4368                }
4369                GradePolicy::Structural | GradePolicy::StructuralNoDedup => {
4370                    if item.policy == GradePolicy::StructuralNoDedup {
4371                        analytics.declaration_blocks.clear();
4372                    }
4373                    tokens.record(&analytics, &rel);
4374                    scoring.add_non_atomic(&analytics);
4375                    if item.report_notable && !analytics.notable_rules.is_empty() {
4376                        file_reports.push(CssFileAnalytics {
4377                            path: rel.clone(),
4378                            analytics,
4379                        });
4380                    }
4381                }
4382            }
4383        }
4384        if file_had_sheet {
4385            summary.files_analyzed = summary.files_analyzed.saturating_add(1);
4386        }
4387    }
4388
4389    CssWalkAccum {
4390        file_reports,
4391        summary,
4392        scoped_unused,
4393        tokens,
4394        scoring,
4395    }
4396}
4397
4398/// Credit Tailwind-markup-applied keyframes, then finalize the whole-project
4399/// token metrics and prune unused `@font-face` families referenced elsewhere.
4400fn finalize_css_token_metrics(
4401    tokens: &mut CssTokenSets,
4402    summary: &mut fallow_output::CssAnalyticsSummary,
4403    files: &[fallow_types::discover::DiscoveredFile],
4404    config: &ResolvedConfig,
4405    ignore_set: &globset::GlobSet,
4406) -> CssTokenMetrics {
4407    // Credit @keyframes applied via Tailwind markup (`animate-[name_...]` /
4408    // `animate-name`), not just CSS `animation:` declarations, before the
4409    // unreferenced diff. Filtered to actually-defined keyframes so a stray
4410    // `animate-*` suffix never manufactures a false `undefined_keyframes`.
4411    for name in collect_markup_keyframe_references(files, config, ignore_set) {
4412        if tokens.defined_keyframes.contains(&name) {
4413            tokens.referenced_keyframes.insert(name);
4414        }
4415    }
4416
4417    let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
4418    let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
4419    let unused_at_rules = tokens.group_unused_at_rules(summary);
4420    let font_size_unit_mix = tokens.font_size_unit_mix(summary);
4421    let mut unused_font_faces = tokens.unused_font_faces(summary);
4422    // The CSS-only set difference cannot see a font family applied from
4423    // JavaScript / canvas (Excalidraw) or referenced from a `.scss`/`.sass`
4424    // theme the parser never reads (reveal.js). Drop any candidate whose family
4425    // name appears as a substring in ANY non-CSS source file, so only a font
4426    // declared and used nowhere at all survives. (Real-world smoke.)
4427    if !unused_font_faces.is_empty() {
4428        let referenced =
4429            font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
4430        unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
4431        summary.unused_font_faces = saturate_len(unused_font_faces.len());
4432    }
4433
4434    CssTokenMetrics {
4435        unreferenced_keyframes,
4436        undefined_keyframes,
4437        duplicate_declaration_blocks,
4438        unused_at_rules,
4439        font_size_unit_mix,
4440        unused_font_faces,
4441    }
4442}
4443
4444#[cfg(test)]
4445fn compute_css_analytics_report(
4446    files: &[fallow_types::discover::DiscoveredFile],
4447    modules: &[fallow_types::extract::ModuleInfo],
4448    ctx: HealthScanCtx<'_>,
4449) -> Option<CssAnalyticsComputation> {
4450    compute_css_analytics_report_with_artifacts(files, modules, ctx, None)
4451}
4452
4453pub(super) fn compute_css_analytics_report_with_artifacts(
4454    files: &[fallow_types::discover::DiscoveredFile],
4455    modules: &[fallow_types::extract::ModuleInfo],
4456    ctx: HealthScanCtx<'_>,
4457    styling_artifacts: Option<&StylingAnalysisArtifacts>,
4458) -> Option<CssAnalyticsComputation> {
4459    let HealthScanCtx {
4460        config,
4461        ignore_set,
4462        changed_files,
4463        output_changed_files,
4464        ws_roots,
4465    } = ctx;
4466    let css_deep = output_changed_files.is_some();
4467
4468    let mut walk = styling_artifacts
4469        .filter(|_| changed_files.is_none() && output_changed_files.is_none() && ws_roots.is_none())
4470        .map_or_else(
4471            || walk_css_files(files, ctx),
4472            |artifacts| artifacts.whole_scope_walk.clone(),
4473        );
4474    let mut styling_token_candidates = comparable_theme_token_candidates(&walk.tokens, config);
4475    styling_token_candidates.extend(comparable_custom_property_token_candidates(&walk.tokens));
4476    styling_token_candidates.extend(comparable_css_in_js_token_candidates(
4477        files, modules, config,
4478    ));
4479    styling_token_candidates.extend(comparable_project_vocabulary_candidates(&walk.tokens));
4480    styling_token_candidates.sort_by(|a, b| theme_token_sort_key(a).cmp(&theme_token_sort_key(b)));
4481    annotate_raw_style_value_nearest_tokens(&mut walk.tokens, &styling_token_candidates);
4482    let metrics = finalize_css_token_metrics(
4483        &mut walk.tokens,
4484        &mut walk.summary,
4485        files,
4486        config,
4487        ignore_set,
4488    );
4489    let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
4490        tokens: &walk.tokens,
4491        files,
4492        config,
4493        ignore_set,
4494        changed_files,
4495        output_changed_files,
4496        css_deep,
4497        ws_roots,
4498        styling_artifacts,
4499        token_candidates: &styling_token_candidates,
4500        summary: &mut walk.summary,
4501    });
4502    let mut token_consumers = build_token_consumers(&TokenConsumersInput {
4503        tokens: &walk.tokens,
4504        files,
4505        config,
4506        ignore_set,
4507        changed_files,
4508        ws_roots,
4509    });
4510    // Phase 3d: additively append the CSS-in-JS design-token blast-radius (StyleX
4511    // `defineVars` / vanilla-extract `createTheme` family), derived from the
4512    // graph-independent `ModuleInfo` imports + a bounded re-parse, gated on the same
4513    // `project_uses_css_in_js` dep gate the CSS-in-JS walk uses (a non-CSS-in-JS
4514    // project appends nothing, so Tailwind output is byte-identical). The combined
4515    // list is then sorted globally by `(token, definition_path)` so the contract is
4516    // a single ordered list, not a Tailwind block then a CSS-in-JS block.
4517    token_consumers.extend(build_css_in_js_token_consumers(files, modules, config));
4518    token_consumers.sort_by(|a, b| {
4519        a.token
4520            .cmp(&b.token)
4521            .then_with(|| a.definition_path.cmp(&b.definition_path))
4522    });
4523    let scoring_inputs = super::styling_score::StylingScoringInputs {
4524        theme_tokens_defined: saturate_len(walk.tokens.theme_token_definers.len()),
4525        non_atomic_declarations: walk.scoring.non_atomic_declarations,
4526        non_atomic_important_declarations: walk.scoring.non_atomic_important_declarations,
4527        non_atomic_max_nesting_depth: walk.scoring.non_atomic_max_nesting_depth,
4528        atomic_declarations: walk.scoring.atomic_declarations,
4529    };
4530    let report = assemble_css_report(CssReportAssemblyInput {
4531        walk,
4532        metrics,
4533        candidates,
4534        token_consumers,
4535        config,
4536        output_changed_files,
4537    })?;
4538    Some(CssAnalyticsComputation {
4539        report,
4540        scoring_inputs,
4541    })
4542}
4543
4544/// Assemble the final CSS analytics report from the walk accumulator, finalized
4545/// token metrics, and markup candidates; returns `None` when nothing notable was
4546/// found (no analyzed files and every candidate list empty).
4547struct CssReportAssemblyInput<'a> {
4548    walk: CssWalkAccum,
4549    metrics: CssTokenMetrics,
4550    candidates: MarkupCssCandidates,
4551    token_consumers: Vec<fallow_output::TokenConsumers>,
4552    config: &'a ResolvedConfig,
4553    output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4554}
4555
4556fn assemble_css_report(
4557    input: CssReportAssemblyInput<'_>,
4558) -> Option<fallow_output::CssAnalyticsReport> {
4559    use fallow_output::CssAnalyticsReport;
4560
4561    let CssReportAssemblyInput {
4562        mut walk,
4563        mut metrics,
4564        mut candidates,
4565        mut token_consumers,
4566        config,
4567        output_changed_files,
4568    } = input;
4569
4570    if let Some(changed) = output_changed_files {
4571        retain_css_report_changed_scope(CssReportChangedScopeInput {
4572            walk: &mut walk,
4573            metrics: &mut metrics,
4574            candidates: &mut candidates,
4575            token_consumers: &mut token_consumers,
4576            config,
4577            changed,
4578        });
4579    }
4580
4581    let candidates_empty = candidates.tailwind_arbitrary_values.is_empty()
4582        && candidates.cva_duplicate_variant_blocks.is_empty()
4583        && candidates.cva_variant_token_drifts.is_empty()
4584        && candidates.unresolved_class_references.is_empty()
4585        && candidates.unreferenced_css_classes.is_empty()
4586        && metrics.unused_font_faces.is_empty()
4587        && candidates.unused_theme_tokens.is_empty()
4588        && candidates.near_duplicate_theme_tokens.is_empty()
4589        && token_consumers.is_empty();
4590    if walk.summary.files_analyzed == 0 && walk.scoped_unused.is_empty() && candidates_empty {
4591        return None;
4592    }
4593    let mut scoped_unused = walk.scoped_unused;
4594    scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
4595    let mut raw_style_values = walk.tokens.raw_style_values;
4596    raw_style_values.sort_by(|a, b| {
4597        (&a.path, a.line, &a.axis, &a.property, &a.value).cmp(&(
4598            &b.path,
4599            b.line,
4600            &b.axis,
4601            &b.property,
4602            &b.value,
4603        ))
4604    });
4605    walk.summary.raw_style_values = saturate_len(raw_style_values.len());
4606    Some(CssAnalyticsReport {
4607        files: walk.file_reports,
4608        summary: walk.summary,
4609        scoped_unused,
4610        unreferenced_keyframes: metrics.unreferenced_keyframes,
4611        undefined_keyframes: metrics.undefined_keyframes,
4612        duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
4613        cva_duplicate_variant_blocks: candidates.cva_duplicate_variant_blocks,
4614        cva_variant_token_drifts: candidates.cva_variant_token_drifts,
4615        tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
4616        raw_style_values,
4617        unused_at_rules: metrics.unused_at_rules,
4618        unresolved_class_references: candidates.unresolved_class_references,
4619        unreferenced_css_classes: candidates.unreferenced_css_classes,
4620        unused_font_faces: metrics.unused_font_faces,
4621        unused_theme_tokens: candidates.unused_theme_tokens,
4622        near_duplicate_theme_tokens: candidates.near_duplicate_theme_tokens,
4623        token_consumers,
4624        font_size_unit_mix: metrics.font_size_unit_mix,
4625    })
4626}
4627
4628struct CssReportChangedScopeInput<'a> {
4629    walk: &'a mut CssWalkAccum,
4630    metrics: &'a mut CssTokenMetrics,
4631    candidates: &'a mut MarkupCssCandidates,
4632    token_consumers: &'a mut Vec<fallow_output::TokenConsumers>,
4633    config: &'a ResolvedConfig,
4634    changed: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
4635}
4636
4637fn retain_css_report_changed_scope(input: CssReportChangedScopeInput<'_>) {
4638    let CssReportChangedScopeInput {
4639        walk,
4640        metrics,
4641        candidates,
4642        token_consumers,
4643        config,
4644        changed,
4645    } = input;
4646    let in_scope = |path: &str| css_output_path_in_changed_scope(path, config, changed);
4647    walk.file_reports.retain(|file| in_scope(&file.path));
4648    walk.scoped_unused.retain(|item| in_scope(&item.path));
4649    metrics
4650        .unreferenced_keyframes
4651        .retain(|item| in_scope(&item.path));
4652    metrics
4653        .undefined_keyframes
4654        .retain(|item| in_scope(&item.path));
4655    metrics.duplicate_declaration_blocks.retain_mut(|block| {
4656        let has_scoped_occurrence = block.occurrences.iter().any(|item| in_scope(&item.path));
4657        if has_scoped_occurrence {
4658            block.occurrences.sort_by(|a, b| {
4659                let a_out_of_scope = !in_scope(&a.path);
4660                let b_out_of_scope = !in_scope(&b.path);
4661                a_out_of_scope
4662                    .cmp(&b_out_of_scope)
4663                    .then_with(|| a.path.cmp(&b.path))
4664                    .then_with(|| a.line.cmp(&b.line))
4665            });
4666        }
4667        has_scoped_occurrence
4668    });
4669    metrics.unused_at_rules.retain(|item| in_scope(&item.path));
4670    metrics
4671        .unused_font_faces
4672        .retain(|item| in_scope(&item.path));
4673    candidates
4674        .tailwind_arbitrary_values
4675        .retain(|item| in_scope(&item.path));
4676    candidates
4677        .cva_duplicate_variant_blocks
4678        .retain(|item| item.occurrences.iter().any(|occ| in_scope(&occ.path)));
4679    candidates
4680        .cva_variant_token_drifts
4681        .retain(|item| in_scope(&item.path));
4682    candidates
4683        .unresolved_class_references
4684        .retain(|item| in_scope(&item.path));
4685    candidates
4686        .unreferenced_css_classes
4687        .retain(|item| in_scope(&item.path));
4688    candidates
4689        .unused_theme_tokens
4690        .retain(|item| in_scope(&item.path));
4691    candidates
4692        .near_duplicate_theme_tokens
4693        .retain(|item| in_scope(&item.path));
4694    walk.tokens
4695        .raw_style_values
4696        .retain(|item| in_scope(&item.path));
4697    token_consumers.retain(|item| in_scope(&item.definition_path));
4698}
4699
4700fn css_output_path_in_changed_scope(
4701    path: &str,
4702    config: &ResolvedConfig,
4703    changed: &rustc_hash::FxHashSet<std::path::PathBuf>,
4704) -> bool {
4705    let relative = std::path::Path::new(path);
4706    let absolute = config.root.join(relative);
4707    changed.contains(relative) || changed.contains(&absolute)
4708}
4709
4710#[cfg(test)]
4711#[allow(
4712    clippy::unwrap_used,
4713    reason = "tests use unwrap to keep token-consumer assertions concise"
4714)]
4715mod token_consumer_tests {
4716    use super::*;
4717    use fallow_config::{FallowConfig, OutputFormat};
4718    use fallow_output::ConsumerKind;
4719    use fallow_types::discover::{DiscoveredFile, FileId};
4720    use std::path::Path;
4721
4722    /// Resolve a default config rooted at `root`.
4723    fn config_at(root: &Path) -> ResolvedConfig {
4724        FallowConfig::default().resolve(
4725            root.to_path_buf(),
4726            OutputFormat::Human,
4727            1,
4728            true,
4729            true,
4730            None,
4731        )
4732    }
4733
4734    /// Write `relative` under `root` with `body`, returning a `DiscoveredFile`.
4735    fn write_file(root: &Path, id: u32, relative: &str, body: &str) -> DiscoveredFile {
4736        let path = root.join(relative);
4737        if let Some(parent) = path.parent() {
4738            std::fs::create_dir_all(parent).unwrap();
4739        }
4740        std::fs::write(&path, body).unwrap();
4741        DiscoveredFile {
4742            id: FileId(id),
4743            size_bytes: u64::try_from(body.len()).unwrap(),
4744            path,
4745        }
4746    }
4747
4748    /// A `CssTokenSets` populated from a single stylesheet's `@theme` / `@apply`
4749    /// / `var()` content (exercises the real located scans in `record_theme`).
4750    fn tokens_from(theme_css: &str, rel: &str) -> CssTokenSets {
4751        let mut tokens = CssTokenSets::default();
4752        tokens.record_theme(theme_css, rel);
4753        tokens
4754    }
4755
4756    #[test]
4757    fn token_read_by_two_markup_files_counts_two_utility() {
4758        let dir = tempfile::tempdir().unwrap();
4759        let root = dir.path();
4760        std::fs::write(
4761            root.join("package.json"),
4762            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4763        )
4764        .unwrap();
4765        let f1 = write_file(
4766            root,
4767            0,
4768            "src/Button.tsx",
4769            "export const Button = () => <button className=\"bg-brand\" />;",
4770        );
4771        let f2 = write_file(
4772            root,
4773            1,
4774            "src/Card.tsx",
4775            "export const Card = () => <div className=\"text-brand p-4\" />;",
4776        );
4777        let files = vec![f1, f2];
4778        let config = config_at(root);
4779        let tokens = tokens_from("@theme {\n  --color-brand: #f00;\n}", "src/theme.css");
4780
4781        let out = build_token_consumers(&TokenConsumersInput {
4782            tokens: &tokens,
4783            files: &files,
4784            config: &config,
4785            ignore_set: &globset::GlobSet::empty(),
4786            changed_files: None,
4787            ws_roots: None,
4788        });
4789
4790        assert_eq!(out.len(), 1);
4791        let entry = &out[0];
4792        assert_eq!(entry.token, "--color-brand");
4793        assert_eq!(entry.consumer_count, 2);
4794        assert!(
4795            entry
4796                .consumers
4797                .iter()
4798                .all(|c| c.kind == ConsumerKind::Utility)
4799        );
4800        let paths: Vec<&str> = entry.consumers.iter().map(|c| c.path.as_str()).collect();
4801        assert_eq!(paths, vec!["src/Button.tsx", "src/Card.tsx"]);
4802    }
4803
4804    #[test]
4805    fn token_with_no_consumer_counts_zero() {
4806        let dir = tempfile::tempdir().unwrap();
4807        let root = dir.path();
4808        std::fs::write(
4809            root.join("package.json"),
4810            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4811        )
4812        .unwrap();
4813        // Markup uses an unrelated utility, so `--color-unused` has no consumer.
4814        let files = vec![write_file(
4815            root,
4816            0,
4817            "src/App.tsx",
4818            "export const App = () => <div className=\"flex gap-2\" />;",
4819        )];
4820        let config = config_at(root);
4821        let tokens = tokens_from("@theme {\n  --color-unused: #abc;\n}", "src/theme.css");
4822
4823        let out = build_token_consumers(&TokenConsumersInput {
4824            tokens: &tokens,
4825            files: &files,
4826            config: &config,
4827            ignore_set: &globset::GlobSet::empty(),
4828            changed_files: None,
4829            ws_roots: None,
4830        });
4831
4832        assert_eq!(out.len(), 1);
4833        assert_eq!(out[0].token, "--color-unused");
4834        assert_eq!(out[0].consumer_count, 0);
4835        assert!(out[0].consumers.is_empty());
4836    }
4837
4838    #[test]
4839    fn theme_var_and_css_var_reads_locate_distinct_kinds() {
4840        let dir = tempfile::tempdir().unwrap();
4841        let root = dir.path();
4842        std::fs::write(
4843            root.join("package.json"),
4844            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4845        )
4846        .unwrap();
4847        // `--color-brand` is read once inside @theme (theme-var) and once in a
4848        // regular rule (css-var); both must surface as distinct kinds.
4849        let theme_css = "@theme {\n  --color-brand: #f00;\n  --color-accent: var(--color-brand);\n}\n.note {\n  color: var(--color-brand);\n}";
4850        let files: Vec<DiscoveredFile> = Vec::new();
4851        let config = config_at(root);
4852        let tokens = tokens_from(theme_css, "src/theme.css");
4853
4854        let out = build_token_consumers(&TokenConsumersInput {
4855            tokens: &tokens,
4856            files: &files,
4857            config: &config,
4858            ignore_set: &globset::GlobSet::empty(),
4859            changed_files: None,
4860            ws_roots: None,
4861        });
4862
4863        let brand = out
4864            .iter()
4865            .find(|t| t.token == "--color-brand")
4866            .expect("--color-brand present");
4867        assert_eq!(brand.consumer_count, 2);
4868        let kinds: Vec<ConsumerKind> = brand.consumers.iter().map(|c| c.kind).collect();
4869        assert!(kinds.contains(&ConsumerKind::ThemeVar));
4870        assert!(kinds.contains(&ConsumerKind::CssVar));
4871    }
4872
4873    #[test]
4874    fn apply_body_locates_apply_kind() {
4875        let dir = tempfile::tempdir().unwrap();
4876        let root = dir.path();
4877        std::fs::write(
4878            root.join("package.json"),
4879            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4880        )
4881        .unwrap();
4882        let theme_css = "@theme {\n  --color-brand: #f00;\n}\n.btn {\n  @apply bg-brand;\n}";
4883        let files: Vec<DiscoveredFile> = Vec::new();
4884        let config = config_at(root);
4885        let tokens = tokens_from(theme_css, "src/theme.css");
4886
4887        let out = build_token_consumers(&TokenConsumersInput {
4888            tokens: &tokens,
4889            files: &files,
4890            config: &config,
4891            ignore_set: &globset::GlobSet::empty(),
4892            changed_files: None,
4893            ws_roots: None,
4894        });
4895
4896        let brand = out.iter().find(|t| t.token == "--color-brand").unwrap();
4897        assert_eq!(brand.consumer_count, 1);
4898        assert_eq!(brand.consumers[0].kind, ConsumerKind::Apply);
4899    }
4900
4901    #[test]
4902    fn non_tailwind_project_emits_nothing() {
4903        let dir = tempfile::tempdir().unwrap();
4904        let root = dir.path();
4905        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
4906        let files = vec![write_file(
4907            root,
4908            0,
4909            "src/App.tsx",
4910            "export const App = () => <div className=\"bg-brand\" />;",
4911        )];
4912        let config = config_at(root);
4913        let tokens = tokens_from("@theme {\n  --color-brand: #f00;\n}", "src/theme.css");
4914
4915        let out = build_token_consumers(&TokenConsumersInput {
4916            tokens: &tokens,
4917            files: &files,
4918            config: &config,
4919            ignore_set: &globset::GlobSet::empty(),
4920            changed_files: None,
4921            ws_roots: None,
4922        });
4923        assert!(out.is_empty(), "non-Tailwind project must abstain");
4924    }
4925
4926    #[test]
4927    fn plugin_project_emits_nothing() {
4928        let dir = tempfile::tempdir().unwrap();
4929        let root = dir.path();
4930        std::fs::write(
4931            root.join("package.json"),
4932            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4933        )
4934        .unwrap();
4935        let files: Vec<DiscoveredFile> = Vec::new();
4936        let config = config_at(root);
4937        // A `@plugin` directive trips the DI-blind-spot abstain.
4938        let tokens = tokens_from(
4939            "@plugin \"@tailwindcss/typography\";\n@theme {\n  --color-brand: #f00;\n}",
4940            "src/theme.css",
4941        );
4942
4943        let out = build_token_consumers(&TokenConsumersInput {
4944            tokens: &tokens,
4945            files: &files,
4946            config: &config,
4947            ignore_set: &globset::GlobSet::empty(),
4948            changed_files: None,
4949            ws_roots: None,
4950        });
4951        assert!(out.is_empty(), "plugin project must abstain");
4952    }
4953
4954    #[test]
4955    fn partial_scope_emits_nothing() {
4956        let dir = tempfile::tempdir().unwrap();
4957        let root = dir.path();
4958        std::fs::write(
4959            root.join("package.json"),
4960            r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
4961        )
4962        .unwrap();
4963        let files: Vec<DiscoveredFile> = Vec::new();
4964        let config = config_at(root);
4965        let tokens = tokens_from("@theme {\n  --color-brand: #f00;\n}", "src/theme.css");
4966        let changed: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
4967
4968        let out = build_token_consumers(&TokenConsumersInput {
4969            tokens: &tokens,
4970            files: &files,
4971            config: &config,
4972            ignore_set: &globset::GlobSet::empty(),
4973            changed_files: Some(&changed),
4974            ws_roots: None,
4975        });
4976        assert!(out.is_empty(), "partial scope must abstain");
4977    }
4978
4979    // --- CSS program Phase 3c: object-notation CSS-in-JS engine wiring ---
4980
4981    /// Run the CSS analytics walk over a temp project and return the computation
4982    /// (report + scoring inputs), or `None` when nothing analyzable was found.
4983    fn css_computation(root: &Path, files: &[DiscoveredFile]) -> Option<CssAnalyticsComputation> {
4984        let config = config_at(root);
4985        // The 3c CSS-analytics tests do not exercise the Phase 3d CSS-in-JS token
4986        // blast-radius (which needs `ModuleInfo`), so pass an empty module slice;
4987        // the token-consumer driver then no-ops (no definers).
4988        compute_css_analytics_report(
4989            files,
4990            &[],
4991            HealthScanCtx {
4992                config: &config,
4993                ignore_set: &globset::GlobSet::empty(),
4994                changed_files: None,
4995                output_changed_files: None,
4996                ws_roots: None,
4997            },
4998        )
4999    }
5000
5001    #[test]
5002    fn cva_duplicate_variant_blocks_surface_as_css_copy_paste() {
5003        let dir = tempfile::tempdir().unwrap();
5004        let root = dir.path();
5005        std::fs::write(
5006            root.join("package.json"),
5007            r#"{"dependencies":{"class-variance-authority":"0.7.0","tailwindcss":"4.0.0"}}"#,
5008        )
5009        .unwrap();
5010        let button = write_file(
5011            root,
5012            0,
5013            "src/button.ts",
5014            "import { cva } from 'class-variance-authority';\n\
5015             export const button = cva('inline-flex', {\n\
5016               variants: {\n\
5017                 tone: {\n\
5018                   primary: 'px-3 py-2 text-sm font-medium',\n\
5019                   secondary: 'px-3 py-2 text-sm font-medium',\n\
5020                 },\n\
5021               },\n\
5022             });\n",
5023        );
5024
5025        let computation = css_computation(root, &[button]).expect("cva candidates keep report");
5026        let blocks = &computation.report.cva_duplicate_variant_blocks;
5027        assert_eq!(blocks.len(), 1);
5028        assert_eq!(blocks[0].value, "px-3 py-2 text-sm font-medium");
5029        assert_eq!(blocks[0].occurrence_count, 2);
5030        assert_eq!(blocks[0].occurrences[0].path, "src/button.ts");
5031    }
5032
5033    // --- CSS program Phase 3d: CSS-in-JS design-token blast-radius ---
5034
5035    /// Like [`css_computation`] but parses each file into a `ModuleInfo` so the
5036    /// Phase 3d CSS-in-JS token-consumer driver (which reads imports +
5037    /// member-access) actually runs.
5038    fn css_computation_3d(root: &Path, files: &[DiscoveredFile]) -> CssAnalyticsComputation {
5039        let config = config_at(root);
5040        let modules: Vec<fallow_types::extract::ModuleInfo> = files
5041            .iter()
5042            .map(|f| {
5043                let src = std::fs::read_to_string(&f.path).unwrap_or_default();
5044                fallow_extract::parse_source_to_module(f.id, &f.path, &src, 0, false)
5045            })
5046            .collect();
5047        compute_css_analytics_report(
5048            files,
5049            &modules,
5050            HealthScanCtx {
5051                config: &config,
5052                ignore_set: &globset::GlobSet::empty(),
5053                changed_files: None,
5054                output_changed_files: None,
5055                ws_roots: None,
5056            },
5057        )
5058        .expect("css_analytics is non-null")
5059    }
5060
5061    /// The CSS-in-JS (`js-member`) token-consumer entries from a computation.
5062    fn js_token_consumers(
5063        computation: &CssAnalyticsComputation,
5064    ) -> Vec<&fallow_output::TokenConsumers> {
5065        computation
5066            .report
5067            .token_consumers
5068            .iter()
5069            .filter(|t| {
5070                t.consumers
5071                    .iter()
5072                    .all(|c| c.kind == fallow_output::ConsumerKind::JsMember)
5073                    && t.token.contains('.')
5074                    && !t.token.starts_with("--")
5075            })
5076            .collect()
5077    }
5078
5079    fn find_token<'a>(
5080        computation: &'a CssAnalyticsComputation,
5081        token: &str,
5082    ) -> Option<&'a fallow_output::TokenConsumers> {
5083        computation
5084            .report
5085            .token_consumers
5086            .iter()
5087            .find(|t| t.token == token)
5088    }
5089
5090    #[test]
5091    fn stylex_define_vars_blast_radius_located_js_member_consumers() {
5092        let dir = tempfile::tempdir().unwrap();
5093        let root = dir.path();
5094        std::fs::write(
5095            root.join("package.json"),
5096            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5097        )
5098        .unwrap();
5099        let def = write_file(
5100            root,
5101            0,
5102            "src/tokens.stylex.ts",
5103            "import * as stylex from '@stylexjs/stylex';\n\
5104             export const vars = stylex.defineVars({ color: { primary: '#000', secondary: '#fff' } });\n",
5105        );
5106        let consumer = write_file(
5107            root,
5108            1,
5109            "src/card.ts",
5110            "import * as stylex from '@stylexjs/stylex';\n\
5111             import { vars } from './tokens.stylex';\n\
5112             export const s = stylex.create({ root: { color: vars.color.primary } });\n",
5113        );
5114        let computation = css_computation_3d(root, &[def, consumer]);
5115        let primary = find_token(&computation, "vars.color.primary")
5116            .expect("vars.color.primary blast radius present");
5117        assert_eq!(primary.namespace, "vars");
5118        assert_eq!(primary.definition_path, "src/tokens.stylex.ts");
5119        assert_eq!(primary.consumer_count, 1);
5120        assert_eq!(primary.consumers.len(), 1);
5121        assert_eq!(
5122            primary.consumers[0].kind,
5123            fallow_output::ConsumerKind::JsMember
5124        );
5125        assert_eq!(primary.consumers[0].path, "src/card.ts");
5126        // Defined-but-unconsumed leaf -> count 0 (criterion 6).
5127        let secondary =
5128            find_token(&computation, "vars.color.secondary").expect("secondary present");
5129        assert_eq!(secondary.consumer_count, 0);
5130    }
5131
5132    #[test]
5133    fn stylex_define_vars_blast_radius_resolves_tsconfig_alias_consumers() {
5134        let dir = tempfile::tempdir().unwrap();
5135        let root = dir.path();
5136        std::fs::write(
5137            root.join("package.json"),
5138            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5139        )
5140        .unwrap();
5141        std::fs::write(
5142            root.join("tsconfig.json"),
5143            r#"{"compilerOptions":{"baseUrl":".","paths":{"@tokens/*":["src/tokens/*"]}}}"#,
5144        )
5145        .unwrap();
5146        let def = write_file(
5147            root,
5148            0,
5149            "src/tokens/theme.stylex.ts",
5150            "import * as stylex from '@stylexjs/stylex';\n\
5151             export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
5152        );
5153        let consumer = write_file(
5154            root,
5155            1,
5156            "src/card.ts",
5157            "import { vars } from '@tokens/theme.stylex';\n\
5158             export const color = vars.color.primary;\n",
5159        );
5160
5161        let computation = css_computation_3d(root, &[def, consumer]);
5162        let primary = find_token(&computation, "vars.color.primary")
5163            .expect("vars.color.primary blast radius present");
5164        assert_eq!(
5165            primary.consumer_count, 1,
5166            "tsconfig alias import should count as a CSS-in-JS token consumer"
5167        );
5168        assert_eq!(primary.consumers[0].path, "src/card.ts");
5169    }
5170
5171    #[test]
5172    fn stylex_define_vars_blast_radius_resolves_workspace_package_consumers() {
5173        let dir = tempfile::tempdir().unwrap();
5174        let root = dir.path();
5175        std::fs::write(
5176            root.join("package.json"),
5177            r#"{"private":true,"workspaces":["packages/*"],"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5178        )
5179        .unwrap();
5180        std::fs::create_dir_all(root.join("packages/tokens")).unwrap();
5181        std::fs::write(
5182            root.join("packages/tokens/package.json"),
5183            r#"{"name":"@acme/tokens","exports":"./src/index.ts"}"#,
5184        )
5185        .unwrap();
5186        let def = write_file(
5187            root,
5188            0,
5189            "packages/tokens/src/index.ts",
5190            "import * as stylex from '@stylexjs/stylex';\n\
5191             export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
5192        );
5193        let consumer = write_file(
5194            root,
5195            1,
5196            "src/card.ts",
5197            "import { vars } from '@acme/tokens';\n\
5198             export const color = vars.color.primary;\n",
5199        );
5200
5201        let computation = css_computation_3d(root, &[def, consumer]);
5202        let primary = find_token(&computation, "vars.color.primary")
5203            .expect("vars.color.primary blast radius present");
5204        assert_eq!(
5205            primary.consumer_count, 1,
5206            "workspace package import should count as a CSS-in-JS token consumer"
5207        );
5208        assert_eq!(primary.consumers[0].path, "src/card.ts");
5209    }
5210
5211    #[test]
5212    fn vanilla_extract_create_theme_blast_radius_resolves_tsconfig_alias_consumers() {
5213        let dir = tempfile::tempdir().unwrap();
5214        let root = dir.path();
5215        std::fs::write(
5216            root.join("package.json"),
5217            r#"{"dependencies":{"@vanilla-extract/css":"1.0.0"}}"#,
5218        )
5219        .unwrap();
5220        std::fs::write(
5221            root.join("tsconfig.json"),
5222            r#"{"compilerOptions":{"baseUrl":".","paths":{"@theme/*":["src/theme/*"]}}}"#,
5223        )
5224        .unwrap();
5225        let def = write_file(
5226            root,
5227            0,
5228            "src/theme/tokens.css.ts",
5229            "import { createTheme } from '@vanilla-extract/css';\n\
5230             export const [themeClass, vars] = createTheme({ color: { brand: 'red' } });\n",
5231        );
5232        let consumer = write_file(
5233            root,
5234            1,
5235            "src/box.css.ts",
5236            "import { style } from '@vanilla-extract/css';\n\
5237             import { vars } from '@theme/tokens.css';\n\
5238             export const box = style({ color: vars.color.brand });\n",
5239        );
5240
5241        let computation = css_computation_3d(root, &[def, consumer]);
5242        let brand =
5243            find_token(&computation, "vars.color.brand").expect("brand blast radius present");
5244        assert_eq!(
5245            brand.consumer_count, 1,
5246            "tsconfig alias import should count for vanilla-extract token consumers"
5247        );
5248        assert_eq!(brand.consumers[0].path, "src/box.css.ts");
5249        assert_eq!(
5250            brand.consumers[0].kind,
5251            fallow_output::ConsumerKind::JsMember
5252        );
5253    }
5254
5255    #[test]
5256    fn pandacss_define_tokens_blast_radius_located_js_call_consumers() {
5257        let dir = tempfile::tempdir().unwrap();
5258        let root = dir.path();
5259        std::fs::write(
5260            root.join("package.json"),
5261            r#"{"dependencies":{"@pandacss/dev":"0.54.0"}}"#,
5262        )
5263        .unwrap();
5264        let def = write_file(
5265            root,
5266            0,
5267            "panda.config.ts",
5268            "import { defineTokens } from '@pandacss/dev';\n\
5269             export const tokens = defineTokens({ colors: { brand: { value: '#f05a28' }, accent: { value: '#111' } } });\n",
5270        );
5271        let consumer = write_file(
5272            root,
5273            1,
5274            "src/card.ts",
5275            "import { css } from '../styled-system/css';\n\
5276             import { token } from '../styled-system/tokens';\n\
5277             export const card = css({ color: token('colors.brand') });\n",
5278        );
5279        let computation = css_computation_3d(root, &[def, consumer]);
5280        let brand = find_token(&computation, "tokens.colors.brand")
5281            .expect("Panda token blast radius present");
5282        assert_eq!(brand.namespace, "tokens");
5283        assert_eq!(brand.definition_path, "panda.config.ts");
5284        assert_eq!(brand.consumer_count, 1);
5285        assert_eq!(brand.consumers.len(), 1);
5286        assert_eq!(brand.consumers[0].kind, fallow_output::ConsumerKind::JsCall);
5287        assert_eq!(brand.consumers[0].path, "src/card.ts");
5288        let accent = find_token(&computation, "tokens.colors.accent")
5289            .expect("unconsumed Panda token still present");
5290        assert_eq!(accent.consumer_count, 0);
5291    }
5292
5293    #[test]
5294    fn pandacss_define_tokens_blast_radius_counts_style_object_token_strings() {
5295        let dir = tempfile::tempdir().unwrap();
5296        let root = dir.path();
5297        std::fs::write(
5298            root.join("package.json"),
5299            r#"{"dependencies":{"@pandacss/dev":"0.54.0"}}"#,
5300        )
5301        .unwrap();
5302        let def = write_file(
5303            root,
5304            0,
5305            "panda.config.ts",
5306            "import { defineTokens } from '@pandacss/dev';\n\
5307             export const tokens = defineTokens({ colors: { brand: { value: '#f05a28' }, accent: { value: '#111' } } });\n",
5308        );
5309        let consumer = write_file(
5310            root,
5311            1,
5312            "src/card.ts",
5313            "import { css } from '../styled-system/css';\n\
5314             export const card = css({ color: 'colors.brand', _hover: { bg: 'colors.accent' } });\n",
5315        );
5316        let computation = css_computation_3d(root, &[def, consumer]);
5317        let brand = find_token(&computation, "tokens.colors.brand").expect("brand token present");
5318        assert_eq!(brand.consumer_count, 1);
5319        assert_eq!(brand.consumers[0].kind, fallow_output::ConsumerKind::JsCall);
5320        assert_eq!(brand.consumers[0].path, "src/card.ts");
5321        let accent =
5322            find_token(&computation, "tokens.colors.accent").expect("accent token present");
5323        assert_eq!(accent.consumer_count, 1);
5324        assert_eq!(
5325            accent.consumers[0].kind,
5326            fallow_output::ConsumerKind::JsCall
5327        );
5328    }
5329
5330    #[test]
5331    fn pandacss_define_config_tokens_feed_blast_radius_and_raw_value_evidence() {
5332        let dir = tempfile::tempdir().unwrap();
5333        let root = dir.path();
5334        std::fs::write(
5335            root.join("package.json"),
5336            r#"{"dependencies":{"@pandacss/dev":"0.54.0"}}"#,
5337        )
5338        .unwrap();
5339        let config = write_file(
5340            root,
5341            0,
5342            "panda.config.ts",
5343            "import { defineConfig } from '@pandacss/dev';\n\
5344             export default defineConfig({\n\
5345               theme: {\n\
5346                 tokens: { colors: { brand: { value: '#f05a28' } } },\n\
5347                 semanticTokens: { colors: { surface: { value: { base: '{colors.brand}', _dark: '#111111' } } } },\n\
5348                 recipes: { card: { base: { color: 'colors.brand' } } },\n\
5349               },\n\
5350             });\n",
5351        );
5352        let consumer = write_file(
5353            root,
5354            1,
5355            "src/card.ts",
5356            "import { css } from '../styled-system/css';\n\
5357             export const card = css({ color: 'colors.brand', bg: 'colors.surface' });\n",
5358        );
5359        let css = write_file(
5360            root,
5361            2,
5362            "src/styles.css",
5363            ".panda-match { color: #f05a28; }\n",
5364        );
5365        let computation = css_computation_3d(root, &[config, consumer, css]);
5366
5367        let brand =
5368            find_token(&computation, "pandaConfig.colors.brand").expect("config token present");
5369        assert_eq!(brand.definition_path, "panda.config.ts");
5370        assert_eq!(brand.consumer_count, 1);
5371        assert_eq!(brand.consumers[0].kind, fallow_output::ConsumerKind::JsCall);
5372
5373        let surface =
5374            find_token(&computation, "pandaConfig.colors.surface").expect("semantic token present");
5375        assert_eq!(surface.consumer_count, 1);
5376
5377        assert!(
5378            computation.report.raw_style_values.iter().any(|raw| {
5379                raw.nearest_token
5380                    .as_ref()
5381                    .is_some_and(|token| token.name == "pandaConfig.colors.brand")
5382            }),
5383            "raw CSS should point at the static Panda config token"
5384        );
5385    }
5386
5387    #[test]
5388    fn style_vocabulary_repeated_project_values_explain_nearby_raw_drift() {
5389        let dir = tempfile::tempdir().unwrap();
5390        let root = dir.path();
5391        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
5392        let base = write_file(
5393            root,
5394            0,
5395            "src/base.css",
5396            ".card { color: #33679a; }\n.panel { border-color: #33679a; }\n",
5397        );
5398        let feature = write_file(root, 1, "src/feature.css", ".feature { color: #33679b; }\n");
5399
5400        let computation = css_computation(root, &[base, feature]).expect("raw CSS keeps report");
5401        let feature_value = computation
5402            .report
5403            .raw_style_values
5404            .iter()
5405            .find(|raw| raw.path == "src/feature.css" && raw.value == "#33679b")
5406            .expect("feature raw value is reported");
5407        let nearest = feature_value
5408            .nearest_token
5409            .as_ref()
5410            .expect("nearby project vocabulary value is suggested");
5411        assert_eq!(nearest.name, "project-vocabulary.color.#33679a");
5412        assert_eq!(nearest.value, "#33679a");
5413        assert_eq!(nearest.path, "src/base.css");
5414    }
5415
5416    #[test]
5417    fn style_vocabulary_abstains_on_alpha_color_nearest_values() {
5418        let dir = tempfile::tempdir().unwrap();
5419        let root = dir.path();
5420        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
5421        let base = write_file(
5422            root,
5423            0,
5424            "src/base.css",
5425            ".overlay { color: #00000040; }\n.scrim { border-color: #00000040; }\n",
5426        );
5427        let feature = write_file(root, 1, "src/feature.css", ".feature { color: #0000; }\n");
5428
5429        let computation = css_computation(root, &[base, feature]).expect("raw CSS keeps report");
5430        let feature_value = computation
5431            .report
5432            .raw_style_values
5433            .iter()
5434            .find(|raw| raw.path == "src/feature.css" && raw.value == "#0000")
5435            .expect("feature alpha raw value is reported");
5436        assert!(
5437            feature_value.nearest_token.is_none(),
5438            "project-vocabulary should not compare alpha-bearing color values through RGB-only distance"
5439        );
5440    }
5441
5442    #[test]
5443    fn style_vocabulary_abstains_when_raw_alpha_color_is_near_opaque_value() {
5444        let dir = tempfile::tempdir().unwrap();
5445        let root = dir.path();
5446        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
5447        let base = write_file(
5448            root,
5449            0,
5450            "src/base.css",
5451            ".card { color: #ffffff; }\n.panel { border-color: #ffffff; }\n",
5452        );
5453        let feature = write_file(
5454            root,
5455            1,
5456            "src/feature.css",
5457            ".feature { color: #ffffff80; }\n",
5458        );
5459
5460        let computation = css_computation(root, &[base, feature]).expect("raw CSS keeps report");
5461        let feature_value = computation
5462            .report
5463            .raw_style_values
5464            .iter()
5465            .find(|raw| raw.path == "src/feature.css" && raw.value == "#ffffff80")
5466            .expect("feature alpha raw value is reported");
5467        assert!(
5468            feature_value.nearest_token.is_none(),
5469            "project-vocabulary should not compare alpha raw values through RGB-only distance"
5470        );
5471    }
5472
5473    #[test]
5474    fn raw_style_value_abstains_when_alpha_color_is_near_explicit_token() {
5475        let dir = tempfile::tempdir().unwrap();
5476        let root = dir.path();
5477        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
5478        let file = write_file(
5479            root,
5480            0,
5481            "src/styles.css",
5482            ":root { --color-black: #000; }\n.feature { background-color: #0000; }\n",
5483        );
5484
5485        let computation = css_computation(root, &[file]).expect("raw CSS keeps report");
5486        let feature_value = computation
5487            .report
5488            .raw_style_values
5489            .iter()
5490            .find(|raw| raw.path == "src/styles.css" && raw.value == "#0000")
5491            .expect("feature alpha raw value is reported");
5492        assert!(
5493            feature_value.nearest_token.is_none(),
5494            "raw alpha colors should not compare to opaque explicit tokens through RGB-only distance"
5495        );
5496    }
5497
5498    #[test]
5499    fn style_vocabulary_abstains_between_two_repeated_project_values() {
5500        let dir = tempfile::tempdir().unwrap();
5501        let root = dir.path();
5502        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
5503        let base = write_file(
5504            root,
5505            0,
5506            "src/base.css",
5507            ".card { color: #ffffff; }\n.panel { border-color: #ffffff; }\n",
5508        );
5509        let alternate = write_file(
5510            root,
5511            1,
5512            "src/alternate.css",
5513            ".soft { color: #fafafa; }\n.muted { border-color: #fafafa; }\n",
5514        );
5515
5516        let computation = css_computation(root, &[base, alternate]).expect("raw CSS keeps report");
5517        let repeated_with_suggestions = computation
5518            .report
5519            .raw_style_values
5520            .iter()
5521            .filter(|raw| raw.nearest_token.is_some())
5522            .count();
5523        assert_eq!(
5524            repeated_with_suggestions, 0,
5525            "project-vocabulary should not suggest one repeated local convention over another repeated convention"
5526        );
5527    }
5528
5529    #[test]
5530    fn pandacss_define_tokens_blast_radius_accepts_aliased_generated_token_imports() {
5531        let dir = tempfile::tempdir().unwrap();
5532        let root = dir.path();
5533        std::fs::write(
5534            root.join("package.json"),
5535            r#"{"dependencies":{"@pandacss/dev":"0.54.0"}}"#,
5536        )
5537        .unwrap();
5538        std::fs::write(
5539            root.join("tsconfig.json"),
5540            r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
5541        )
5542        .unwrap();
5543        let def = write_file(
5544            root,
5545            0,
5546            "panda.config.ts",
5547            "import { defineTokens } from '@pandacss/dev';\n\
5548             export const tokens = defineTokens({ colors: { brand: { value: '#f05a28' } } });\n",
5549        );
5550        let consumer = write_file(
5551            root,
5552            1,
5553            "src/card.ts",
5554            "import { token as pandaToken } from '@/styled-system/tokens';\n\
5555             export const cardColor = pandaToken('colors.brand');\n",
5556        );
5557
5558        let computation = css_computation_3d(root, &[def, consumer]);
5559        let brand = find_token(&computation, "tokens.colors.brand")
5560            .expect("Panda token blast radius present");
5561        assert_eq!(
5562            brand.consumer_count, 1,
5563            "path-aliased styled-system token import should count for Panda consumers"
5564        );
5565        assert_eq!(brand.consumers[0].path, "src/card.ts");
5566        assert_eq!(brand.consumers[0].kind, fallow_output::ConsumerKind::JsCall);
5567    }
5568
5569    #[test]
5570    fn both_tailwind_and_css_in_js_tokens_merge_in_deterministic_global_order() {
5571        // A project using BOTH Tailwind v4 @theme tokens AND StyleX defineVars: the
5572        // combined token_consumers carries both origins and is globally sorted by
5573        // (token, definition_path), not Tailwind-block-then-CSS-in-JS-block.
5574        let dir = tempfile::tempdir().unwrap();
5575        let root = dir.path();
5576        std::fs::write(
5577            root.join("package.json"),
5578            r#"{"dependencies":{"tailwindcss":"4.0.0","@stylexjs/stylex":"0.1.0"}}"#,
5579        )
5580        .unwrap();
5581        let theme = write_file(
5582            root,
5583            0,
5584            "src/theme.css",
5585            "@theme {\n  --color-brand: #3b82f6;\n}\n",
5586        );
5587        // A markup consumer of the Tailwind token (utility class `text-brand`).
5588        let markup = write_file(
5589            root,
5590            1,
5591            "src/App.tsx",
5592            "export const A = () => <p className=\"text-brand\">x</p>;\n",
5593        );
5594        let tokens_file = write_file(
5595            root,
5596            2,
5597            "src/tokens.stylex.ts",
5598            "import * as stylex from '@stylexjs/stylex';\n\
5599             export const vars = stylex.defineVars({ accent: '#000' });\n",
5600        );
5601        let card = write_file(
5602            root,
5603            3,
5604            "src/Card.ts",
5605            "import { vars } from './tokens.stylex';\nexport const x = vars.accent;\n",
5606        );
5607        let computation = css_computation_3d(root, &[theme, markup, tokens_file, card]);
5608        let tokens: Vec<&str> = computation
5609            .report
5610            .token_consumers
5611            .iter()
5612            .map(|t| t.token.as_str())
5613            .collect();
5614        // Both origins present.
5615        assert!(
5616            tokens.iter().any(|t| t.starts_with("--")),
5617            "Tailwind @theme token present: {tokens:?}"
5618        );
5619        assert!(
5620            tokens.iter().any(|t| t == &"vars.accent"),
5621            "CSS-in-JS token present: {tokens:?}"
5622        );
5623        // Globally sorted by token (the combined-list contract).
5624        let mut sorted = tokens.clone();
5625        sorted.sort_unstable();
5626        assert_eq!(
5627            tokens, sorted,
5628            "combined token_consumers is globally token-sorted"
5629        );
5630    }
5631
5632    #[test]
5633    fn vanilla_extract_create_theme_tuple_blast_radius() {
5634        let dir = tempfile::tempdir().unwrap();
5635        let root = dir.path();
5636        std::fs::write(
5637            root.join("package.json"),
5638            r#"{"dependencies":{"@vanilla-extract/css":"1.0.0"}}"#,
5639        )
5640        .unwrap();
5641        let def = write_file(
5642            root,
5643            0,
5644            "src/theme.css.ts",
5645            "import { createTheme } from '@vanilla-extract/css';\n\
5646             export const [themeClass, vars] = createTheme({ color: { brand: 'red' } });\n",
5647        );
5648        let consumer = write_file(
5649            root,
5650            1,
5651            "src/box.css.ts",
5652            "import { style } from '@vanilla-extract/css';\n\
5653             import { vars } from './theme.css';\n\
5654             export const box = style({ color: vars.color.brand });\n",
5655        );
5656        let computation = css_computation_3d(root, &[def, consumer]);
5657        let brand =
5658            find_token(&computation, "vars.color.brand").expect("brand blast radius present");
5659        assert_eq!(brand.consumer_count, 1);
5660        assert_eq!(brand.consumers[0].path, "src/box.css.ts");
5661        assert_eq!(
5662            brand.consumers[0].kind,
5663            fallow_output::ConsumerKind::JsMember
5664        );
5665    }
5666
5667    #[test]
5668    fn styled_components_and_emotion_theme_reads_feed_token_consumers() {
5669        let dir = tempfile::tempdir().unwrap();
5670        let root = dir.path();
5671        std::fs::write(
5672            root.join("package.json"),
5673            r#"{"dependencies":{"styled-components":"6.1.0","@emotion/react":"11.0.0","@emotion/styled":"11.0.0"}}"#,
5674        )
5675        .unwrap();
5676        let theme = write_file(
5677            root,
5678            0,
5679            "src/theme.ts",
5680            "export const appTheme = { colors: { brand: '#f05a28' }, space: { card: '1rem' } };\n",
5681        );
5682        let provider = write_file(
5683            root,
5684            1,
5685            "src/App.tsx",
5686            "import { ThemeProvider } from 'styled-components';\n\
5687             import { appTheme } from './theme';\n\
5688             export const App = ({ children }) => <ThemeProvider theme={appTheme}>{children}</ThemeProvider>;\n",
5689        );
5690        let styled_template = write_file(
5691            root,
5692            2,
5693            "src/Card.tsx",
5694            "import styled from 'styled-components';\n\
5695             export const Card = styled.div`\n\
5696               color: ${({ theme }) => theme.colors.brand};\n\
5697               margin: ${props => props.theme.space.card};\n\
5698             `;\n",
5699        );
5700        let emotion = write_file(
5701            root,
5702            3,
5703            "src/Emotion.tsx",
5704            "import styled from '@emotion/styled';\n\
5705             export const Link = styled.a(({ theme }) => ({ color: theme.colors.brand }));\n\
5706             export const Box = () => <div css={(theme) => ({ margin: theme.space.card })} />;\n",
5707        );
5708
5709        let computation = css_computation_3d(root, &[theme, provider, styled_template, emotion]);
5710        let brand = find_token(&computation, "appTheme.colors.brand")
5711            .expect("theme brand blast radius present");
5712        assert_eq!(brand.definition_path, "src/theme.ts");
5713        assert_eq!(brand.consumer_count, 2);
5714        assert!(
5715            brand
5716                .consumers
5717                .iter()
5718                .all(|consumer| consumer.kind == fallow_output::ConsumerKind::JsMember)
5719        );
5720        let space = find_token(&computation, "appTheme.space.card")
5721            .expect("theme spacing blast radius present");
5722        assert_eq!(space.consumer_count, 2);
5723        let paths: Vec<&str> = space
5724            .consumers
5725            .iter()
5726            .map(|consumer| consumer.path.as_str())
5727            .collect();
5728        assert!(paths.contains(&"src/Card.tsx") && paths.contains(&"src/Emotion.tsx"));
5729    }
5730
5731    #[test]
5732    fn theme_object_without_theme_provider_is_not_a_token_surface() {
5733        let dir = tempfile::tempdir().unwrap();
5734        let root = dir.path();
5735        std::fs::write(
5736            root.join("package.json"),
5737            r#"{"dependencies":{"styled-components":"6.1.0"}}"#,
5738        )
5739        .unwrap();
5740        let theme = write_file(
5741            root,
5742            0,
5743            "src/theme.ts",
5744            "export const appTheme = { colors: { brand: '#f05a28' } };\n",
5745        );
5746        let consumer = write_file(
5747            root,
5748            1,
5749            "src/Card.tsx",
5750            "import styled from 'styled-components';\n\
5751             export const Card = styled.div`${({ theme }) => theme.colors.brand}`;\n",
5752        );
5753        let computation = css_computation_3d(root, &[theme, consumer]);
5754        assert!(
5755            find_token(&computation, "appTheme.colors.brand").is_none(),
5756            "theme-like objects require ThemeProvider wiring"
5757        );
5758    }
5759
5760    #[test]
5761    fn zero_false_consumer_same_name_from_unrelated_module() {
5762        let dir = tempfile::tempdir().unwrap();
5763        let root = dir.path();
5764        std::fs::write(
5765            root.join("package.json"),
5766            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5767        )
5768        .unwrap();
5769        let def = write_file(
5770            root,
5771            0,
5772            "src/tokens.stylex.ts",
5773            "import * as stylex from '@stylexjs/stylex';\n\
5774             export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
5775        );
5776        // A DIFFERENT module also exporting `vars`, read as `vars.color.primary`,
5777        // must NOT be counted against the design-token `vars`.
5778        let other = write_file(
5779            root,
5780            1,
5781            "src/other.ts",
5782            "export const vars = { color: { primary: 1 } };\n",
5783        );
5784        let consumer = write_file(
5785            root,
5786            2,
5787            "src/use-other.ts",
5788            "import { vars } from './other';\n\
5789             export const x = vars.color.primary;\n",
5790        );
5791        let computation = css_computation_3d(root, &[def, other, consumer]);
5792        let primary = find_token(&computation, "vars.color.primary").expect("token present");
5793        assert_eq!(
5794            primary.consumer_count, 0,
5795            "import of same-named `vars` from an unrelated module must not be a consumer",
5796        );
5797    }
5798
5799    #[test]
5800    fn zero_double_count_one_site_counts_once_and_intermediate_not_counted() {
5801        let dir = tempfile::tempdir().unwrap();
5802        let root = dir.path();
5803        std::fs::write(
5804            root.join("package.json"),
5805            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5806        )
5807        .unwrap();
5808        let def = write_file(
5809            root,
5810            0,
5811            "src/t.stylex.ts",
5812            "import * as stylex from '@stylexjs/stylex';\n\
5813             export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
5814        );
5815        // One access site reads `vars.color.primary` (which records TWO member-access
5816        // records: {vars.color, primary} + {vars, color}). It must count ONCE, and
5817        // the intermediate `vars.color` group must not be a separate consumer.
5818        let consumer = write_file(
5819            root,
5820            1,
5821            "src/c.ts",
5822            "import { vars } from './t.stylex';\nexport const x = vars.color.primary;\n",
5823        );
5824        let computation = css_computation_3d(root, &[def, consumer]);
5825        let primary = find_token(&computation, "vars.color.primary").expect("token present");
5826        assert_eq!(primary.consumer_count, 1, "one access site counts once");
5827        // `vars.color` (intermediate group) is not a defined leaf, so no entry.
5828        assert!(find_token(&computation, "vars.color").is_none());
5829    }
5830
5831    #[test]
5832    fn aliased_import_and_multi_file_counting() {
5833        let dir = tempfile::tempdir().unwrap();
5834        let root = dir.path();
5835        std::fs::write(
5836            root.join("package.json"),
5837            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5838        )
5839        .unwrap();
5840        let def = write_file(
5841            root,
5842            0,
5843            "src/t.stylex.ts",
5844            "import * as stylex from '@stylexjs/stylex';\n\
5845             export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
5846        );
5847        let c1 = write_file(
5848            root,
5849            1,
5850            "src/a.ts",
5851            "import { vars as v } from './t.stylex';\nexport const x = v.color.primary;\n",
5852        );
5853        let c2 = write_file(
5854            root,
5855            2,
5856            "src/b.ts",
5857            "import { vars } from './t.stylex';\nexport const y = vars.color.primary;\n",
5858        );
5859        let computation = css_computation_3d(root, &[def, c1, c2]);
5860        let primary = find_token(&computation, "vars.color.primary").expect("token present");
5861        assert_eq!(
5862            primary.consumer_count, 2,
5863            "aliased + plain imports both counted across files"
5864        );
5865        let paths: Vec<&str> = primary.consumers.iter().map(|c| c.path.as_str()).collect();
5866        assert!(paths.contains(&"src/a.ts") && paths.contains(&"src/b.ts"));
5867    }
5868
5869    #[test]
5870    fn non_css_in_js_project_emits_no_js_member_consumers() {
5871        let dir = tempfile::tempdir().unwrap();
5872        let root = dir.path();
5873        std::fs::write(
5874            root.join("package.json"),
5875            r#"{"dependencies":{"react":"18.0.0"}}"#,
5876        )
5877        .unwrap();
5878        let f = write_file(
5879            root,
5880            0,
5881            "src/x.ts",
5882            "export const vars = { color: { primary: '#000' } };\nexport const y = vars.color.primary;\n",
5883        );
5884        let modules = vec![fallow_extract::parse_source_to_module(
5885            f.id,
5886            &f.path,
5887            &std::fs::read_to_string(&f.path).unwrap(),
5888            0,
5889            false,
5890        )];
5891        let config = config_at(root);
5892        let computation = compute_css_analytics_report(
5893            &[f],
5894            &modules,
5895            HealthScanCtx {
5896                config: &config,
5897                ignore_set: &globset::GlobSet::empty(),
5898                changed_files: None,
5899                output_changed_files: None,
5900                ws_roots: None,
5901            },
5902        );
5903        // No CSS-in-JS deps -> the gate is closed; whether or not css_analytics is
5904        // None, there are no js-member token consumers.
5905        if let Some(computation) = computation {
5906            assert!(js_token_consumers(&computation).is_empty());
5907        }
5908    }
5909
5910    #[test]
5911    fn vanilla_extract_object_styles_feed_css_analytics_and_grade() {
5912        let dir = tempfile::tempdir().unwrap();
5913        let root = dir.path();
5914        std::fs::write(
5915            root.join("package.json"),
5916            r#"{"dependencies":{"@vanilla-extract/css":"1.0.0"}}"#,
5917        )
5918        .unwrap();
5919        // Two identical 4-declaration style() buckets -> a duplicate block; two
5920        // distinct colors -> token sprawl. vanilla-extract is non-atomic.
5921        let file = write_file(
5922            root,
5923            0,
5924            "src/styles.css.ts",
5925            "import { style } from '@vanilla-extract/css';\n\
5926             export const a = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n\
5927             export const b = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n\
5928             export const c = style({ color: 'blue' });\n",
5929        );
5930        let computation = css_computation(root, &[file]).expect("css_analytics is non-null");
5931        let report = &computation.report;
5932        assert!(
5933            report.summary.files_analyzed >= 1,
5934            "object styles analyzed: {:?}",
5935            report.summary
5936        );
5937        assert!(
5938            report.summary.unique_colors >= 2,
5939            "distinct colors counted from object styles: {:?}",
5940            report.summary
5941        );
5942        assert!(
5943            !report.duplicate_declaration_blocks.is_empty(),
5944            "identical object buckets surface a duplicate block",
5945        );
5946        // Non-atomic: the declarations feed the grade inputs, no atomic.
5947        assert!(computation.scoring_inputs.non_atomic_declarations >= 8);
5948        assert_eq!(computation.scoring_inputs.atomic_declarations, 0);
5949        let styling = crate::health::styling_score::compute_styling_health_with_inputs(
5950            report,
5951            &computation.scoring_inputs,
5952        );
5953        // A real (non-inflated) grade with a real duplication penalty.
5954        assert!(styling.penalties.duplication > 0.0, "duplication penalized");
5955    }
5956
5957    #[test]
5958    fn stylex_atomic_styles_do_not_inflate_grade() {
5959        let dir = tempfile::tempdir().unwrap();
5960        let root = dir.path();
5961        std::fs::write(
5962            root.join("package.json"),
5963            r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
5964        )
5965        .unwrap();
5966        let file = write_file(
5967            root,
5968            0,
5969            "src/styles.ts",
5970            "import * as stylex from '@stylexjs/stylex';\n\
5971             export const s = stylex.create({\n\
5972             root: { color: 'red', padding: 16, margin: 8, fontSize: 14 },\n\
5973             card: { color: 'blue', display: 'flex' },\n\
5974             });\n",
5975        );
5976        let computation = css_computation(root, &[file]).expect("css_analytics is non-null");
5977        let report = &computation.report;
5978        // Token sprawl IS fed for atomic CSS (two distinct colors).
5979        assert!(
5980            report.summary.unique_colors >= 2,
5981            "atomic token sprawl counted: {:?}",
5982            report.summary
5983        );
5984        // Atomic declarations are tracked but excluded from the grade inputs.
5985        assert!(computation.scoring_inputs.atomic_declarations >= 4);
5986        assert_eq!(
5987            computation.scoring_inputs.non_atomic_declarations, 0,
5988            "no non-atomic gradeable surface in a pure-StyleX project",
5989        );
5990        let styling = crate::health::styling_score::compute_styling_health_with_inputs(
5991            report,
5992            &computation.scoring_inputs,
5993        );
5994        // The structural penalty is not driven up OR down by the flat atomic
5995        // rules (computed over the empty non-atomic surface), and the grade is
5996        // marked low-confidence with the atomic reason rather than a confident A.
5997        assert_eq!(
5998            styling.confidence,
5999            fallow_output::StylingHealthConfidence::Low,
6000            "predominantly-atomic project is low-confidence",
6001        );
6002        let reason = styling.confidence_reason.expect("atomic caveat");
6003        assert!(
6004            reason.contains("compile-time-atomic"),
6005            "atomic reason names non-assessability: {reason:?}",
6006        );
6007    }
6008
6009    #[test]
6010    fn non_object_css_in_js_project_is_byte_identical() {
6011        let dir = tempfile::tempdir().unwrap();
6012        let root = dir.path();
6013        // No CSS-in-JS dependency declared at all.
6014        std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
6015        // A local `style({...})` helper that LOOKS like vanilla-extract but is not
6016        // gated in: the JS/TS arm is never scanned, so there is nothing to analyze.
6017        let file = write_file(
6018            root,
6019            0,
6020            "src/styles.ts",
6021            "const style = (o) => o;\n\
6022             export const a = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n",
6023        );
6024        assert!(
6025            css_computation(root, &[file]).is_none(),
6026            "a project with no CSS-in-JS deps yields no CSS analytics (byte-identical to pre-3c)",
6027        );
6028    }
6029}