use fallow_config::ResolvedConfig;
use super::package_json::{
class_matches_dependency_prefix, dependency_class_prefixes, project_uses_tailwind,
project_uses_tailwind_plugin, published_css_paths,
};
use super::runtime_filter::relative_to_root;
use super::tailwind_theme;
#[derive(Clone, Copy)]
pub(super) struct HealthScanCtx<'a> {
pub(super) config: &'a ResolvedConfig,
pub(super) ignore_set: &'a globset::GlobSet,
pub(super) changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
pub(super) ws_roots: Option<&'a [std::path::PathBuf]>,
}
#[derive(Default)]
struct CssTokenSets {
colors: rustc_hash::FxHashSet<String>,
font_sizes: rustc_hash::FxHashSet<String>,
z_indexes: rustc_hash::FxHashSet<String>,
box_shadows: rustc_hash::FxHashSet<String>,
border_radii: rustc_hash::FxHashSet<String>,
line_heights: rustc_hash::FxHashSet<String>,
defined_custom_props: rustc_hash::FxHashSet<String>,
referenced_custom_props: rustc_hash::FxHashSet<String>,
defined_keyframes: rustc_hash::FxHashSet<String>,
referenced_keyframes: rustc_hash::FxHashSet<String>,
keyframes_definers: rustc_hash::FxHashMap<String, String>,
keyframe_referencers: rustc_hash::FxHashMap<String, String>,
declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
registered_custom_props: rustc_hash::FxHashSet<String>,
declared_layers: rustc_hash::FxHashSet<String>,
populated_layers: rustc_hash::FxHashSet<String>,
property_registrars: rustc_hash::FxHashMap<String, String>,
layer_declarers: rustc_hash::FxHashMap<String, String>,
defined_font_faces: rustc_hash::FxHashSet<String>,
referenced_font_families: rustc_hash::FxHashSet<String>,
font_face_definers: rustc_hash::FxHashMap<String, String>,
theme_token_definers: rustc_hash::FxHashMap<String, (String, u32)>,
apply_tokens: rustc_hash::FxHashSet<String>,
theme_var_reads: rustc_hash::FxHashSet<String>,
theme_var_reads_located: Vec<(String, String, u32)>,
css_var_reads_located: Vec<(String, String, u32)>,
apply_uses_located: Vec<(String, String, u32)>,
any_plugin_directive: bool,
}
impl CssTokenSets {
fn group_duplicate_blocks(
&self,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::CssDuplicateBlock> {
use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
let mut groups: Vec<CssDuplicateBlock> = self
.declaration_blocks
.values()
.filter(|(_, occurrences)| occurrences.len() >= 2)
.map(|(declaration_count, occurrences)| {
let occurrence_count = saturate_len(occurrences.len());
let estimated_savings = occurrence_count
.saturating_sub(1)
.saturating_mul(u32::from(*declaration_count));
let mut occ: Vec<CssBlockOccurrence> = occurrences
.iter()
.map(|(path, line)| CssBlockOccurrence {
path: path.clone(),
line: *line,
})
.collect();
occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
CssDuplicateBlock {
declaration_count: *declaration_count,
occurrence_count,
estimated_savings,
occurrences: occ,
actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
}
})
.collect();
groups.sort_by(|a, b| {
b.estimated_savings
.cmp(&a.estimated_savings)
.then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
});
summary.duplicate_declaration_blocks = saturate_len(groups.len());
summary.duplicate_declarations_total = groups
.iter()
.fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
groups
}
fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
self.colors.extend(analytics.colors.iter().cloned());
self.font_sizes.extend(analytics.font_sizes.iter().cloned());
self.z_indexes.extend(analytics.z_indexes.iter().cloned());
self.box_shadows
.extend(analytics.box_shadows.iter().cloned());
self.border_radii
.extend(analytics.border_radii.iter().cloned());
self.line_heights
.extend(analytics.line_heights.iter().cloned());
self.defined_custom_props
.extend(analytics.defined_custom_properties.iter().cloned());
self.referenced_custom_props
.extend(analytics.referenced_custom_properties.iter().cloned());
for keyframes in &analytics.referenced_keyframes {
self.referenced_keyframes.insert(keyframes.clone());
self.keyframe_referencers
.entry(keyframes.clone())
.or_insert_with(|| rel.to_owned());
}
for keyframes in &analytics.defined_keyframes {
self.defined_keyframes.insert(keyframes.clone());
self.keyframes_definers
.entry(keyframes.clone())
.or_insert_with(|| rel.to_owned());
}
for block in &analytics.declaration_blocks {
self.declaration_blocks
.entry(block.fingerprint)
.or_insert_with(|| (block.declaration_count, Vec::new()))
.1
.push((rel.to_owned(), block.line));
}
for name in &analytics.registered_custom_properties {
self.registered_custom_props.insert(name.clone());
self.property_registrars
.entry(name.clone())
.or_insert_with(|| rel.to_owned());
}
for family in &analytics.referenced_font_families {
self.referenced_font_families.insert(family.clone());
}
for family in &analytics.defined_font_faces {
self.defined_font_faces.insert(family.clone());
self.font_face_definers
.entry(family.clone())
.or_insert_with(|| rel.to_owned());
}
for name in &analytics.populated_layers {
self.populated_layers.insert(name.clone());
}
for name in &analytics.declared_layers {
self.declared_layers.insert(name.clone());
self.layer_declarers
.entry(name.clone())
.or_insert_with(|| rel.to_owned());
}
}
fn record_theme(&mut self, source: &str, rel: &str) {
let scan = crate::css::scan_theme_blocks(source);
for token in scan.tokens {
self.theme_token_definers
.entry(token.name)
.or_insert_with(|| (rel.to_owned(), token.line));
}
for (name, line) in scan.theme_var_reads {
self.theme_var_reads.insert(name.clone());
self.theme_var_reads_located
.push((name, rel.to_owned(), line));
}
self.apply_tokens
.extend(crate::css::extract_apply_tokens(source));
self.apply_uses_located.extend(
crate::css::extract_apply_tokens_located(source)
.into_iter()
.map(|(token, line)| (token, rel.to_owned(), line)),
);
self.css_var_reads_located.extend(
crate::css::extract_css_var_reads_located(source)
.into_iter()
.map(|(name, line)| (name, rel.to_owned(), line)),
);
if source.contains("@plugin") {
self.any_plugin_directive = true;
}
}
fn group_unused_at_rules(
&self,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::UnusedAtRule> {
use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
let mut out: Vec<UnusedAtRule> = Vec::new();
for name in self
.registered_custom_props
.difference(&self.referenced_custom_props)
{
out.push(UnusedAtRule {
kind: UnusedAtRuleKind::PropertyRegistration,
name: name.clone(),
path: self
.property_registrars
.get(name)
.cloned()
.unwrap_or_default(),
actions: vec![CssCandidateAction::verify_unused_at_rule(
UnusedAtRuleKind::PropertyRegistration,
name,
)],
});
}
summary.unused_property_registrations = saturate_len(out.len());
let property_count = out.len();
for name in self.declared_layers.difference(&self.populated_layers) {
out.push(UnusedAtRule {
kind: UnusedAtRuleKind::Layer,
name: name.clone(),
path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
actions: vec![CssCandidateAction::verify_unused_at_rule(
UnusedAtRuleKind::Layer,
name,
)],
});
}
summary.unused_layers = saturate_len(out.len() - property_count);
out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
out
}
fn finalize(
&self,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> (
Vec<fallow_output::UnreferencedKeyframes>,
Vec<fallow_output::UndefinedKeyframes>,
) {
use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
summary.unique_colors = saturate_len(self.colors.len());
summary.unique_font_sizes = saturate_len(self.font_sizes.len());
summary.unique_z_indexes = saturate_len(self.z_indexes.len());
summary.unique_box_shadows = saturate_len(self.box_shadows.len());
summary.unique_border_radii = saturate_len(self.border_radii.len());
summary.unique_line_heights = saturate_len(self.line_heights.len());
summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
summary.custom_properties_unreferenced = saturate_len(
self.defined_custom_props
.difference(&self.referenced_custom_props)
.count(),
);
summary.custom_properties_undefined = saturate_len(
self.referenced_custom_props
.difference(&self.defined_custom_props)
.count(),
);
summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
summary.keyframes_unreferenced = saturate_len(
self.defined_keyframes
.difference(&self.referenced_keyframes)
.count(),
);
summary.keyframes_undefined = saturate_len(
self.referenced_keyframes
.difference(&self.defined_keyframes)
.count(),
);
let unreferenced_keyframes = locate_keyframe_diff(
&self.defined_keyframes,
&self.referenced_keyframes,
&self.keyframes_definers,
)
.into_iter()
.map(|(name, path)| UnreferencedKeyframes {
actions: vec![CssCandidateAction::verify_keyframe(&name)],
name,
path,
})
.collect();
let undefined_keyframes = locate_keyframe_diff(
&self.referenced_keyframes,
&self.defined_keyframes,
&self.keyframe_referencers,
)
.into_iter()
.map(|(name, path)| UndefinedKeyframes {
actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
name,
path,
})
.collect();
(unreferenced_keyframes, undefined_keyframes)
}
fn unused_font_faces(
&self,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::UnusedFontFace> {
use fallow_output::{CssCandidateAction, UnusedFontFace};
let referenced_lower: rustc_hash::FxHashSet<String> = self
.referenced_font_families
.iter()
.map(|family| family.to_ascii_lowercase())
.collect();
let mut out: Vec<UnusedFontFace> = self
.defined_font_faces
.iter()
.filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
.map(|family| UnusedFontFace {
actions: vec![CssCandidateAction::verify_unused_font_face(family)],
path: self
.font_face_definers
.get(family)
.cloned()
.unwrap_or_default(),
family: family.clone(),
})
.collect();
out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
summary.unused_font_faces = saturate_len(out.len());
out
}
fn font_size_unit_mix(
&self,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Option<fallow_output::CssNotationConsistency> {
use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
for value in &self.font_sizes {
if let Some(unit) = classify_font_size_unit(value) {
*counts.entry(unit).or_insert(0) += 1;
}
}
summary.font_size_units_used = saturate_len(counts.len());
let total: u32 = counts.values().copied().sum();
if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
return None;
}
let mut notations: Vec<CssNotationCount> = counts
.into_iter()
.map(|(notation, count)| CssNotationCount {
notation: notation.to_owned(),
count,
})
.collect();
notations.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.notation.cmp(&b.notation))
});
let dominant = notations[0].notation.clone();
Some(CssNotationConsistency {
actions: vec![CssCandidateAction::standardize_notation(
"Font sizes",
&dominant,
)],
axis: "Font sizes".to_owned(),
notations,
})
}
}
const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
fn classify_font_size_unit(value: &str) -> Option<&'static str> {
let v = value.trim();
if v.is_empty() || v.contains('(') {
return None;
}
if let Some(stripped) = v.strip_suffix('%') {
return stripped
.chars()
.all(|c| c.is_ascii_digit() || c == '.')
.then_some("%");
}
let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
let (number, unit) = v.split_at(unit_start);
if number.is_empty()
|| !number
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
{
return None;
}
match unit.to_ascii_lowercase().as_str() {
"px" => Some("px"),
"rem" => Some("rem"),
"em" => Some("em"),
"pt" => Some("pt"),
_ => Some("other"),
}
}
fn locate_keyframe_diff(
present: &rustc_hash::FxHashSet<String>,
absent: &rustc_hash::FxHashSet<String>,
locator: &rustc_hash::FxHashMap<String, String>,
) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = present
.difference(absent)
.map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
.collect();
out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
out
}
fn saturate_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
block
.occurrences
.first()
.map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
}
fn read_markup_scan_source(
file: &fallow_types::discover::DiscoveredFile,
ctx: HealthScanCtx<'_>,
) -> Option<(String, String)> {
let HealthScanCtx {
config,
ignore_set,
changed_files,
ws_roots,
} = ctx;
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !matches!(
extension,
Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
) {
return None;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
return None;
}
if let Some(changed) = changed_files
&& !changed.contains(path)
{
return None;
}
if let Some(roots) = ws_roots
&& !roots.iter().any(|root| path.starts_with(root))
{
return None;
}
let source = std::fs::read_to_string(path).ok()?;
let rel = relative.to_string_lossy().replace('\\', "/");
Some((rel, source))
}
fn scan_markup_tailwind_arbitrary_values(
files: &[fallow_types::discover::DiscoveredFile],
ctx: HealthScanCtx<'_>,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::TailwindArbitraryValue> {
let HealthScanCtx { config, .. } = ctx;
use fallow_output::TailwindArbitraryValue;
if !project_uses_tailwind(&config.root) {
return Vec::new();
}
let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
rustc_hash::FxHashMap::default();
let mut total_uses: u32 = 0;
for file in files {
let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
continue;
};
for arb in crate::css::scan_tailwind_arbitrary_values(&source) {
total_uses = total_uses.saturating_add(1);
let entry = agg
.entry(arb.value)
.or_insert_with(|| (0, rel.clone(), arb.line));
entry.0 = entry.0.saturating_add(1);
}
}
summary.tailwind_arbitrary_values = saturate_len(agg.len());
summary.tailwind_arbitrary_value_uses = total_uses;
let mut out: Vec<TailwindArbitraryValue> = agg
.into_iter()
.map(|(value, (count, path, line))| TailwindArbitraryValue {
actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
&value,
)],
value,
count,
path,
line,
})
.collect();
out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
out
}
fn is_tailwind_class_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}
fn collect_animate_keyframe_names(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
let bytes = source.as_bytes();
const PREFIX: &str = "animate-";
let mut search = 0;
while let Some(rel) = source[search..].find(PREFIX) {
let start = search + rel;
search = start + PREFIX.len();
if start > 0 && is_tailwind_class_byte(bytes[start - 1]) {
continue;
}
let after = start + PREFIX.len();
if after >= bytes.len() {
continue;
}
if bytes[after] == b'[' {
let name_start = after + 1;
let mut j = name_start;
while j < bytes.len() {
let c = bytes[j];
if c == b'-' || c.is_ascii_alphanumeric() {
j += 1;
} else {
break;
}
}
if j > name_start {
out.insert(source[name_start..j].to_owned());
}
} else {
let mut j = after;
while j < bytes.len() {
let c = bytes[j];
if c == b'-' || c.is_ascii_lowercase() || c.is_ascii_digit() {
j += 1;
} else {
break;
}
}
let name = source[after..j].trim_end_matches('-');
if !name.is_empty() {
out.insert(name.to_owned());
}
}
}
}
fn collect_markup_keyframe_references(
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> rustc_hash::FxHashSet<String> {
let mut out: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for file in files {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !matches!(
extension,
Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte" | "js" | "ts" | "mjs" | "cjs")
) {
continue;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
continue;
}
if let Ok(source) = std::fs::read_to_string(path) {
collect_animate_keyframe_names(&source, &mut out);
collect_quoted_class_tokens(&source, &mut out, false);
}
}
out
}
const MIN_DEFINED_CLASS_LEN: usize = 6;
const MIN_TOKEN_LEN: usize = 5;
fn count_stylesheet_kinds(
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> (usize, usize) {
let mut css = 0usize;
let mut preprocessor = 0usize;
for file in files {
let path = &file.path;
let kind = match path.extension().and_then(|ext| ext.to_str()) {
Some("css") => &mut css,
Some("scss" | "sass" | "less") => &mut preprocessor,
_ => continue,
};
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
continue;
}
*kind += 1;
}
(css, preprocessor)
}
fn collect_defined_css_classes(
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> rustc_hash::FxHashSet<String> {
use fallow_types::extract::ExportName;
let mut defined: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for file in files {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
let is_preprocessor = matches!(extension, Some("scss" | "sass" | "less"));
let is_css = extension == Some("css") || is_preprocessor;
let has_style_blocks = matches!(extension, Some("astro" | "vue" | "svelte"));
if !is_css && !has_style_blocks {
continue;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
continue;
}
let Ok(source) = std::fs::read_to_string(path) else {
continue;
};
if has_style_blocks {
for style in crate::css::extract_sfc_styles(&source) {
let is_style_scss = style
.lang
.as_deref()
.is_some_and(|lang| matches!(lang, "scss" | "sass"));
for export in crate::css::extract_css_module_exports(&style.body, is_style_scss) {
if let ExportName::Named(name) = export.name {
defined.insert(name);
}
}
}
continue;
}
for export in crate::css::extract_css_module_exports(&source, is_preprocessor) {
if let ExportName::Named(name) = export.name {
defined.insert(name);
}
}
}
defined
}
fn best_class_suggestion<'a>(
token: &str,
by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
) -> Option<&'a str> {
let len = token.len();
let mut best: Option<&str> = None;
for candidate_len in [len.wrapping_sub(1), len, len + 1] {
let Some(bucket) = by_len.get(&candidate_len) else {
continue;
};
for &defined in bucket {
if defined.len() < MIN_DEFINED_CLASS_LEN {
continue;
}
if crate::css::is_typo_edit(token, defined)
&& best.is_none_or(|current| defined < current)
{
best = Some(defined);
}
}
}
best
}
fn is_tailwind_shaped(token: &str) -> bool {
token.contains([':', '/', '[', ']'])
}
fn build_typo_target_index(
defined: &rustc_hash::FxHashSet<String>,
) -> rustc_hash::FxHashMap<usize, Vec<&str>> {
let mut by_len: rustc_hash::FxHashMap<usize, Vec<&str>> = rustc_hash::FxHashMap::default();
for class in defined {
if class.len() >= MIN_DEFINED_CLASS_LEN && !class.ends_with('-') && !class.ends_with('_') {
by_len.entry(class.len()).or_default().push(class.as_str());
}
}
by_len
}
fn collect_unresolved_class_refs_in_file<'a>(
source: &str,
rel: &str,
defined: &rustc_hash::FxHashSet<String>,
by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
seen: &mut rustc_hash::FxHashSet<(String, u32, String)>,
out: &mut Vec<fallow_output::UnresolvedClassReference>,
) {
use fallow_output::{CssCandidateAction, UnresolvedClassReference};
for token in crate::css::scan_markup_class_tokens(source).static_tokens {
if token.value.len() < MIN_TOKEN_LEN
|| is_tailwind_shaped(&token.value)
|| defined.contains(&token.value)
{
continue;
}
let Some(suggestion) = best_class_suggestion(&token.value, by_len) else {
continue;
};
let key = (rel.to_owned(), token.line, token.value.clone());
if !seen.insert(key) {
continue;
}
out.push(UnresolvedClassReference {
actions: vec![CssCandidateAction::verify_unresolved_class(
&token.value,
suggestion,
)],
class: token.value,
suggestion: suggestion.to_owned(),
path: rel.to_owned(),
line: token.line,
});
}
}
fn scan_unresolved_class_references(
files: &[fallow_types::discover::DiscoveredFile],
ctx: HealthScanCtx<'_>,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::UnresolvedClassReference> {
let HealthScanCtx {
config, ignore_set, ..
} = ctx;
use fallow_output::UnresolvedClassReference;
let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
if preprocessor_files > css_files {
return Vec::new();
}
let defined = collect_defined_css_classes(files, config, ignore_set);
if defined.is_empty() {
return Vec::new();
}
let by_len = build_typo_target_index(&defined);
let mut out: Vec<UnresolvedClassReference> = Vec::new();
let mut seen: rustc_hash::FxHashSet<(String, u32, String)> = rustc_hash::FxHashSet::default();
for file in files {
let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
continue;
};
collect_unresolved_class_refs_in_file(
&source, &rel, &defined, &by_len, &mut seen, &mut out,
);
}
out.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.class.cmp(&b.class))
});
summary.unresolved_class_references = saturate_len(out.len());
out
}
fn mask_font_face_blocks(lower_source: &str) -> String {
if !lower_source.contains("@font-face") {
return lower_source.to_owned();
}
let mut bytes = lower_source.as_bytes().to_vec();
let sb = lower_source.as_bytes();
let mut search = 0;
while let Some(rel) = lower_source[search..].find("@font-face") {
let start = search + rel;
let Some(brace_rel) = lower_source[start..].find('{') else {
break;
};
let mut depth = 0usize;
let mut j = start + brace_rel;
while j < sb.len() {
match sb[j] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
let end = (j + 1).min(bytes.len());
for b in &mut bytes[start..end] {
*b = b' ';
}
search = end;
}
String::from_utf8(bytes).unwrap_or_else(|_| lower_source.to_owned())
}
fn font_families_referenced_in_source(
candidates: &[fallow_output::UnusedFontFace],
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> rustc_hash::FxHashSet<String> {
let mut pending: Vec<(String, String)> = candidates
.iter()
.map(|c| (c.family.clone(), c.family.to_ascii_lowercase()))
.collect();
let mut found: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for file in files {
if pending.is_empty() {
break;
}
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !matches!(
extension,
Some(
"css"
| "scss"
| "sass"
| "less"
| "js"
| "jsx"
| "ts"
| "tsx"
| "mjs"
| "cjs"
| "vue"
| "svelte"
| "astro"
| "html"
| "mdx"
)
) {
continue;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
continue;
}
let Ok(source) = std::fs::read_to_string(path) else {
continue;
};
let source_lower = mask_font_face_blocks(&source.to_ascii_lowercase());
pending.retain(|(family, family_lower)| {
if source_lower.contains(family_lower.as_str()) {
found.insert(family.clone());
false
} else {
true
}
});
}
found
}
const MIN_UNREF_CLASS_LEN: usize = 5;
fn collect_quoted_class_tokens(
source: &str,
out: &mut rustc_hash::FxHashSet<String>,
require_dash: bool,
) {
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
let quote = bytes[i];
if quote == b'"' || quote == b'\'' || quote == b'`' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != quote {
j += 1;
}
if let Some(content) = source.get(start..j) {
for token in content
.split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
{
let shaped = token.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
&& !token.ends_with('-')
&& (if require_dash {
token.contains('-')
} else {
token.len() >= 3
});
if shaped {
out.insert(token.to_owned());
}
}
}
i = j + 1;
} else {
i += 1;
}
}
}
fn collect_global_scoped_classes(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
let bytes = source.as_bytes();
let mut i = 0;
while let Some(rel) = source[i..].find(":global(") {
let open = i + rel + ":global(".len();
let mut depth = 1usize;
let mut j = open;
while j < bytes.len() && depth > 0 {
match bytes[j] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
j += 1;
}
let inner_end = j.saturating_sub(1).max(open);
if let Some(inner) = source.get(open..inner_end) {
extract_dotted_class_names(inner, out);
}
i = j.max(open + 1);
}
}
fn extract_dotted_class_names(selector: &str, out: &mut rustc_hash::FxHashSet<String>) {
let bytes = selector.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'.' {
let start = i + 1;
if start < bytes.len()
&& (bytes[start].is_ascii_alphabetic() || matches!(bytes[start], b'_' | b'-'))
{
let mut j = start;
while j < bytes.len()
&& (bytes[j].is_ascii_alphanumeric() || matches!(bytes[j], b'_' | b'-'))
{
j += 1;
}
if let Some(name) = selector.get(start..j) {
out.insert(name.to_owned());
}
i = j;
continue;
}
}
i += 1;
}
}
fn collect_defined_css_classes_located(
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> Vec<(String, Vec<(String, u32)>)> {
use fallow_types::extract::ExportName;
let mut out: Vec<(String, Vec<(String, u32)>)> = Vec::new();
for file in files {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
let is_scss = extension == Some("scss");
if extension != Some("css") && !is_scss {
continue;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
continue;
}
let Ok(source) = std::fs::read_to_string(path) else {
continue;
};
let mut global_scoped: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
collect_global_scoped_classes(&source, &mut global_scoped);
let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
let mut classes: Vec<(String, u32)> = Vec::new();
for export in crate::css::extract_css_module_exports(&source, is_scss) {
let ExportName::Named(name) = export.name else {
continue;
};
if global_scoped.contains(&name) {
continue;
}
if !seen.insert(name.clone()) {
continue;
}
let start = export.span.start as usize;
let line = 1 + source
.get(..start)
.map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
classes.push((name, u32::try_from(line).unwrap_or(u32::MAX)));
}
if !classes.is_empty() {
out.push((relative.to_string_lossy().replace('\\', "/"), classes));
}
}
out
}
fn scan_unreferenced_css_classes(
files: &[fallow_types::discover::DiscoveredFile],
ctx: HealthScanCtx<'_>,
summary: &mut fallow_output::CssAnalyticsSummary,
) -> Vec<fallow_output::UnreferencedCssClass> {
let HealthScanCtx {
config,
ignore_set,
changed_files,
ws_roots,
} = ctx;
use fallow_output::UnreferencedCssClass;
if changed_files.is_some() || ws_roots.is_some() {
return Vec::new();
}
let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
if preprocessor_files > css_files {
return Vec::new();
}
let reference_surface = css_reference_surface(files, config, ignore_set);
let published = published_css_paths(config);
let dependency_prefixes = dependency_class_prefixes(config);
let located = collect_defined_css_classes_located(files, config, ignore_set);
let mut out: Vec<UnreferencedCssClass> = Vec::new();
for (rel, classes) in located {
push_unreferenced_css_class_candidates(
&mut out,
&rel,
classes,
&published,
&dependency_prefixes,
&reference_surface,
);
}
out.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.class.cmp(&b.class))
});
summary.unreferenced_css_classes = saturate_len(out.len());
out
}
struct CssReferenceSurface {
static_tokens: rustc_hash::FxHashSet<String>,
dynamic_corpus: String,
}
impl CssReferenceSurface {
fn references(&self, class: &str) -> bool {
self.static_tokens.contains(class)
|| self.dynamic_corpus.contains(class)
|| self.dynamic_prefix_referenced(class)
}
fn dynamic_prefix_referenced(&self, class: &str) -> bool {
let Some(dash) = class.rfind('-') else {
return false;
};
let head = &class[..=dash];
const INTERP_MARKERS: [&str; 6] = ["${", "' +", "'+", "\" +", "\"+", "` +"];
INTERP_MARKERS
.iter()
.any(|marker| self.dynamic_corpus.contains(&format!("{head}{marker}")))
}
}
fn css_reference_surface(
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> CssReferenceSurface {
let mut surface = CssReferenceSurface {
static_tokens: rustc_hash::FxHashSet::default(),
dynamic_corpus: String::new(),
};
for file in files {
collect_css_reference_surface_file(&mut surface, file, config, ignore_set);
}
surface
}
fn collect_css_reference_surface_file(
surface: &mut CssReferenceSurface,
file: &fallow_types::discover::DiscoveredFile,
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !matches!(
extension,
Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
) {
return;
}
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
return;
}
let Ok(source) = std::fs::read_to_string(path) else {
return;
};
let scan = crate::css::scan_markup_class_tokens(&source);
for token in scan.static_tokens {
surface.static_tokens.insert(token.value);
}
collect_quoted_class_tokens(&source, &mut surface.static_tokens, true);
if scan.has_dynamic {
surface.dynamic_corpus.push_str(&source);
surface.dynamic_corpus.push('\n');
}
}
fn push_unreferenced_css_class_candidates(
out: &mut Vec<fallow_output::UnreferencedCssClass>,
rel: &str,
classes: Vec<(String, u32)>,
published: &rustc_hash::FxHashSet<String>,
dependency_prefixes: &rustc_hash::FxHashSet<String>,
reference_surface: &CssReferenceSurface,
) {
use fallow_output::{CssCandidateAction, UnreferencedCssClass};
if published.contains(rel)
|| !classes
.iter()
.any(|(class, _)| reference_surface.references(class))
{
return;
}
for (class, line) in classes {
if class.len() >= MIN_UNREF_CLASS_LEN
&& !reference_surface.references(&class)
&& !class_matches_dependency_prefix(&class, dependency_prefixes)
{
out.push(UnreferencedCssClass {
actions: vec![CssCandidateAction::verify_unreferenced_class(&class)],
class,
path: rel.to_string(),
line,
});
}
}
}
const THEME_USAGE_SOURCE_EXTS: &[&str] = &[
"scss", "sass", "less", "js", "jsx", "ts", "tsx", "mjs", "cjs", "vue", "svelte", "astro",
"html", "mdx",
];
fn collect_class_shaped_tokens(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
let start = i;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
i += 1;
} else {
break;
}
}
let tok = source[start..i].trim_matches('-');
if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
out.insert(tok.to_owned());
}
} else {
i += 1;
}
}
}
fn collect_class_shaped_tokens_located(
source: &str,
rel: &str,
out: &mut Vec<(String, String, u32)>,
) {
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
let start = i;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
i += 1;
} else {
break;
}
}
let tok = source[start..i].trim_matches('-');
if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
out.push((
tok.to_owned(),
rel.to_owned(),
line_at_offset(source, start),
));
}
} else {
i += 1;
}
}
}
fn line_at_offset(source: &str, offset: usize) -> u32 {
let count = source
.get(..offset)
.map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
u32::try_from(1 + count).unwrap_or(u32::MAX)
}
struct UnusedThemeTokenScanInput<'a> {
tokens: &'a CssTokenSets,
files: &'a [fallow_types::discover::DiscoveredFile],
config: &'a ResolvedConfig,
ignore_set: &'a globset::GlobSet,
changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
ws_roots: Option<&'a [std::path::PathBuf]>,
summary: &'a mut fallow_output::CssAnalyticsSummary,
}
struct ThemeTokenCandidate {
token: String,
namespace: String,
name: String,
path: String,
line: u32,
}
fn classify_theme_token_candidates(
input: &UnusedThemeTokenScanInput<'_>,
) -> Vec<ThemeTokenCandidate> {
let published = published_css_paths(input.config);
let mut candidates: Vec<ThemeTokenCandidate> = Vec::new();
for (raw, (path, line)) in &input.tokens.theme_token_definers {
if published.contains(path) {
continue;
}
let Some(classified) = tailwind_theme::classify(raw) else {
continue;
};
if classified.is_variant {
continue;
}
candidates.push(ThemeTokenCandidate {
token: format!("--{raw}"),
namespace: classified.namespace,
name: classified.name,
path: path.clone(),
line: *line,
});
}
candidates
}
fn collect_theme_usage_tokens(
input: &UnusedThemeTokenScanInput<'_>,
) -> rustc_hash::FxHashSet<String> {
let mut utility_tokens: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for apply in &input.tokens.apply_tokens {
collect_class_shaped_tokens(apply, &mut utility_tokens);
}
for file in input.files {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
continue;
}
let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
if input.ignore_set.is_match(relative) {
continue;
}
if let Ok(source) = std::fs::read_to_string(path) {
collect_class_shaped_tokens(&source, &mut utility_tokens);
}
}
utility_tokens
}
fn collect_theme_var_reads(tokens: &CssTokenSets) -> rustc_hash::FxHashSet<String> {
let mut var_reads: rustc_hash::FxHashSet<String> = tokens.theme_var_reads.clone();
for referenced in &tokens.referenced_custom_props {
var_reads.insert(referenced.trim_start_matches('-').to_owned());
}
var_reads
}
fn scan_unused_theme_tokens(
input: &mut UnusedThemeTokenScanInput<'_>,
) -> Vec<fallow_output::UnusedThemeToken> {
use fallow_output::{CssCandidateAction, UnusedThemeToken};
if input.changed_files.is_some() || input.ws_roots.is_some() {
return Vec::new();
}
if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
return Vec::new();
}
if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
return Vec::new();
}
let candidates = classify_theme_token_candidates(input);
if candidates.is_empty() {
input.summary.unused_theme_tokens = 0;
return Vec::new();
}
let utility_tokens = collect_theme_usage_tokens(input);
let var_reads = collect_theme_var_reads(input.tokens);
let mut out: Vec<UnusedThemeToken> = Vec::new();
for candidate in candidates {
let dash_name = format!("-{}", candidate.name);
let raw = candidate.token.trim_start_matches('-');
let used = var_reads.contains(raw)
|| utility_tokens
.iter()
.any(|t| t.len() > dash_name.len() && t.ends_with(&dash_name));
if used {
continue;
}
out.push(UnusedThemeToken {
actions: vec![CssCandidateAction::verify_unused_theme_token(
&candidate.token,
&candidate.namespace,
&candidate.name,
)],
token: candidate.token,
namespace: candidate.namespace,
path: candidate.path,
line: candidate.line,
});
}
out.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.token.cmp(&b.token))
});
input.summary.unused_theme_tokens = saturate_len(out.len());
out
}
struct TokenConsumersInput<'a> {
tokens: &'a CssTokenSets,
files: &'a [fallow_types::discover::DiscoveredFile],
config: &'a ResolvedConfig,
ignore_set: &'a globset::GlobSet,
changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
ws_roots: Option<&'a [std::path::PathBuf]>,
}
fn collect_located_utility_consumers(
input: &TokenConsumersInput<'_>,
) -> Vec<(String, String, u32)> {
let mut located: Vec<(String, String, u32)> = Vec::new();
for file in input.files {
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
continue;
}
let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
if input.ignore_set.is_match(relative) {
continue;
}
let rel = relative.to_string_lossy().replace('\\', "/");
if let Ok(source) = std::fs::read_to_string(path) {
collect_class_shaped_tokens_located(&source, &rel, &mut located);
}
}
located
}
fn build_token_consumers(input: &TokenConsumersInput<'_>) -> Vec<fallow_output::TokenConsumers> {
use fallow_output::{
ConsumerKind, TOKEN_CONSUMER_SAMPLE_CAP, TokenConsumerLocation, TokenConsumers,
};
if input.changed_files.is_some() || input.ws_roots.is_some() {
return Vec::new();
}
if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
return Vec::new();
}
if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
return Vec::new();
}
let mut summary = fallow_output::CssAnalyticsSummary::default();
let candidates = classify_theme_token_candidates(&UnusedThemeTokenScanInput {
tokens: input.tokens,
files: input.files,
config: input.config,
ignore_set: input.ignore_set,
changed_files: input.changed_files,
ws_roots: input.ws_roots,
summary: &mut summary,
});
if candidates.is_empty() {
return Vec::new();
}
let utility_located = collect_located_utility_consumers(input);
let mut out: Vec<TokenConsumers> = candidates
.into_iter()
.map(|candidate| {
let dash_name = format!("-{}", candidate.name);
let raw = candidate.token.trim_start_matches('-').to_owned();
let mut consumers: Vec<TokenConsumerLocation> = Vec::new();
for (name, path, line) in &input.tokens.theme_var_reads_located {
if *name == raw {
consumers.push(TokenConsumerLocation {
path: path.clone(),
line: *line,
kind: ConsumerKind::ThemeVar,
});
}
}
for (name, path, line) in &input.tokens.css_var_reads_located {
if *name == raw {
consumers.push(TokenConsumerLocation {
path: path.clone(),
line: *line,
kind: ConsumerKind::CssVar,
});
}
}
for (token, path, line) in &input.tokens.apply_uses_located {
if token.len() > dash_name.len() && token.ends_with(&dash_name) {
consumers.push(TokenConsumerLocation {
path: path.clone(),
line: *line,
kind: ConsumerKind::Apply,
});
}
}
for (token, path, line) in &utility_located {
if token.len() > dash_name.len() && token.ends_with(&dash_name) {
consumers.push(TokenConsumerLocation {
path: path.clone(),
line: *line,
kind: ConsumerKind::Utility,
});
}
}
consumers.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| consumer_kind_rank(a.kind).cmp(&consumer_kind_rank(b.kind)))
});
let consumer_count = saturate_len(consumers.len());
consumers.truncate(TOKEN_CONSUMER_SAMPLE_CAP);
TokenConsumers {
token: candidate.token,
namespace: candidate.namespace,
definition_path: candidate.path,
definition_line: candidate.line,
consumer_count,
consumers,
}
})
.collect();
out.sort_by(|a, b| a.token.cmp(&b.token));
out
}
struct CssInJsDefiner {
rel_path: String,
binding: String,
leaves: Vec<fallow_extract::CssInJsToken>,
}
struct CssInJsDefiners {
entries: Vec<CssInJsDefiner>,
index: rustc_hash::FxHashMap<(std::path::PathBuf, String), usize>,
paths: rustc_hash::FxHashSet<std::path::PathBuf>,
}
fn is_css_in_js_token_lib(specifier: &str) -> bool {
matches!(specifier, "@stylexjs/stylex" | "@vanilla-extract/css")
}
fn source_mentions_token_definer(source: &str) -> bool {
source.contains("defineVars")
|| source.contains("createThemeContract")
|| source.contains("createGlobalTheme")
|| source.contains("createTheme")
}
fn is_relative_specifier(specifier: &str) -> bool {
specifier.starts_with('.')
}
fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn resolve_relative_specifier(
consumer_abs: &std::path::Path,
specifier: &str,
definer_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
const EXTS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"];
let base = lexical_normalize(&consumer_abs.parent()?.join(specifier));
if definer_paths.contains(&base) {
return Some(base);
}
for ext in EXTS {
let mut candidate = base.clone().into_os_string();
candidate.push(".");
candidate.push(ext);
let candidate = std::path::PathBuf::from(candidate);
if definer_paths.contains(&candidate) {
return Some(candidate);
}
}
for ext in EXTS {
let candidate = base.join(format!("index.{ext}"));
if definer_paths.contains(&candidate) {
return Some(candidate);
}
}
None
}
fn collect_css_in_js_definers(
modules: &[fallow_types::extract::ModuleInfo],
path_by_id: &rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path>,
config: &ResolvedConfig,
) -> CssInJsDefiners {
let mut definers: Vec<CssInJsDefiner> = Vec::new();
let mut definer_index: rustc_hash::FxHashMap<(std::path::PathBuf, String), usize> =
rustc_hash::FxHashMap::default();
let mut definer_paths: rustc_hash::FxHashSet<std::path::PathBuf> =
rustc_hash::FxHashSet::default();
for module in modules {
let imports_token_lib = module
.imports
.iter()
.any(|i| !i.is_type_only && is_css_in_js_token_lib(&i.source));
if !imports_token_lib {
continue;
}
let Some(abs) = path_by_id.get(&module.file_id).copied() else {
continue;
};
let Ok(source) = std::fs::read_to_string(abs) else {
continue;
};
if !source_mentions_token_definer(&source) {
continue;
}
let defs = fallow_extract::css_in_js_token_defs(&source, abs);
if defs.is_empty() {
continue;
}
let Some(rel) = relative_to_root(abs, &config.root) else {
continue;
};
let norm = lexical_normalize(abs);
for def in defs {
let idx = definers.len();
definer_index.insert((norm.clone(), def.binding.clone()), idx);
definer_paths.insert(norm.clone());
definers.push(CssInJsDefiner {
rel_path: rel.clone(),
binding: def.binding,
leaves: def.tokens,
});
}
}
CssInJsDefiners {
entries: definers,
index: definer_index,
paths: definer_paths,
}
}
fn collect_css_in_js_consumers(
modules: &[fallow_types::extract::ModuleInfo],
path_by_id: &rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path>,
config: &ResolvedConfig,
definers: &CssInJsDefiners,
) -> rustc_hash::FxHashMap<(usize, String), rustc_hash::FxHashSet<(String, u32)>> {
use fallow_types::extract::ImportedName;
let mut hits: rustc_hash::FxHashMap<(usize, String), rustc_hash::FxHashSet<(String, u32)>> =
rustc_hash::FxHashMap::default();
for module in modules {
let Some(consumer_abs) = path_by_id.get(&module.file_id).copied() else {
continue;
};
let mut matches: Vec<(usize, &str)> = Vec::new();
for import in &module.imports {
if import.is_type_only {
continue;
}
let ImportedName::Named(name) = &import.imported_name else {
continue;
};
if !is_relative_specifier(&import.source) {
continue;
}
let Some(resolved) =
resolve_relative_specifier(consumer_abs, &import.source, &definers.paths)
else {
continue;
};
if let Some(&idx) = definers.index.get(&(resolved, name.clone())) {
matches.push((idx, import.local_name.as_str()));
}
}
if matches.is_empty() {
continue;
}
let Ok(source) = std::fs::read_to_string(consumer_abs) else {
continue;
};
let Some(consumer_rel) = relative_to_root(consumer_abs, &config.root) else {
continue;
};
for (idx, alias) in matches {
let leaf_set: rustc_hash::FxHashSet<String> = definers.entries[idx]
.leaves
.iter()
.map(|t| t.path.clone())
.collect();
for hit in
fallow_extract::css_in_js_token_consumers(&source, consumer_abs, alias, &leaf_set)
{
hits.entry((idx, hit.token_path))
.or_default()
.insert((consumer_rel.clone(), hit.line));
}
}
}
hits
}
fn build_css_in_js_token_consumers(
files: &[fallow_types::discover::DiscoveredFile],
modules: &[fallow_types::extract::ModuleInfo],
config: &ResolvedConfig,
) -> Vec<fallow_output::TokenConsumers> {
use fallow_output::{
ConsumerKind, TOKEN_CONSUMER_SAMPLE_CAP, TokenConsumerLocation, TokenConsumers,
};
if !project_uses_css_in_js(&config.root) {
return Vec::new();
}
let path_by_id: rustc_hash::FxHashMap<fallow_types::discover::FileId, &std::path::Path> =
files.iter().map(|f| (f.id, f.path.as_path())).collect();
let definers = collect_css_in_js_definers(modules, &path_by_id, config);
if definers.entries.is_empty() {
return Vec::new();
}
let hits = collect_css_in_js_consumers(modules, &path_by_id, config, &definers);
let mut out: Vec<TokenConsumers> = Vec::new();
for (idx, definer) in definers.entries.iter().enumerate() {
for leaf in &definer.leaves {
let mut consumers: Vec<TokenConsumerLocation> = hits
.get(&(idx, leaf.path.clone()))
.map(|set| {
set.iter()
.map(|(path, line)| TokenConsumerLocation {
path: path.clone(),
line: *line,
kind: ConsumerKind::JsMember,
})
.collect()
})
.unwrap_or_default();
consumers.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
let consumer_count = saturate_len(consumers.len());
consumers.truncate(TOKEN_CONSUMER_SAMPLE_CAP);
out.push(TokenConsumers {
token: format!("{}.{}", definer.binding, leaf.path),
namespace: definer.binding.clone(),
definition_path: definer.rel_path.clone(),
definition_line: leaf.def_line,
consumer_count,
consumers,
});
}
}
out.sort_by(|a, b| {
a.token
.cmp(&b.token)
.then_with(|| a.definition_path.cmp(&b.definition_path))
});
out
}
fn consumer_kind_rank(kind: fallow_output::ConsumerKind) -> u8 {
use fallow_output::ConsumerKind;
match kind {
ConsumerKind::ThemeVar => 0,
ConsumerKind::CssVar => 1,
ConsumerKind::Utility => 2,
ConsumerKind::Apply => 3,
ConsumerKind::JsMember => 4,
}
}
struct MarkupCssCandidates {
tailwind_arbitrary_values: Vec<fallow_output::TailwindArbitraryValue>,
unresolved_class_references: Vec<fallow_output::UnresolvedClassReference>,
unreferenced_css_classes: Vec<fallow_output::UnreferencedCssClass>,
unused_theme_tokens: Vec<fallow_output::UnusedThemeToken>,
}
struct MarkupCssCandidateInput<'a> {
tokens: &'a CssTokenSets,
files: &'a [fallow_types::discover::DiscoveredFile],
config: &'a ResolvedConfig,
ignore_set: &'a globset::GlobSet,
changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
ws_roots: Option<&'a [std::path::PathBuf]>,
summary: &'a mut fallow_output::CssAnalyticsSummary,
}
fn scan_markup_css_candidates(input: &mut MarkupCssCandidateInput<'_>) -> MarkupCssCandidates {
MarkupCssCandidates {
tailwind_arbitrary_values: scan_markup_tailwind_arbitrary_values(
input.files,
HealthScanCtx {
config: input.config,
ignore_set: input.ignore_set,
changed_files: input.changed_files,
ws_roots: input.ws_roots,
},
input.summary,
),
unresolved_class_references: scan_unresolved_class_references(
input.files,
HealthScanCtx {
config: input.config,
ignore_set: input.ignore_set,
changed_files: input.changed_files,
ws_roots: input.ws_roots,
},
input.summary,
),
unreferenced_css_classes: scan_unreferenced_css_classes(
input.files,
HealthScanCtx {
config: input.config,
ignore_set: input.ignore_set,
changed_files: input.changed_files,
ws_roots: input.ws_roots,
},
input.summary,
),
unused_theme_tokens: scan_unused_theme_tokens(&mut UnusedThemeTokenScanInput {
tokens: input.tokens,
files: input.files,
config: input.config,
ignore_set: input.ignore_set,
changed_files: input.changed_files,
ws_roots: input.ws_roots,
summary: input.summary,
}),
}
}
fn project_uses_css_in_js(root: &std::path::Path) -> bool {
const CSS_IN_JS_DEPS: &[&str] = &[
"styled-components",
"@emotion/styled",
"@emotion/react",
"@emotion/css",
"@linaria/core",
"@linaria/react",
"@vanilla-extract/css",
"@pandacss/dev",
"@stylexjs/stylex",
];
let Ok(text) = std::fs::read_to_string(root.join("package.json")) else {
return false;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
["dependencies", "devDependencies", "peerDependencies"]
.iter()
.any(|key| {
json.get(key)
.and_then(serde_json::Value::as_object)
.is_some_and(|deps| deps.keys().any(|k| CSS_IN_JS_DEPS.contains(&k.as_str())))
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CssScanKind {
Css,
Sfc,
CssInJs,
}
fn css_report_scan_target<'a>(
file: &'a fallow_types::discover::DiscoveredFile,
ctx: HealthScanCtx<'_>,
css_in_js: bool,
) -> Option<(&'a std::path::Path, CssScanKind)> {
let HealthScanCtx {
config,
ignore_set,
changed_files,
ws_roots,
} = ctx;
let path = &file.path;
let extension = path.extension().and_then(|ext| ext.to_str());
let kind = match extension {
Some("css") => CssScanKind::Css,
Some("vue") | Some("svelte") => CssScanKind::Sfc,
Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts") if css_in_js => {
CssScanKind::CssInJs
}
_ => return None,
};
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set.is_match(relative) {
return None;
}
if let Some(changed) = changed_files
&& !changed.contains(path)
{
return None;
}
if let Some(roots) = ws_roots
&& !roots.iter().any(|root| path.starts_with(root))
{
return None;
}
Some((relative, kind))
}
fn record_scoped_unused_classes(
source: &str,
relative: &std::path::Path,
summary: &mut fallow_output::CssAnalyticsSummary,
scoped_unused: &mut Vec<fallow_output::ScopedUnusedClasses>,
) {
let classes = crate::css::scoped_unused_classes(source);
if classes.is_empty() {
return;
}
summary.scoped_unused_classes = summary
.scoped_unused_classes
.saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX));
scoped_unused.push(fallow_output::ScopedUnusedClasses {
path: relative.to_string_lossy().replace('\\', "/"),
classes,
actions: vec![fallow_output::CssCandidateAction::verify_scoped_classes()],
});
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum GradePolicy {
Structural,
StructuralNoDedup,
Atomic,
}
struct CssScanItem<'a> {
source: std::borrow::Cow<'a, str>,
policy: GradePolicy,
report_notable: bool,
}
fn css_report_scan_items<'a>(
source: &'a str,
path: &std::path::Path,
kind: CssScanKind,
) -> Vec<CssScanItem<'a>> {
use std::borrow::Cow;
match kind {
CssScanKind::Css => vec![CssScanItem {
source: Cow::Borrowed(source),
policy: GradePolicy::Structural,
report_notable: true,
}],
CssScanKind::Sfc => crate::css::sfc_virtual_stylesheet(source)
.map(|virtual_css| {
vec![CssScanItem {
source: Cow::Owned(virtual_css),
policy: GradePolicy::Structural,
report_notable: true,
}]
})
.unwrap_or_default(),
CssScanKind::CssInJs => {
let mut items = Vec::new();
if let Some(virtual_css) = crate::css::css_in_js_virtual_stylesheet(source) {
items.push(CssScanItem {
source: Cow::Owned(virtual_css),
policy: GradePolicy::Structural,
report_notable: true,
});
}
let sheets = crate::css::css_in_js_object_sheets(source, path);
if let Some(structural) = sheets.structural {
items.push(CssScanItem {
source: Cow::Owned(structural),
policy: GradePolicy::Structural,
report_notable: false,
});
}
if let Some(partial) = sheets.structural_partial {
items.push(CssScanItem {
source: Cow::Owned(partial),
policy: GradePolicy::StructuralNoDedup,
report_notable: false,
});
}
if let Some(atomic) = sheets.atomic {
items.push(CssScanItem {
source: Cow::Owned(atomic),
policy: GradePolicy::Atomic,
report_notable: false,
});
}
items
}
}
}
fn record_css_analytics_summary(
summary: &mut fallow_output::CssAnalyticsSummary,
analytics: &fallow_types::extract::CssAnalytics,
) {
summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
summary.total_declarations = summary
.total_declarations
.saturating_add(analytics.total_declarations);
summary.important_declarations = summary
.important_declarations
.saturating_add(analytics.important_declarations);
summary.empty_rules = summary
.empty_rules
.saturating_add(analytics.empty_rule_count);
summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
if analytics.notable_truncated {
summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
}
}
struct CssWalkAccum {
file_reports: Vec<fallow_output::CssFileAnalytics>,
summary: fallow_output::CssAnalyticsSummary,
scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
tokens: CssTokenSets,
scoring: CssGradeScoring,
}
#[derive(Default)]
struct CssGradeScoring {
non_atomic_declarations: u32,
non_atomic_important_declarations: u32,
non_atomic_max_nesting_depth: u8,
atomic_declarations: u32,
}
impl CssGradeScoring {
fn add_non_atomic(&mut self, analytics: &fallow_types::extract::CssAnalytics) {
self.non_atomic_declarations = self
.non_atomic_declarations
.saturating_add(analytics.total_declarations);
self.non_atomic_important_declarations = self
.non_atomic_important_declarations
.saturating_add(analytics.important_declarations);
self.non_atomic_max_nesting_depth = self
.non_atomic_max_nesting_depth
.max(analytics.max_nesting_depth);
}
}
struct CssTokenMetrics {
unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
unused_at_rules: Vec<fallow_output::UnusedAtRule>,
font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
unused_font_faces: Vec<fallow_output::UnusedFontFace>,
}
pub(super) struct CssAnalyticsComputation {
pub(super) report: fallow_output::CssAnalyticsReport,
pub(super) scoring_inputs: super::styling_score::StylingScoringInputs,
}
fn walk_css_files(
files: &[fallow_types::discover::DiscoveredFile],
ctx: HealthScanCtx<'_>,
) -> CssWalkAccum {
use fallow_output::{CssAnalyticsSummary, CssFileAnalytics, ScopedUnusedClasses};
let mut file_reports = Vec::new();
let mut summary = CssAnalyticsSummary::default();
let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
let mut tokens = CssTokenSets::default();
let mut scoring = CssGradeScoring::default();
let css_in_js = project_uses_css_in_js(&ctx.config.root);
for file in files {
let Some((relative, kind)) = css_report_scan_target(file, ctx, css_in_js) else {
continue;
};
let Ok(source) = std::fs::read_to_string(&file.path) else {
continue;
};
if kind == CssScanKind::Sfc {
record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
}
let rel = relative.to_string_lossy().replace('\\', "/");
let mut file_had_sheet = false;
for item in css_report_scan_items(&source, &file.path, kind) {
let Some(mut analytics) = crate::css::compute_css_analytics(&item.source) else {
continue;
};
file_had_sheet = true;
record_css_analytics_summary(&mut summary, &analytics);
tokens.record_theme(item.source.as_ref(), &rel);
match item.policy {
GradePolicy::Atomic => {
analytics.declaration_blocks.clear();
tokens.record(&analytics, &rel);
scoring.atomic_declarations = scoring
.atomic_declarations
.saturating_add(analytics.total_declarations);
}
GradePolicy::Structural | GradePolicy::StructuralNoDedup => {
if item.policy == GradePolicy::StructuralNoDedup {
analytics.declaration_blocks.clear();
}
tokens.record(&analytics, &rel);
scoring.add_non_atomic(&analytics);
if item.report_notable && !analytics.notable_rules.is_empty() {
file_reports.push(CssFileAnalytics {
path: rel.clone(),
analytics,
});
}
}
}
}
if file_had_sheet {
summary.files_analyzed = summary.files_analyzed.saturating_add(1);
}
}
CssWalkAccum {
file_reports,
summary,
scoped_unused,
tokens,
scoring,
}
}
fn finalize_css_token_metrics(
tokens: &mut CssTokenSets,
summary: &mut fallow_output::CssAnalyticsSummary,
files: &[fallow_types::discover::DiscoveredFile],
config: &ResolvedConfig,
ignore_set: &globset::GlobSet,
) -> CssTokenMetrics {
for name in collect_markup_keyframe_references(files, config, ignore_set) {
if tokens.defined_keyframes.contains(&name) {
tokens.referenced_keyframes.insert(name);
}
}
let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
let unused_at_rules = tokens.group_unused_at_rules(summary);
let font_size_unit_mix = tokens.font_size_unit_mix(summary);
let mut unused_font_faces = tokens.unused_font_faces(summary);
if !unused_font_faces.is_empty() {
let referenced =
font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
summary.unused_font_faces = saturate_len(unused_font_faces.len());
}
CssTokenMetrics {
unreferenced_keyframes,
undefined_keyframes,
duplicate_declaration_blocks,
unused_at_rules,
font_size_unit_mix,
unused_font_faces,
}
}
pub(super) fn compute_css_analytics_report(
files: &[fallow_types::discover::DiscoveredFile],
modules: &[fallow_types::extract::ModuleInfo],
ctx: HealthScanCtx<'_>,
) -> Option<CssAnalyticsComputation> {
let HealthScanCtx {
config,
ignore_set,
changed_files,
ws_roots,
} = ctx;
let mut walk = walk_css_files(files, ctx);
let metrics = finalize_css_token_metrics(
&mut walk.tokens,
&mut walk.summary,
files,
config,
ignore_set,
);
let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
tokens: &walk.tokens,
files,
config,
ignore_set,
changed_files,
ws_roots,
summary: &mut walk.summary,
});
let mut token_consumers = build_token_consumers(&TokenConsumersInput {
tokens: &walk.tokens,
files,
config,
ignore_set,
changed_files,
ws_roots,
});
token_consumers.extend(build_css_in_js_token_consumers(files, modules, config));
token_consumers.sort_by(|a, b| {
a.token
.cmp(&b.token)
.then_with(|| a.definition_path.cmp(&b.definition_path))
});
let scoring_inputs = super::styling_score::StylingScoringInputs {
theme_tokens_defined: saturate_len(walk.tokens.theme_token_definers.len()),
non_atomic_declarations: walk.scoring.non_atomic_declarations,
non_atomic_important_declarations: walk.scoring.non_atomic_important_declarations,
non_atomic_max_nesting_depth: walk.scoring.non_atomic_max_nesting_depth,
atomic_declarations: walk.scoring.atomic_declarations,
};
let report = assemble_css_report(walk, metrics, candidates, token_consumers)?;
Some(CssAnalyticsComputation {
report,
scoring_inputs,
})
}
fn assemble_css_report(
walk: CssWalkAccum,
metrics: CssTokenMetrics,
candidates: MarkupCssCandidates,
token_consumers: Vec<fallow_output::TokenConsumers>,
) -> Option<fallow_output::CssAnalyticsReport> {
use fallow_output::CssAnalyticsReport;
let candidates_empty = candidates.tailwind_arbitrary_values.is_empty()
&& candidates.unresolved_class_references.is_empty()
&& candidates.unreferenced_css_classes.is_empty()
&& metrics.unused_font_faces.is_empty()
&& candidates.unused_theme_tokens.is_empty()
&& token_consumers.is_empty();
if walk.summary.files_analyzed == 0 && walk.scoped_unused.is_empty() && candidates_empty {
return None;
}
let mut scoped_unused = walk.scoped_unused;
scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
Some(CssAnalyticsReport {
files: walk.file_reports,
summary: walk.summary,
scoped_unused,
unreferenced_keyframes: metrics.unreferenced_keyframes,
undefined_keyframes: metrics.undefined_keyframes,
duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
unused_at_rules: metrics.unused_at_rules,
unresolved_class_references: candidates.unresolved_class_references,
unreferenced_css_classes: candidates.unreferenced_css_classes,
unused_font_faces: metrics.unused_font_faces,
unused_theme_tokens: candidates.unused_theme_tokens,
token_consumers,
font_size_unit_mix: metrics.font_size_unit_mix,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
reason = "tests use unwrap to keep token-consumer assertions concise"
)]
mod token_consumer_tests {
use super::*;
use fallow_config::{FallowConfig, OutputFormat};
use fallow_output::ConsumerKind;
use fallow_types::discover::{DiscoveredFile, FileId};
use std::path::Path;
fn config_at(root: &Path) -> ResolvedConfig {
FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Human,
1,
true,
true,
None,
)
}
fn write_file(root: &Path, id: u32, relative: &str, body: &str) -> DiscoveredFile {
let path = root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, body).unwrap();
DiscoveredFile {
id: FileId(id),
size_bytes: u64::try_from(body.len()).unwrap(),
path,
}
}
fn tokens_from(theme_css: &str, rel: &str) -> CssTokenSets {
let mut tokens = CssTokenSets::default();
tokens.record_theme(theme_css, rel);
tokens
}
#[test]
fn token_read_by_two_markup_files_counts_two_utility() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let f1 = write_file(
root,
0,
"src/Button.tsx",
"export const Button = () => <button className=\"bg-brand\" />;",
);
let f2 = write_file(
root,
1,
"src/Card.tsx",
"export const Card = () => <div className=\"text-brand p-4\" />;",
);
let files = vec![f1, f2];
let config = config_at(root);
let tokens = tokens_from("@theme {\n --color-brand: #f00;\n}", "src/theme.css");
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
assert_eq!(out.len(), 1);
let entry = &out[0];
assert_eq!(entry.token, "--color-brand");
assert_eq!(entry.consumer_count, 2);
assert!(
entry
.consumers
.iter()
.all(|c| c.kind == ConsumerKind::Utility)
);
let paths: Vec<&str> = entry.consumers.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, vec!["src/Button.tsx", "src/Card.tsx"]);
}
#[test]
fn token_with_no_consumer_counts_zero() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let files = vec![write_file(
root,
0,
"src/App.tsx",
"export const App = () => <div className=\"flex gap-2\" />;",
)];
let config = config_at(root);
let tokens = tokens_from("@theme {\n --color-unused: #abc;\n}", "src/theme.css");
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
assert_eq!(out.len(), 1);
assert_eq!(out[0].token, "--color-unused");
assert_eq!(out[0].consumer_count, 0);
assert!(out[0].consumers.is_empty());
}
#[test]
fn theme_var_and_css_var_reads_locate_distinct_kinds() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let theme_css = "@theme {\n --color-brand: #f00;\n --color-accent: var(--color-brand);\n}\n.note {\n color: var(--color-brand);\n}";
let files: Vec<DiscoveredFile> = Vec::new();
let config = config_at(root);
let tokens = tokens_from(theme_css, "src/theme.css");
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
let brand = out
.iter()
.find(|t| t.token == "--color-brand")
.expect("--color-brand present");
assert_eq!(brand.consumer_count, 2);
let kinds: Vec<ConsumerKind> = brand.consumers.iter().map(|c| c.kind).collect();
assert!(kinds.contains(&ConsumerKind::ThemeVar));
assert!(kinds.contains(&ConsumerKind::CssVar));
}
#[test]
fn apply_body_locates_apply_kind() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let theme_css = "@theme {\n --color-brand: #f00;\n}\n.btn {\n @apply bg-brand;\n}";
let files: Vec<DiscoveredFile> = Vec::new();
let config = config_at(root);
let tokens = tokens_from(theme_css, "src/theme.css");
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
let brand = out.iter().find(|t| t.token == "--color-brand").unwrap();
assert_eq!(brand.consumer_count, 1);
assert_eq!(brand.consumers[0].kind, ConsumerKind::Apply);
}
#[test]
fn non_tailwind_project_emits_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
let files = vec![write_file(
root,
0,
"src/App.tsx",
"export const App = () => <div className=\"bg-brand\" />;",
)];
let config = config_at(root);
let tokens = tokens_from("@theme {\n --color-brand: #f00;\n}", "src/theme.css");
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
assert!(out.is_empty(), "non-Tailwind project must abstain");
}
#[test]
fn plugin_project_emits_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let files: Vec<DiscoveredFile> = Vec::new();
let config = config_at(root);
let tokens = tokens_from(
"@plugin \"@tailwindcss/typography\";\n@theme {\n --color-brand: #f00;\n}",
"src/theme.css",
);
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
});
assert!(out.is_empty(), "plugin project must abstain");
}
#[test]
fn partial_scope_emits_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0"}}"#,
)
.unwrap();
let files: Vec<DiscoveredFile> = Vec::new();
let config = config_at(root);
let tokens = tokens_from("@theme {\n --color-brand: #f00;\n}", "src/theme.css");
let changed: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
let out = build_token_consumers(&TokenConsumersInput {
tokens: &tokens,
files: &files,
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: Some(&changed),
ws_roots: None,
});
assert!(out.is_empty(), "partial scope must abstain");
}
fn css_computation(root: &Path, files: &[DiscoveredFile]) -> Option<CssAnalyticsComputation> {
let config = config_at(root);
compute_css_analytics_report(
files,
&[],
HealthScanCtx {
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
},
)
}
fn css_computation_3d(root: &Path, files: &[DiscoveredFile]) -> CssAnalyticsComputation {
let config = config_at(root);
let modules: Vec<fallow_types::extract::ModuleInfo> = files
.iter()
.map(|f| {
let src = std::fs::read_to_string(&f.path).unwrap_or_default();
fallow_extract::parse_source_to_module(f.id, &f.path, &src, 0, false)
})
.collect();
compute_css_analytics_report(
files,
&modules,
HealthScanCtx {
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
},
)
.expect("css_analytics is non-null")
}
fn js_token_consumers(
computation: &CssAnalyticsComputation,
) -> Vec<&fallow_output::TokenConsumers> {
computation
.report
.token_consumers
.iter()
.filter(|t| {
t.consumers
.iter()
.all(|c| c.kind == fallow_output::ConsumerKind::JsMember)
&& t.token.contains('.')
&& !t.token.starts_with("--")
})
.collect()
}
fn find_token<'a>(
computation: &'a CssAnalyticsComputation,
token: &str,
) -> Option<&'a fallow_output::TokenConsumers> {
computation
.report
.token_consumers
.iter()
.find(|t| t.token == token)
}
#[test]
fn stylex_define_vars_blast_radius_located_js_member_consumers() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let def = write_file(
root,
0,
"src/tokens.stylex.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const vars = stylex.defineVars({ color: { primary: '#000', secondary: '#fff' } });\n",
);
let consumer = write_file(
root,
1,
"src/card.ts",
"import * as stylex from '@stylexjs/stylex';\n\
import { vars } from './tokens.stylex';\n\
export const s = stylex.create({ root: { color: vars.color.primary } });\n",
);
let computation = css_computation_3d(root, &[def, consumer]);
let primary = find_token(&computation, "vars.color.primary")
.expect("vars.color.primary blast radius present");
assert_eq!(primary.namespace, "vars");
assert_eq!(primary.definition_path, "src/tokens.stylex.ts");
assert_eq!(primary.consumer_count, 1);
assert_eq!(primary.consumers.len(), 1);
assert_eq!(
primary.consumers[0].kind,
fallow_output::ConsumerKind::JsMember
);
assert_eq!(primary.consumers[0].path, "src/card.ts");
let secondary =
find_token(&computation, "vars.color.secondary").expect("secondary present");
assert_eq!(secondary.consumer_count, 0);
}
#[test]
fn both_tailwind_and_css_in_js_tokens_merge_in_deterministic_global_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"tailwindcss":"4.0.0","@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let theme = write_file(
root,
0,
"src/theme.css",
"@theme {\n --color-brand: #3b82f6;\n}\n",
);
let markup = write_file(
root,
1,
"src/App.tsx",
"export const A = () => <p className=\"text-brand\">x</p>;\n",
);
let tokens_file = write_file(
root,
2,
"src/tokens.stylex.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const vars = stylex.defineVars({ accent: '#000' });\n",
);
let card = write_file(
root,
3,
"src/Card.ts",
"import { vars } from './tokens.stylex';\nexport const x = vars.accent;\n",
);
let computation = css_computation_3d(root, &[theme, markup, tokens_file, card]);
let tokens: Vec<&str> = computation
.report
.token_consumers
.iter()
.map(|t| t.token.as_str())
.collect();
assert!(
tokens.iter().any(|t| t.starts_with("--")),
"Tailwind @theme token present: {tokens:?}"
);
assert!(
tokens.iter().any(|t| t == &"vars.accent"),
"CSS-in-JS token present: {tokens:?}"
);
let mut sorted = tokens.clone();
sorted.sort_unstable();
assert_eq!(
tokens, sorted,
"combined token_consumers is globally token-sorted"
);
}
#[test]
fn vanilla_extract_create_theme_tuple_blast_radius() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@vanilla-extract/css":"1.0.0"}}"#,
)
.unwrap();
let def = write_file(
root,
0,
"src/theme.css.ts",
"import { createTheme } from '@vanilla-extract/css';\n\
export const [themeClass, vars] = createTheme({ color: { brand: 'red' } });\n",
);
let consumer = write_file(
root,
1,
"src/box.css.ts",
"import { style } from '@vanilla-extract/css';\n\
import { vars } from './theme.css';\n\
export const box = style({ color: vars.color.brand });\n",
);
let computation = css_computation_3d(root, &[def, consumer]);
let brand =
find_token(&computation, "vars.color.brand").expect("brand blast radius present");
assert_eq!(brand.consumer_count, 1);
assert_eq!(brand.consumers[0].path, "src/box.css.ts");
assert_eq!(
brand.consumers[0].kind,
fallow_output::ConsumerKind::JsMember
);
}
#[test]
fn zero_false_consumer_same_name_from_unrelated_module() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let def = write_file(
root,
0,
"src/tokens.stylex.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
);
let other = write_file(
root,
1,
"src/other.ts",
"export const vars = { color: { primary: 1 } };\n",
);
let consumer = write_file(
root,
2,
"src/use-other.ts",
"import { vars } from './other';\n\
export const x = vars.color.primary;\n",
);
let computation = css_computation_3d(root, &[def, other, consumer]);
let primary = find_token(&computation, "vars.color.primary").expect("token present");
assert_eq!(
primary.consumer_count, 0,
"import of same-named `vars` from an unrelated module must not be a consumer",
);
}
#[test]
fn zero_double_count_one_site_counts_once_and_intermediate_not_counted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let def = write_file(
root,
0,
"src/t.stylex.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
);
let consumer = write_file(
root,
1,
"src/c.ts",
"import { vars } from './t.stylex';\nexport const x = vars.color.primary;\n",
);
let computation = css_computation_3d(root, &[def, consumer]);
let primary = find_token(&computation, "vars.color.primary").expect("token present");
assert_eq!(primary.consumer_count, 1, "one access site counts once");
assert!(find_token(&computation, "vars.color").is_none());
}
#[test]
fn aliased_import_and_multi_file_counting() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let def = write_file(
root,
0,
"src/t.stylex.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const vars = stylex.defineVars({ color: { primary: '#000' } });\n",
);
let c1 = write_file(
root,
1,
"src/a.ts",
"import { vars as v } from './t.stylex';\nexport const x = v.color.primary;\n",
);
let c2 = write_file(
root,
2,
"src/b.ts",
"import { vars } from './t.stylex';\nexport const y = vars.color.primary;\n",
);
let computation = css_computation_3d(root, &[def, c1, c2]);
let primary = find_token(&computation, "vars.color.primary").expect("token present");
assert_eq!(
primary.consumer_count, 2,
"aliased + plain imports both counted across files"
);
let paths: Vec<&str> = primary.consumers.iter().map(|c| c.path.as_str()).collect();
assert!(paths.contains(&"src/a.ts") && paths.contains(&"src/b.ts"));
}
#[test]
fn non_css_in_js_project_emits_no_js_member_consumers() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"react":"18.0.0"}}"#,
)
.unwrap();
let f = write_file(
root,
0,
"src/x.ts",
"export const vars = { color: { primary: '#000' } };\nexport const y = vars.color.primary;\n",
);
let modules = vec![fallow_extract::parse_source_to_module(
f.id,
&f.path,
&std::fs::read_to_string(&f.path).unwrap(),
0,
false,
)];
let config = config_at(root);
let computation = compute_css_analytics_report(
&[f],
&modules,
HealthScanCtx {
config: &config,
ignore_set: &globset::GlobSet::empty(),
changed_files: None,
ws_roots: None,
},
);
if let Some(computation) = computation {
assert!(js_token_consumers(&computation).is_empty());
}
}
#[test]
fn vanilla_extract_object_styles_feed_css_analytics_and_grade() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@vanilla-extract/css":"1.0.0"}}"#,
)
.unwrap();
let file = write_file(
root,
0,
"src/styles.css.ts",
"import { style } from '@vanilla-extract/css';\n\
export const a = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n\
export const b = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n\
export const c = style({ color: 'blue' });\n",
);
let computation = css_computation(root, &[file]).expect("css_analytics is non-null");
let report = &computation.report;
assert!(
report.summary.files_analyzed >= 1,
"object styles analyzed: {:?}",
report.summary
);
assert!(
report.summary.unique_colors >= 2,
"distinct colors counted from object styles: {:?}",
report.summary
);
assert!(
!report.duplicate_declaration_blocks.is_empty(),
"identical object buckets surface a duplicate block",
);
assert!(computation.scoring_inputs.non_atomic_declarations >= 8);
assert_eq!(computation.scoring_inputs.atomic_declarations, 0);
let styling = crate::health::styling_score::compute_styling_health_with_inputs(
report,
&computation.scoring_inputs,
);
assert!(styling.penalties.duplication > 0.0, "duplication penalized");
}
#[test]
fn stylex_atomic_styles_do_not_inflate_grade() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"@stylexjs/stylex":"0.1.0"}}"#,
)
.unwrap();
let file = write_file(
root,
0,
"src/styles.ts",
"import * as stylex from '@stylexjs/stylex';\n\
export const s = stylex.create({\n\
root: { color: 'red', padding: 16, margin: 8, fontSize: 14 },\n\
card: { color: 'blue', display: 'flex' },\n\
});\n",
);
let computation = css_computation(root, &[file]).expect("css_analytics is non-null");
let report = &computation.report;
assert!(
report.summary.unique_colors >= 2,
"atomic token sprawl counted: {:?}",
report.summary
);
assert!(computation.scoring_inputs.atomic_declarations >= 4);
assert_eq!(
computation.scoring_inputs.non_atomic_declarations, 0,
"no non-atomic gradeable surface in a pure-StyleX project",
);
let styling = crate::health::styling_score::compute_styling_health_with_inputs(
report,
&computation.scoring_inputs,
);
assert_eq!(
styling.confidence,
fallow_output::StylingHealthConfidence::Low,
"predominantly-atomic project is low-confidence",
);
let reason = styling.confidence_reason.expect("atomic caveat");
assert!(
reason.contains("compile-time-atomic"),
"atomic reason names non-assessability: {reason:?}",
);
}
#[test]
fn non_object_css_in_js_project_is_byte_identical() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("package.json"), r#"{"dependencies":{}}"#).unwrap();
let file = write_file(
root,
0,
"src/styles.ts",
"const style = (o) => o;\n\
export const a = style({ color: 'red', padding: 8, margin: 4, top: 1 });\n",
);
assert!(
css_computation(root, &[file]).is_none(),
"a project with no CSS-in-JS deps yields no CSS analytics (byte-identical to pre-3c)",
);
}
}