1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::backgrounds;
7use crate::borders;
8use crate::colors;
9use crate::effects;
10use crate::interactivity;
11use crate::layout;
12use crate::misc;
13use crate::sizing;
14use crate::spacing;
15use crate::tables;
16use crate::transforms;
17use crate::typography;
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct CssProperty {
22 pub property: String,
23 pub value: String,
24}
25
26impl CssProperty {
27 pub fn new(property: &str, value: &str) -> Self {
28 Self {
29 property: property.to_string(),
30 value: value.to_string(),
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum Variant {
38 None,
40 Responsive(String),
42 State(String),
44 Dark,
46 Combined(Vec<Variant>),
48}
49
50impl Variant {
51 pub fn is_none(&self) -> bool {
52 matches!(self, Variant::None)
53 }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct TailwindOutput {
59 pub base: Vec<CssProperty>,
61 pub variants: HashMap<String, Vec<CssProperty>>,
63}
64
65impl TailwindOutput {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn add(&mut self, variant: Variant, property: CssProperty) {
71 match variant {
72 Variant::None => self.base.push(property),
73 Variant::Responsive(bp) => {
74 self.variants
75 .entry(format!("@{}", bp))
76 .or_default()
77 .push(property);
78 }
79 Variant::State(state) => {
80 self.variants
81 .entry(format!(":{}", state))
82 .or_default()
83 .push(property);
84 }
85 Variant::Dark => {
86 self.variants
87 .entry("dark".to_string())
88 .or_default()
89 .push(property);
90 }
91 Variant::Combined(variants) => {
92 let key = variants
94 .iter()
95 .map(|v| match v {
96 Variant::Responsive(bp) => format!("@{}", bp),
97 Variant::State(state) => format!(":{}", state),
98 Variant::Dark => "dark".to_string(),
99 _ => String::new(),
100 })
101 .collect::<Vec<_>>()
102 .join("");
103 self.variants.entry(key).or_default().push(property);
104 }
105 }
106 }
107
108 pub fn to_props(&self) -> HashMap<String, String> {
111 let mut props = HashMap::new();
112
113 for prop in &self.base {
114 props.insert(prop.property.clone(), prop.value.clone());
115 }
116
117 for (variant, properties) in &self.variants {
118 for prop in properties {
119 let key = format!("{}{}", prop.property, variant);
120 props.insert(key, prop.value.clone());
121 }
122 }
123
124 props
125 }
126}
127
128pub fn parse_classes(input: &str) -> TailwindOutput {
130 let mut output = TailwindOutput::new();
131
132 for class in input.split_whitespace() {
133 if let Some((variant, properties)) = parse_class(class) {
134 for prop in properties {
135 output.add(variant.clone(), prop);
136 }
137 }
138 }
139
140 resolve_gradient_groups(&mut output);
141
142 output
143}
144
145fn resolve_gradient_groups(output: &mut TailwindOutput) {
146 resolve_gradient_props(&mut output.base);
147 for properties in output.variants.values_mut() {
148 resolve_gradient_props(properties);
149 }
150}
151
152fn resolve_gradient_props(properties: &mut Vec<CssProperty>) {
153 let mut background_image_index = None;
154 let mut from = None;
155 let mut via = None;
156 let mut to = None;
157
158 for (index, prop) in properties.iter().enumerate() {
159 match prop.property.as_str() {
160 "background-image" if prop.value.contains("var(--tw-gradient-stops)") => {
161 background_image_index = Some(index);
162 }
163 "--tw-gradient-from" => from = Some(prop.value.clone()),
164 "--tw-gradient-via" => via = Some(prop.value.clone()),
165 "--tw-gradient-to" => to = Some(prop.value.clone()),
166 _ => {}
167 }
168 }
169
170 let Some(index) = background_image_index else {
171 return;
172 };
173
174 let direction = gradient_direction(&properties[index].value);
175 let from = from.unwrap_or_else(|| "transparent".to_string());
176 let to = to.unwrap_or_else(|| "transparent".to_string());
177 let stops = if let Some(via) = via {
178 format!("{}, {}, {}", from, via, to)
179 } else {
180 format!("{}, {}", from, to)
181 };
182
183 properties[index].value = format!("linear-gradient({}, {})", direction, stops);
184 properties.retain(|prop| !prop.property.starts_with("--tw-gradient-"));
185}
186
187fn gradient_direction(value: &str) -> &str {
188 value
189 .strip_prefix("linear-gradient(")
190 .and_then(|rest| rest.split_once(", var(--tw-gradient-stops))"))
191 .map(|(direction, _)| direction)
192 .unwrap_or("to bottom")
193}
194
195pub fn parse_class(class: &str) -> Option<(Variant, Vec<CssProperty>)> {
198 let (variant, utility) = extract_variant(class);
200
201 let properties = parse_utility(utility)?;
203
204 Some((variant, properties))
205}
206
207fn extract_variant(class: &str) -> (Variant, &str) {
211 let mut parts: Vec<&str> = Vec::new();
213 let mut start = 0;
214 let mut bracket_depth: usize = 0;
215 for (i, ch) in class.char_indices() {
216 match ch {
217 '[' => bracket_depth += 1,
218 ']' => bracket_depth = bracket_depth.saturating_sub(1),
219 ':' if bracket_depth == 0 => {
220 parts.push(&class[start..i]);
221 start = i + 1;
222 }
223 _ => {}
224 }
225 }
226 parts.push(&class[start..]);
227
228 if parts.len() == 1 {
229 return (Variant::None, class);
230 }
231
232 let utility = parts.last().unwrap();
233 let variant_parts = &parts[..parts.len() - 1];
234
235 if variant_parts.len() == 1 {
236 let v = parse_variant_name(variant_parts[0]);
237 (v, utility)
238 } else {
239 let variants: Vec<Variant> = variant_parts
240 .iter()
241 .map(|p| parse_variant_name(p))
242 .filter(|v| !v.is_none())
243 .collect();
244
245 if variants.is_empty() {
246 (Variant::None, utility)
247 } else if variants.len() == 1 {
248 (variants.into_iter().next().unwrap(), utility)
249 } else {
250 (Variant::Combined(variants), utility)
251 }
252 }
253}
254
255fn parse_variant_name(name: &str) -> Variant {
256 match name {
257 "sm" => Variant::Responsive("sm".to_string()),
259 "md" => Variant::Responsive("md".to_string()),
260 "lg" => Variant::Responsive("lg".to_string()),
261 "xl" => Variant::Responsive("xl".to_string()),
262 "2xl" => Variant::Responsive("2xl".to_string()),
263 "hover" => Variant::State("hover".to_string()),
265 "focus" => Variant::State("focus".to_string()),
266 "focus-within" => Variant::State("focus-within".to_string()),
267 "focus-visible" => Variant::State("focus-visible".to_string()),
268 "active" => Variant::State("active".to_string()),
269 "disabled" => Variant::State("disabled".to_string()),
270 "visited" => Variant::State("visited".to_string()),
271 "checked" => Variant::State("checked".to_string()),
272 "required" => Variant::State("required".to_string()),
273 "placeholder" => Variant::State(":placeholder".to_string()),
274 "first" => Variant::State("first-child".to_string()),
275 "last" => Variant::State("last-child".to_string()),
276 "only" => Variant::State("only-child".to_string()),
277 "odd" => Variant::State("nth-child(odd)".to_string()),
278 "even" => Variant::State("nth-child(even)".to_string()),
279 "first-of-type" => Variant::State("first-of-type".to_string()),
280 "last-of-type" => Variant::State("last-of-type".to_string()),
281 "empty" => Variant::State("empty".to_string()),
282 "group-hover" => Variant::State("group-hover".to_string()),
284 "group-focus" => Variant::State("group-focus".to_string()),
285 "dark" => Variant::Dark,
287 _ => Variant::None,
288 }
289}
290
291fn parse_utility(utility: &str) -> Option<Vec<CssProperty>> {
293 None.or_else(|| spacing::parse(utility))
295 .or_else(|| sizing::parse(utility))
296 .or_else(|| colors::parse(utility))
297 .or_else(|| typography::parse(utility))
298 .or_else(|| layout::parse(utility))
299 .or_else(|| borders::parse(utility))
300 .or_else(|| effects::parse(utility))
301 .or_else(|| transforms::parse(utility))
302 .or_else(|| backgrounds::parse(utility))
303 .or_else(|| tables::parse(utility))
304 .or_else(|| interactivity::parse(utility))
305 .or_else(|| misc::parse(utility))
306 .or_else(|| parse_arbitrary(utility))
307}
308
309fn parse_arbitrary(utility: &str) -> Option<Vec<CssProperty>> {
311 let bracket_start = utility.find('[')?;
313 if !utility.ends_with(']') {
314 return None;
315 }
316 let value = &utility[bracket_start + 1..utility.len() - 1];
317 if value.is_empty() {
318 return None;
319 }
320 let prefix = &utility[..bracket_start.checked_sub(1)?]; if utility.as_bytes()[bracket_start - 1] != b'-' {
322 return None;
323 }
324
325 let is_negative = prefix.starts_with('-');
328 let bare_prefix = if is_negative { &prefix[1..] } else { prefix };
329 let negated_value;
330 let neg_val = if is_negative {
331 negated_value = format!("-{}", value);
332 negated_value.as_str()
333 } else {
334 value
335 };
336
337 None.or_else(|| spacing::parse_arbitrary(prefix, value))
342 .or_else(|| {
343 if is_negative {
344 None
345 } else {
346 sizing::parse_arbitrary(prefix, value)
347 }
348 })
349 .or_else(|| {
350 if is_negative {
351 None
352 } else {
353 typography::parse_arbitrary(prefix, value)
354 }
355 })
356 .or_else(|| layout::parse_arbitrary(bare_prefix, neg_val))
357 .or_else(|| {
358 if is_negative {
359 None
360 } else {
361 borders::parse_arbitrary(prefix, value)
362 }
363 })
364 .or_else(|| effects::parse_arbitrary(bare_prefix, neg_val))
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn test_parse_simple_class() {
373 let output = parse_classes("p-4");
374 assert_eq!(output.base.len(), 1);
375 assert_eq!(output.base[0].property, "padding");
376 assert_eq!(output.base[0].value, "1rem");
377 }
378
379 #[test]
380 fn test_parse_with_variant() {
381 let output = parse_classes("md:p-4");
382 assert!(output.base.is_empty());
383 assert!(output.variants.contains_key("@md"));
384 let md_props = output.variants.get("@md").unwrap();
385 assert_eq!(md_props[0].property, "padding");
386 }
387
388 #[test]
389 fn test_parse_multiple_classes() {
390 let output = parse_classes("p-4 m-2 text-blue-500");
391 assert_eq!(output.base.len(), 3);
392 }
393
394 #[test]
395 fn test_parse_hover_variant() {
396 let output = parse_classes("hover:bg-white");
397 assert!(output.variants.contains_key(":hover"));
398 }
399
400 #[test]
401 fn test_to_props() {
402 let output = parse_classes("p-4 md:p-8");
403 let props = output.to_props();
404 assert_eq!(props.get("padding"), Some(&"1rem".to_string()));
405 assert_eq!(props.get("padding@md"), Some(&"2rem".to_string()));
406 }
407
408 #[test]
409 fn test_resolves_gradient_stops_to_concrete_background_image() {
410 let output =
411 parse_classes("bg-gradient-to-br from-indigo-950 via-slate-900 to-fuchsia-950");
412 let props = output.to_props();
413
414 assert_eq!(
415 props.get("background-image"),
416 Some(&"linear-gradient(to bottom right, #1e1b4b, #0f172a, #4a044e)".to_string())
417 );
418 assert!(!props.contains_key("--tw-gradient-from"));
419 assert!(!props.contains_key("--tw-gradient-via"));
420 assert!(!props.contains_key("--tw-gradient-to"));
421 }
422
423 #[test]
424 fn test_resolves_two_stop_gradient_to_concrete_background_image() {
425 let output = parse_classes("bg-gradient-to-b from-slate-950 via-indigo-950 to-slate-950");
426 let props = output.to_props();
427
428 assert_eq!(
429 props.get("background-image"),
430 Some(&"linear-gradient(to bottom, #020617, #1e1b4b, #020617)".to_string())
431 );
432 }
433}