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};
29use std::hash::{Hash, Hasher};
30use std::sync::Arc;
31
32use super::{
33    GrimoireCssError, component::get_css_property, source_file::SourceFile, spell_value_validator,
34};
35
36#[derive(Debug, Clone)]
37struct SpellParts {
38    area: std::ops::Range<usize>,
39    focus: std::ops::Range<usize>,
40    effects: std::ops::Range<usize>,
41    component: std::ops::Range<usize>,
42    component_target: std::ops::Range<usize>,
43}
44
45#[derive(Debug, Clone)]
46pub struct Spell {
47    pub raw_spell: String,
48    pub with_template: bool,
49    pub scroll_spells: Option<Vec<Spell>>,
50    pub span: (usize, usize),
51    pub source: Option<Arc<SourceFile>>,
52    parts: Option<SpellParts>,
53}
54
55impl PartialEq for Spell {
56    fn eq(&self, other: &Self) -> bool {
57        self.raw_spell == other.raw_spell
58            && self.with_template == other.with_template
59            && self.scroll_spells == other.scroll_spells
60    }
61}
62
63impl Eq for Spell {}
64
65impl Hash for Spell {
66    fn hash<H: Hasher>(&self, state: &mut H) {
67        self.raw_spell.hash(state);
68        self.with_template.hash(state);
69        self.scroll_spells.hash(state);
70    }
71}
72
73impl Spell {
74    pub fn area(&self) -> &str {
75        self.parts
76            .as_ref()
77            .map(|p| &self.raw_spell[p.area.clone()])
78            .unwrap_or("")
79    }
80
81    pub fn focus(&self) -> &str {
82        self.parts
83            .as_ref()
84            .map(|p| &self.raw_spell[p.focus.clone()])
85            .unwrap_or("")
86    }
87
88    pub fn effects(&self) -> &str {
89        self.parts
90            .as_ref()
91            .map(|p| &self.raw_spell[p.effects.clone()])
92            .unwrap_or("")
93    }
94
95    pub fn component(&self) -> &str {
96        self.parts
97            .as_ref()
98            .map(|p| &self.raw_spell[p.component.clone()])
99            .unwrap_or("")
100    }
101
102    pub fn component_target(&self) -> &str {
103        self.parts
104            .as_ref()
105            .map(|p| &self.raw_spell[p.component_target.clone()])
106            .unwrap_or("")
107    }
108
109    /// Example input: "md__{_>_p}hover:display=none"
110    pub fn new(
111        raw_spell: &str,
112        shared_spells: &HashSet<String>,
113        scrolls: &Option<HashMap<String, Vec<String>>>,
114        span: (usize, usize),
115        source: Option<Arc<SourceFile>>,
116    ) -> Result<Option<Self>, GrimoireCssError> {
117        let with_template = Self::check_for_template(raw_spell);
118        let raw_spell_cleaned = if with_template {
119            raw_spell
120                .strip_prefix("g!")
121                .and_then(|s| s.strip_suffix(";"))
122                .unwrap_or(raw_spell)
123        } else {
124            raw_spell
125        };
126
127        let raw_spell_split: Vec<&str> = raw_spell_cleaned
128            .split("--")
129            .filter(|s| !s.is_empty())
130            .collect();
131
132        // Template spell: keep outer spell and parse inner spells.
133        if with_template && !raw_spell_split.is_empty() {
134            let mut scroll_spells: Vec<Spell> = Vec::new();
135
136            for rs in raw_spell_split {
137                if let Some(spell) = Spell::new(rs, shared_spells, scrolls, span, source.clone())? {
138                    let mut spell = spell;
139
140                    // If a template part is a scroll invocation (e.g. complex-card=120px_red_100px),
141                    // `Spell::new` will produce a *container spell* whose `scroll_spells` are the
142                    // real property spells.
143                    //
144                    // For templates we want to flatten those property spells into the template list
145                    // so the builder can generate CSS and unify the class name to the outer template.
146                    let area = spell.area().to_string();
147                    let focus = spell.focus().to_string();
148                    let effects = spell.effects().to_string();
149
150                    if let Some(inner_scroll_spells) = spell.scroll_spells.take() {
151                        let has_prefix =
152                            !area.is_empty() || !focus.is_empty() || !effects.is_empty();
153
154                        if has_prefix {
155                            let mut prefix = String::new();
156
157                            if !area.is_empty() {
158                                prefix.push_str(&area);
159                                prefix.push_str("__");
160                            }
161
162                            if !focus.is_empty() {
163                                prefix.push('{');
164                                prefix.push_str(&focus);
165                                prefix.push('}');
166                            }
167
168                            if !effects.is_empty() {
169                                prefix.push_str(&effects);
170                                prefix.push(':');
171                            }
172
173                            for inner in inner_scroll_spells {
174                                let combined = format!("{prefix}{}", inner.raw_spell);
175                                if let Some(reparsed) = Spell::new(
176                                    &combined,
177                                    shared_spells,
178                                    scrolls,
179                                    span,
180                                    source.clone(),
181                                )? {
182                                    scroll_spells.push(reparsed);
183                                }
184                            }
185                        } else {
186                            scroll_spells.extend(inner_scroll_spells);
187                        }
188                    } else {
189                        scroll_spells.push(spell);
190                    }
191                }
192            }
193
194            return Ok(Some(Spell {
195                raw_spell: raw_spell_cleaned.to_string(),
196                with_template,
197                scroll_spells: Some(scroll_spells),
198                span,
199                source,
200                parts: None,
201            }));
202        }
203
204        let raw = raw_spell_cleaned.to_string();
205
206        // Parse into byte ranges within `raw`.
207        let mut area_range = 0..0;
208        let mut focus_range = 0..0;
209        let mut effects_range = 0..0;
210
211        let mut rest_start = 0usize;
212        if let Some(pos) = raw.find("__") {
213            area_range = 0..pos;
214            rest_start = pos + 2;
215        }
216
217        let mut after_focus_start = rest_start;
218        if rest_start < raw.len()
219            && let Some(close_rel) = raw[rest_start..].find('}')
220        {
221            let focus_part_start = if raw.as_bytes().get(rest_start) == Some(&b'{') {
222                rest_start + 1
223            } else {
224                rest_start
225            };
226            focus_range = focus_part_start..(rest_start + close_rel);
227            after_focus_start = rest_start + close_rel + 1;
228        }
229
230        let mut after_effects_start = after_focus_start;
231        if after_focus_start < raw.len()
232            && let Some(colon_rel) = raw[after_focus_start..].find(':')
233        {
234            effects_range = after_focus_start..(after_focus_start + colon_rel);
235            after_effects_start = after_focus_start + colon_rel + 1;
236        }
237
238        // component=target
239        if after_effects_start <= raw.len()
240            && let Some(eq_rel) = raw[after_effects_start..].find('=')
241        {
242            let component_range = after_effects_start..(after_effects_start + eq_rel);
243            let component_target_range = (after_effects_start + eq_rel + 1)..raw.len();
244
245            let component_target = &raw[component_target_range.clone()];
246            if let Some(err) = spell_value_validator::validate_component_target(component_target) {
247                let message = match err {
248                    spell_value_validator::SpellValueValidationError::UnexpectedClosingParen => {
249                        format!(
250                            "Invalid value '{component_target}': unexpected ')'.\n\n\
251If you intended a CSS function (e.g. calc(...)), ensure parentheses are balanced."
252                        )
253                    }
254                    spell_value_validator::SpellValueValidationError::UnclosedParen => {
255                        format!(
256                            "Invalid value '{component_target}': unclosed '('.\n\n\
257Common cause: spaces inside a class attribute split the spell into multiple tokens.\n\
258Fix: replace spaces with '_' inside the value, e.g.:\n\
259  h=calc(100vh - 50px)  ->  h=calc(100vh_-_50px)"
260                        )
261                    }
262                };
263
264                if let Some(src) = &source {
265                    return Err(GrimoireCssError::CompileError {
266                        message,
267                        span,
268                        label: "invalid spell value".to_string(),
269                        help: Some(
270                            "In HTML class attributes, spaces split classes.\n\
271Use '_' inside spell values to represent spaces."
272                                .to_string(),
273                        ),
274                        source_file: Some(src.clone()),
275                    });
276                }
277
278                return Err(GrimoireCssError::InvalidInput(message));
279            }
280
281            let parts = SpellParts {
282                area: area_range,
283                focus: focus_range,
284                effects: effects_range,
285                component: component_range.clone(),
286                component_target: component_target_range.clone(),
287            };
288
289            let mut spell = Spell {
290                raw_spell: raw,
291                with_template,
292                scroll_spells: None,
293                span,
294                source: source.clone(),
295                parts: Some(parts),
296            };
297
298            let component = spell.component();
299
300            if let Some(raw_scroll_spells) = Self::check_raw_scroll_spells(component, scrolls) {
301                spell.scroll_spells = Self::parse_scroll(
302                    component,
303                    raw_scroll_spells,
304                    spell.component_target(),
305                    shared_spells,
306                    scrolls,
307                    span,
308                    source,
309                )?;
310            } else if !component.starts_with("--") && get_css_property(component).is_none() {
311                let message = format!("Unknown component or scroll: '{component}'");
312                if let Some(src) = &source {
313                    return Err(GrimoireCssError::InvalidSpellFormat {
314                            message,
315                            span,
316                            label: "Error in this spell".to_string(),
317                            help: Some(
318                                "Check that the component name exists (built-in CSS property alias) or that the scroll is defined in config.scrolls."
319                                    .to_string(),
320                            ),
321                            source_file: Some(src.clone()),
322                        });
323                } else {
324                    return Err(GrimoireCssError::InvalidInput(message));
325                }
326            }
327
328            return Ok(Some(spell));
329        }
330
331        // scroll (no '=')
332        if after_effects_start <= raw.len()
333            && let Some(raw_scroll_spells) =
334                Self::check_raw_scroll_spells(&raw[after_effects_start..], scrolls)
335        {
336            let component_range = after_effects_start..raw.len();
337            let parts = SpellParts {
338                area: area_range,
339                focus: focus_range,
340                effects: effects_range,
341                component: component_range.clone(),
342                component_target: 0..0,
343            };
344
345            let mut spell = Spell {
346                raw_spell: raw,
347                with_template,
348                scroll_spells: None,
349                span,
350                source: source.clone(),
351                parts: Some(parts),
352            };
353
354            let component = spell.component();
355            spell.scroll_spells = Self::parse_scroll(
356                component,
357                raw_scroll_spells,
358                "",
359                shared_spells,
360                scrolls,
361                span,
362                source,
363            )?;
364
365            return Ok(Some(spell));
366        }
367
368        Ok(None) // Return None if format is invalid
369    }
370
371    fn check_for_template(raw_spell: &str) -> bool {
372        raw_spell.starts_with("g!") && raw_spell.ends_with(';')
373    }
374
375    fn check_raw_scroll_spells<'a>(
376        scroll_name: &str,
377        scrolls: &'a Option<HashMap<String, Vec<String>>>,
378    ) -> Option<&'a Vec<String>> {
379        scrolls.as_ref()?.get(scroll_name)
380    }
381
382    #[allow(clippy::too_many_arguments)]
383    fn parse_scroll(
384        scroll_name: &str,
385        raw_scroll_spells: &[String],
386        component_target: &str,
387        shared_spells: &HashSet<String>,
388        scrolls: &Option<HashMap<String, Vec<String>>>,
389        span: (usize, usize),
390        source: Option<Arc<SourceFile>>,
391    ) -> Result<Option<Vec<Spell>>, GrimoireCssError> {
392        if raw_scroll_spells.is_empty() {
393            return Ok(None);
394        }
395
396        let scroll_variables: Vec<&str> = component_target.split('_').collect();
397        let count_of_variables = if component_target.is_empty() {
398            0
399        } else {
400            scroll_variables.len()
401        };
402        let mut count_of_used_variables = 0;
403
404        let mut spells = Vec::with_capacity(raw_scroll_spells.len());
405
406        for raw_spell in raw_scroll_spells.iter() {
407            if raw_spell.contains("=$") {
408                if count_of_used_variables > scroll_variables.len().saturating_sub(1) {
409                    break;
410                }
411
412                let variabled_raw_spell = raw_spell.replace(
413                    "=$",
414                    format!("={}", scroll_variables[count_of_used_variables]).as_str(),
415                );
416
417                if let Ok(Some(spell)) = Spell::new(
418                    &variabled_raw_spell,
419                    shared_spells,
420                    scrolls,
421                    span,
422                    source.clone(),
423                ) {
424                    spells.push(spell);
425                }
426
427                count_of_used_variables += 1;
428            } else if let Ok(Some(spell)) =
429                Spell::new(raw_spell, shared_spells, scrolls, span, source.clone())
430            {
431                spells.push(spell);
432            }
433        }
434
435        if count_of_used_variables != count_of_variables {
436            let message = format!(
437                "Variable count mismatch for scroll '{scroll_name}'. Provided {count_of_variables} arguments, but scroll definition uses {count_of_used_variables}",
438            );
439
440            if let Some(src) = &source {
441                return Err(GrimoireCssError::InvalidSpellFormat {
442                    message,
443                    span,
444                    label: "Error in this spell".to_string(),
445                    help: Some(
446                        "Pass exactly N arguments separated by '_' (underscores).\n\
447Example: complex-card=arg1_arg2_arg3"
448                            .to_string(),
449                    ),
450                    source_file: Some(src.clone()),
451                });
452            } else {
453                return Err(GrimoireCssError::InvalidInput(message));
454            }
455        }
456
457        if spells.is_empty() {
458            Ok(None)
459        } else {
460            Ok(Some(spells))
461        }
462    }
463
464    pub fn generate_spells_from_classes(
465        css_classes: Vec<(String, (usize, usize))>,
466        shared_spells: &HashSet<String>,
467        scrolls: &Option<HashMap<String, Vec<String>>>,
468        source: Option<Arc<SourceFile>>,
469    ) -> Result<Vec<Spell>, GrimoireCssError> {
470        let mut spells = Vec::with_capacity(css_classes.len());
471
472        for (cs, span) in css_classes {
473            if !shared_spells.contains(&cs)
474                && let Some(spell) = Spell::new(&cs, shared_spells, scrolls, span, source.clone())?
475            {
476                spells.push(spell);
477            }
478        }
479
480        Ok(spells)
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use crate::core::source_file::SourceFile;
487    use crate::core::spell::Spell;
488    use std::collections::{HashMap, HashSet};
489    use std::sync::Arc;
490
491    #[test]
492    fn test_multiple_raw_spells_in_template() {
493        let shared_spells = HashSet::new();
494        let scrolls: Option<HashMap<String, Vec<String>>> = None;
495        let raw = "g!color=red--display=flex;";
496        let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
497            .expect("parse ok")
498            .expect("not None");
499        assert!(spell.with_template);
500        assert!(spell.scroll_spells.is_some());
501        let spells = spell.scroll_spells.as_ref().unwrap();
502        assert_eq!(spells.len(), 2);
503        assert_eq!(spells[0].component(), "color");
504        assert_eq!(spells[0].component_target(), "red");
505        assert_eq!(spells[1].component(), "display");
506        assert_eq!(spells[1].component_target(), "flex");
507    }
508
509    #[test]
510    fn test_scroll_can_be_used_inside_template_attribute() {
511        let shared_spells = HashSet::new();
512        let mut scrolls_map: HashMap<String, Vec<String>> = HashMap::new();
513        scrolls_map.insert(
514            "complex-card".to_string(),
515            vec!["h=$".to_string(), "c=$".to_string(), "w=$".to_string()],
516        );
517        let scrolls = Some(scrolls_map);
518
519        // This is the desired HTML usage pattern: use scroll invocation via g! ... ;
520        // (i.e. not inside class="...").
521        let raw = "g!complex-card=120px_red_100px;";
522        let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
523            .expect("parse ok")
524            .expect("not None");
525
526        assert!(spell.with_template);
527        let spells = spell.scroll_spells.as_ref().expect("template spells");
528        assert_eq!(spells.len(), 3);
529        assert_eq!(spells[0].component(), "h");
530        assert_eq!(spells[0].component_target(), "120px");
531        assert_eq!(spells[1].component(), "c");
532        assert_eq!(spells[1].component_target(), "red");
533        assert_eq!(spells[2].component(), "w");
534        assert_eq!(spells[2].component_target(), "100px");
535    }
536
537    #[test]
538    fn test_non_grimoire_plain_class_is_ignored() {
539        let shared_spells = HashSet::new();
540        let scrolls: Option<HashMap<String, Vec<String>>> = None;
541
542        // Plain CSS class (no '=') must not be treated as a spell.
543        let spell = Spell::new(
544            "red",
545            &shared_spells,
546            &scrolls,
547            (12, 3),
548            Some(Arc::new(SourceFile::new(
549                None,
550                "test".to_string(),
551                "<div class=\"red primary-button\"></div>".to_string(),
552            ))),
553        )
554        .expect("parsing must not fail");
555
556        assert!(spell.is_none());
557    }
558}