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