use std::sync::LazyLock;
use rustc_hash::FxHashSet;
use crate::ExportName;
use crate::css::extract_css_module_exports;
static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(
r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
)
});
fn has_scoped_attr(attrs: &str) -> bool {
attrs
.split(|c: char| c.is_whitespace() || c == '=' || c == '"' || c == '\'')
.any(|token| token.eq_ignore_ascii_case("scoped"))
}
fn has_non_css_lang(attrs: &str) -> bool {
let lower = attrs.to_ascii_lowercase();
has_preprocessor_lang_value(&lower)
|| [
"lang=\"stylus\"",
"lang='stylus'",
"lang=\"postcss\"",
"lang='postcss'",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn has_preprocessor_lang(attrs: &str) -> bool {
has_preprocessor_lang_value(&attrs.to_ascii_lowercase())
}
fn has_preprocessor_lang_value(lower_attrs: &str) -> bool {
[
"lang=\"scss\"",
"lang='scss'",
"lang=\"sass\"",
"lang='sass'",
"lang=\"less\"",
"lang='less'",
]
.iter()
.any(|needle| lower_attrs.contains(needle))
}
fn block_escapes_scope(body: &str) -> bool {
body.contains(":global")
|| body.contains(":deep")
|| body.contains("::v-deep")
|| body.contains("/deep/")
|| body.contains("@apply")
}
#[must_use]
pub fn scoped_unused_classes(source: &str) -> Vec<String> {
let mut scoped_classes: FxHashSet<String> = FxHashSet::default();
let mut style_ranges: Vec<(usize, usize)> = Vec::new();
for caps in STYLE_BLOCK_RE.captures_iter(source) {
if let Some(whole) = caps.get(0) {
style_ranges.push((whole.start(), whole.end()));
}
let attrs = caps.name("attrs").map_or("", |m| m.as_str());
let body = caps.name("body").map_or("", |m| m.as_str());
if !has_scoped_attr(attrs) || has_non_css_lang(attrs) || block_escapes_scope(body) {
continue;
}
for export in extract_css_module_exports(body, false) {
if let ExportName::Named(name) = export.name {
scoped_classes.insert(name);
}
}
}
if scoped_classes.is_empty() {
return Vec::new();
}
let search = blank_ranges(source, &style_ranges);
let transition_classes = vue_transition_classes(&search);
let mut candidates: Vec<String> = scoped_classes
.into_iter()
.filter(|class| !transition_classes.contains(class) && !class_token_appears(&search, class))
.collect();
candidates.sort_unstable();
candidates
}
static TRANSITION_TAG_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(
r#"<(?P<tag>TransitionGroup|transition-group|Transition|transition)(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>"#,
)
});
static TAG_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(
r#"(?P<key>[^\s=/>"']+)(?:\s*=\s*(?:"(?P<dq>[^"]*)"|'(?P<sq>[^']*)'|(?P<uq>[^\s>"']+)))?"#,
)
});
const TRANSITION_CLASS_SUFFIXES: [&str; 6] = [
"enter-from",
"enter-active",
"enter-to",
"leave-from",
"leave-active",
"leave-to",
];
const TRANSITION_GROUP_MOVE_SUFFIX: &str = "move";
const DEFAULT_TRANSITION_NAME: &str = "v";
const TRANSITION_CLASS_PROPS: [&str; 20] = [
"enter-from-class",
"enter-active-class",
"enter-to-class",
"leave-from-class",
"leave-active-class",
"leave-to-class",
"appear-from-class",
"appear-active-class",
"appear-to-class",
"move-class",
"enterFromClass",
"enterActiveClass",
"enterToClass",
"leaveFromClass",
"leaveActiveClass",
"leaveToClass",
"appearFromClass",
"appearActiveClass",
"appearToClass",
"moveClass",
];
enum TransitionName<'a> {
Absent,
Literal(&'a str),
Bound,
}
#[must_use]
pub fn vue_transition_classes(source: &str) -> FxHashSet<String> {
let mut out = FxHashSet::default();
for caps in TRANSITION_TAG_RE.captures_iter(source) {
let attrs = caps.name("attrs").map_or("", |m| m.as_str());
if attrs
.chars()
.next()
.is_some_and(|c| !c.is_whitespace() && c != '/')
{
continue;
}
let is_group = caps
.name("tag")
.is_some_and(|m| matches!(m.as_str(), "TransitionGroup" | "transition-group"));
collect_transition_tag_classes(attrs, is_group, &mut out);
}
out
}
fn is_bound_name_attr(key: &str) -> bool {
if key == "v-bind" {
return true;
}
let Some(arg) = key
.strip_prefix(':')
.or_else(|| key.strip_prefix("v-bind:"))
else {
return false;
};
arg.split('.').next() == Some("name")
}
fn collect_transition_tag_classes(attrs: &str, is_group: bool, out: &mut FxHashSet<String>) {
let mut name = TransitionName::Absent;
for caps in TAG_ATTR_RE.captures_iter(attrs) {
let key = caps.name("key").map_or("", |m| m.as_str());
let value = caps
.name("dq")
.or_else(|| caps.name("sq"))
.or_else(|| caps.name("uq"))
.map(|m| m.as_str());
match (key, value) {
("name", Some(value)) => name = TransitionName::Literal(value.trim()),
(key, _) if is_bound_name_attr(key) => name = TransitionName::Bound,
(key, Some(value)) if TRANSITION_CLASS_PROPS.contains(&key) => {
out.extend(value.split_whitespace().map(str::to_string));
}
_ => {}
}
}
let prefix = match name {
TransitionName::Bound => return,
TransitionName::Absent | TransitionName::Literal("") => DEFAULT_TRANSITION_NAME,
TransitionName::Literal(value) if value.contains(char::is_whitespace) => return,
TransitionName::Literal(value) => value,
};
out.extend(
TRANSITION_CLASS_SUFFIXES
.iter()
.map(|suffix| format!("{prefix}-{suffix}")),
);
if is_group {
out.insert(format!("{prefix}-{TRANSITION_GROUP_MOVE_SUFFIX}"));
}
}
#[must_use]
pub fn sfc_virtual_stylesheet(source: &str) -> Option<String> {
virtual_stylesheet(source, |attrs| !has_non_css_lang(attrs))
}
#[must_use]
pub fn sfc_preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
virtual_stylesheet(source, has_preprocessor_lang)
}
fn virtual_stylesheet(source: &str, keep: impl Fn(&str) -> bool) -> Option<String> {
let mut out = String::new();
let mut current_line: usize = 1;
let mut found = false;
for caps in STYLE_BLOCK_RE.captures_iter(source) {
let attrs = caps.name("attrs").map_or("", |m| m.as_str());
if !keep(attrs) {
continue;
}
let Some(body) = caps.name("body") else {
continue;
};
found = true;
let block_line = 1 + source[..body.start()]
.bytes()
.filter(|&b| b == b'\n')
.count();
while current_line < block_line {
out.push('\n');
current_line += 1;
}
out.push_str(body.as_str());
current_line += body.as_str().bytes().filter(|&b| b == b'\n').count();
}
found.then_some(out)
}
fn blank_ranges(source: &str, ranges: &[(usize, usize)]) -> String {
let mut out = source.as_bytes().to_vec();
for &(start, end) in ranges {
if start <= end && end <= out.len() {
for byte in &mut out[start..end] {
*byte = b' ';
}
}
}
String::from_utf8(out).unwrap_or_else(|_| source.to_string())
}
fn class_token_appears(text: &str, name: &str) -> bool {
if name.is_empty() {
return false;
}
let bytes = text.as_bytes();
let len = name.len();
let mut from = 0;
while let Some(offset) = text[from..].find(name) {
let start = from + offset;
let end = start + len;
let before_ok = start == 0 || !is_identifier_byte(bytes[start - 1]);
let after_ok = end >= bytes.len() || !is_identifier_byte(bytes[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
if from >= text.len() {
break;
}
}
false
}
fn is_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
}
#[cfg(all(test, not(miri)))]
mod tests {
use super::*;
#[test]
fn flags_unused_scoped_class() {
let dead = scoped_unused_classes(
"<template><div class=\"used\"></div></template>\n\
<style scoped>.used { color: red; } .dead { color: blue; }</style>",
);
assert_eq!(dead, vec!["dead".to_string()]);
}
#[test]
fn class_used_in_dynamic_binding_is_not_flagged() {
let dead = scoped_unused_classes(
"<template><div :class=\"{ active: isActive }\"></div></template>\n\
<style scoped>.active { color: red; }</style>",
);
assert!(dead.is_empty(), "got {dead:?}");
}
#[test]
fn class_used_in_svelte_directive_is_not_flagged() {
let dead = scoped_unused_classes(
"<button class:selected={on}>x</button>\n\
<style>.selected { color: red; }</style>",
);
assert!(dead.is_empty(), "got {dead:?}");
}
#[test]
fn class_referenced_in_script_is_not_flagged() {
let dead = scoped_unused_classes(
"<script>const c = \"highlight\";</script>\n\
<template><div :class=\"c\"></div></template>\n\
<style scoped>.highlight { color: red; }</style>",
);
assert!(dead.is_empty(), "got {dead:?}");
}
#[test]
fn global_selector_block_is_skipped() {
let dead = scoped_unused_classes(
"<template><div></div></template>\n\
<style scoped>:global(.x) { color: red; } .y { color: blue; }</style>",
);
assert!(dead.is_empty(), "blocks with :global are skipped wholesale");
}
#[test]
fn scss_scoped_block_is_skipped() {
let dead = scoped_unused_classes(
"<template><div></div></template>\n\
<style scoped lang=\"scss\">.dead { color: red; }</style>",
);
assert!(dead.is_empty(), "scss is not parsed");
}
#[test]
fn non_scoped_block_is_not_analyzed() {
let dead = scoped_unused_classes(
"<template><div></div></template>\n\
<style>.dead { color: red; }</style>",
);
assert!(dead.is_empty(), "only scoped blocks are analyzed");
}
#[test]
fn virtual_stylesheet_places_rules_at_sfc_lines() {
let source = "<template>\n <div/>\n</template>\n<style>\n.a { color: red; }\n</style>";
let vcss = super::sfc_virtual_stylesheet(source).expect("has a plain-CSS style block");
let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
.bytes()
.filter(|&b| b == b'\n')
.count();
let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
.bytes()
.filter(|&b| b == b'\n')
.count();
assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
}
#[test]
fn virtual_stylesheet_none_without_plain_css_block() {
assert!(super::sfc_virtual_stylesheet("<template><div/></template>").is_none());
assert!(
super::sfc_virtual_stylesheet("<style lang=\"scss\">.a { .b {} }</style>").is_none(),
"scss-only SFC yields no virtual stylesheet"
);
}
#[test]
fn preprocessor_virtual_stylesheet_keeps_sfc_lines() {
let source =
"<template>\n <div/>\n</template>\n<style lang=\"scss\">\n.a { .b {} }\n</style>";
let vcss = super::sfc_preprocessor_virtual_stylesheet(source)
.expect("has a preprocessor style block");
let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
.bytes()
.filter(|&b| b == b'\n')
.count();
let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
.bytes()
.filter(|&b| b == b'\n')
.count();
assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
}
#[test]
fn stylus_and_postcss_blocks_reach_neither_virtual_stylesheet() {
for source in [
"<template>\n <div/>\n</template>\n<style lang=\"stylus\">\n.a\n color red\n</style>",
"<template>\n <div/>\n</template>\n<style lang=\"postcss\">\n.a { color: red; }\n</style>",
] {
assert!(
super::sfc_virtual_stylesheet(source).is_none(),
"plain-CSS sheet must skip it: {source:?}"
);
assert!(
super::sfc_preprocessor_virtual_stylesheet(source).is_none(),
"preprocessor sheet must skip it: {source:?}"
);
}
}
#[test]
fn hyphenated_class_token_boundary() {
let dead = scoped_unused_classes(
"<template><div class=\"foo-bar\"></div></template>\n\
<style scoped>.foo { color: red; } .foo-bar { color: blue; }</style>",
);
assert_eq!(dead, vec!["foo".to_string()]);
}
#[test]
fn literal_transition_name_credits_generated_classes() {
let dead = scoped_unused_classes(
"<template><Transition name=\"fade\"><div v-if=\"v\" class=\"panel\"/></Transition></template>\n\
<style scoped>.panel {} .fade-enter-active { transition: opacity 1s; } \
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-move {} .stale {}</style>",
);
assert_eq!(dead, vec!["fade-move".to_string(), "stale".to_string()]);
}
#[test]
fn kebab_transition_group_credits_move_class() {
let dead = scoped_unused_classes(
"<template><transition-group name='list' tag=\"ul\"><li/></transition-group></template>\n\
<style scoped>.list-move {} .list-leave-active {} .other-move {}</style>",
);
assert_eq!(dead, vec!["other-move".to_string()]);
}
#[test]
fn unnamed_transition_credits_v_prefix() {
let dead = scoped_unused_classes(
"<template><TransitionGroup><li/></TransitionGroup></template>\n\
<style scoped>.v-enter-active {} .v-move {} .fade-enter-active {}</style>",
);
assert_eq!(dead, vec!["fade-enter-active".to_string()]);
}
#[test]
fn bound_transition_name_with_modifier_credits_nothing() {
let dead = scoped_unused_classes(
"<template><Transition :name.camel=\"kind\"><div/></Transition>\
<Transition v-bind:name.prop=\"kind\"><div/></Transition></template>\n\
<style scoped>.v-enter-active {}</style>",
);
assert_eq!(dead, vec!["v-enter-active".to_string()]);
}
#[test]
fn bound_transition_name_credits_nothing() {
let dead = scoped_unused_classes(
"<template><Transition :name=\"kind\"><div/></Transition></template>\n\
<style scoped>.fade-enter-active {} .v-enter-active {}</style>",
);
assert_eq!(
dead,
vec![
"fade-enter-active".to_string(),
"v-enter-active".to_string()
]
);
}
#[test]
fn transition_prefixed_component_is_not_a_transition() {
let dead = scoped_unused_classes(
"<template><TransitionWrapper name=\"fade\"/></template>\n\
<style scoped>.fade-enter-active {}</style>",
);
assert_eq!(dead, vec!["fade-enter-active".to_string()]);
}
#[test]
fn vue_transition_classes_reads_custom_class_props() {
let classes = vue_transition_classes(
"<Transition name=\"x\" enter-active-class=\"animate__animated animate__bounce\" \
leaveToClass=\"gone\"><p/></Transition>\
<TransitionGroup :name=\"n\" move-class=\"shuffle\"/>",
);
let mut sorted: Vec<&str> = classes.iter().map(String::as_str).collect();
sorted.sort_unstable();
assert_eq!(
sorted,
vec![
"animate__animated",
"animate__bounce",
"gone",
"shuffle",
"x-enter-active",
"x-enter-from",
"x-enter-to",
"x-leave-active",
"x-leave-from",
"x-leave-to",
]
);
}
}