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 has_preprocessor_lang_value(&lower)
43 || [
44 "lang=\"stylus\"",
45 "lang='stylus'",
46 "lang=\"postcss\"",
47 "lang='postcss'",
48 ]
49 .iter()
50 .any(|needle| lower.contains(needle))
51}
52
53fn has_preprocessor_lang(attrs: &str) -> bool {
54 has_preprocessor_lang_value(&attrs.to_ascii_lowercase())
55}
56
57fn has_preprocessor_lang_value(lower_attrs: &str) -> bool {
58 [
59 "lang=\"scss\"",
60 "lang='scss'",
61 "lang=\"sass\"",
62 "lang='sass'",
63 "lang=\"less\"",
64 "lang='less'",
65 ]
66 .iter()
67 .any(|needle| lower_attrs.contains(needle))
68}
69
70fn block_escapes_scope(body: &str) -> bool {
74 body.contains(":global")
75 || body.contains(":deep")
76 || body.contains("::v-deep")
77 || body.contains("/deep/")
78 || body.contains("@apply")
79}
80
81#[must_use]
85pub fn scoped_unused_classes(source: &str) -> Vec<String> {
86 let mut scoped_classes: FxHashSet<String> = FxHashSet::default();
87 let mut style_ranges: Vec<(usize, usize)> = Vec::new();
90
91 for caps in STYLE_BLOCK_RE.captures_iter(source) {
92 if let Some(whole) = caps.get(0) {
93 style_ranges.push((whole.start(), whole.end()));
94 }
95 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
96 let body = caps.name("body").map_or("", |m| m.as_str());
97 if !has_scoped_attr(attrs) || has_non_css_lang(attrs) || block_escapes_scope(body) {
98 continue;
99 }
100 for export in extract_css_module_exports(body, false) {
101 if let ExportName::Named(name) = export.name {
102 scoped_classes.insert(name);
103 }
104 }
105 }
106
107 if scoped_classes.is_empty() {
108 return Vec::new();
109 }
110
111 let search = blank_ranges(source, &style_ranges);
112 let mut candidates: Vec<String> = scoped_classes
113 .into_iter()
114 .filter(|class| !class_token_appears(&search, class))
115 .collect();
116 candidates.sort_unstable();
117 candidates
118}
119
120#[must_use]
127pub fn sfc_virtual_stylesheet(source: &str) -> Option<String> {
128 virtual_stylesheet(source, |attrs| !has_non_css_lang(attrs))
129}
130
131#[must_use]
134pub fn sfc_preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
135 virtual_stylesheet(source, has_preprocessor_lang)
136}
137
138fn virtual_stylesheet(source: &str, keep: impl Fn(&str) -> bool) -> Option<String> {
147 let mut out = String::new();
148 let mut current_line: usize = 1;
149 let mut found = false;
150 for caps in STYLE_BLOCK_RE.captures_iter(source) {
151 let attrs = caps.name("attrs").map_or("", |m| m.as_str());
152 if !keep(attrs) {
153 continue;
154 }
155 let Some(body) = caps.name("body") else {
156 continue;
157 };
158 found = true;
159 let block_line = 1 + source[..body.start()]
160 .bytes()
161 .filter(|&b| b == b'\n')
162 .count();
163 while current_line < block_line {
164 out.push('\n');
165 current_line += 1;
166 }
167 out.push_str(body.as_str());
168 current_line += body.as_str().bytes().filter(|&b| b == b'\n').count();
169 }
170 found.then_some(out)
171}
172
173fn blank_ranges(source: &str, ranges: &[(usize, usize)]) -> String {
177 let mut out = source.as_bytes().to_vec();
178 for &(start, end) in ranges {
179 if start <= end && end <= out.len() {
180 for byte in &mut out[start..end] {
181 *byte = b' ';
182 }
183 }
184 }
185 String::from_utf8(out).unwrap_or_else(|_| source.to_string())
188}
189
190fn class_token_appears(text: &str, name: &str) -> bool {
194 if name.is_empty() {
195 return false;
196 }
197 let bytes = text.as_bytes();
198 let len = name.len();
199 let mut from = 0;
200 while let Some(offset) = text[from..].find(name) {
201 let start = from + offset;
202 let end = start + len;
203 let before_ok = start == 0 || !is_identifier_byte(bytes[start - 1]);
204 let after_ok = end >= bytes.len() || !is_identifier_byte(bytes[end]);
205 if before_ok && after_ok {
206 return true;
207 }
208 from = start + 1;
209 if from >= text.len() {
210 break;
211 }
212 }
213 false
214}
215
216fn is_identifier_byte(byte: u8) -> bool {
217 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
218}
219
220#[cfg(all(test, not(miri)))]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn flags_unused_scoped_class() {
226 let dead = scoped_unused_classes(
227 "<template><div class=\"used\"></div></template>\n\
228 <style scoped>.used { color: red; } .dead { color: blue; }</style>",
229 );
230 assert_eq!(dead, vec!["dead".to_string()]);
231 }
232
233 #[test]
234 fn class_used_in_dynamic_binding_is_not_flagged() {
235 let dead = scoped_unused_classes(
237 "<template><div :class=\"{ active: isActive }\"></div></template>\n\
238 <style scoped>.active { color: red; }</style>",
239 );
240 assert!(dead.is_empty(), "got {dead:?}");
241 }
242
243 #[test]
244 fn class_used_in_svelte_directive_is_not_flagged() {
245 let dead = scoped_unused_classes(
246 "<button class:selected={on}>x</button>\n\
247 <style>.selected { color: red; }</style>",
248 );
249 assert!(dead.is_empty(), "got {dead:?}");
252 }
253
254 #[test]
255 fn class_referenced_in_script_is_not_flagged() {
256 let dead = scoped_unused_classes(
257 "<script>const c = \"highlight\";</script>\n\
258 <template><div :class=\"c\"></div></template>\n\
259 <style scoped>.highlight { color: red; }</style>",
260 );
261 assert!(dead.is_empty(), "got {dead:?}");
262 }
263
264 #[test]
265 fn global_selector_block_is_skipped() {
266 let dead = scoped_unused_classes(
267 "<template><div></div></template>\n\
268 <style scoped>:global(.x) { color: red; } .y { color: blue; }</style>",
269 );
270 assert!(dead.is_empty(), "blocks with :global are skipped wholesale");
271 }
272
273 #[test]
274 fn scss_scoped_block_is_skipped() {
275 let dead = scoped_unused_classes(
276 "<template><div></div></template>\n\
277 <style scoped lang=\"scss\">.dead { color: red; }</style>",
278 );
279 assert!(dead.is_empty(), "scss is not parsed");
280 }
281
282 #[test]
283 fn non_scoped_block_is_not_analyzed() {
284 let dead = scoped_unused_classes(
285 "<template><div></div></template>\n\
286 <style>.dead { color: red; }</style>",
287 );
288 assert!(dead.is_empty(), "only scoped blocks are analyzed");
289 }
290
291 #[test]
292 fn virtual_stylesheet_places_rules_at_sfc_lines() {
293 let source = "<template>\n <div/>\n</template>\n<style>\n.a { color: red; }\n</style>";
296 let vcss = super::sfc_virtual_stylesheet(source).expect("has a plain-CSS style block");
297 let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
298 .bytes()
299 .filter(|&b| b == b'\n')
300 .count();
301 let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
302 .bytes()
303 .filter(|&b| b == b'\n')
304 .count();
305 assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
306 }
307
308 #[test]
309 fn virtual_stylesheet_none_without_plain_css_block() {
310 assert!(super::sfc_virtual_stylesheet("<template><div/></template>").is_none());
311 assert!(
312 super::sfc_virtual_stylesheet("<style lang=\"scss\">.a { .b {} }</style>").is_none(),
313 "scss-only SFC yields no virtual stylesheet"
314 );
315 }
316
317 #[test]
318 fn preprocessor_virtual_stylesheet_keeps_sfc_lines() {
319 let source =
320 "<template>\n <div/>\n</template>\n<style lang=\"scss\">\n.a { .b {} }\n</style>";
321 let vcss = super::sfc_preprocessor_virtual_stylesheet(source)
322 .expect("has a preprocessor style block");
323 let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
324 .bytes()
325 .filter(|&b| b == b'\n')
326 .count();
327 let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
328 .bytes()
329 .filter(|&b| b == b'\n')
330 .count();
331 assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
332 }
333
334 #[test]
335 fn stylus_and_postcss_blocks_reach_neither_virtual_stylesheet() {
336 for source in [
341 "<template>\n <div/>\n</template>\n<style lang=\"stylus\">\n.a\n color red\n</style>",
342 "<template>\n <div/>\n</template>\n<style lang=\"postcss\">\n.a { color: red; }\n</style>",
343 ] {
344 assert!(
345 super::sfc_virtual_stylesheet(source).is_none(),
346 "plain-CSS sheet must skip it: {source:?}"
347 );
348 assert!(
349 super::sfc_preprocessor_virtual_stylesheet(source).is_none(),
350 "preprocessor sheet must skip it: {source:?}"
351 );
352 }
353 }
354
355 #[test]
356 fn hyphenated_class_token_boundary() {
357 let dead = scoped_unused_classes(
359 "<template><div class=\"foo-bar\"></div></template>\n\
360 <style scoped>.foo { color: red; } .foo-bar { color: blue; }</style>",
361 );
362 assert_eq!(dead, vec!["foo".to_string()]);
363 }
364}