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