fallow_extract/
sfc_css.rs1use std::sync::LazyLock;
14
15use rustc_hash::FxHashSet;
16
17use crate::ExportName;
18use crate::css::extract_css_module_exports;
19
20static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
24 crate::static_regex(
25 r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
26 )
27});
28
29fn has_scoped_attr(attrs: &str) -> bool {
32 attrs
33 .split(|c: char| c.is_whitespace() || c == '=' || c == '"' || c == '\'')
34 .any(|token| token.eq_ignore_ascii_case("scoped"))
35}
36
37fn has_non_css_lang(attrs: &str) -> bool {
41 let lower = attrs.to_ascii_lowercase();
42 [
43 "lang=\"scss\"",
44 "lang='scss'",
45 "lang=\"sass\"",
46 "lang='sass'",
47 "lang=\"less\"",
48 "lang='less'",
49 "lang=\"stylus\"",
50 "lang='stylus'",
51 "lang=\"postcss\"",
52 "lang='postcss'",
53 ]
54 .iter()
55 .any(|needle| lower.contains(needle))
56}
57
58fn block_escapes_scope(body: &str) -> bool {
62 body.contains(":global")
63 || body.contains(":deep")
64 || body.contains("::v-deep")
65 || body.contains("/deep/")
66 || body.contains("@apply")
67}
68
69#[must_use]
73pub fn scoped_unused_classes(source: &str) -> Vec<String> {
74 let mut scoped_classes: FxHashSet<String> = FxHashSet::default();
75 let mut style_ranges: Vec<(usize, usize)> = Vec::new();
78
79 for caps in STYLE_BLOCK_RE.captures_iter(source) {
80 if let Some(whole) = caps.get(0) {
81 style_ranges.push((whole.start(), whole.end()));
82 }
83 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
84 let body = caps.name("body").map_or("", |m| m.as_str());
85 if !has_scoped_attr(attrs) || has_non_css_lang(attrs) || block_escapes_scope(body) {
86 continue;
87 }
88 for export in extract_css_module_exports(body, false) {
89 if let ExportName::Named(name) = export.name {
90 scoped_classes.insert(name);
91 }
92 }
93 }
94
95 if scoped_classes.is_empty() {
96 return Vec::new();
97 }
98
99 let search = blank_ranges(source, &style_ranges);
100 let mut candidates: Vec<String> = scoped_classes
101 .into_iter()
102 .filter(|class| !class_token_appears(&search, class))
103 .collect();
104 candidates.sort_unstable();
105 candidates
106}
107
108#[must_use]
115pub fn sfc_virtual_stylesheet(source: &str) -> Option<String> {
116 let mut out = String::new();
117 let mut current_line: usize = 1;
118 let mut found = false;
119 for caps in STYLE_BLOCK_RE.captures_iter(source) {
120 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
121 if has_non_css_lang(attrs) {
122 continue;
123 }
124 let Some(body) = caps.name("body") else {
125 continue;
126 };
127 found = true;
128 let block_line = 1 + source[..body.start()]
129 .bytes()
130 .filter(|&b| b == b'\n')
131 .count();
132 while current_line < block_line {
133 out.push('\n');
134 current_line += 1;
135 }
136 out.push_str(body.as_str());
137 current_line += body.as_str().bytes().filter(|&b| b == b'\n').count();
138 }
139 found.then_some(out)
140}
141
142fn blank_ranges(source: &str, ranges: &[(usize, usize)]) -> String {
146 let mut out = source.as_bytes().to_vec();
147 for &(start, end) in ranges {
148 if start <= end && end <= out.len() {
149 for byte in &mut out[start..end] {
150 *byte = b' ';
151 }
152 }
153 }
154 String::from_utf8(out).unwrap_or_else(|_| source.to_string())
157}
158
159fn class_token_appears(text: &str, name: &str) -> bool {
163 if name.is_empty() {
164 return false;
165 }
166 let bytes = text.as_bytes();
167 let len = name.len();
168 let mut from = 0;
169 while let Some(offset) = text[from..].find(name) {
170 let start = from + offset;
171 let end = start + len;
172 let before_ok = start == 0 || !is_identifier_byte(bytes[start - 1]);
173 let after_ok = end >= bytes.len() || !is_identifier_byte(bytes[end]);
174 if before_ok && after_ok {
175 return true;
176 }
177 from = start + 1;
178 if from >= text.len() {
179 break;
180 }
181 }
182 false
183}
184
185fn is_identifier_byte(byte: u8) -> bool {
186 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn flags_unused_scoped_class() {
195 let dead = scoped_unused_classes(
196 "<template><div class=\"used\"></div></template>\n\
197 <style scoped>.used { color: red; } .dead { color: blue; }</style>",
198 );
199 assert_eq!(dead, vec!["dead".to_string()]);
200 }
201
202 #[test]
203 fn class_used_in_dynamic_binding_is_not_flagged() {
204 let dead = scoped_unused_classes(
206 "<template><div :class=\"{ active: isActive }\"></div></template>\n\
207 <style scoped>.active { color: red; }</style>",
208 );
209 assert!(dead.is_empty(), "got {dead:?}");
210 }
211
212 #[test]
213 fn class_used_in_svelte_directive_is_not_flagged() {
214 let dead = scoped_unused_classes(
215 "<button class:selected={on}>x</button>\n\
216 <style>.selected { color: red; }</style>",
217 );
218 assert!(dead.is_empty(), "got {dead:?}");
221 }
222
223 #[test]
224 fn class_referenced_in_script_is_not_flagged() {
225 let dead = scoped_unused_classes(
226 "<script>const c = \"highlight\";</script>\n\
227 <template><div :class=\"c\"></div></template>\n\
228 <style scoped>.highlight { color: red; }</style>",
229 );
230 assert!(dead.is_empty(), "got {dead:?}");
231 }
232
233 #[test]
234 fn global_selector_block_is_skipped() {
235 let dead = scoped_unused_classes(
236 "<template><div></div></template>\n\
237 <style scoped>:global(.x) { color: red; } .y { color: blue; }</style>",
238 );
239 assert!(dead.is_empty(), "blocks with :global are skipped wholesale");
240 }
241
242 #[test]
243 fn scss_scoped_block_is_skipped() {
244 let dead = scoped_unused_classes(
245 "<template><div></div></template>\n\
246 <style scoped lang=\"scss\">.dead { color: red; }</style>",
247 );
248 assert!(dead.is_empty(), "scss is not parsed");
249 }
250
251 #[test]
252 fn non_scoped_block_is_not_analyzed() {
253 let dead = scoped_unused_classes(
254 "<template><div></div></template>\n\
255 <style>.dead { color: red; }</style>",
256 );
257 assert!(dead.is_empty(), "only scoped blocks are analyzed");
258 }
259
260 #[test]
261 fn virtual_stylesheet_places_rules_at_sfc_lines() {
262 let source = "<template>\n <div/>\n</template>\n<style>\n.a { color: red; }\n</style>";
265 let vcss = super::sfc_virtual_stylesheet(source).expect("has a plain-CSS style block");
266 let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
267 .bytes()
268 .filter(|&b| b == b'\n')
269 .count();
270 let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
271 .bytes()
272 .filter(|&b| b == b'\n')
273 .count();
274 assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
275 }
276
277 #[test]
278 fn virtual_stylesheet_none_without_plain_css_block() {
279 assert!(super::sfc_virtual_stylesheet("<template><div/></template>").is_none());
280 assert!(
281 super::sfc_virtual_stylesheet("<style lang=\"scss\">.a { .b {} }</style>").is_none(),
282 "scss-only SFC yields no virtual stylesheet"
283 );
284 }
285
286 #[test]
287 fn hyphenated_class_token_boundary() {
288 let dead = scoped_unused_classes(
290 "<template><div class=\"foo-bar\"></div></template>\n\
291 <style scoped>.foo { color: red; } .foo-bar { color: blue; }</style>",
292 );
293 assert_eq!(dead, vec!["foo".to_string()]);
294 }
295}