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