grimoire_css_lib/core/
spell.rs

1//! This module defines the `Spell` struct, which represents a parsed CSS spell string.
2//!
3//! A spell is a complex string format used to encode CSS properties and their associated targets,
4//! including screen size (area), pseudo-classes (effects), and specific focus areas.
5//! The `Spell` struct provides methods to parse such a string into its components and store
6//! them in a structured way.
7//!
8//! # Example
9//!
10//! A spell string might look like this:
11//!
12//! ```text
13//! "md__{_>_p}hover:display=none"
14//! ```
15//!
16//! This string is parsed into the following components:
17//!
18//! * `area`: "md" (medium screen size)
19//! * `focus`: "_>_p" (a specific selector path)
20//! * `effects`: "hover" (pseudo-class)
21//! * `component`: "display" (CSS property)
22//! * `component_target`: "none" (CSS value)
23//!
24//! The `Spell` struct is created by passing a spell string to the `Spell::new` function, which
25//! parses the string and returns a `Result` containing either the parsed `Spell` or a `GrimoireCSSError`
26//! if the string format is invalid.
27
28use std::collections::{HashMap, HashSet};
29
30use super::{GrimoireCssError, component::get_css_property};
31
32#[derive(Eq, Hash, PartialEq, Debug, Clone)]
33pub struct Spell {
34    pub raw_spell: String,
35    pub component: String,
36    pub component_target: String,
37    pub effects: String,
38    pub area: String,
39    pub focus: String,
40    pub with_template: bool,
41    pub scroll_spells: Option<Vec<Spell>>,
42}
43
44impl Spell {
45    /// Example input: "md__{_>_p}hover:display=none"
46    pub fn new(
47        raw_spell: &str,
48        shared_spells: &HashSet<String>,
49        scrolls: &Option<HashMap<String, Vec<String>>>,
50    ) -> Result<Option<Self>, GrimoireCssError> {
51        let with_template = Self::check_for_template(raw_spell);
52        let raw_spell = if with_template {
53            raw_spell
54                .strip_prefix("g!")
55                .and_then(|s| s.strip_suffix(";"))
56                .unwrap_or(raw_spell)
57        } else {
58            raw_spell
59        };
60
61        let raw_spell_split: Vec<&str> = raw_spell.split("--").filter(|s| !s.is_empty()).collect();
62
63        if with_template && !raw_spell_split.is_empty() {
64            let mut scroll_spells: Vec<Spell> = Vec::new();
65            for rs in raw_spell_split {
66                if let Some(spell) = Spell::new(rs, shared_spells, scrolls)? {
67                    scroll_spells.push(spell);
68                }
69            }
70
71            return Ok(Some(Spell {
72                raw_spell: raw_spell.to_string(),
73                component: String::new(),
74                component_target: String::new(),
75                effects: String::new(),
76                area: String::new(),
77                focus: String::new(),
78                with_template,
79                scroll_spells: Some(scroll_spells),
80            }));
81        }
82
83        // Split the input string by "__" to separate the area (screen size) and the rest
84        let (area, rest) = raw_spell.split_once("__").unwrap_or(("", raw_spell));
85
86        // Split the raw spell by "}" to get the focus and the rest
87        let (focus, rest) = rest
88            .split_once('}')
89            .map_or(("", rest), |(f, r)| (f.strip_prefix('{').unwrap_or(f), r));
90
91        // Split the rest by ":" to get the effects (pseudo-class) and the rest
92        let (effects, rest) = rest.split_once(':').unwrap_or(("", rest));
93
94        // Split the rest by "=" to separate the component (property) and component_target (value)
95        if let Some((component, component_target)) = rest.split_once("=") {
96            let mut spell = Spell {
97                raw_spell: raw_spell.to_string(),
98                component: component.to_string(),
99                component_target: component_target.to_string(),
100                effects: effects.to_string(),
101                area: area.to_string(),
102                focus: focus.to_string(),
103                with_template,
104                scroll_spells: None,
105            };
106
107            if let Some(raw_scroll_spells) =
108                Self::check_raw_scroll_spells(&spell.component, scrolls)
109            {
110                spell.scroll_spells = Self::parse_scroll(
111                    component,
112                    raw_scroll_spells,
113                    &spell.component_target,
114                    shared_spells,
115                    scrolls,
116                )?;
117            }
118
119            return Ok(Some(spell));
120        } else if let Some(raw_scroll_spells) = Self::check_raw_scroll_spells(rest, scrolls) {
121            return Ok(Some(Spell {
122                raw_spell: raw_spell.to_string(),
123                component: rest.to_string(),
124                component_target: String::new(),
125                effects: effects.to_string(),
126                area: area.to_string(),
127                focus: focus.to_string(),
128                with_template,
129                scroll_spells: Self::parse_scroll(
130                    rest,
131                    raw_scroll_spells,
132                    "",
133                    shared_spells,
134                    scrolls,
135                )?,
136            }));
137        }
138
139        Ok(None) // Return None if format is invalid
140    }
141
142    fn check_for_template(class_name: &str) -> bool {
143        class_name.starts_with("g!") && class_name.ends_with(";")
144    }
145
146    fn check_raw_scroll_spells<'a>(
147        spell_component: &'a str,
148        scrolls: &'a Option<HashMap<String, Vec<String>>>,
149    ) -> Option<&'a Vec<String>> {
150        if get_css_property(spell_component).is_some() {
151            return None;
152        }
153
154        if let Some(scrolls) = scrolls {
155            return scrolls.get(spell_component);
156        };
157
158        None
159    }
160
161    fn parse_scroll(
162        scroll_name: &str,
163        raw_scroll_spells: &[String],
164        component_target: &str,
165        shared_spells: &HashSet<String>,
166        scrolls: &Option<HashMap<String, Vec<String>>>,
167    ) -> Result<Option<Vec<Spell>>, GrimoireCssError> {
168        if raw_scroll_spells.is_empty() {
169            return Ok(None);
170        }
171
172        let scroll_variables: Vec<&str> = component_target.split('_').collect();
173        let count_of_variables = if component_target.is_empty() {
174            0
175        } else {
176            scroll_variables.len()
177        };
178        let mut count_of_used_variables = 0;
179
180        let mut spells = Vec::with_capacity(raw_scroll_spells.len());
181
182        for raw_spell in raw_scroll_spells.iter() {
183            if raw_spell.contains("=$") {
184                if count_of_used_variables > scroll_variables.len() - 1 {
185                    break;
186                }
187
188                let variabled_raw_spell = raw_spell.replace(
189                    "=$",
190                    format!("={}", scroll_variables[count_of_used_variables]).as_str(),
191                );
192
193                if let Ok(Some(spell)) = Spell::new(&variabled_raw_spell, shared_spells, scrolls) {
194                    spells.push(spell);
195                }
196
197                count_of_used_variables += 1;
198            } else if let Ok(Some(spell)) = Spell::new(raw_spell, shared_spells, scrolls) {
199                spells.push(spell);
200            }
201        }
202
203        if count_of_used_variables != count_of_variables {
204            return Err(GrimoireCssError::InvalidInput(format!(
205                "Not all variables used in scroll '{scroll_name}'. Expected {count_of_variables}, but used {count_of_used_variables}",
206            )));
207        }
208
209        if spells.is_empty() {
210            Ok(None)
211        } else {
212            Ok(Some(spells))
213        }
214    }
215
216    pub fn generate_spells_from_classes(
217        css_classes: Vec<String>,
218        shared_spells: &HashSet<String>,
219        scrolls: &Option<HashMap<String, Vec<String>>>,
220    ) -> Result<Vec<Spell>, GrimoireCssError> {
221        let mut spells = Vec::with_capacity(css_classes.len());
222
223        for cs in css_classes {
224            if !shared_spells.contains(&cs) {
225                if let Some(spell) = Spell::new(&cs, shared_spells, scrolls)? {
226                    spells.push(spell);
227                }
228            }
229        }
230
231        Ok(spells)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use crate::core::spell::Spell;
238    use std::collections::{HashMap, HashSet};
239
240    #[test]
241    fn test_multiple_raw_spells_in_template() {
242        let shared_spells = HashSet::new();
243        let scrolls: Option<HashMap<String, Vec<String>>> = None;
244        let raw = "g!color=red--display=flex;";
245        let spell = Spell::new(raw, &shared_spells, &scrolls)
246            .expect("parse ok")
247            .expect("not None");
248        assert!(spell.with_template);
249        assert!(spell.scroll_spells.is_some());
250        let spells = spell.scroll_spells.as_ref().unwrap();
251        assert_eq!(spells.len(), 2);
252        assert_eq!(spells[0].component, "color");
253        assert_eq!(spells[0].component_target, "red");
254        assert_eq!(spells[1].component, "display");
255        assert_eq!(spells[1].component_target, "flex");
256    }
257}