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}