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();
[
"lang=\"scss\"",
"lang='scss'",
"lang=\"sass\"",
"lang='sass'",
"lang=\"less\"",
"lang='less'",
"lang=\"stylus\"",
"lang='stylus'",
"lang=\"postcss\"",
"lang='postcss'",
]
.iter()
.any(|needle| lower.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 mut candidates: Vec<String> = scoped_classes
.into_iter()
.filter(|class| !class_token_appears(&search, class))
.collect();
candidates.sort_unstable();
candidates
}
#[must_use]
pub fn sfc_virtual_stylesheet(source: &str) -> 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 has_non_css_lang(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(test)]
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 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()]);
}
}