fallow_extract/tailwind.rs
1//! Tailwind CSS arbitrary-value detection.
2//!
3//! Tailwind "arbitrary value" utilities (`w-[13px]`, `bg-[#abc]`,
4//! `grid-cols-[1fr_2fr]`) hardcode a one-off value in markup instead of using a
5//! configured scale token. They are not wrong, but a high count is a design-
6//! token-bypass signal that no per-rule linter aggregates across a codebase, and
7//! AI-assisted edits over-produce them. This scanner finds them in markup so
8//! `fallow health --css` can surface them as candidates. The caller MUST gate on
9//! the project actually using Tailwind: the `prefix-[value]` shape is Tailwind-
10//! specific in practice but not formally exclusive.
11
12use std::sync::LazyLock;
13
14/// Matches a Tailwind arbitrary-value utility token: a lowercase kebab utility
15/// prefix followed immediately by a bracketed value, e.g. `w-[13px]`,
16/// `grid-cols-[1fr_2fr]`, `bg-[#abc]`. The bracketed value excludes single and
17/// double quotes, backticks, brackets, and whitespace, and is length-capped to
18/// avoid runaway matches.
19/// Variant prefixes (`hover:`, `md:`, `dark:`) are not captured: the utility +
20/// value token is the unit of interest, and the same `w-[13px]` under different
21/// variants is the same bypass.
22static ARBITRARY_VALUE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
23 crate::static_regex(r#"[a-z][a-z0-9]*(?:-[a-z0-9]+)*-\[[^\]\[\s"'`]{1,100}\]"#)
24});
25
26/// One use of a Tailwind arbitrary-value utility, with the 1-based line it
27/// appears on.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct TailwindArbitraryUse {
30 /// The matched `prefix-[value]` token.
31 pub value: String,
32 /// 1-based line in the source.
33 pub line: u32,
34}
35
36/// Scan markup source for Tailwind arbitrary-value utility tokens, one entry per
37/// occurrence. The caller must gate this on the project using Tailwind (the
38/// token shape is Tailwind-specific but not exclusive).
39#[must_use]
40pub fn scan_tailwind_arbitrary_values(source: &str) -> Vec<TailwindArbitraryUse> {
41 let mut out = Vec::new();
42 for m in ARBITRARY_VALUE_RE.find_iter(source) {
43 let line = 1 + source[..m.start()].bytes().filter(|&b| b == b'\n').count();
44 out.push(TailwindArbitraryUse {
45 value: m.as_str().to_owned(),
46 line: u32::try_from(line).unwrap_or(u32::MAX),
47 });
48 }
49 out
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55
56 fn values(source: &str) -> Vec<String> {
57 scan_tailwind_arbitrary_values(source)
58 .into_iter()
59 .map(|u| u.value)
60 .collect()
61 }
62
63 #[test]
64 fn matches_common_arbitrary_value_shapes() {
65 let v = values(r#"<div class="w-[13px] bg-[#abc] grid-cols-[1fr_2fr] top-[7px]">x</div>"#);
66 assert_eq!(
67 v,
68 vec!["w-[13px]", "bg-[#abc]", "grid-cols-[1fr_2fr]", "top-[7px]"]
69 );
70 }
71
72 #[test]
73 fn ignores_plain_scale_utilities() {
74 // No brackets -> not an arbitrary value.
75 let v = values(r#"<div class="w-4 bg-red-500 grid-cols-3">x</div>"#);
76 assert!(v.is_empty(), "got {v:?}");
77 }
78
79 #[test]
80 fn does_not_match_attribute_selectors() {
81 // `a[href]` / `[data-x]` are not `prefix-[value]` (no dash before bracket).
82 let v = values("a[href] { color: red; } [data-state] { color: blue; }");
83 assert!(v.is_empty(), "got {v:?}");
84 }
85
86 #[test]
87 fn reports_one_based_line() {
88 let uses = scan_tailwind_arbitrary_values("\n\n<i class=\"h-[3px]\"></i>");
89 assert_eq!(uses.len(), 1);
90 assert_eq!(uses[0].line, 3);
91 }
92
93 #[test]
94 fn captures_utility_prefix_not_variant() {
95 // The `hover:` variant is not part of the captured token; the utility +
96 // value is.
97 let v = values(r#"<a class="hover:w-[20px]">x</a>"#);
98 assert_eq!(v, vec!["w-[20px]"]);
99 }
100}