Skip to main content

hypen_tailwind_parse/
parser.rs

1//! Main parser for Tailwind classes
2
3use 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/// Represents a parsed CSS property
20#[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/// Variant type for responsive/state/dark mode
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum Variant {
38    /// No variant (default)
39    None,
40    /// Responsive breakpoint: sm, md, lg, xl, 2xl
41    Responsive(String),
42    /// State: hover, focus, active, disabled
43    State(String),
44    /// Dark mode
45    Dark,
46    /// Combined variants like "md:hover:"
47    Combined(Vec<Variant>),
48}
49
50impl Variant {
51    pub fn is_none(&self) -> bool {
52        matches!(self, Variant::None)
53    }
54}
55
56/// Output from parsing Tailwind classes
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct TailwindOutput {
59    /// Properties with no variant (default styles)
60    pub base: Vec<CssProperty>,
61    /// Properties grouped by variant
62    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                // For combined variants, create a compound key
93                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    /// Convert to a flat map for engine props
109    /// Format: { "padding": "1rem", "padding@md": "2rem", "backgroundColor:hover": "#fff" }
110    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
128/// Parse a string of space-separated Tailwind classes
129pub 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
195/// Parse a single Tailwind class
196/// Returns the variant and list of CSS properties
197pub fn parse_class(class: &str) -> Option<(Variant, Vec<CssProperty>)> {
198    // Extract variant prefix(es): "md:hover:bg-blue-500" -> (Combined[Responsive(md), State(hover)], "bg-blue-500")
199    let (variant, utility) = extract_variant(class);
200
201    // Parse the utility class
202    let properties = parse_utility(utility)?;
203
204    Some((variant, properties))
205}
206
207/// Extract variant prefix from class.
208/// Only splits on ':' at bracket depth 0, so that arbitrary values
209/// like `bg-[url(data:image/svg+xml;...)]` are kept intact.
210fn extract_variant(class: &str) -> (Variant, &str) {
211    // Split on ':' only when not inside brackets
212    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        // Responsive
258        "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        // State
264        "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 variants
283        "group-hover" => Variant::State("group-hover".to_string()),
284        "group-focus" => Variant::State("group-focus".to_string()),
285        // Dark mode
286        "dark" => Variant::Dark,
287        _ => Variant::None,
288    }
289}
290
291/// Parse a utility class (without variant prefix) into CSS properties
292fn parse_utility(utility: &str) -> Option<Vec<CssProperty>> {
293    // Try each parser in order
294    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
309/// Parse arbitrary value syntax like `p-[32px]`, `w-[200px]`, `text-[#ff00ff]`
310fn parse_arbitrary(utility: &str) -> Option<Vec<CssProperty>> {
311    // Match pattern: prefix-[value]
312    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)?]; // strip trailing '-'
321    if utility.as_bytes()[bracket_start - 1] != b'-' {
322        return None;
323    }
324
325    // Normalize negative prefix for categories that support negation.
326    // Only spacing, layout, and effects (z-index) accept negative arbitrary values.
327    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    // Delegate to category modules:
338    // - spacing handles negation internally, receives original prefix/value
339    // - layout and effects support negative values (top, inset, z-index)
340    // - sizing, typography, borders do NOT support negation — skip if negative
341    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}