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
12mod classes;
13mod cva;
14mod markup_scan;
15mod near_duplicates;
16mod preprocessor;
17mod theme_tokens;
18mod token_consumers;
19
20use classes::*;
21use cva::*;
22use markup_scan::*;
23use near_duplicates::*;
24use preprocessor::*;
25use theme_tokens::*;
26use token_consumers::*;
27
28const MAX_REPORTED_RAW_STYLE_VALUES: usize = 200;
29
30#[derive(Clone, Copy)]
34pub(super) struct HealthScanCtx<'a> {
35 pub(super) config: &'a ResolvedConfig,
36 pub(super) ignore_set: &'a globset::GlobSet,
37 pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
38 pub(super) output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
39 pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
40}
41
42#[derive(Clone, Debug)]
45pub struct StylingAnalysisArtifacts {
46 reference_surface: CssReferenceSurface,
47 class_inventory: CssClassInventory,
48 whole_scope_walk: CssWalkAccum,
49}
50
51pub(super) fn build_styling_analysis_artifacts(
52 files: &[fallow_types::discover::DiscoveredFile],
53 config: &ResolvedConfig,
54) -> StylingAnalysisArtifacts {
55 let ignore_set = super::ignore::build_ignore_set(&config.health.ignore);
56 StylingAnalysisArtifacts {
57 reference_surface: css_reference_surface(files, config, &ignore_set),
58 class_inventory: css_class_inventory(files, config, &ignore_set),
59 whole_scope_walk: walk_css_files(
60 files,
61 HealthScanCtx {
62 config,
63 ignore_set: &ignore_set,
64 changed_files: None,
65 output_changed_files: None,
66 ws_roots: None,
67 },
68 ),
69 }
70}
71
72#[derive(Clone, Default, Debug)]
85struct CssTokenSets {
86 colors: rustc_hash::FxHashSet<String>,
87 font_sizes: rustc_hash::FxHashSet<String>,
88 z_indexes: rustc_hash::FxHashSet<String>,
89 box_shadows: rustc_hash::FxHashSet<String>,
90 border_radii: rustc_hash::FxHashSet<String>,
91 line_heights: rustc_hash::FxHashSet<String>,
92 defined_custom_props: rustc_hash::FxHashSet<String>,
93 referenced_custom_props: rustc_hash::FxHashSet<String>,
94 defined_keyframes: rustc_hash::FxHashSet<String>,
95 referenced_keyframes: rustc_hash::FxHashSet<String>,
96 keyframes_definers: rustc_hash::FxHashMap<String, String>,
97 keyframe_referencers: rustc_hash::FxHashMap<String, String>,
98 declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
101 registered_custom_props: rustc_hash::FxHashSet<String>,
104 declared_layers: rustc_hash::FxHashSet<String>,
105 populated_layers: rustc_hash::FxHashSet<String>,
106 property_registrars: rustc_hash::FxHashMap<String, String>,
107 layer_declarers: rustc_hash::FxHashMap<String, String>,
108 defined_font_faces: rustc_hash::FxHashSet<String>,
111 referenced_font_families: rustc_hash::FxHashSet<String>,
112 font_face_definers: rustc_hash::FxHashMap<String, String>,
113 theme_token_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
116 custom_property_definers: rustc_hash::FxHashMap<String, ThemeTokenDefinition>,
119 apply_tokens: rustc_hash::FxHashSet<String>,
122 theme_var_reads: rustc_hash::FxHashSet<String>,
127 theme_var_reads_located: Vec<(String, String, u32)>,
129 css_var_reads_located: Vec<(String, String, u32)>,
131 apply_uses_located: Vec<(String, String, u32)>,
133 any_plugin_directive: bool,
138 raw_style_values: Vec<fallow_output::RawStyleValue>,
140}
141
142#[derive(Clone, Debug)]
143struct ThemeTokenDefinition {
144 path: String,
145 line: u32,
146 value: String,
147}
148
149impl CssTokenSets {
150 fn group_duplicate_blocks(
154 &self,
155 summary: &mut fallow_output::CssAnalyticsSummary,
156 ) -> Vec<fallow_output::CssDuplicateBlock> {
157 use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
158
159 let mut groups: Vec<CssDuplicateBlock> = self
160 .declaration_blocks
161 .values()
162 .filter(|(_, occurrences)| occurrences.len() >= 2)
163 .map(|(declaration_count, occurrences)| {
164 let occurrence_count = saturate_len(occurrences.len());
165 let estimated_savings = occurrence_count
166 .saturating_sub(1)
167 .saturating_mul(u32::from(*declaration_count));
168 let mut occ: Vec<CssBlockOccurrence> = occurrences
169 .iter()
170 .map(|(path, line)| CssBlockOccurrence {
171 path: path.clone(),
172 line: *line,
173 })
174 .collect();
175 occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
176 CssDuplicateBlock {
177 declaration_count: *declaration_count,
178 occurrence_count,
179 estimated_savings,
180 occurrences: occ,
181 actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
182 }
183 })
184 .collect();
185 groups.sort_by(|a, b| {
188 b.estimated_savings
189 .cmp(&a.estimated_savings)
190 .then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
191 });
192 summary.duplicate_declaration_blocks = saturate_len(groups.len());
193 summary.duplicate_declarations_total = groups
194 .iter()
195 .fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
196 groups
197 }
198
199 fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
202 self.record_design_tokens(analytics);
203 self.record_custom_properties(analytics, rel);
204 self.record_keyframes(analytics, rel);
205 self.record_declaration_blocks(analytics, rel);
206 self.record_font_faces_and_layers(analytics, rel);
207 self.record_raw_style_values(analytics, rel);
208 }
209
210 fn record_design_tokens(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
211 self.colors.extend(analytics.colors.iter().cloned());
212 self.font_sizes.extend(analytics.font_sizes.iter().cloned());
213 self.z_indexes.extend(analytics.z_indexes.iter().cloned());
214 self.box_shadows
215 .extend(analytics.box_shadows.iter().cloned());
216 self.border_radii
217 .extend(analytics.border_radii.iter().cloned());
218 self.line_heights
219 .extend(analytics.line_heights.iter().cloned());
220 }
221
222 fn record_custom_properties(
223 &mut self,
224 analytics: &fallow_types::extract::CssAnalytics,
225 rel: &str,
226 ) {
227 self.defined_custom_props
228 .extend(analytics.defined_custom_properties.iter().cloned());
229 for token in &analytics.custom_property_definitions {
230 self.custom_property_definers
231 .entry(token.name.clone())
232 .or_insert_with(|| ThemeTokenDefinition {
233 path: rel.to_owned(),
234 line: token.line,
235 value: token.value.clone(),
236 });
237 }
238 self.referenced_custom_props
239 .extend(analytics.referenced_custom_properties.iter().cloned());
240 for name in &analytics.registered_custom_properties {
241 self.registered_custom_props.insert(name.clone());
242 self.property_registrars
243 .entry(name.clone())
244 .or_insert_with(|| rel.to_owned());
245 }
246 }
247
248 fn record_keyframes(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
249 for keyframes in &analytics.referenced_keyframes {
250 self.referenced_keyframes.insert(keyframes.clone());
251 self.keyframe_referencers
252 .entry(keyframes.clone())
253 .or_insert_with(|| rel.to_owned());
254 }
255 for keyframes in &analytics.defined_keyframes {
256 self.defined_keyframes.insert(keyframes.clone());
257 self.keyframes_definers
258 .entry(keyframes.clone())
259 .or_insert_with(|| rel.to_owned());
260 }
261 }
262
263 fn record_declaration_blocks(
264 &mut self,
265 analytics: &fallow_types::extract::CssAnalytics,
266 rel: &str,
267 ) {
268 for block in &analytics.declaration_blocks {
269 self.declaration_blocks
270 .entry(block.fingerprint)
271 .or_insert_with(|| (block.declaration_count, Vec::new()))
272 .1
273 .push((rel.to_owned(), block.line));
274 }
275 }
276
277 fn record_font_faces_and_layers(
278 &mut self,
279 analytics: &fallow_types::extract::CssAnalytics,
280 rel: &str,
281 ) {
282 for family in &analytics.referenced_font_families {
283 self.referenced_font_families.insert(family.clone());
284 }
285 for family in &analytics.defined_font_faces {
286 self.defined_font_faces.insert(family.clone());
287 self.font_face_definers
288 .entry(family.clone())
289 .or_insert_with(|| rel.to_owned());
290 }
291 for name in &analytics.populated_layers {
292 self.populated_layers.insert(name.clone());
293 }
294 for name in &analytics.declared_layers {
295 self.declared_layers.insert(name.clone());
296 self.layer_declarers
297 .entry(name.clone())
298 .or_insert_with(|| rel.to_owned());
299 }
300 }
301
302 fn record_raw_style_values(
303 &mut self,
304 analytics: &fallow_types::extract::CssAnalytics,
305 rel: &str,
306 ) {
307 for raw in &analytics.raw_style_values {
308 if self.raw_style_values.len() >= MAX_REPORTED_RAW_STYLE_VALUES {
309 break;
310 }
311 self.raw_style_values.push(fallow_output::RawStyleValue {
312 axis: raw.axis.clone(),
313 property: raw.property.clone(),
314 value: raw.value.clone(),
315 path: rel.to_owned(),
316 line: raw.line,
317 nearest_token: None,
318 actions: vec![fallow_output::CssCandidateAction::replace_raw_style_value(
319 &raw.axis, &raw.value,
320 )],
321 });
322 }
323 }
324
325 fn record_theme(&mut self, source: &str, rel: &str) {
331 let scan = crate::css::scan_theme_blocks(source);
332 for token in scan.tokens {
333 self.theme_token_definers
334 .entry(token.name)
335 .or_insert_with(|| ThemeTokenDefinition {
336 path: rel.to_owned(),
337 line: token.line,
338 value: token.value,
339 });
340 }
341 for (name, line) in scan.theme_var_reads {
342 self.theme_var_reads.insert(name.clone());
343 self.theme_var_reads_located
344 .push((name, rel.to_owned(), line));
345 }
346 self.apply_tokens
347 .extend(crate::css::extract_apply_tokens(source));
348 self.apply_uses_located.extend(
349 crate::css::extract_apply_tokens_located(source)
350 .into_iter()
351 .map(|(token, line)| (token, rel.to_owned(), line)),
352 );
353 self.css_var_reads_located.extend(
354 crate::css::extract_css_var_reads_located(source)
355 .into_iter()
356 .map(|(name, line)| (name, rel.to_owned(), line)),
357 );
358 if source.contains("@plugin") {
359 self.any_plugin_directive = true;
360 }
361 }
362
363 fn group_unused_at_rules(
367 &self,
368 summary: &mut fallow_output::CssAnalyticsSummary,
369 ) -> Vec<fallow_output::UnusedAtRule> {
370 use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
371
372 let mut out: Vec<UnusedAtRule> = Vec::new();
373 for name in self
374 .registered_custom_props
375 .difference(&self.referenced_custom_props)
376 {
377 out.push(UnusedAtRule {
378 kind: UnusedAtRuleKind::PropertyRegistration,
379 name: name.clone(),
380 path: self
381 .property_registrars
382 .get(name)
383 .cloned()
384 .unwrap_or_default(),
385 actions: vec![CssCandidateAction::verify_unused_at_rule(
386 UnusedAtRuleKind::PropertyRegistration,
387 name,
388 )],
389 });
390 }
391 summary.unused_property_registrations = saturate_len(out.len());
392 let property_count = out.len();
393 for name in self.declared_layers.difference(&self.populated_layers) {
394 out.push(UnusedAtRule {
395 kind: UnusedAtRuleKind::Layer,
396 name: name.clone(),
397 path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
398 actions: vec![CssCandidateAction::verify_unused_at_rule(
399 UnusedAtRuleKind::Layer,
400 name,
401 )],
402 });
403 }
404 summary.unused_layers = saturate_len(out.len() - property_count);
405 out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
406 out
407 }
408
409 fn finalize(
413 &self,
414 summary: &mut fallow_output::CssAnalyticsSummary,
415 ) -> (
416 Vec<fallow_output::UnreferencedKeyframes>,
417 Vec<fallow_output::UndefinedKeyframes>,
418 ) {
419 use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
420
421 summary.unique_colors = saturate_len(self.colors.len());
422 summary.unique_font_sizes = saturate_len(self.font_sizes.len());
423 summary.unique_z_indexes = saturate_len(self.z_indexes.len());
424 summary.unique_box_shadows = saturate_len(self.box_shadows.len());
425 summary.unique_border_radii = saturate_len(self.border_radii.len());
426 summary.unique_line_heights = saturate_len(self.line_heights.len());
427 summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
428 summary.custom_properties_unreferenced = saturate_len(
429 self.defined_custom_props
430 .difference(&self.referenced_custom_props)
431 .count(),
432 );
433 summary.custom_properties_undefined = saturate_len(
437 self.referenced_custom_props
438 .difference(&self.defined_custom_props)
439 .count(),
440 );
441 summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
442 summary.keyframes_unreferenced = saturate_len(
443 self.defined_keyframes
444 .difference(&self.referenced_keyframes)
445 .count(),
446 );
447 summary.keyframes_undefined = saturate_len(
448 self.referenced_keyframes
449 .difference(&self.defined_keyframes)
450 .count(),
451 );
452
453 let unreferenced_keyframes = locate_keyframe_diff(
456 &self.defined_keyframes,
457 &self.referenced_keyframes,
458 &self.keyframes_definers,
459 )
460 .into_iter()
461 .map(|(name, path)| UnreferencedKeyframes {
462 actions: vec![CssCandidateAction::verify_keyframe(&name)],
463 name,
464 path,
465 })
466 .collect();
467 let undefined_keyframes = locate_keyframe_diff(
468 &self.referenced_keyframes,
469 &self.defined_keyframes,
470 &self.keyframe_referencers,
471 )
472 .into_iter()
473 .map(|(name, path)| UndefinedKeyframes {
474 actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
475 name,
476 path,
477 })
478 .collect();
479 (unreferenced_keyframes, undefined_keyframes)
480 }
481
482 fn unused_font_faces(
486 &self,
487 summary: &mut fallow_output::CssAnalyticsSummary,
488 ) -> Vec<fallow_output::UnusedFontFace> {
489 use fallow_output::{CssCandidateAction, UnusedFontFace};
490 let referenced_lower: rustc_hash::FxHashSet<String> = self
495 .referenced_font_families
496 .iter()
497 .map(|family| family.to_ascii_lowercase())
498 .collect();
499 let mut out: Vec<UnusedFontFace> = self
500 .defined_font_faces
501 .iter()
502 .filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
503 .map(|family| UnusedFontFace {
504 actions: vec![CssCandidateAction::verify_unused_font_face(family)],
505 path: self
506 .font_face_definers
507 .get(family)
508 .cloned()
509 .unwrap_or_default(),
510 family: family.clone(),
511 })
512 .collect();
513 out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
514 summary.unused_font_faces = saturate_len(out.len());
515 out
516 }
517
518 fn font_size_unit_mix(
524 &self,
525 summary: &mut fallow_output::CssAnalyticsSummary,
526 ) -> Option<fallow_output::CssNotationConsistency> {
527 use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
528
529 let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
530 for value in &self.font_sizes {
531 if let Some(unit) = classify_font_size_unit(value) {
532 *counts.entry(unit).or_insert(0) += 1;
533 }
534 }
535 summary.font_size_units_used = saturate_len(counts.len());
536
537 let total: u32 = counts.values().copied().sum();
541 if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
542 return None;
543 }
544 let mut notations: Vec<CssNotationCount> = counts
545 .into_iter()
546 .map(|(notation, count)| CssNotationCount {
547 notation: notation.to_owned(),
548 count,
549 })
550 .collect();
551 notations.sort_by(|a, b| {
553 b.count
554 .cmp(&a.count)
555 .then_with(|| a.notation.cmp(&b.notation))
556 });
557 let dominant = notations[0].notation.clone();
559 Some(CssNotationConsistency {
560 actions: vec![CssCandidateAction::standardize_notation(
561 "Font sizes",
562 &dominant,
563 )],
564 axis: "Font sizes".to_owned(),
565 notations,
566 })
567 }
568}
569
570const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
574
575fn classify_font_size_unit(value: &str) -> Option<&'static str> {
581 let v = value.trim();
582 if v.is_empty() || v.contains('(') {
583 return None;
584 }
585 if let Some(stripped) = v.strip_suffix('%') {
586 return stripped
588 .chars()
589 .all(|c| c.is_ascii_digit() || c == '.')
590 .then_some("%");
591 }
592 let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
593 let (number, unit) = v.split_at(unit_start);
594 if number.is_empty()
597 || !number
598 .chars()
599 .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
600 {
601 return None;
602 }
603 match unit.to_ascii_lowercase().as_str() {
604 "px" => Some("px"),
605 "rem" => Some("rem"),
606 "em" => Some("em"),
607 "pt" => Some("pt"),
608 _ => Some("other"),
609 }
610}
611
612fn locate_keyframe_diff(
616 present: &rustc_hash::FxHashSet<String>,
617 absent: &rustc_hash::FxHashSet<String>,
618 locator: &rustc_hash::FxHashMap<String, String>,
619) -> Vec<(String, String)> {
620 let mut out: Vec<(String, String)> = present
621 .difference(absent)
622 .map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
623 .collect();
624 out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
625 out
626}
627
628fn saturate_len(len: usize) -> u32 {
630 u32::try_from(len).unwrap_or(u32::MAX)
631}
632
633fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
636 block
637 .occurrences
638 .first()
639 .map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
640}
641
642fn read_markup_scan_source(
652 file: &fallow_types::discover::DiscoveredFile,
653 ctx: HealthScanCtx<'_>,
654) -> Option<(String, String)> {
655 let HealthScanCtx {
656 config,
657 ignore_set,
658 changed_files,
659 output_changed_files: _,
660 ws_roots,
661 } = ctx;
662
663 let path = &file.path;
664 let extension = path.extension().and_then(|ext| ext.to_str());
665 if !extension.is_some_and(is_markup_source_extension) {
666 return None;
667 }
668 let relative = path.strip_prefix(&config.root).unwrap_or(path);
669 if ignore_set.is_match(relative) {
670 return None;
671 }
672 if let Some(changed) = changed_files
673 && !changed.contains(path)
674 {
675 return None;
676 }
677 if let Some(roots) = ws_roots
678 && !roots.iter().any(|root| path.starts_with(root))
679 {
680 return None;
681 }
682 let source = std::fs::read_to_string(path).ok()?;
683 let rel = relative.to_string_lossy().replace('\\', "/");
684 Some((rel, source))
685}
686
687fn scan_markup_tailwind_arbitrary_values(
688 files: &[fallow_types::discover::DiscoveredFile],
689 ctx: HealthScanCtx<'_>,
690 summary: &mut fallow_output::CssAnalyticsSummary,
691) -> Vec<fallow_output::TailwindArbitraryValue> {
692 let HealthScanCtx { config, .. } = ctx;
693
694 use fallow_output::TailwindArbitraryValue;
695
696 if !project_uses_tailwind(&config.root) {
697 return Vec::new();
698 }
699 let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
702 rustc_hash::FxHashMap::default();
703 let mut total_uses: u32 = 0;
704 for file in files {
705 let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
706 continue;
707 };
708 for arb in crate::css::scan_tailwind_arbitrary_values(&source) {
709 total_uses = total_uses.saturating_add(1);
710 let entry = agg
711 .entry(arb.value)
712 .or_insert_with(|| (0, rel.clone(), arb.line));
713 entry.0 = entry.0.saturating_add(1);
714 }
715 }
716
717 summary.tailwind_arbitrary_values = saturate_len(agg.len());
718 summary.tailwind_arbitrary_value_uses = total_uses;
719 let mut out: Vec<TailwindArbitraryValue> = agg
720 .into_iter()
721 .map(|(value, (count, path, line))| TailwindArbitraryValue {
722 actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
723 &value,
724 )],
725 value,
726 count,
727 path,
728 line,
729 })
730 .collect();
731 out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
732 out
733}
734
735fn record_css_analytics_summary(
736 summary: &mut fallow_output::CssAnalyticsSummary,
737 analytics: &fallow_types::extract::CssAnalytics,
738) {
739 summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
740 summary.total_declarations = summary
741 .total_declarations
742 .saturating_add(analytics.total_declarations);
743 summary.important_declarations = summary
744 .important_declarations
745 .saturating_add(analytics.important_declarations);
746 summary.empty_rules = summary
747 .empty_rules
748 .saturating_add(analytics.empty_rule_count);
749 summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
750 if analytics.notable_truncated {
751 summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
752 }
753}
754
755#[derive(Clone, Debug)]
758struct CssWalkAccum {
759 file_reports: Vec<fallow_output::CssFileAnalytics>,
760 summary: fallow_output::CssAnalyticsSummary,
761 scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
762 tokens: CssTokenSets,
763 scoring: CssGradeScoring,
764}
765
766#[derive(Clone, Debug, Default)]
767struct CssGradeScoring {
768 non_atomic_declarations: u32,
769 non_atomic_important_declarations: u32,
770 non_atomic_max_nesting_depth: u8,
771 atomic_declarations: u32,
772}
773
774impl CssGradeScoring {
775 fn add_non_atomic(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
776 self.non_atomic_declarations = self
777 .non_atomic_declarations
778 .saturating_add(analytics.total_declarations);
779 self.non_atomic_important_declarations = self
780 .non_atomic_important_declarations
781 .saturating_add(analytics.important_declarations);
782 self.non_atomic_max_nesting_depth = self
783 .non_atomic_max_nesting_depth
784 .max(analytics.max_nesting_depth);
785 }
786}
787
788struct CssTokenMetrics {
791 unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
792 undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
793 duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
794 unused_at_rules: Vec<fallow_output::UnusedAtRule>,
795 font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
796 unused_font_faces: Vec<fallow_output::UnusedFontFace>,
797}
798
799pub(super) struct CssAnalyticsComputation {
801 pub(super) report: fallow_output::CssAnalyticsReport,
802 pub(super) scoring_inputs: super::styling_score::StylingScoringInputs,
803}
804
805fn walk_css_files(
808 files: &[fallow_types::discover::DiscoveredFile],
809 ctx: HealthScanCtx<'_>,
810) -> CssWalkAccum {
811 use fallow_output::{CssAnalyticsSummary, ScopedUnusedClasses};
812
813 let mut file_reports = Vec::new();
814 let mut summary = CssAnalyticsSummary::default();
815 let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
816 let mut tokens = CssTokenSets::default();
820 let mut scoring = CssGradeScoring::default();
821 let css_in_js = project_uses_css_in_js(&ctx.config.root);
822
823 for file in files {
824 let Some((relative, kind)) = css_report_scan_target(file, ctx, css_in_js) else {
825 continue;
826 };
827 let Ok(source) = std::fs::read_to_string(&file.path) else {
828 continue;
829 };
830
831 if kind == CssScanKind::Sfc {
832 record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
833 }
834
835 let rel = relative.to_string_lossy().replace('\\', "/");
836 let mut file_had_sheet = false;
837 for item in css_report_scan_items(&source, &file.path, kind) {
838 file_had_sheet |= record_css_scan_item(
839 &item,
840 &rel,
841 &mut file_reports,
842 &mut summary,
843 &mut tokens,
844 &mut scoring,
845 );
846 }
847 if file_had_sheet {
848 summary.files_analyzed = summary.files_analyzed.saturating_add(1);
849 }
850 }
851
852 CssWalkAccum {
853 file_reports,
854 summary,
855 scoped_unused,
856 tokens,
857 scoring,
858 }
859}
860
861fn record_css_scan_item(
862 item: &CssScanItem<'_>,
863 rel: &str,
864 file_reports: &mut Vec<fallow_output::CssFileAnalytics>,
865 summary: &mut fallow_output::CssAnalyticsSummary,
866 tokens: &mut CssTokenSets,
867 scoring: &mut CssGradeScoring,
868) -> bool {
869 let Some(mut analytics) = crate::css::compute_css_analytics(&item.source) else {
870 return false;
871 };
872 record_css_analytics_summary(summary, &analytics);
873 tokens.record_theme(item.source.as_ref(), rel);
874
875 match item.policy {
876 GradePolicy::Atomic => {
877 analytics.declaration_blocks.clear();
878 analytics.raw_style_values.clear();
879 tokens.record(&analytics, rel);
880 scoring.atomic_declarations = scoring
881 .atomic_declarations
882 .saturating_add(analytics.total_declarations);
883 }
884 GradePolicy::Structural | GradePolicy::StructuralNoDedup => {
885 if item.policy == GradePolicy::StructuralNoDedup {
886 analytics.declaration_blocks.clear();
887 }
888 tokens.record(&analytics, rel);
889 scoring.add_non_atomic(&analytics);
890 if item.report_notable && !analytics.notable_rules.is_empty() {
891 file_reports.push(fallow_output::CssFileAnalytics {
892 path: rel.to_owned(),
893 analytics,
894 });
895 }
896 }
897 }
898
899 true
900}
901
902fn finalize_css_token_metrics(
905 tokens: &mut CssTokenSets,
906 summary: &mut fallow_output::CssAnalyticsSummary,
907 files: &[fallow_types::discover::DiscoveredFile],
908 config: &ResolvedConfig,
909 ignore_set: &globset::GlobSet,
910) -> CssTokenMetrics {
911 for name in collect_markup_keyframe_references(files, config, ignore_set) {
916 if tokens.defined_keyframes.contains(&name) {
917 tokens.referenced_keyframes.insert(name);
918 }
919 }
920
921 let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
922 let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
923 let unused_at_rules = tokens.group_unused_at_rules(summary);
924 let font_size_unit_mix = tokens.font_size_unit_mix(summary);
925 let mut unused_font_faces = tokens.unused_font_faces(summary);
926 if !unused_font_faces.is_empty() {
932 let referenced =
933 font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
934 unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
935 summary.unused_font_faces = saturate_len(unused_font_faces.len());
936 }
937
938 CssTokenMetrics {
939 unreferenced_keyframes,
940 undefined_keyframes,
941 duplicate_declaration_blocks,
942 unused_at_rules,
943 font_size_unit_mix,
944 unused_font_faces,
945 }
946}
947
948#[cfg(test)]
949fn compute_css_analytics_report(
950 files: &[fallow_types::discover::DiscoveredFile],
951 modules: &[fallow_types::extract::ModuleInfo],
952 ctx: HealthScanCtx<'_>,
953) -> Option<CssAnalyticsComputation> {
954 compute_css_analytics_report_with_artifacts(files, modules, ctx, None)
955}
956
957pub(super) fn compute_css_analytics_report_with_artifacts(
958 files: &[fallow_types::discover::DiscoveredFile],
959 modules: &[fallow_types::extract::ModuleInfo],
960 ctx: HealthScanCtx<'_>,
961 styling_artifacts: Option<&StylingAnalysisArtifacts>,
962) -> Option<CssAnalyticsComputation> {
963 let HealthScanCtx {
964 config,
965 ignore_set,
966 changed_files,
967 output_changed_files,
968 ws_roots,
969 } = ctx;
970 let css_deep = output_changed_files.is_some();
971
972 let css_in_js_definers = project_uses_css_in_js(&config.root).then(|| {
977 let path_by_id: rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path> =
978 files.iter().map(|f| (f.id, f.path.as_path())).collect();
979 collect_css_in_js_definers(modules, &path_by_id, config)
980 });
981
982 let mut walk = css_report_walk(files, ctx, styling_artifacts);
983 let styling_token_candidates =
984 css_report_token_candidates(&walk.tokens, config, css_in_js_definers.as_ref());
985 annotate_raw_style_value_nearest_tokens(&mut walk.tokens, &styling_token_candidates);
986 let metrics = finalize_css_token_metrics(
987 &mut walk.tokens,
988 &mut walk.summary,
989 files,
990 config,
991 ignore_set,
992 );
993 let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
994 tokens: &walk.tokens,
995 files,
996 css_in_js_definers: css_in_js_definers.as_ref(),
997 config,
998 ignore_set,
999 changed_files,
1000 output_changed_files,
1001 css_deep,
1002 ws_roots,
1003 styling_artifacts,
1004 token_candidates: &styling_token_candidates,
1005 summary: &mut walk.summary,
1006 });
1007 let token_consumers = css_report_token_consumers(
1008 &TokenConsumersInput {
1009 tokens: &walk.tokens,
1010 files,
1011 config,
1012 ignore_set,
1013 changed_files,
1014 ws_roots,
1015 },
1016 modules,
1017 css_in_js_definers.as_ref(),
1018 );
1019 let scoring_inputs = css_report_scoring_inputs(&walk);
1020 let report = assemble_css_report(CssReportAssemblyInput {
1021 walk,
1022 metrics,
1023 candidates,
1024 token_consumers,
1025 config,
1026 output_changed_files,
1027 })?;
1028 Some(CssAnalyticsComputation {
1029 report,
1030 scoring_inputs,
1031 })
1032}
1033
1034fn css_report_walk(
1035 files: &[fallow_types::discover::DiscoveredFile],
1036 ctx: HealthScanCtx<'_>,
1037 styling_artifacts: Option<&StylingAnalysisArtifacts>,
1038) -> CssWalkAccum {
1039 let HealthScanCtx {
1040 changed_files,
1041 output_changed_files,
1042 ws_roots,
1043 ..
1044 } = ctx;
1045
1046 styling_artifacts
1047 .filter(|_| changed_files.is_none() && output_changed_files.is_none() && ws_roots.is_none())
1048 .map_or_else(
1049 || walk_css_files(files, ctx),
1050 |artifacts| artifacts.whole_scope_walk.clone(),
1051 )
1052}
1053
1054fn css_report_scoring_inputs(walk: &CssWalkAccum) -> super::styling_score::StylingScoringInputs {
1055 super::styling_score::StylingScoringInputs {
1056 theme_tokens_defined: saturate_len(walk.tokens.theme_token_definers.len()),
1057 non_atomic_declarations: walk.scoring.non_atomic_declarations,
1058 non_atomic_important_declarations: walk.scoring.non_atomic_important_declarations,
1059 non_atomic_max_nesting_depth: walk.scoring.non_atomic_max_nesting_depth,
1060 atomic_declarations: walk.scoring.atomic_declarations,
1061 }
1062}
1063
1064fn css_report_token_candidates(
1065 tokens: &CssTokenSets,
1066 config: &ResolvedConfig,
1067 css_in_js_definers: Option<&CssInJsDefiners>,
1068) -> Vec<ComparableThemeTokenCandidate> {
1069 let mut candidates = comparable_theme_token_candidates(tokens, config);
1070 candidates.extend(comparable_custom_property_token_candidates(tokens));
1071 candidates.extend(comparable_css_in_js_token_candidates(css_in_js_definers));
1072 candidates.extend(comparable_project_vocabulary_candidates(tokens));
1073 candidates.sort_by(|a, b| theme_token_sort_key(a).cmp(&theme_token_sort_key(b)));
1074 candidates
1075}
1076
1077fn css_report_token_consumers(
1078 input: &TokenConsumersInput<'_>,
1079 modules: &[fallow_types::extract::ModuleInfo],
1080 css_in_js_definers: Option<&CssInJsDefiners>,
1081) -> Vec<fallow_output::TokenConsumers> {
1082 let mut consumers = build_token_consumers(input);
1083 consumers.extend(build_css_in_js_token_consumers(
1084 input.files,
1085 modules,
1086 input.config,
1087 css_in_js_definers,
1088 ));
1089 consumers.sort_by(|a, b| {
1090 a.token
1091 .cmp(&b.token)
1092 .then_with(|| a.definition_path.cmp(&b.definition_path))
1093 });
1094 consumers
1095}
1096
1097struct CssReportAssemblyInput<'a> {
1101 walk: CssWalkAccum,
1102 metrics: CssTokenMetrics,
1103 candidates: MarkupCssCandidates,
1104 token_consumers: Vec<fallow_output::TokenConsumers>,
1105 config: &'a ResolvedConfig,
1106 output_changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
1107}
1108
1109fn assemble_css_report(
1110 input: CssReportAssemblyInput<'_>,
1111) -> Option<fallow_output::CssAnalyticsReport> {
1112 use fallow_output::CssAnalyticsReport;
1113
1114 let CssReportAssemblyInput {
1115 mut walk,
1116 mut metrics,
1117 mut candidates,
1118 mut token_consumers,
1119 config,
1120 output_changed_files,
1121 } = input;
1122
1123 if let Some(changed) = output_changed_files {
1124 retain_css_report_changed_scope(CssReportChangedScopeInput {
1125 walk: &mut walk,
1126 metrics: &mut metrics,
1127 candidates: &mut candidates,
1128 token_consumers: &mut token_consumers,
1129 config,
1130 changed,
1131 });
1132 }
1133
1134 if css_report_is_empty(&walk, &metrics, &candidates, &token_consumers) {
1135 return None;
1136 }
1137 let mut scoped_unused = walk.scoped_unused;
1138 scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
1139 let mut raw_style_values = walk.tokens.raw_style_values;
1140 sort_raw_style_values(&mut raw_style_values);
1141 walk.summary.raw_style_values = saturate_len(raw_style_values.len());
1142 Some(CssAnalyticsReport {
1143 files: walk.file_reports,
1144 summary: walk.summary,
1145 scoped_unused,
1146 unreferenced_keyframes: metrics.unreferenced_keyframes,
1147 undefined_keyframes: metrics.undefined_keyframes,
1148 duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
1149 cva_duplicate_variant_blocks: candidates.cva_duplicate_variant_blocks,
1150 cva_variant_token_drifts: candidates.cva_variant_token_drifts,
1151 tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
1152 raw_style_values,
1153 unused_at_rules: metrics.unused_at_rules,
1154 unresolved_class_references: candidates.unresolved_class_references,
1155 unreferenced_css_classes: candidates.unreferenced_css_classes,
1156 unused_font_faces: metrics.unused_font_faces,
1157 unused_theme_tokens: candidates.unused_theme_tokens,
1158 near_duplicate_theme_tokens: candidates.near_duplicate_theme_tokens,
1159 near_duplicate_css_in_js_tokens: candidates.near_duplicate_css_in_js_tokens,
1160 token_consumers,
1161 font_size_unit_mix: metrics.font_size_unit_mix,
1162 })
1163}
1164
1165fn css_report_is_empty(
1166 walk: &CssWalkAccum,
1167 metrics: &CssTokenMetrics,
1168 candidates: &MarkupCssCandidates,
1169 token_consumers: &[fallow_output::TokenConsumers],
1170) -> bool {
1171 walk.summary.files_analyzed == 0
1172 && walk.scoped_unused.is_empty()
1173 && candidates.tailwind_arbitrary_values.is_empty()
1174 && candidates.cva_duplicate_variant_blocks.is_empty()
1175 && candidates.cva_variant_token_drifts.is_empty()
1176 && candidates.unresolved_class_references.is_empty()
1177 && candidates.unreferenced_css_classes.is_empty()
1178 && metrics.unused_font_faces.is_empty()
1179 && candidates.unused_theme_tokens.is_empty()
1180 && candidates.near_duplicate_theme_tokens.is_empty()
1181 && candidates.near_duplicate_css_in_js_tokens.is_empty()
1182 && token_consumers.is_empty()
1183}
1184
1185fn sort_raw_style_values(values: &mut [fallow_output::RawStyleValue]) {
1186 values.sort_by(|a, b| {
1187 (&a.path, a.line, &a.axis, &a.property, &a.value).cmp(&(
1188 &b.path,
1189 b.line,
1190 &b.axis,
1191 &b.property,
1192 &b.value,
1193 ))
1194 });
1195}
1196
1197struct CssReportChangedScopeInput<'a> {
1198 walk: &'a mut CssWalkAccum,
1199 metrics: &'a mut CssTokenMetrics,
1200 candidates: &'a mut MarkupCssCandidates,
1201 token_consumers: &'a mut Vec<fallow_output::TokenConsumers>,
1202 config: &'a ResolvedConfig,
1203 changed: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
1204}
1205
1206fn retain_css_report_changed_scope(input: CssReportChangedScopeInput<'_>) {
1207 let CssReportChangedScopeInput {
1208 walk,
1209 metrics,
1210 candidates,
1211 token_consumers,
1212 config,
1213 changed,
1214 } = input;
1215 let in_scope = |path: &str| css_output_path_in_changed_scope(path, config, changed);
1216 walk.file_reports.retain(|file| in_scope(&file.path));
1217 walk.scoped_unused.retain(|item| in_scope(&item.path));
1218 retain_css_metrics_changed_scope(metrics, &in_scope);
1219 retain_markup_candidates_changed_scope(candidates, &in_scope);
1220 walk.tokens
1221 .raw_style_values
1222 .retain(|item| in_scope(&item.path));
1223 token_consumers.retain(|item| in_scope(&item.definition_path));
1224}
1225
1226fn retain_css_metrics_changed_scope(
1227 metrics: &mut CssTokenMetrics,
1228 in_scope: &impl Fn(&str) -> bool,
1229) {
1230 metrics
1231 .unreferenced_keyframes
1232 .retain(|item| in_scope(&item.path));
1233 metrics
1234 .undefined_keyframes
1235 .retain(|item| in_scope(&item.path));
1236 metrics.duplicate_declaration_blocks.retain_mut(|block| {
1237 let has_scoped_occurrence = block.occurrences.iter().any(|item| in_scope(&item.path));
1238 if has_scoped_occurrence {
1239 block.occurrences.sort_by(|a, b| {
1240 let a_out_of_scope = !in_scope(&a.path);
1241 let b_out_of_scope = !in_scope(&b.path);
1242 a_out_of_scope
1243 .cmp(&b_out_of_scope)
1244 .then_with(|| a.path.cmp(&b.path))
1245 .then_with(|| a.line.cmp(&b.line))
1246 });
1247 }
1248 has_scoped_occurrence
1249 });
1250 metrics.unused_at_rules.retain(|item| in_scope(&item.path));
1251 metrics
1252 .unused_font_faces
1253 .retain(|item| in_scope(&item.path));
1254}
1255
1256fn retain_markup_candidates_changed_scope(
1257 candidates: &mut MarkupCssCandidates,
1258 in_scope: &impl Fn(&str) -> bool,
1259) {
1260 candidates
1261 .tailwind_arbitrary_values
1262 .retain(|item| in_scope(&item.path));
1263 candidates
1264 .cva_duplicate_variant_blocks
1265 .retain(|item| item.occurrences.iter().any(|occ| in_scope(&occ.path)));
1266 candidates
1267 .cva_variant_token_drifts
1268 .retain(|item| in_scope(&item.path));
1269 candidates
1270 .unresolved_class_references
1271 .retain(|item| in_scope(&item.path));
1272 candidates
1273 .unreferenced_css_classes
1274 .retain(|item| in_scope(&item.path));
1275 candidates
1276 .unused_theme_tokens
1277 .retain(|item| in_scope(&item.path));
1278 candidates
1279 .near_duplicate_theme_tokens
1280 .retain(|item| in_scope(&item.path));
1281 candidates
1282 .near_duplicate_css_in_js_tokens
1283 .retain(|item| in_scope(&item.path));
1284}
1285
1286fn css_output_path_in_changed_scope(
1287 path: &str,
1288 config: &ResolvedConfig,
1289 changed: &rustc_hash::FxHashSet<std::path::PathBuf>,
1290) -> bool {
1291 let relative = std::path::Path::new(path);
1292 let absolute = config.root.join(relative);
1293 changed.contains(relative) || changed.contains(&absolute)
1294}
1295
1296#[cfg(test)]
1297#[allow(
1298 clippy::unwrap_used,
1299 reason = "tests use unwrap to keep token-consumer assertions concise"
1300)]
1301mod token_consumer_tests;