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    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}