use std::sync::LazyLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkupClassToken {
pub value: String,
pub line: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MarkupClassScan {
pub static_tokens: Vec<MarkupClassToken>,
pub has_dynamic: bool,
}
static STATIC_CLASS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(r#"(?:\bclass|\bclassName)\s*=\s*(?:"([^"]*)"|'([^']*)')"#)
});
const DYNAMIC_CLASS_MARKERS: &[&str] = &[
"className={", "className ={",
"class={", "class ={", ":class", "v-bind:class", "[class]", "[ngClass]", "class:", "clsx(", "classnames(",
"classNames(",
"cx(",
"cva(",
"twMerge(",
"tw`", "classList", ];
fn value_is_interpolated(value: &str) -> bool {
value.contains('{') || value.contains('}') || value.contains('$') || value.contains('`')
}
fn is_plausible_class_token(token: &str) -> bool {
!token.is_empty() && !token.contains(['{', '}', '$', '`', '"', '\'', '(', ')', '<', '>', '='])
}
#[must_use]
pub fn scan_markup_class_tokens(source: &str) -> MarkupClassScan {
let has_dynamic = DYNAMIC_CLASS_MARKERS.iter().any(|m| source.contains(m));
let mut static_tokens = Vec::new();
let mut any_interpolated = false;
for caps in STATIC_CLASS_ATTR_RE.captures_iter(source) {
let Some(m) = caps.get(0) else { continue };
let value = caps
.get(1)
.or_else(|| caps.get(2))
.map_or("", |g| g.as_str());
if value_is_interpolated(value) {
any_interpolated = true;
continue;
}
let line = 1 + source[..m.start()].bytes().filter(|&b| b == b'\n').count();
let line = u32::try_from(line).unwrap_or(u32::MAX);
for token in value.split_whitespace() {
if is_plausible_class_token(token) {
static_tokens.push(MarkupClassToken {
value: token.to_owned(),
line,
});
}
}
}
MarkupClassScan {
static_tokens,
has_dynamic: has_dynamic || any_interpolated,
}
}
#[must_use]
pub fn is_edit_distance_one(a: &str, b: &str) -> bool {
let (ab, bb) = (a.as_bytes(), b.as_bytes());
let (la, lb) = (ab.len(), bb.len());
if la == lb {
let mut diffs = 0;
for i in 0..la {
if ab[i] != bb[i] {
diffs += 1;
if diffs > 1 {
return false;
}
}
}
return diffs == 1;
}
if la.abs_diff(lb) != 1 {
return false;
}
let (short, long) = if la < lb { (ab, bb) } else { (bb, ab) };
let (mut i, mut j, mut skipped) = (0usize, 0usize, false);
while i < short.len() && j < long.len() {
if short[i] == long[j] {
i += 1;
} else {
if skipped {
return false;
}
skipped = true; }
j += 1;
}
true
}
#[must_use]
pub fn is_typo_edit(token: &str, defined: &str) -> bool {
let (tb, db) = (token.as_bytes(), defined.as_bytes());
let (lt, ld) = (tb.len(), db.len());
if lt == ld {
let mut diff = None;
for i in 0..lt {
if tb[i] != db[i] {
if diff.is_some() {
return false;
}
diff = Some(i);
}
}
return diff.is_some_and(|i| !tb[i].is_ascii_digit() && !db[i].is_ascii_digit());
}
if lt.abs_diff(ld) != 1 {
return false;
}
let (short, long) = if lt < ld { (tb, db) } else { (db, tb) };
if long.last() == Some(&b's') && short == &long[..long.len() - 1] {
return false;
}
let (mut i, mut j, mut skipped) = (0usize, 0usize, false);
let mut edit_byte = *long.last().unwrap_or(&0);
while i < short.len() && j < long.len() {
if short[i] == long[j] {
i += 1;
} else {
if skipped {
return false;
}
skipped = true;
edit_byte = long[j];
}
j += 1;
}
!edit_byte.is_ascii_digit()
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens(source: &str) -> Vec<String> {
scan_markup_class_tokens(source)
.static_tokens
.into_iter()
.map(|t| t.value)
.collect()
}
#[test]
fn extracts_static_class_and_classname_tokens() {
assert_eq!(
tokens(r#"<div class="card card-title">x</div>"#),
vec!["card", "card-title"]
);
assert_eq!(
tokens(r#"<div className="btn btn-primary">x</div>"#),
vec!["btn", "btn-primary"]
);
assert_eq!(tokens(r"<i class='solo'></i>"), vec!["solo"]);
}
#[test]
fn reports_one_based_line() {
let scan = scan_markup_class_tokens("\n\n<i class=\"on-line-three\"></i>");
assert_eq!(scan.static_tokens.len(), 1);
assert_eq!(scan.static_tokens[0].line, 3);
}
#[test]
fn flags_dynamic_construction_and_skips_its_tokens() {
for src in [
r#"<div className={clsx("a", x)}>y</div>"#,
r"<div className={`btn-${size}`}>y</div>",
r#"<div :class="{ active: isOn }">y</div>"#,
r#"<div class="a-{cls}">y</div>"#, r#"el.classList.add("toggled")"#,
] {
let scan = scan_markup_class_tokens(src);
assert!(scan.has_dynamic, "expected dynamic for {src:?}");
}
}
#[test]
fn static_attr_in_dynamic_file_still_yields_its_tokens() {
let scan = scan_markup_class_tokens(
r#"<div className={clsx(x)}>a</div><span class="card-tite">b</span>"#,
);
assert!(scan.has_dynamic);
assert_eq!(
scan.static_tokens
.iter()
.map(|t| t.value.as_str())
.collect::<Vec<_>>(),
vec!["card-tite"]
);
}
#[test]
fn edit_distance_one_substitution() {
assert!(is_edit_distance_one("card-tite", "card-tit=")); assert!(is_edit_distance_one("btn-primary", "btn-primery"));
assert!(!is_edit_distance_one("btn", "btn")); assert!(!is_edit_distance_one("btn-primary", "btn-secondary"));
}
#[test]
fn edit_distance_one_insertion_deletion() {
assert!(is_edit_distance_one("card-title", "card-titl")); assert!(is_edit_distance_one("card-titl", "card-title")); assert!(is_edit_distance_one("nav", "navs")); assert!(!is_edit_distance_one("nav", "navxs")); assert!(!is_edit_distance_one("nav", "xyz")); }
#[test]
fn typo_edit_accepts_real_alphabetic_typos() {
assert!(is_typo_edit("card-tite", "card-title")); assert!(is_typo_edit("sidebar-nev", "sidebar-nav")); assert!(is_typo_edit("widget-labl", "widget-label")); assert!(is_typo_edit("headar", "header")); }
#[test]
fn typo_edit_rejects_numeric_scale_families() {
assert!(!is_typo_edit("col-lg-6", "col-lg-4")); assert!(!is_typo_edit("display-4", "display-5"));
assert!(!is_typo_edit("gap-2", "gap-3"));
assert!(!is_typo_edit("display-4", "display-")); assert!(!is_typo_edit("z-10", "z-50")); }
#[test]
fn typo_edit_rejects_singular_plural() {
assert!(!is_typo_edit("button", "buttons"));
assert!(!is_typo_edit("buttons", "button"));
assert!(!is_typo_edit("card", "cards"));
}
}