Skip to main content

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    // Incremental line counter: `find_iter` yields matches in source order, so
43    // count only the newlines between the previous match and this one instead of
44    // rescanning the whole prefix per match (issue #1843 follow-up: the naive
45    // `source[..m.start()]` rescan is O(matches * source_len), worst on a single
46    // long line with no newlines).
47    let mut last_pos = 0usize;
48    let mut last_line = 1usize;
49    for m in ARBITRARY_VALUE_RE.find_iter(source) {
50        if is_arbitrary_variant_match(source.as_bytes(), m.end()) {
51            continue;
52        }
53        last_line += source
54            .get(last_pos..m.start())
55            .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
56        last_pos = m.start();
57        out.push(TailwindArbitraryUse {
58            value: m.as_str().to_owned(),
59            line: u32::try_from(last_line).unwrap_or(u32::MAX),
60        });
61    }
62    out
63}
64
65fn is_arbitrary_variant_match(source: &[u8], end: usize) -> bool {
66    if source.get(end) == Some(&b':') {
67        return true;
68    }
69    if source.get(end) != Some(&b'/') {
70        return false;
71    }
72    for &byte in &source[end + 1..] {
73        match byte {
74            b':' => return true,
75            b' ' | b'\n' | b'\r' | b'\t' | b'"' | b'\'' | b'`' => return false,
76            _ => {}
77        }
78    }
79    false
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn values(source: &str) -> Vec<String> {
87        scan_tailwind_arbitrary_values(source)
88            .into_iter()
89            .map(|u| u.value)
90            .collect()
91    }
92
93    #[test]
94    fn matches_common_arbitrary_value_shapes() {
95        let v = values(r#"<div class="w-[13px] bg-[#abc] grid-cols-[1fr_2fr] top-[7px]">x</div>"#);
96        assert_eq!(
97            v,
98            vec!["w-[13px]", "bg-[#abc]", "grid-cols-[1fr_2fr]", "top-[7px]"]
99        );
100    }
101
102    #[test]
103    fn ignores_plain_scale_utilities() {
104        // No brackets -> not an arbitrary value.
105        let v = values(r#"<div class="w-4 bg-red-500 grid-cols-3">x</div>"#);
106        assert!(v.is_empty(), "got {v:?}");
107    }
108
109    #[test]
110    fn does_not_match_attribute_selectors() {
111        // `a[href]` / `[data-x]` are not `prefix-[value]` (no dash before bracket).
112        let v = values("a[href] { color: red; } [data-state] { color: blue; }");
113        assert!(v.is_empty(), "got {v:?}");
114    }
115
116    #[test]
117    fn reports_one_based_line() {
118        let uses = scan_tailwind_arbitrary_values("\n\n<i class=\"h-[3px]\"></i>");
119        assert_eq!(uses.len(), 1);
120        assert_eq!(uses[0].line, 3);
121    }
122
123    #[test]
124    fn captures_utility_prefix_not_variant() {
125        // The `hover:` variant is not part of the captured token; the utility +
126        // value is.
127        let v = values(r#"<a class="hover:w-[20px]">x</a>"#);
128        assert_eq!(v, vec!["w-[20px]"]);
129    }
130
131    #[test]
132    fn ignores_arbitrary_variants() {
133        let v = values(
134            r#"<div class="data-[side=left]:slide-in min-[320px]:text-sm group-data-[collapsible=icon]:hidden group-data-[size=sm]/dialog:grid peer-data-[size=lg]/button:top-2 hover:w-[20px]">x</div>"#,
135        );
136        assert_eq!(v, vec!["w-[20px]"]);
137    }
138
139    #[test]
140    fn keeps_arbitrary_value_modifiers() {
141        let v = values(r#"<div class="bg-[#fff]/50 ring-[3px]">x</div>"#);
142        assert_eq!(v, vec!["bg-[#fff]", "ring-[3px]"]);
143    }
144
145    #[test]
146    fn line_numbers_match_naive_reference_on_dense_line() {
147        // Many arbitrary-value tokens packed onto a single long line (the
148        // pathological zero-newline prefix): the incremental line counter must
149        // agree byte-for-byte with the naive per-match prefix rescan.
150        use std::fmt::Write as _;
151        let mut src = String::from("<div class=\"");
152        for i in 0..500 {
153            let _ = write!(src, "w-[{i}px] ");
154        }
155        src.push_str("\">x</div>\n<span class=\"h-[3px]\"></span>");
156
157        let got: Vec<(String, u32)> = scan_tailwind_arbitrary_values(&src)
158            .into_iter()
159            .map(|u| (u.value, u.line))
160            .collect();
161
162        // Reference: recompute each match's line via a full prefix rescan.
163        let want: Vec<(String, u32)> = ARBITRARY_VALUE_RE
164            .find_iter(&src)
165            .filter(|m| !is_arbitrary_variant_match(src.as_bytes(), m.end()))
166            .map(|m| {
167                let line = 1 + src[..m.start()].bytes().filter(|&b| b == b'\n').count();
168                (
169                    m.as_str().to_owned(),
170                    u32::try_from(line).unwrap_or(u32::MAX),
171                )
172            })
173            .collect();
174
175        assert_eq!(got, want);
176        assert!(got.len() > 500, "expected the dense line plus the trailer");
177        // The trailer sits on line 2; the packed tokens all sit on line 1.
178        assert_eq!(got.last().map(|(_, l)| *l), Some(2));
179        assert!(got[..got.len() - 1].iter().all(|(_, l)| *l == 1));
180    }
181}