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    output
141}
142
143/// Parse a single Tailwind class
144/// Returns the variant and list of CSS properties
145pub fn parse_class(class: &str) -> Option<(Variant, Vec<CssProperty>)> {
146    // Extract variant prefix(es): "md:hover:bg-blue-500" -> (Combined[Responsive(md), State(hover)], "bg-blue-500")
147    let (variant, utility) = extract_variant(class);
148
149    // Parse the utility class
150    let properties = parse_utility(utility)?;
151
152    Some((variant, properties))
153}
154
155/// Extract variant prefix from class.
156/// Only splits on ':' at bracket depth 0, so that arbitrary values
157/// like `bg-[url(data:image/svg+xml;...)]` are kept intact.
158fn extract_variant(class: &str) -> (Variant, &str) {
159    // Split on ':' only when not inside brackets
160    let mut parts: Vec<&str> = Vec::new();
161    let mut start = 0;
162    let mut bracket_depth: usize = 0;
163    for (i, ch) in class.char_indices() {
164        match ch {
165            '[' => bracket_depth += 1,
166            ']' => bracket_depth = bracket_depth.saturating_sub(1),
167            ':' if bracket_depth == 0 => {
168                parts.push(&class[start..i]);
169                start = i + 1;
170            }
171            _ => {}
172        }
173    }
174    parts.push(&class[start..]);
175
176    if parts.len() == 1 {
177        return (Variant::None, class);
178    }
179
180    let utility = parts.last().unwrap();
181    let variant_parts = &parts[..parts.len() - 1];
182
183    if variant_parts.len() == 1 {
184        let v = parse_variant_name(variant_parts[0]);
185        (v, utility)
186    } else {
187        let variants: Vec<Variant> = variant_parts
188            .iter()
189            .map(|p| parse_variant_name(p))
190            .filter(|v| !v.is_none())
191            .collect();
192
193        if variants.is_empty() {
194            (Variant::None, utility)
195        } else if variants.len() == 1 {
196            (variants.into_iter().next().unwrap(), utility)
197        } else {
198            (Variant::Combined(variants), utility)
199        }
200    }
201}
202
203fn parse_variant_name(name: &str) -> Variant {
204    match name {
205        // Responsive
206        "sm" => Variant::Responsive("sm".to_string()),
207        "md" => Variant::Responsive("md".to_string()),
208        "lg" => Variant::Responsive("lg".to_string()),
209        "xl" => Variant::Responsive("xl".to_string()),
210        "2xl" => Variant::Responsive("2xl".to_string()),
211        // State
212        "hover" => Variant::State("hover".to_string()),
213        "focus" => Variant::State("focus".to_string()),
214        "focus-within" => Variant::State("focus-within".to_string()),
215        "focus-visible" => Variant::State("focus-visible".to_string()),
216        "active" => Variant::State("active".to_string()),
217        "disabled" => Variant::State("disabled".to_string()),
218        "visited" => Variant::State("visited".to_string()),
219        "checked" => Variant::State("checked".to_string()),
220        "required" => Variant::State("required".to_string()),
221        "placeholder" => Variant::State(":placeholder".to_string()),
222        "first" => Variant::State("first-child".to_string()),
223        "last" => Variant::State("last-child".to_string()),
224        "only" => Variant::State("only-child".to_string()),
225        "odd" => Variant::State("nth-child(odd)".to_string()),
226        "even" => Variant::State("nth-child(even)".to_string()),
227        "first-of-type" => Variant::State("first-of-type".to_string()),
228        "last-of-type" => Variant::State("last-of-type".to_string()),
229        "empty" => Variant::State("empty".to_string()),
230        // Group variants
231        "group-hover" => Variant::State("group-hover".to_string()),
232        "group-focus" => Variant::State("group-focus".to_string()),
233        // Dark mode
234        "dark" => Variant::Dark,
235        _ => Variant::None,
236    }
237}
238
239/// Parse a utility class (without variant prefix) into CSS properties
240fn parse_utility(utility: &str) -> Option<Vec<CssProperty>> {
241    // Try each parser in order
242    None.or_else(|| spacing::parse(utility))
243        .or_else(|| sizing::parse(utility))
244        .or_else(|| colors::parse(utility))
245        .or_else(|| typography::parse(utility))
246        .or_else(|| layout::parse(utility))
247        .or_else(|| borders::parse(utility))
248        .or_else(|| effects::parse(utility))
249        .or_else(|| transforms::parse(utility))
250        .or_else(|| backgrounds::parse(utility))
251        .or_else(|| tables::parse(utility))
252        .or_else(|| interactivity::parse(utility))
253        .or_else(|| misc::parse(utility))
254        .or_else(|| parse_arbitrary(utility))
255}
256
257/// Parse arbitrary value syntax like `p-[32px]`, `w-[200px]`, `text-[#ff00ff]`
258fn parse_arbitrary(utility: &str) -> Option<Vec<CssProperty>> {
259    // Match pattern: prefix-[value]
260    let bracket_start = utility.find('[')?;
261    if !utility.ends_with(']') {
262        return None;
263    }
264    let value = &utility[bracket_start + 1..utility.len() - 1];
265    if value.is_empty() {
266        return None;
267    }
268    let prefix = &utility[..bracket_start.checked_sub(1)?]; // strip trailing '-'
269    if utility.as_bytes()[bracket_start - 1] != b'-' {
270        return None;
271    }
272
273    // Normalize negative prefix for categories that support negation.
274    // Only spacing, layout, and effects (z-index) accept negative arbitrary values.
275    let is_negative = prefix.starts_with('-');
276    let bare_prefix = if is_negative { &prefix[1..] } else { prefix };
277    let negated_value;
278    let neg_val = if is_negative {
279        negated_value = format!("-{}", value);
280        negated_value.as_str()
281    } else {
282        value
283    };
284
285    // Delegate to category modules:
286    // - spacing handles negation internally, receives original prefix/value
287    // - layout and effects support negative values (top, inset, z-index)
288    // - sizing, typography, borders do NOT support negation — skip if negative
289    None.or_else(|| spacing::parse_arbitrary(prefix, value))
290        .or_else(|| if is_negative { None } else { sizing::parse_arbitrary(prefix, value) })
291        .or_else(|| if is_negative { None } else { typography::parse_arbitrary(prefix, value) })
292        .or_else(|| layout::parse_arbitrary(bare_prefix, neg_val))
293        .or_else(|| if is_negative { None } else { borders::parse_arbitrary(prefix, value) })
294        .or_else(|| effects::parse_arbitrary(bare_prefix, neg_val))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_parse_simple_class() {
303        let output = parse_classes("p-4");
304        assert_eq!(output.base.len(), 1);
305        assert_eq!(output.base[0].property, "padding");
306        assert_eq!(output.base[0].value, "1rem");
307    }
308
309    #[test]
310    fn test_parse_with_variant() {
311        let output = parse_classes("md:p-4");
312        assert!(output.base.is_empty());
313        assert!(output.variants.contains_key("@md"));
314        let md_props = output.variants.get("@md").unwrap();
315        assert_eq!(md_props[0].property, "padding");
316    }
317
318    #[test]
319    fn test_parse_multiple_classes() {
320        let output = parse_classes("p-4 m-2 text-blue-500");
321        assert_eq!(output.base.len(), 3);
322    }
323
324    #[test]
325    fn test_parse_hover_variant() {
326        let output = parse_classes("hover:bg-white");
327        assert!(output.variants.contains_key(":hover"));
328    }
329
330    #[test]
331    fn test_to_props() {
332        let output = parse_classes("p-4 md:p-8");
333        let props = output.to_props();
334        assert_eq!(props.get("padding"), Some(&"1rem".to_string()));
335        assert_eq!(props.get("padding@md"), Some(&"2rem".to_string()));
336    }
337}