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 transition_classes = vue_transition_classes(&search);
113    let mut candidates: Vec<String> = scoped_classes
114        .into_iter()
115        .filter(|class| !transition_classes.contains(class) && !class_token_appears(&search, class))
116        .collect();
117    candidates.sort_unstable();
118    candidates
119}
120
121/// Matches an opening Vue `<Transition>` / `<TransitionGroup>` tag in
122/// PascalCase or kebab-case. The caller rejects a longer tag name such as
123/// `<TransitionWrapper>`, because the regex crate has no look-ahead.
124static TRANSITION_TAG_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
125    crate::static_regex(
126        r#"<(?P<tag>TransitionGroup|transition-group|Transition|transition)(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>"#,
127    )
128});
129
130/// Matches one attribute of an opening tag. Quoted values are consumed whole,
131/// so an attribute-like text inside a value is never read as an attribute.
132static TAG_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
133    crate::static_regex(
134        r#"(?P<key>[^\s=/>"']+)(?:\s*=\s*(?:"(?P<dq>[^"]*)"|'(?P<sq>[^']*)'|(?P<uq>[^\s>"']+)))?"#,
135    )
136});
137
138/// Class suffixes that Vue applies for every `<Transition>` name.
139const TRANSITION_CLASS_SUFFIXES: [&str; 6] = [
140    "enter-from",
141    "enter-active",
142    "enter-to",
143    "leave-from",
144    "leave-active",
145    "leave-to",
146];
147
148/// The class suffix that Vue adds for a `<TransitionGroup>` move.
149const TRANSITION_GROUP_MOVE_SUFFIX: &str = "move";
150
151/// The name prefix that Vue uses when a transition has no `name` attribute.
152const DEFAULT_TRANSITION_NAME: &str = "v";
153
154/// Props that give literal custom transition classes, in kebab-case and
155/// camelCase.
156const TRANSITION_CLASS_PROPS: [&str; 20] = [
157    "enter-from-class",
158    "enter-active-class",
159    "enter-to-class",
160    "leave-from-class",
161    "leave-active-class",
162    "leave-to-class",
163    "appear-from-class",
164    "appear-active-class",
165    "appear-to-class",
166    "move-class",
167    "enterFromClass",
168    "enterActiveClass",
169    "enterToClass",
170    "leaveFromClass",
171    "leaveActiveClass",
172    "leaveToClass",
173    "appearFromClass",
174    "appearActiveClass",
175    "appearToClass",
176    "moveClass",
177];
178
179/// How a transition tag sets its `name`.
180enum TransitionName<'a> {
181    Absent,
182    Literal(&'a str),
183    Bound,
184}
185
186/// Returns the classes that Vue applies through the `<Transition>` and
187/// `<TransitionGroup>` tags in `source` (issue #3350).
188///
189/// A literal `name="fade"` gives `fade-enter-from` through `fade-leave-to`, and
190/// a TransitionGroup also gives `fade-move`. A tag without `name` gives the
191/// same classes with the `v` prefix. A bound `:name` or a `v-bind` object gives
192/// no generated classes, because the name is not known statically. Literal
193/// custom class props such as `enter-active-class="a b"` give their classes.
194#[must_use]
195pub fn vue_transition_classes(source: &str) -> FxHashSet<String> {
196    let mut out = FxHashSet::default();
197    for caps in TRANSITION_TAG_RE.captures_iter(source) {
198        let attrs = caps.name("attrs").map_or("", |m| m.as_str());
199        if attrs
200            .chars()
201            .next()
202            .is_some_and(|c| !c.is_whitespace() && c != '/')
203        {
204            continue;
205        }
206        let is_group = caps
207            .name("tag")
208            .is_some_and(|m| matches!(m.as_str(), "TransitionGroup" | "transition-group"));
209        collect_transition_tag_classes(attrs, is_group, &mut out);
210    }
211    out
212}
213
214/// Returns `true` for an attribute that binds `name` to an expression:
215/// `:name`, `v-bind:name`, either with modifiers such as `.camel`, or a
216/// `v-bind` object that can carry `name`.
217fn is_bound_name_attr(key: &str) -> bool {
218    if key == "v-bind" {
219        return true;
220    }
221    let Some(arg) = key
222        .strip_prefix(':')
223        .or_else(|| key.strip_prefix("v-bind:"))
224    else {
225        return false;
226    };
227    arg.split('.').next() == Some("name")
228}
229
230fn collect_transition_tag_classes(attrs: &str, is_group: bool, out: &mut FxHashSet<String>) {
231    let mut name = TransitionName::Absent;
232    for caps in TAG_ATTR_RE.captures_iter(attrs) {
233        let key = caps.name("key").map_or("", |m| m.as_str());
234        let value = caps
235            .name("dq")
236            .or_else(|| caps.name("sq"))
237            .or_else(|| caps.name("uq"))
238            .map(|m| m.as_str());
239        match (key, value) {
240            ("name", Some(value)) => name = TransitionName::Literal(value.trim()),
241            (key, _) if is_bound_name_attr(key) => name = TransitionName::Bound,
242            (key, Some(value)) if TRANSITION_CLASS_PROPS.contains(&key) => {
243                out.extend(value.split_whitespace().map(str::to_string));
244            }
245            _ => {}
246        }
247    }
248    let prefix = match name {
249        TransitionName::Bound => return,
250        TransitionName::Absent | TransitionName::Literal("") => DEFAULT_TRANSITION_NAME,
251        TransitionName::Literal(value) if value.contains(char::is_whitespace) => return,
252        TransitionName::Literal(value) => value,
253    };
254    out.extend(
255        TRANSITION_CLASS_SUFFIXES
256            .iter()
257            .map(|suffix| format!("{prefix}-{suffix}")),
258    );
259    if is_group {
260        out.insert(format!("{prefix}-{TRANSITION_GROUP_MOVE_SUFFIX}"));
261    }
262}
263
264/// Build a "virtual stylesheet" from an SFC's plain-CSS `<style>` blocks (any
265/// scoping). Each block body is placed at its real line in the SFC via blank-line
266/// padding, so CSS metric line numbers from `compute_css_analytics` map straight
267/// back onto the SFC. Returns `None` when the SFC has no plain-CSS `<style>`
268/// block (e.g. only `lang="scss"` blocks, which the CSS parser cannot read), so
269/// callers run the standard `.css` metric path on Vue/Svelte component styles.
270#[must_use]
271pub fn sfc_virtual_stylesheet(source: &str) -> Option<String> {
272    virtual_stylesheet(source, |attrs| !has_non_css_lang(attrs))
273}
274
275/// Build a virtual stylesheet from SFC preprocessor `<style>` blocks that the
276/// health layer can conservatively lower before CSS analytics.
277#[must_use]
278pub fn sfc_preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
279    virtual_stylesheet(source, has_preprocessor_lang)
280}
281
282/// Build a virtual stylesheet from the `<style>` blocks whose opening-tag
283/// attribute string satisfies `keep`. Each kept body is placed at its real line
284/// in the SFC via blank-line padding, so CSS metric line numbers map straight
285/// back onto the SFC. Returns `None` when `keep` selected no block.
286///
287/// The two callers' predicates are deliberately not complements: a
288/// `lang="stylus"` or `lang="postcss"` block is rejected by both, so neither
289/// predicate may be rewritten as the negation of the other.
290fn virtual_stylesheet(source: &str, keep: impl Fn(&str) -> bool) -> Option<String> {
291    let mut out = String::new();
292    let mut current_line: usize = 1;
293    let mut found = false;
294    for caps in STYLE_BLOCK_RE.captures_iter(source) {
295        let attrs = caps.name("attrs").map_or("", |m| m.as_str());
296        if !keep(attrs) {
297            continue;
298        }
299        let Some(body) = caps.name("body") else {
300            continue;
301        };
302        found = true;
303        let block_line = 1 + source[..body.start()]
304            .bytes()
305            .filter(|&b| b == b'\n')
306            .count();
307        while current_line < block_line {
308            out.push('\n');
309            current_line += 1;
310        }
311        out.push_str(body.as_str());
312        current_line += body.as_str().bytes().filter(|&b| b == b'\n').count();
313    }
314    found.then_some(out)
315}
316
317/// Replace the given byte ranges in `source` with spaces (preserving length),
318/// so the returned string can be searched for class uses without the `<style>`
319/// blocks themselves matching.
320fn blank_ranges(source: &str, ranges: &[(usize, usize)]) -> String {
321    let mut out = source.as_bytes().to_vec();
322    for &(start, end) in ranges {
323        if start <= end && end <= out.len() {
324            for byte in &mut out[start..end] {
325                *byte = b' ';
326            }
327        }
328    }
329    // The blanked ranges align to `<style>`/`</style>` tag boundaries, which are
330    // ASCII, so the result stays valid UTF-8.
331    String::from_utf8(out).unwrap_or_else(|_| source.to_string())
332}
333
334/// Returns `true` when `name` appears as a whole class token in `text` (not as a
335/// substring of a longer identifier). `-` and `_` are treated as identifier
336/// characters so `foo` does not match inside `foo-bar`.
337fn class_token_appears(text: &str, name: &str) -> bool {
338    if name.is_empty() {
339        return false;
340    }
341    let bytes = text.as_bytes();
342    let len = name.len();
343    let mut from = 0;
344    while let Some(offset) = text[from..].find(name) {
345        let start = from + offset;
346        let end = start + len;
347        let before_ok = start == 0 || !is_identifier_byte(bytes[start - 1]);
348        let after_ok = end >= bytes.len() || !is_identifier_byte(bytes[end]);
349        if before_ok && after_ok {
350            return true;
351        }
352        from = start + 1;
353        if from >= text.len() {
354            break;
355        }
356    }
357    false
358}
359
360fn is_identifier_byte(byte: u8) -> bool {
361    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
362}
363
364#[cfg(all(test, not(miri)))]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn flags_unused_scoped_class() {
370        let dead = scoped_unused_classes(
371            "<template><div class=\"used\"></div></template>\n\
372             <style scoped>.used { color: red; } .dead { color: blue; }</style>",
373        );
374        assert_eq!(dead, vec!["dead".to_string()]);
375    }
376
377    #[test]
378    fn class_used_in_dynamic_binding_is_not_flagged() {
379        // The `active` token appears in the `:class` binding object, so it is a use.
380        let dead = scoped_unused_classes(
381            "<template><div :class=\"{ active: isActive }\"></div></template>\n\
382             <style scoped>.active { color: red; }</style>",
383        );
384        assert!(dead.is_empty(), "got {dead:?}");
385    }
386
387    #[test]
388    fn class_used_in_svelte_directive_is_not_flagged() {
389        let dead = scoped_unused_classes(
390            "<button class:selected={on}>x</button>\n\
391             <style>.selected { color: red; }</style>",
392        );
393        // No `scoped` attr on Svelte (styles are scoped by default), so this
394        // block is not analyzed and nothing is flagged.
395        assert!(dead.is_empty(), "got {dead:?}");
396    }
397
398    #[test]
399    fn class_referenced_in_script_is_not_flagged() {
400        let dead = scoped_unused_classes(
401            "<script>const c = \"highlight\";</script>\n\
402             <template><div :class=\"c\"></div></template>\n\
403             <style scoped>.highlight { color: red; }</style>",
404        );
405        assert!(dead.is_empty(), "got {dead:?}");
406    }
407
408    #[test]
409    fn global_selector_block_is_skipped() {
410        let dead = scoped_unused_classes(
411            "<template><div></div></template>\n\
412             <style scoped>:global(.x) { color: red; } .y { color: blue; }</style>",
413        );
414        assert!(dead.is_empty(), "blocks with :global are skipped wholesale");
415    }
416
417    #[test]
418    fn scss_scoped_block_is_skipped() {
419        let dead = scoped_unused_classes(
420            "<template><div></div></template>\n\
421             <style scoped lang=\"scss\">.dead { color: red; }</style>",
422        );
423        assert!(dead.is_empty(), "scss is not parsed");
424    }
425
426    #[test]
427    fn non_scoped_block_is_not_analyzed() {
428        let dead = scoped_unused_classes(
429            "<template><div></div></template>\n\
430             <style>.dead { color: red; }</style>",
431        );
432        assert!(dead.is_empty(), "only scoped blocks are analyzed");
433    }
434
435    #[test]
436    fn virtual_stylesheet_places_rules_at_sfc_lines() {
437        // The `.a` rule is on line 3 of the SFC; the virtual stylesheet must keep
438        // it on line 3 so metric line numbers map back onto the source.
439        let source = "<template>\n  <div/>\n</template>\n<style>\n.a { color: red; }\n</style>";
440        let vcss = super::sfc_virtual_stylesheet(source).expect("has a plain-CSS style block");
441        let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
442            .bytes()
443            .filter(|&b| b == b'\n')
444            .count();
445        let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
446            .bytes()
447            .filter(|&b| b == b'\n')
448            .count();
449        assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
450    }
451
452    #[test]
453    fn virtual_stylesheet_none_without_plain_css_block() {
454        assert!(super::sfc_virtual_stylesheet("<template><div/></template>").is_none());
455        assert!(
456            super::sfc_virtual_stylesheet("<style lang=\"scss\">.a { .b {} }</style>").is_none(),
457            "scss-only SFC yields no virtual stylesheet"
458        );
459    }
460
461    #[test]
462    fn preprocessor_virtual_stylesheet_keeps_sfc_lines() {
463        let source =
464            "<template>\n  <div/>\n</template>\n<style lang=\"scss\">\n.a { .b {} }\n</style>";
465        let vcss = super::sfc_preprocessor_virtual_stylesheet(source)
466            .expect("has a preprocessor style block");
467        let line_of_a = 1 + vcss[..vcss.find(".a").unwrap()]
468            .bytes()
469            .filter(|&b| b == b'\n')
470            .count();
471        let sfc_line_of_a = 1 + source[..source.find(".a").unwrap()]
472            .bytes()
473            .filter(|&b| b == b'\n')
474            .count();
475        assert_eq!(line_of_a, sfc_line_of_a, "vcss={vcss:?}");
476    }
477
478    #[test]
479    fn stylus_and_postcss_blocks_reach_neither_virtual_stylesheet() {
480        // `has_non_css_lang` keeps stylus and postcss out of the plain-CSS
481        // sheet, and `has_preprocessor_lang` does not select them for the
482        // preprocessor sheet, so the two predicates are not complements and
483        // neither may be rewritten as the negation of the other.
484        for source in [
485            "<template>\n  <div/>\n</template>\n<style lang=\"stylus\">\n.a\n  color red\n</style>",
486            "<template>\n  <div/>\n</template>\n<style lang=\"postcss\">\n.a { color: red; }\n</style>",
487        ] {
488            assert!(
489                super::sfc_virtual_stylesheet(source).is_none(),
490                "plain-CSS sheet must skip it: {source:?}"
491            );
492            assert!(
493                super::sfc_preprocessor_virtual_stylesheet(source).is_none(),
494                "preprocessor sheet must skip it: {source:?}"
495            );
496        }
497    }
498
499    #[test]
500    fn hyphenated_class_token_boundary() {
501        // `.foo` is unused even though `foo-bar` appears in the template.
502        let dead = scoped_unused_classes(
503            "<template><div class=\"foo-bar\"></div></template>\n\
504             <style scoped>.foo { color: red; } .foo-bar { color: blue; }</style>",
505        );
506        assert_eq!(dead, vec!["foo".to_string()]);
507    }
508
509    #[test]
510    fn literal_transition_name_credits_generated_classes() {
511        let dead = scoped_unused_classes(
512            "<template><Transition name=\"fade\"><div v-if=\"v\" class=\"panel\"/></Transition></template>\n\
513             <style scoped>.panel {} .fade-enter-active { transition: opacity 1s; } \
514             .fade-enter-from, .fade-leave-to { opacity: 0; } .fade-move {} .stale {}</style>",
515        );
516        // `fade-move` belongs to TransitionGroup only.
517        assert_eq!(dead, vec!["fade-move".to_string(), "stale".to_string()]);
518    }
519
520    #[test]
521    fn kebab_transition_group_credits_move_class() {
522        let dead = scoped_unused_classes(
523            "<template><transition-group name='list' tag=\"ul\"><li/></transition-group></template>\n\
524             <style scoped>.list-move {} .list-leave-active {} .other-move {}</style>",
525        );
526        assert_eq!(dead, vec!["other-move".to_string()]);
527    }
528
529    #[test]
530    fn unnamed_transition_credits_v_prefix() {
531        let dead = scoped_unused_classes(
532            "<template><TransitionGroup><li/></TransitionGroup></template>\n\
533             <style scoped>.v-enter-active {} .v-move {} .fade-enter-active {}</style>",
534        );
535        assert_eq!(dead, vec!["fade-enter-active".to_string()]);
536    }
537
538    #[test]
539    fn bound_transition_name_with_modifier_credits_nothing() {
540        let dead = scoped_unused_classes(
541            "<template><Transition :name.camel=\"kind\"><div/></Transition>\
542             <Transition v-bind:name.prop=\"kind\"><div/></Transition></template>\n\
543             <style scoped>.v-enter-active {}</style>",
544        );
545        assert_eq!(dead, vec!["v-enter-active".to_string()]);
546    }
547
548    #[test]
549    fn bound_transition_name_credits_nothing() {
550        let dead = scoped_unused_classes(
551            "<template><Transition :name=\"kind\"><div/></Transition></template>\n\
552             <style scoped>.fade-enter-active {} .v-enter-active {}</style>",
553        );
554        assert_eq!(
555            dead,
556            vec![
557                "fade-enter-active".to_string(),
558                "v-enter-active".to_string()
559            ]
560        );
561    }
562
563    #[test]
564    fn transition_prefixed_component_is_not_a_transition() {
565        let dead = scoped_unused_classes(
566            "<template><TransitionWrapper name=\"fade\"/></template>\n\
567             <style scoped>.fade-enter-active {}</style>",
568        );
569        assert_eq!(dead, vec!["fade-enter-active".to_string()]);
570    }
571
572    #[test]
573    fn vue_transition_classes_reads_custom_class_props() {
574        let classes = vue_transition_classes(
575            "<Transition name=\"x\" enter-active-class=\"animate__animated animate__bounce\" \
576             leaveToClass=\"gone\"><p/></Transition>\
577             <TransitionGroup :name=\"n\" move-class=\"shuffle\"/>",
578        );
579        let mut sorted: Vec<&str> = classes.iter().map(String::as_str).collect();
580        sorted.sort_unstable();
581        assert_eq!(
582            sorted,
583            vec![
584                "animate__animated",
585                "animate__bounce",
586                "gone",
587                "shuffle",
588                "x-enter-active",
589                "x-enter-from",
590                "x-enter-to",
591                "x-leave-active",
592                "x-leave-from",
593                "x-leave-to",
594            ]
595        );
596    }
597}