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.entry(format!("@{}", bp)).or_default().push(property);
75            }
76            Variant::State(state) => {
77                self.variants.entry(format!(":{}", state)).or_default().push(property);
78            }
79            Variant::Dark => {
80                self.variants.entry("dark".to_string()).or_default().push(property);
81            }
82            Variant::Combined(variants) => {
83                // For combined variants, create a compound key
84                let key = variants.iter().map(|v| match v {
85                    Variant::Responsive(bp) => format!("@{}", bp),
86                    Variant::State(state) => format!(":{}", state),
87                    Variant::Dark => "dark".to_string(),
88                    _ => String::new(),
89                }).collect::<Vec<_>>().join("");
90                self.variants.entry(key).or_default().push(property);
91            }
92        }
93    }
94
95    /// Convert to a flat map for engine props
96    /// Format: { "padding": "1rem", "padding@md": "2rem", "backgroundColor:hover": "#fff" }
97    pub fn to_props(&self) -> HashMap<String, String> {
98        let mut props = HashMap::new();
99
100        for prop in &self.base {
101            props.insert(prop.property.clone(), prop.value.clone());
102        }
103
104        for (variant, properties) in &self.variants {
105            for prop in properties {
106                let key = format!("{}{}", prop.property, variant);
107                props.insert(key, prop.value.clone());
108            }
109        }
110
111        props
112    }
113}
114
115/// Parse a string of space-separated Tailwind classes
116pub fn parse_classes(input: &str) -> TailwindOutput {
117    let mut output = TailwindOutput::new();
118
119    for class in input.split_whitespace() {
120        if let Some((variant, properties)) = parse_class(class) {
121            for prop in properties {
122                output.add(variant.clone(), prop);
123            }
124        }
125    }
126
127    output
128}
129
130/// Parse a single Tailwind class
131/// Returns the variant and list of CSS properties
132pub fn parse_class(class: &str) -> Option<(Variant, Vec<CssProperty>)> {
133    // Extract variant prefix(es): "md:hover:bg-blue-500" -> (Combined[Responsive(md), State(hover)], "bg-blue-500")
134    let (variant, utility) = extract_variant(class);
135
136    // Parse the utility class
137    let properties = parse_utility(utility)?;
138
139    Some((variant, properties))
140}
141
142/// Extract variant prefix from class
143fn extract_variant(class: &str) -> (Variant, &str) {
144    let parts: Vec<&str> = class.split(':').collect();
145
146    if parts.len() == 1 {
147        return (Variant::None, class);
148    }
149
150    let utility = parts.last().unwrap();
151    let variant_parts = &parts[..parts.len() - 1];
152
153    if variant_parts.len() == 1 {
154        let v = parse_variant_name(variant_parts[0]);
155        (v, utility)
156    } else {
157        let variants: Vec<Variant> = variant_parts.iter()
158            .map(|p| parse_variant_name(p))
159            .filter(|v| !v.is_none())
160            .collect();
161
162        if variants.is_empty() {
163            (Variant::None, utility)
164        } else if variants.len() == 1 {
165            (variants.into_iter().next().unwrap(), utility)
166        } else {
167            (Variant::Combined(variants), utility)
168        }
169    }
170}
171
172fn parse_variant_name(name: &str) -> Variant {
173    match name {
174        // Responsive
175        "sm" => Variant::Responsive("sm".to_string()),
176        "md" => Variant::Responsive("md".to_string()),
177        "lg" => Variant::Responsive("lg".to_string()),
178        "xl" => Variant::Responsive("xl".to_string()),
179        "2xl" => Variant::Responsive("2xl".to_string()),
180        // State
181        "hover" => Variant::State("hover".to_string()),
182        "focus" => Variant::State("focus".to_string()),
183        "active" => Variant::State("active".to_string()),
184        "disabled" => Variant::State("disabled".to_string()),
185        "visited" => Variant::State("visited".to_string()),
186        "first" => Variant::State("first-child".to_string()),
187        "last" => Variant::State("last-child".to_string()),
188        "odd" => Variant::State("nth-child(odd)".to_string()),
189        "even" => Variant::State("nth-child(even)".to_string()),
190        // Dark mode
191        "dark" => Variant::Dark,
192        _ => Variant::None,
193    }
194}
195
196/// Parse a utility class (without variant prefix) into CSS properties
197fn parse_utility(utility: &str) -> Option<Vec<CssProperty>> {
198    // Try each parser in order
199    None
200        .or_else(|| spacing::parse(utility))
201        .or_else(|| sizing::parse(utility))
202        .or_else(|| colors::parse(utility))
203        .or_else(|| typography::parse(utility))
204        .or_else(|| layout::parse(utility))
205        .or_else(|| borders::parse(utility))
206        .or_else(|| effects::parse(utility))
207        .or_else(|| transforms::parse(utility))
208        .or_else(|| backgrounds::parse(utility))
209        .or_else(|| tables::parse(utility))
210        .or_else(|| interactivity::parse(utility))
211        .or_else(|| misc::parse(utility))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_parse_simple_class() {
220        let output = parse_classes("p-4");
221        assert_eq!(output.base.len(), 1);
222        assert_eq!(output.base[0].property, "padding");
223        assert_eq!(output.base[0].value, "1rem");
224    }
225
226    #[test]
227    fn test_parse_with_variant() {
228        let output = parse_classes("md:p-4");
229        assert!(output.base.is_empty());
230        assert!(output.variants.contains_key("@md"));
231        let md_props = output.variants.get("@md").unwrap();
232        assert_eq!(md_props[0].property, "padding");
233    }
234
235    #[test]
236    fn test_parse_multiple_classes() {
237        let output = parse_classes("p-4 m-2 text-blue-500");
238        assert_eq!(output.base.len(), 3);
239    }
240
241    #[test]
242    fn test_parse_hover_variant() {
243        let output = parse_classes("hover:bg-white");
244        assert!(output.variants.contains_key(":hover"));
245    }
246
247    #[test]
248    fn test_to_props() {
249        let output = parse_classes("p-4 md:p-8");
250        let props = output.to_props();
251        assert_eq!(props.get("padding"), Some(&"1rem".to_string()));
252        assert_eq!(props.get("padding@md"), Some(&"2rem".to_string()));
253    }
254}