Skip to main content

fallow_extract/
sfc_css.rs

1//! Dead scoped-CSS class detection for Vue/Svelte single-file components.
2//!
3//! A class defined in a `<style scoped>` block applies only to its own
4//! component's markup (that is what `scoped` means), so a scoped class whose
5//! name appears nowhere else in the same SFC is a cleanup candidate. The
6//! "appears nowhere else" test is deliberately broad: any occurrence of the
7//! class name as a whole token anywhere outside the `<style>` blocks (a static
8//! `class="..."`, a dynamic `:class="{ name: x }"` key, a `class:name`
9//! directive, or even a string in `<script>`) counts as a use. That keeps the
10//! signal conservative (it errs toward "used"), so it is reported as a candidate
11//! rather than a hard dead-code finding.
12
13use std::sync::LazyLock;
14
15use rustc_hash::FxHashSet;
16
17use crate::ExportName;
18use crate::css::extract_css_module_exports;
19
20/// Matches `<style ...>BODY</style>` blocks, capturing the opening-tag
21/// attributes and the body. Mirrors the SFC style scanner: handles `>` inside
22/// quoted attribute values.
23static 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
29/// Returns `true` when an opening-`<style>` attribute string carries a bare
30/// `scoped` attribute.
31fn 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
37/// Returns `true` when the `<style>` block declares a non-CSS preprocessor
38/// language (`scss` / `sass` / `less` / `stylus` / `postcss`), which lightningcss
39/// does not parse, so we skip scoped-deadness analysis for it.
40fn 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
70/// A `<style scoped>` block whose classes escape the component (`:global`,
71/// `:deep`, `::v-deep`) or whose used-set we cannot fully see (`@apply` pulls in
72/// classes by name) is skipped wholesale, conservatively.
73fn 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/// Returns class names defined in `<style scoped>` blocks of an SFC that appear
82/// nowhere else in the component (cleanup candidates), sorted. Returns an empty
83/// vec when the source has no analyzable scoped block.
84#[must_use]
85pub fn scoped_unused_classes(source: &str) -> Vec<String> {
86    let mut scoped_classes: FxHashSet<String> = FxHashSet::default();
87    // Byte ranges of every `<style>` block, blanked out of the search text so a
88    // class's own definition does not count as a use of itself.
89    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/// Build a "virtual stylesheet" from an SFC's plain-CSS `<style>` blocks (any
121/// scoping). Each block body is placed at its real line in the SFC via blank-line
122/// padding, so CSS metric line numbers from `compute_css_analytics` map straight
123/// back onto the SFC. Returns `None` when the SFC has no plain-CSS `<style>`
124/// block (e.g. only `lang="scss"` blocks, which the CSS parser cannot read), so
125/// callers run the standard `.css` metric path on Vue/Svelte component styles.
126#[must_use]
127pub fn sfc_virtual_stylesheet(source: &str) -> Option<String> {
128    virtual_stylesheet(source, |attrs| !has_non_css_lang(attrs))
129}
130
131/// Build a virtual stylesheet from SFC preprocessor `<style>` blocks that the
132/// health layer can conservatively lower before CSS analytics.
133#[must_use]
134pub fn sfc_preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
135    virtual_stylesheet(source, has_preprocessor_lang)
136}
137
138/// Build a virtual stylesheet from the `<style>` blocks whose opening-tag
139/// attribute string satisfies `keep`. Each kept body is placed at its real line
140/// in the SFC via blank-line padding, so CSS metric line numbers map straight
141/// back onto the SFC. Returns `None` when `keep` selected no block.
142///
143/// The two callers' predicates are deliberately not complements: a
144/// `lang="stylus"` or `lang="postcss"` block is rejected by both, so neither
145/// predicate may be rewritten as the negation of the other.
146fn 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
173/// Replace the given byte ranges in `source` with spaces (preserving length),
174/// so the returned string can be searched for class uses without the `<style>`
175/// blocks themselves matching.
176fn 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    // The blanked ranges align to `<style>`/`</style>` tag boundaries, which are
186    // ASCII, so the result stays valid UTF-8.
187    String::from_utf8(out).unwrap_or_else(|_| source.to_string())
188}
189
190/// Returns `true` when `name` appears as a whole class token in `text` (not as a
191/// substring of a longer identifier). `-` and `_` are treated as identifier
192/// characters so `foo` does not match inside `foo-bar`.
193fn 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        // The `active` token appears in the `:class` binding object, so it is a use.
236        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        // No `scoped` attr on Svelte (styles are scoped by default), so this
250        // block is not analyzed and nothing is flagged.
251        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        // The `.a` rule is on line 3 of the SFC; the virtual stylesheet must keep
294        // it on line 3 so metric line numbers map back onto the source.
295        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        // `has_non_css_lang` keeps stylus and postcss out of the plain-CSS
337        // sheet, and `has_preprocessor_lang` does not select them for the
338        // preprocessor sheet, so the two predicates are not complements and
339        // neither may be rewritten as the negation of the other.
340        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        // `.foo` is unused even though `foo-bar` appears in the template.
358        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}