fallow_extract/
tailwind.rs1use std::sync::LazyLock;
13
14static 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct TailwindArbitraryUse {
30 pub value: String,
32 pub line: u32,
34}
35
36#[must_use]
40pub fn scan_tailwind_arbitrary_values(source: &str) -> Vec<TailwindArbitraryUse> {
41 let mut out = Vec::new();
42 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 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 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 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 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 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 assert_eq!(got.last().map(|(_, l)| *l), Some(2));
179 assert!(got[..got.len() - 1].iter().all(|(_, l)| *l == 1));
180 }
181}