Skip to main content

fallow_extract/
css_classes.rs

1//! Markup CSS-class reference scanning and class-name similarity.
2//!
3//! Supports the `fallow health --css` class-reach candidates (the CSS analogue
4//! of `unresolved-import`). [`scan_markup_class_tokens`] pulls the STATIC class
5//! tokens out of `class` / `className` attributes across every markup surface
6//! fallow visits (JSX/TSX, HTML, Vue/Svelte/Astro), and flags whether the file
7//! also constructs classes DYNAMICALLY (`clsx(...)`, `` `btn-${x}` ``,
8//! `:class`, spread props), which downstream consumers use to abstain.
9//!
10//! The scanner is intentionally regex-based and conservative: it only collects
11//! tokens from a fully-static quoted attribute value, and treats anything that
12//! could be an interpolation as a dynamic signal rather than a token. It never
13//! tries to evaluate a dynamic expression.
14
15use std::sync::LazyLock;
16
17/// A static class token referenced in markup, with the 1-based line it sits on.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MarkupClassToken {
20    /// The bare class name (no dot), e.g. `card-title`.
21    pub value: String,
22    /// 1-based line of the attribute in the source.
23    pub line: u32,
24}
25
26/// The result of scanning one markup source for class references.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct MarkupClassScan {
29    /// Class tokens from fully-static `class` / `className` attribute values.
30    pub static_tokens: Vec<MarkupClassToken>,
31    /// True when the file constructs classes dynamically anywhere (`clsx(...)`,
32    /// template literals, `:class`, spread/computed props). Consumers that need
33    /// to prove a class unused must abstain on dynamic files; a typo check on a
34    /// static token can still fire.
35    pub has_dynamic: bool,
36}
37
38/// Matches a fully-static `class="..."` / `className="..."` attribute (double or
39/// single quoted) and captures the raw value. The value is split into tokens by
40/// the caller; a value containing `{`, `}`, `$`, or a backtick is treated as a
41/// dynamic interpolation (Svelte `class="a-{b}"`, Vue mustache) and skipped for
42/// token extraction.
43static STATIC_CLASS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
44    crate::static_regex(r#"(?:\bclass|\bclassName)\s*=\s*(?:"([^"]*)"|'([^']*)')"#)
45});
46
47/// Substrings that prove a markup file constructs class names dynamically. Any
48/// hit sets [`MarkupClassScan::has_dynamic`].
49const DYNAMIC_CLASS_MARKERS: &[&str] = &[
50    "className={", // JSX expression container
51    "className ={",
52    "class={",      // Svelte / JSX
53    "class ={",     // tolerate whitespace
54    ":class",       // Vue v-bind shorthand
55    "v-bind:class", // Vue v-bind long form
56    "[class]",      // Angular property binding
57    "[ngClass]",    // Angular ngClass
58    "class:",       // Svelte class directive `class:active`
59    "clsx(",        // common class-combiner libraries
60    "classnames(",
61    "classNames(",
62    "cx(",
63    "cva(",
64    "twMerge(",
65    "tw`",       // tailwind tagged template
66    "classList", // DOM classList manipulation
67];
68
69/// True when a static class value carries an interpolation and must not be
70/// tokenized (the tokens would be partial / wrong). Such a value also implies
71/// the file is dynamic.
72fn value_is_interpolated(value: &str) -> bool {
73    value.contains('{') || value.contains('}') || value.contains('$') || value.contains('`')
74}
75
76/// A token is a usable class name only if it looks like an authored class: it is
77/// non-empty, contains no whitespace (already split), and carries no markup /
78/// interpolation punctuation. Tailwind variant (`hover:`) and opacity (`/50`)
79/// shapes are left in (they simply never match an authored CSS class or a near
80/// miss downstream), but obvious non-class noise is dropped.
81fn is_plausible_class_token(token: &str) -> bool {
82    !token.is_empty() && !token.contains(['{', '}', '$', '`', '"', '\'', '(', ')', '<', '>', '='])
83}
84
85/// Scan a markup source for static class tokens and a dynamic-construction flag.
86///
87/// `class="a b c"` yields three tokens; `className={clsx(...)}` and
88/// `class="a-{x}"` yield no tokens but set `has_dynamic`.
89#[must_use]
90pub fn scan_markup_class_tokens(source: &str) -> MarkupClassScan {
91    let has_dynamic = DYNAMIC_CLASS_MARKERS.iter().any(|m| source.contains(m));
92    let mut static_tokens = Vec::new();
93    let mut any_interpolated = false;
94
95    // Incremental line counter: `captures_iter` yields matches in source order, so
96    // count only the newlines between the previous match and this one instead of
97    // rescanning the whole prefix per match (issue #1843 follow-up: the naive
98    // `source[..m.start()]` rescan is O(matches * source_len), worst on a single
99    // long line with no newlines).
100    let mut last_pos = 0usize;
101    let mut last_line = 1usize;
102    for caps in STATIC_CLASS_ATTR_RE.captures_iter(source) {
103        let Some(m) = caps.get(0) else { continue };
104        let value = caps
105            .get(1)
106            .or_else(|| caps.get(2))
107            .map_or("", |g| g.as_str());
108        if value_is_interpolated(value) {
109            any_interpolated = true;
110            continue;
111        }
112        last_line += source
113            .get(last_pos..m.start())
114            .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
115        last_pos = m.start();
116        let line = u32::try_from(last_line).unwrap_or(u32::MAX);
117        for token in value.split_whitespace() {
118            if is_plausible_class_token(token) {
119                static_tokens.push(MarkupClassToken {
120                    value: token.to_owned(),
121                    line,
122                });
123            }
124        }
125    }
126
127    MarkupClassScan {
128        static_tokens,
129        has_dynamic: has_dynamic || any_interpolated,
130    }
131}
132
133/// True when `a` and `b` differ by exactly one single-character edit (one
134/// substitution, insertion, or deletion). Equal strings return false. Runs in
135/// O(min(len)) without building a full edit-distance matrix.
136///
137/// Used to surface a likely className typo: a markup token that matches no
138/// defined class but is one edit from a class that IS defined (`card-tite` vs
139/// `card-title`). Restricting to distance one keeps the suggestion near-zero
140/// false-positive.
141#[must_use]
142pub fn is_edit_distance_one(a: &str, b: &str) -> bool {
143    let (ab, bb) = (a.as_bytes(), b.as_bytes());
144    let (la, lb) = (ab.len(), bb.len());
145    if la == lb {
146        // Same length: exactly one substitution.
147        let mut diffs = 0;
148        for i in 0..la {
149            if ab[i] != bb[i] {
150                diffs += 1;
151                if diffs > 1 {
152                    return false;
153                }
154            }
155        }
156        return diffs == 1;
157    }
158    // Differ by one in length: exactly one insertion/deletion. Walk both,
159    // allowing a single skip in the longer string.
160    if la.abs_diff(lb) != 1 {
161        return false;
162    }
163    let (short, long) = if la < lb { (ab, bb) } else { (bb, ab) };
164    let (mut i, mut j, mut skipped) = (0usize, 0usize, false);
165    while i < short.len() && j < long.len() {
166        if short[i] == long[j] {
167            i += 1;
168        } else {
169            if skipped {
170                return false;
171            }
172            skipped = true; // skip one char in the longer string
173        }
174        j += 1;
175    }
176    true
177}
178
179/// True when `defined` is a likely TYPO target for `token`: exactly one edit
180/// apart AND that edit is a believable mistake, not a deliberate naming
181/// variation. This is stricter than [`is_edit_distance_one`] because real
182/// codebases are full of one-edit class pairs that are NOT typos:
183///
184/// - **Numeric-scale families** (`col-lg-6` vs `col-lg-4`, `display-4` vs
185///   `display-5`, `gap-2` vs `gap-3`): adjacent members of a Bootstrap /
186///   utility scale differ by one digit but are distinct intentional classes.
187///   Any edit whose changed / inserted / deleted character is an ASCII digit is
188///   rejected.
189/// - **Singular/plural pairs** (`button` vs `buttons`): a single trailing `s`
190///   is a morphological variant, not a typo. Rejected.
191///
192/// Real typos (`card-tite` vs `card-title`, `sidebar-nev` vs `sidebar-nav`) are
193/// alphabetic edits and pass. Caught by real-world smoke on Bootstrap, where the
194/// bare near-miss produced 117 false positives, all numeric-scale or plural.
195#[must_use]
196pub fn is_typo_edit(token: &str, defined: &str) -> bool {
197    let (tb, db) = (token.as_bytes(), defined.as_bytes());
198    let (lt, ld) = (tb.len(), db.len());
199    if lt == ld {
200        // Substitution: find the single differing index; reject if a digit is on
201        // either side (a numeric-scale value, not a typo).
202        let mut diff = None;
203        for i in 0..lt {
204            if tb[i] != db[i] {
205                if diff.is_some() {
206                    return false;
207                }
208                diff = Some(i);
209            }
210        }
211        return diff.is_some_and(|i| !tb[i].is_ascii_digit() && !db[i].is_ascii_digit());
212    }
213    if lt.abs_diff(ld) != 1 {
214        return false;
215    }
216    let (short, long) = if lt < ld { (tb, db) } else { (db, tb) };
217    // Singular/plural: the longer is the shorter plus a trailing `s`.
218    if long.last() == Some(&b's') && short == &long[..long.len() - 1] {
219        return false;
220    }
221    // Locate the single inserted / deleted character.
222    let (mut i, mut j, mut skipped) = (0usize, 0usize, false);
223    let mut edit_byte = *long.last().unwrap_or(&0);
224    while i < short.len() && j < long.len() {
225        if short[i] == long[j] {
226            i += 1;
227        } else {
228            if skipped {
229                return false;
230            }
231            skipped = true;
232            edit_byte = long[j];
233        }
234        j += 1;
235    }
236    // Reject a digit insertion/deletion (numeric-scale variant, not a typo).
237    !edit_byte.is_ascii_digit()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn tokens(source: &str) -> Vec<String> {
245        scan_markup_class_tokens(source)
246            .static_tokens
247            .into_iter()
248            .map(|t| t.value)
249            .collect()
250    }
251
252    #[test]
253    fn extracts_static_class_and_classname_tokens() {
254        assert_eq!(
255            tokens(r#"<div class="card card-title">x</div>"#),
256            vec!["card", "card-title"]
257        );
258        assert_eq!(
259            tokens(r#"<div className="btn btn-primary">x</div>"#),
260            vec!["btn", "btn-primary"]
261        );
262        assert_eq!(tokens(r"<i class='solo'></i>"), vec!["solo"]);
263    }
264
265    #[test]
266    fn reports_one_based_line() {
267        let scan = scan_markup_class_tokens("\n\n<i class=\"on-line-three\"></i>");
268        assert_eq!(scan.static_tokens.len(), 1);
269        assert_eq!(scan.static_tokens[0].line, 3);
270    }
271
272    #[test]
273    fn flags_dynamic_construction_and_skips_its_tokens() {
274        for src in [
275            r#"<div className={clsx("a", x)}>y</div>"#,
276            r"<div className={`btn-${size}`}>y</div>",
277            r#"<div :class="{ active: isOn }">y</div>"#,
278            r#"<div class="a-{cls}">y</div>"#, // Svelte interpolation
279            r#"el.classList.add("toggled")"#,
280        ] {
281            let scan = scan_markup_class_tokens(src);
282            assert!(scan.has_dynamic, "expected dynamic for {src:?}");
283        }
284    }
285
286    #[test]
287    fn static_attr_in_dynamic_file_still_yields_its_tokens() {
288        // A static class attribute is tokenized even when the file is dynamic;
289        // the typo check needs the static token.
290        let scan = scan_markup_class_tokens(
291            r#"<div className={clsx(x)}>a</div><span class="card-tite">b</span>"#,
292        );
293        assert!(scan.has_dynamic);
294        assert_eq!(
295            scan.static_tokens
296                .iter()
297                .map(|t| t.value.as_str())
298                .collect::<Vec<_>>(),
299            vec!["card-tite"]
300        );
301    }
302
303    #[test]
304    fn edit_distance_one_substitution() {
305        assert!(is_edit_distance_one("card-tite", "card-tit=")); // sanity, one sub
306        assert!(is_edit_distance_one("btn-primary", "btn-primery"));
307        assert!(!is_edit_distance_one("btn", "btn")); // equal is not distance one
308        assert!(!is_edit_distance_one("btn-primary", "btn-secondary"));
309    }
310
311    #[test]
312    fn edit_distance_one_insertion_deletion() {
313        assert!(is_edit_distance_one("card-title", "card-titl")); // deletion
314        assert!(is_edit_distance_one("card-titl", "card-title")); // insertion
315        assert!(is_edit_distance_one("nav", "navs")); // append
316        assert!(!is_edit_distance_one("nav", "navxs")); // distance two
317        assert!(!is_edit_distance_one("nav", "xyz")); // unrelated
318    }
319
320    #[test]
321    fn typo_edit_accepts_real_alphabetic_typos() {
322        assert!(is_typo_edit("card-tite", "card-title")); // missing letter
323        assert!(is_typo_edit("sidebar-nev", "sidebar-nav")); // wrong letter
324        assert!(is_typo_edit("widget-labl", "widget-label")); // dropped letter (not plural)
325        assert!(is_typo_edit("headar", "header")); // one letter substitution
326    }
327
328    #[test]
329    fn typo_edit_rejects_numeric_scale_families() {
330        // Adjacent Bootstrap / utility scale members are one digit apart but are
331        // distinct intentional classes, never typos.
332        assert!(!is_typo_edit("col-lg-6", "col-lg-4")); // digit substitution
333        assert!(!is_typo_edit("display-4", "display-5"));
334        assert!(!is_typo_edit("gap-2", "gap-3"));
335        assert!(!is_typo_edit("display-4", "display-")); // digit deletion
336        assert!(!is_typo_edit("z-10", "z-50")); // digit substitution
337    }
338
339    #[test]
340    fn typo_edit_rejects_singular_plural() {
341        assert!(!is_typo_edit("button", "buttons"));
342        assert!(!is_typo_edit("buttons", "button"));
343        assert!(!is_typo_edit("card", "cards"));
344    }
345
346    #[test]
347    fn class_token_lines_match_naive_reference_on_dense_line() {
348        // Many static class attributes packed onto a single long line (the
349        // pathological zero-newline prefix) plus one on the next line: the
350        // incremental line counter must agree byte-for-byte with the naive
351        // per-match prefix rescan.
352        use std::fmt::Write as _;
353        let mut src = String::from("<div>");
354        for i in 0..500 {
355            let _ = write!(src, "<i class=\"c{i}\"></i>");
356        }
357        src.push_str("</div>\n<span class=\"tail\"></span>");
358
359        let got: Vec<(String, u32)> = scan_markup_class_tokens(&src)
360            .static_tokens
361            .into_iter()
362            .map(|t| (t.value, t.line))
363            .collect();
364
365        // Reference: recompute each attribute's line via a full prefix rescan,
366        // tokenizing the captured value exactly as the scanner does.
367        let mut want: Vec<(String, u32)> = Vec::new();
368        for caps in STATIC_CLASS_ATTR_RE.captures_iter(&src) {
369            let Some(m) = caps.get(0) else { continue };
370            let value = caps
371                .get(1)
372                .or_else(|| caps.get(2))
373                .map_or("", |g| g.as_str());
374            if value_is_interpolated(value) {
375                continue;
376            }
377            let line = u32::try_from(1 + src[..m.start()].bytes().filter(|&b| b == b'\n').count())
378                .unwrap_or(u32::MAX);
379            for token in value.split_whitespace() {
380                if is_plausible_class_token(token) {
381                    want.push((token.to_owned(), line));
382                }
383            }
384        }
385
386        assert_eq!(got, want);
387        assert!(got.len() > 500, "expected the dense line plus the trailer");
388        // The trailer sits on line 2; the packed tokens all sit on line 1.
389        assert_eq!(got.last().map(|(_, l)| *l), Some(2));
390        assert!(got[..got.len() - 1].iter().all(|(_, l)| *l == 1));
391    }
392}