Skip to main content

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, ScrollDefinition, component::get_css_property, source_file::SourceFile,
34    spell_value_validator,
35};
36
37#[derive(Debug, Clone)]
38struct SpellParts {
39    area: std::ops::Range<usize>,
40    focus: std::ops::Range<usize>,
41    effects: std::ops::Range<usize>,
42    component: std::ops::Range<usize>,
43    component_target: std::ops::Range<usize>,
44}
45
46#[derive(Debug, Clone)]
47pub struct Spell {
48    pub raw_spell: String,
49    pub with_template: bool,
50    pub scroll_spells: Option<Vec<Spell>>,
51    pub span: (usize, usize),
52    pub source: Option<Arc<SourceFile>>,
53    parts: Option<SpellParts>,
54}
55
56impl PartialEq for Spell {
57    fn eq(&self, other: &Self) -> bool {
58        self.raw_spell == other.raw_spell
59            && self.with_template == other.with_template
60            && self.scroll_spells == other.scroll_spells
61    }
62}
63
64impl Eq for Spell {}
65
66impl Hash for Spell {
67    fn hash<H: Hasher>(&self, state: &mut H) {
68        self.raw_spell.hash(state);
69        self.with_template.hash(state);
70        self.scroll_spells.hash(state);
71    }
72}
73
74impl Spell {
75    pub fn area(&self) -> &str {
76        self.parts
77            .as_ref()
78            .map(|p| &self.raw_spell[p.area.clone()])
79            .unwrap_or("")
80    }
81
82    pub fn focus(&self) -> &str {
83        self.parts
84            .as_ref()
85            .map(|p| &self.raw_spell[p.focus.clone()])
86            .unwrap_or("")
87    }
88
89    pub fn effects(&self) -> &str {
90        self.parts
91            .as_ref()
92            .map(|p| &self.raw_spell[p.effects.clone()])
93            .unwrap_or("")
94    }
95
96    pub fn component(&self) -> &str {
97        self.parts
98            .as_ref()
99            .map(|p| &self.raw_spell[p.component.clone()])
100            .unwrap_or("")
101    }
102
103    pub fn component_target(&self) -> &str {
104        self.parts
105            .as_ref()
106            .map(|p| &self.raw_spell[p.component_target.clone()])
107            .unwrap_or("")
108    }
109
110    /// Example input: "md__{_>_p}hover:display=none"
111    pub fn new(
112        raw_spell: &str,
113        shared_spells: &HashSet<String>,
114        scrolls: &Option<HashMap<String, ScrollDefinition>>,
115        span: (usize, usize),
116        source: Option<Arc<SourceFile>>,
117    ) -> Result<Option<Self>, GrimoireCssError> {
118        let mut expansion_stack: Vec<String> = Vec::new();
119        Self::new_impl(
120            raw_spell,
121            shared_spells,
122            scrolls,
123            span,
124            source,
125            &mut expansion_stack,
126        )
127    }
128
129    fn new_impl(
130        raw_spell: &str,
131        shared_spells: &HashSet<String>,
132        scrolls: &Option<HashMap<String, ScrollDefinition>>,
133        span: (usize, usize),
134        source: Option<Arc<SourceFile>>,
135        expansion_stack: &mut Vec<String>,
136    ) -> Result<Option<Self>, GrimoireCssError> {
137        let with_template = Self::check_for_template(raw_spell);
138        let raw_spell_cleaned = if with_template {
139            raw_spell
140                .strip_prefix("g!")
141                .and_then(|s| s.strip_suffix(";"))
142                .unwrap_or(raw_spell)
143        } else {
144            raw_spell
145        };
146
147        let raw_spell_split: Vec<&str> = raw_spell_cleaned
148            .split("--")
149            .filter(|s| !s.is_empty())
150            .collect();
151
152        // Template spell: keep outer spell and parse inner spells.
153        // Note: templates can either be a list of property spells (e.g. g!color=red--display=flex;)
154        // or a scroll invocation with args (e.g. g!box=10px_20px;).
155        if with_template && !raw_spell_split.is_empty() {
156            let mut scroll_spells: Vec<Spell> = Vec::new();
157
158            for rs in &raw_spell_split {
159                if let Some(spell) = Spell::new_impl(
160                    rs,
161                    shared_spells,
162                    scrolls,
163                    span,
164                    source.clone(),
165                    expansion_stack,
166                )? {
167                    let mut spell = spell;
168
169                    // If a template part is a scroll invocation (e.g. complex-card=120px_red_100px),
170                    // `Spell::new` will produce a *container spell* whose `scroll_spells` are the
171                    // real property spells.
172                    //
173                    // For templates we want to flatten those property spells into the template list
174                    // so the builder can generate CSS and unify the class name to the outer template.
175                    let area = spell.area().to_string();
176                    let focus = spell.focus().to_string();
177                    let effects = spell.effects().to_string();
178
179                    if let Some(inner_scroll_spells) = spell.scroll_spells.take() {
180                        let has_prefix =
181                            !area.is_empty() || !focus.is_empty() || !effects.is_empty();
182
183                        if has_prefix {
184                            let mut prefix = String::new();
185
186                            if !area.is_empty() {
187                                prefix.push_str(&area);
188                                prefix.push_str("__");
189                            }
190
191                            if !focus.is_empty() {
192                                prefix.push('{');
193                                prefix.push_str(&focus);
194                                prefix.push('}');
195                            }
196
197                            if !effects.is_empty() {
198                                prefix.push_str(&effects);
199                                prefix.push(':');
200                            }
201
202                            for inner in inner_scroll_spells {
203                                let combined = format!("{prefix}{}", inner.raw_spell);
204                                if let Some(reparsed) = Spell::new_impl(
205                                    &combined,
206                                    shared_spells,
207                                    scrolls,
208                                    span,
209                                    source.clone(),
210                                    expansion_stack,
211                                )? {
212                                    scroll_spells.push(reparsed);
213                                }
214                            }
215                        } else {
216                            scroll_spells.extend(inner_scroll_spells);
217                        }
218                    } else {
219                        scroll_spells.push(spell);
220                    }
221                }
222            }
223
224            return Ok(Some(Spell {
225                raw_spell: raw_spell_cleaned.to_string(),
226                with_template,
227                scroll_spells: Some(scroll_spells),
228                span,
229                source,
230                parts: None,
231            }));
232        }
233
234        let raw = raw_spell_cleaned.to_string();
235
236        // Parse into byte ranges within `raw`.
237        let mut area_range = 0..0;
238        let mut focus_range = 0..0;
239        let mut effects_range = 0..0;
240
241        let mut rest_start = 0usize;
242        if let Some(pos) = raw.find("__") {
243            area_range = 0..pos;
244            rest_start = pos + 2;
245        }
246
247        let mut after_focus_start = rest_start;
248        if rest_start < raw.len()
249            && let Some(close_rel) = raw[rest_start..].find('}')
250        {
251            let focus_part_start = if raw.as_bytes().get(rest_start) == Some(&b'{') {
252                rest_start + 1
253            } else {
254                rest_start
255            };
256            focus_range = focus_part_start..(rest_start + close_rel);
257            after_focus_start = rest_start + close_rel + 1;
258        }
259
260        let mut after_effects_start = after_focus_start;
261        if after_focus_start < raw.len()
262            && let Some(colon_rel) = raw[after_focus_start..].find(':')
263        {
264            effects_range = after_focus_start..(after_focus_start + colon_rel);
265            after_effects_start = after_focus_start + colon_rel + 1;
266        }
267
268        // component=target
269        if after_effects_start <= raw.len()
270            && let Some(eq_rel) = raw[after_effects_start..].find('=')
271        {
272            let component_range = after_effects_start..(after_effects_start + eq_rel);
273            let component_target_range = (after_effects_start + eq_rel + 1)..raw.len();
274
275            let component_target = &raw[component_target_range.clone()];
276            if let Some(err) = spell_value_validator::validate_component_target(component_target) {
277                let message = match err {
278                    spell_value_validator::SpellValueValidationError::UnexpectedClosingParen => {
279                        format!(
280                            "Invalid value '{component_target}': unexpected ')'.\n\n\
281If you intended a CSS function (e.g. calc(...)), ensure parentheses are balanced."
282                        )
283                    }
284                    spell_value_validator::SpellValueValidationError::UnclosedParen => {
285                        format!(
286                            "Invalid value '{component_target}': unclosed '('.\n\n\
287Common cause: spaces inside a class attribute split the spell into multiple tokens.\n\
288Fix: replace spaces with '_' inside the value, e.g.:\n\
289  h=calc(100vh - 50px)  ->  h=calc(100vh_-_50px)"
290                        )
291                    }
292                };
293
294                if let Some(src) = &source {
295                    return Err(GrimoireCssError::CompileError {
296                        message,
297                        span,
298                        label: "invalid spell value".to_string(),
299                        help: Some(
300                            "In HTML class attributes, spaces split classes.\n\
301Use '_' inside spell values to represent spaces."
302                                .to_string(),
303                        ),
304                        source_file: Some(src.clone()),
305                    });
306                }
307
308                return Err(GrimoireCssError::InvalidInput(message));
309            }
310
311            let parts = SpellParts {
312                area: area_range,
313                focus: focus_range,
314                effects: effects_range,
315                component: component_range.clone(),
316                component_target: component_target_range.clone(),
317            };
318
319            let mut spell = Spell {
320                raw_spell: raw,
321                with_template,
322                scroll_spells: None,
323                span,
324                source: source.clone(),
325                parts: Some(parts),
326            };
327
328            let component = spell.component();
329
330            if let Some(scroll_def) = Self::check_raw_scroll_spells(component, scrolls) {
331                spell.scroll_spells = Self::parse_scroll(
332                    component,
333                    scroll_def,
334                    spell.component_target(),
335                    shared_spells,
336                    scrolls,
337                    span,
338                    source,
339                    expansion_stack,
340                )?;
341            } else if !component.starts_with("--") && get_css_property(component).is_none() {
342                let message = format!("Unknown component or scroll: '{component}'");
343                if let Some(src) = &source {
344                    return Err(GrimoireCssError::InvalidSpellFormat {
345                            message,
346                            span,
347                            label: "Error in this spell".to_string(),
348                            help: Some(
349                                "Check that the component name exists (built-in CSS property alias) or that the scroll is defined in config.scrolls."
350                                    .to_string(),
351                            ),
352                            source_file: Some(src.clone()),
353                        });
354                } else {
355                    return Err(GrimoireCssError::InvalidInput(message));
356                }
357            }
358
359            return Ok(Some(spell));
360        }
361
362        // scroll (no '=')
363        if after_effects_start <= raw.len()
364            && let Some(scroll_def) =
365                Self::check_raw_scroll_spells(&raw[after_effects_start..], scrolls)
366        {
367            let component_range = after_effects_start..raw.len();
368            let parts = SpellParts {
369                area: area_range,
370                focus: focus_range,
371                effects: effects_range,
372                component: component_range.clone(),
373                component_target: 0..0,
374            };
375
376            let mut spell = Spell {
377                raw_spell: raw,
378                with_template,
379                scroll_spells: None,
380                span,
381                source: source.clone(),
382                parts: Some(parts),
383            };
384
385            let component = spell.component();
386            spell.scroll_spells = Self::parse_scroll(
387                component,
388                scroll_def,
389                "",
390                shared_spells,
391                scrolls,
392                span,
393                source,
394                expansion_stack,
395            )?;
396
397            return Ok(Some(spell));
398        }
399
400        Ok(None) // Return None if format is invalid
401    }
402
403    fn check_for_template(raw_spell: &str) -> bool {
404        raw_spell.starts_with("g!") && raw_spell.ends_with(';')
405    }
406
407    fn check_raw_scroll_spells<'a>(
408        scroll_name: &str,
409        scrolls: &'a Option<HashMap<String, ScrollDefinition>>,
410    ) -> Option<&'a ScrollDefinition> {
411        scrolls.as_ref()?.get(scroll_name)
412    }
413
414    #[allow(clippy::too_many_arguments)]
415    fn parse_scroll(
416        scroll_name: &str,
417        scroll_def: &ScrollDefinition,
418        component_target: &str,
419        shared_spells: &HashSet<String>,
420        scrolls: &Option<HashMap<String, ScrollDefinition>>,
421        span: (usize, usize),
422        source: Option<Arc<SourceFile>>,
423        expansion_stack: &mut Vec<String>,
424    ) -> Result<Option<Vec<Spell>>, GrimoireCssError> {
425        let key = if component_target.is_empty() {
426            scroll_name.to_string()
427        } else {
428            format!("{scroll_name}={component_target}")
429        };
430
431        if let Some(start) = expansion_stack.iter().position(|k| k == &key) {
432            let mut cycle = expansion_stack[start..].to_vec();
433            cycle.push(key.clone());
434            let message = format!("Cycle detected in scroll expansion: {}", cycle.join(" -> "));
435
436            if let Some(src) = &source {
437                return Err(GrimoireCssError::InvalidSpellFormat {
438                    message,
439                    span,
440                    label: "Error in this spell".to_string(),
441                    help: Some(
442                        "Fix the scroll definitions so they don't reference each other in a cycle."
443                            .to_string(),
444                    ),
445                    source_file: Some(src.clone()),
446                });
447            }
448
449            return Err(GrimoireCssError::InvalidInput(message));
450        }
451
452        expansion_stack.push(key);
453        let result: Result<Option<Vec<Spell>>, GrimoireCssError> = (|| {
454            let scroll_variables: Vec<&str> = if component_target.is_empty() {
455                Vec::new()
456            } else {
457                component_target.split('_').collect()
458            };
459            let count_of_variables = scroll_variables.len();
460
461            // Select overload by argument count if present.
462            let overload_key = count_of_variables.to_string();
463            let overload_spells_opt = scroll_def
464                .spells_by_args
465                .as_ref()
466                .and_then(|m| m.get(&overload_key));
467
468            // If spellsByArgs exists but no matching arity is defined:
469            // - for 0 args: treat it as "no overload" and compile base spells only
470            // - for N>0: keep strictness (likely a user mistake / unsupported arity)
471            if count_of_variables > 0
472                && let Some(map) = &scroll_def.spells_by_args
473                && !map.is_empty()
474                && overload_spells_opt.is_none()
475            {
476                let mut available: Vec<_> = map.keys().cloned().collect();
477                available.sort();
478                let message = format!(
479                    "No overload for scroll '{scroll_name}' with {count_of_variables} arguments"
480                );
481
482                if let Some(src) = &source {
483                    return Err(GrimoireCssError::InvalidSpellFormat {
484                        message,
485                        span,
486                        label: "Error in this spell".to_string(),
487                        help: Some(format!(
488                            "Define spellsByArgs['{count_of_variables}'] for this scroll, or pass one of the supported arities: {}",
489                            available.join(", ")
490                        )),
491                        source_file: Some(src.clone()),
492                    });
493                } else {
494                    return Err(GrimoireCssError::InvalidInput(message));
495                }
496            }
497
498            // Build selected spells: base + overload.
499            let mut selected: Vec<&String> = scroll_def.spells.iter().collect();
500            if let Some(overload_spells) = overload_spells_opt {
501                selected.extend(overload_spells.iter());
502            }
503
504            if selected.is_empty() {
505                return Ok(None);
506            }
507
508            // Keep strictness: the provided arg count must match what the selected spells require.
509            let expected_arity = Self::infer_expected_scroll_arity(&selected);
510            if expected_arity != count_of_variables {
511                let message = format!(
512                    "Variable count mismatch for scroll '{scroll_name}'. Provided {count_of_variables} arguments, but scroll definition expects {expected_arity}",
513                );
514
515                if let Some(src) = &source {
516                    return Err(GrimoireCssError::InvalidSpellFormat {
517                        message,
518                        span,
519                        label: "Error in this spell".to_string(),
520                        help: Some(
521                            "Pass exactly N arguments separated by '_' (underscores).\n\
522Example: complex-card=arg1_arg2_arg3"
523                                .to_string(),
524                        ),
525                        source_file: Some(src.clone()),
526                    });
527                } else {
528                    return Err(GrimoireCssError::InvalidInput(message));
529                }
530            }
531
532            let mut sequential_index: usize = 0;
533            let mut spells = Vec::with_capacity(selected.len());
534
535            for raw_spell in selected {
536                if let Some((placeholder_pos, digits_len)) = Self::find_placeholder(raw_spell) {
537                    let explicit_index = if digits_len == 0 {
538                        None
539                    } else {
540                        raw_spell[placeholder_pos + 2..placeholder_pos + 2 + digits_len]
541                            .parse::<usize>()
542                            .ok()
543                    };
544
545                    let arg_index_0_based = if let Some(one_based) = explicit_index {
546                        if one_based == 0 {
547                            let message = format!(
548                                "Invalid placeholder '$0' in scroll '{scroll_name}' (arguments are 1-based: $1, $2, ...)"
549                            );
550                            if let Some(src) = &source {
551                                return Err(GrimoireCssError::InvalidSpellFormat {
552                                    message,
553                                    span,
554                                    label: "Error in this spell".to_string(),
555                                    help: Some("Use $1 for the first argument.".to_string()),
556                                    source_file: Some(src.clone()),
557                                });
558                            }
559                            return Err(GrimoireCssError::InvalidInput(message));
560                        }
561                        one_based - 1
562                    } else {
563                        let idx = sequential_index;
564                        sequential_index += 1;
565                        idx
566                    };
567
568                    if arg_index_0_based >= scroll_variables.len() {
569                        let message = format!(
570                            "Scroll '{scroll_name}' references argument {} but only {count_of_variables} were provided",
571                            arg_index_0_based + 1
572                        );
573                        if let Some(src) = &source {
574                            return Err(GrimoireCssError::InvalidSpellFormat {
575                                message,
576                                span,
577                                label: "Error in this spell".to_string(),
578                                help: Some(
579                                    "Pass enough arguments separated by '_' (underscores), or fix the scroll definition placeholders."
580                                        .to_string(),
581                                ),
582                                source_file: Some(src.clone()),
583                            });
584                        }
585                        return Err(GrimoireCssError::InvalidInput(message));
586                    }
587
588                    let replacement = scroll_variables[arg_index_0_based];
589                    let mut variabled_raw_spell = String::new();
590                    variabled_raw_spell.push_str(&raw_spell[..placeholder_pos]);
591                    variabled_raw_spell.push('=');
592                    variabled_raw_spell.push_str(replacement);
593                    variabled_raw_spell.push_str(&raw_spell[placeholder_pos + 2 + digits_len..]);
594
595                    if let Some(spell) = Spell::new_impl(
596                        &variabled_raw_spell,
597                        shared_spells,
598                        scrolls,
599                        span,
600                        source.clone(),
601                        expansion_stack,
602                    )? {
603                        Self::push_flattened_spell(
604                            spell,
605                            &mut spells,
606                            shared_spells,
607                            scrolls,
608                            span,
609                            source.clone(),
610                            expansion_stack,
611                        )?;
612                    }
613                } else if let Some(spell) = Spell::new_impl(
614                    raw_spell,
615                    shared_spells,
616                    scrolls,
617                    span,
618                    source.clone(),
619                    expansion_stack,
620                )? {
621                    Self::push_flattened_spell(
622                        spell,
623                        &mut spells,
624                        shared_spells,
625                        scrolls,
626                        span,
627                        source.clone(),
628                        expansion_stack,
629                    )?;
630                }
631            }
632
633            if spells.is_empty() {
634                Ok(None)
635            } else {
636                Ok(Some(spells))
637            }
638        })();
639
640        // Pop our key before returning.
641        expansion_stack.pop();
642        result
643    }
644
645    #[allow(clippy::too_many_arguments)]
646    fn push_flattened_spell(
647        mut spell: Spell,
648        out: &mut Vec<Spell>,
649        shared_spells: &HashSet<String>,
650        scrolls: &Option<HashMap<String, ScrollDefinition>>,
651        span: (usize, usize),
652        source: Option<Arc<SourceFile>>,
653        expansion_stack: &mut Vec<String>,
654    ) -> Result<(), GrimoireCssError> {
655        let area = spell.area().to_string();
656        let focus = spell.focus().to_string();
657        let effects = spell.effects().to_string();
658
659        if let Some(inner_scroll_spells) = spell.scroll_spells.take() {
660            let has_prefix = !area.is_empty() || !focus.is_empty() || !effects.is_empty();
661
662            if has_prefix {
663                let mut prefix = String::new();
664
665                if !area.is_empty() {
666                    prefix.push_str(&area);
667                    prefix.push_str("__");
668                }
669
670                if !focus.is_empty() {
671                    prefix.push('{');
672                    prefix.push_str(&focus);
673                    prefix.push('}');
674                }
675
676                if !effects.is_empty() {
677                    prefix.push_str(&effects);
678                    prefix.push(':');
679                }
680
681                for inner in inner_scroll_spells {
682                    let combined = format!("{prefix}{}", inner.raw_spell);
683                    if let Some(reparsed) = Spell::new_impl(
684                        &combined,
685                        shared_spells,
686                        scrolls,
687                        span,
688                        source.clone(),
689                        expansion_stack,
690                    )? {
691                        Self::push_flattened_spell(
692                            reparsed,
693                            out,
694                            shared_spells,
695                            scrolls,
696                            span,
697                            source.clone(),
698                            expansion_stack,
699                        )?;
700                    }
701                }
702            } else {
703                for inner in inner_scroll_spells {
704                    Self::push_flattened_spell(
705                        inner,
706                        out,
707                        shared_spells,
708                        scrolls,
709                        span,
710                        source.clone(),
711                        expansion_stack,
712                    )?;
713                }
714            }
715
716            return Ok(());
717        }
718
719        out.push(spell);
720        Ok(())
721    }
722
723    /// Finds the first placeholder occurrence in a spell value.
724    ///
725    /// Supported patterns are `=$` (sequential) and `=$N` (explicit 1-based index).
726    /// Returns `(pos_of_"=$"_start, digits_len_after_$)`.
727    fn find_placeholder(raw_spell: &str) -> Option<(usize, usize)> {
728        let pos = raw_spell.find("=$")?;
729        let mut digits_len = 0usize;
730        for ch in raw_spell[pos + 2..].chars() {
731            if ch.is_ascii_digit() {
732                digits_len += 1;
733            } else {
734                break;
735            }
736        }
737        Some((pos, digits_len))
738    }
739
740    /// Infer expected arity from placeholders in the selected scroll spells.
741    ///
742    /// - Each `=$` consumes one sequential argument.
743    /// - Each `=$N` requires at least `N` arguments.
744    fn infer_expected_scroll_arity(spells: &[&String]) -> usize {
745        let mut sequential = 0usize;
746        let mut max_explicit = 0usize;
747
748        for s in spells {
749            if let Some((pos, digits_len)) = Self::find_placeholder(s) {
750                if digits_len == 0 {
751                    sequential += 1;
752                } else if let Ok(n) = s[pos + 2..pos + 2 + digits_len].parse::<usize>() {
753                    max_explicit = max_explicit.max(n);
754                }
755            }
756        }
757
758        sequential.max(max_explicit)
759    }
760
761    pub fn generate_spells_from_classes(
762        css_classes: Vec<(String, (usize, usize))>,
763        shared_spells: &HashSet<String>,
764        scrolls: &Option<HashMap<String, ScrollDefinition>>,
765        source: Option<Arc<SourceFile>>,
766    ) -> Result<Vec<Spell>, GrimoireCssError> {
767        let mut spells = Vec::with_capacity(css_classes.len());
768
769        for (cs, span) in css_classes {
770            if !shared_spells.contains(&cs)
771                && let Some(spell) = Spell::new(&cs, shared_spells, scrolls, span, source.clone())?
772            {
773                spells.push(spell);
774            }
775        }
776
777        Ok(spells)
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use crate::core::ScrollDefinition;
784    use crate::core::source_file::SourceFile;
785    use crate::core::spell::Spell;
786    use std::collections::{HashMap, HashSet};
787    use std::sync::Arc;
788
789    #[test]
790    fn test_multiple_raw_spells_in_template() {
791        let shared_spells = HashSet::new();
792        let scrolls: Option<HashMap<String, ScrollDefinition>> = None;
793        let raw = "g!color=red--display=flex;";
794        let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
795            .expect("parse ok")
796            .expect("not None");
797        assert!(spell.with_template);
798        assert!(spell.scroll_spells.is_some());
799        let spells = spell.scroll_spells.as_ref().unwrap();
800        assert_eq!(spells.len(), 2);
801        assert_eq!(spells[0].component(), "color");
802        assert_eq!(spells[0].component_target(), "red");
803        assert_eq!(spells[1].component(), "display");
804        assert_eq!(spells[1].component_target(), "flex");
805    }
806
807    #[test]
808    fn test_scroll_can_be_used_inside_template_attribute() {
809        let shared_spells = HashSet::new();
810        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
811        scrolls_map.insert(
812            "complex-card".to_string(),
813            ScrollDefinition {
814                spells: vec!["h=$".to_string(), "c=$".to_string(), "w=$".to_string()],
815                spells_by_args: None,
816            },
817        );
818        let scrolls = Some(scrolls_map);
819
820        // This is the desired HTML usage pattern: use scroll invocation via g! ... ;
821        // (i.e. not inside class="...").
822        let raw = "g!complex-card=120px_red_100px;";
823        let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
824            .expect("parse ok")
825            .expect("not None");
826
827        assert!(spell.with_template);
828        let spells = spell.scroll_spells.as_ref().expect("template spells");
829        assert_eq!(spells.len(), 3);
830        assert_eq!(spells[0].component(), "h");
831        assert_eq!(spells[0].component_target(), "120px");
832        assert_eq!(spells[1].component(), "c");
833        assert_eq!(spells[1].component_target(), "red");
834        assert_eq!(spells[2].component(), "w");
835        assert_eq!(spells[2].component_target(), "100px");
836    }
837
838    #[test]
839    fn test_non_grimoire_plain_class_is_ignored() {
840        let shared_spells = HashSet::new();
841        let scrolls: Option<HashMap<String, ScrollDefinition>> = None;
842
843        // Plain CSS class (no '=') must not be treated as a spell.
844        let spell = Spell::new(
845            "red",
846            &shared_spells,
847            &scrolls,
848            (12, 3),
849            Some(Arc::new(SourceFile::new(
850                None,
851                "test".to_string(),
852                "<div class=\"red primary-button\"></div>".to_string(),
853            ))),
854        )
855        .expect("parsing must not fail");
856
857        assert!(spell.is_none());
858    }
859
860    #[test]
861    fn test_scroll_spells_by_args_overload_and_explicit_indices() {
862        let shared_spells = HashSet::new();
863
864        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
865        scrolls_map.insert(
866            "box".to_string(),
867            ScrollDefinition {
868                spells: vec![
869                    "height=var(--box-height)".to_string(),
870                    "width=var(--box-width)".to_string(),
871                ],
872                spells_by_args: Some(HashMap::from([
873                    (
874                        "0".to_string(),
875                        vec![
876                            "padding-top=100%".to_string(),
877                            "padding-right=100%".to_string(),
878                            "padding-bottom=100%".to_string(),
879                            "padding-left=100%".to_string(),
880                        ],
881                    ),
882                    (
883                        "2".to_string(),
884                        vec![
885                            "padding-top=$1".to_string(),
886                            "padding-bottom=$1".to_string(),
887                            "padding-left=$2".to_string(),
888                            "padding-right=$2".to_string(),
889                        ],
890                    ),
891                ])),
892            },
893        );
894
895        let scrolls = Some(scrolls_map);
896
897        let raw = "g!box=10px_20px;";
898        let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
899            .expect("parse ok")
900            .expect("not None");
901        let spells = spell.scroll_spells.as_ref().expect("template spells");
902        let raw_spells: Vec<String> = spells.iter().map(|s| s.raw_spell.clone()).collect();
903
904        assert!(raw_spells.contains(&"height=var(--box-height)".to_string()));
905        assert!(raw_spells.contains(&"width=var(--box-width)".to_string()));
906        assert!(raw_spells.contains(&"padding-top=10px".to_string()));
907        assert!(raw_spells.contains(&"padding-bottom=10px".to_string()));
908        assert!(raw_spells.contains(&"padding-left=20px".to_string()));
909        assert!(raw_spells.contains(&"padding-right=20px".to_string()));
910
911        // 0-args overload via scroll invocation without '=' (inside template)
912        let raw0 = "g!box;";
913        let spell0 = Spell::new(raw0, &shared_spells, &scrolls, (0, 0), None)
914            .expect("parse ok")
915            .expect("not None");
916        let spells0 = spell0.scroll_spells.as_ref().expect("template spells");
917        let raw_spells0: Vec<String> = spells0.iter().map(|s| s.raw_spell.clone()).collect();
918        assert!(raw_spells0.contains(&"padding-top=100%".to_string()));
919    }
920
921    #[test]
922    fn test_scroll_spells_by_args_missing_zero_overload_compiles_base_spells() {
923        let shared_spells = HashSet::new();
924
925        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
926        scrolls_map.insert(
927            "box".to_string(),
928            ScrollDefinition {
929                spells: vec![
930                    "height=var(--box-height)".to_string(),
931                    "width=var(--box-width)".to_string(),
932                ],
933                spells_by_args: Some(HashMap::from([(
934                    "1".to_string(),
935                    vec!["padding-top=$1".to_string()],
936                )])),
937            },
938        );
939
940        let scrolls = Some(scrolls_map);
941
942        // 0-args invocation must not error even without spellsByArgs["0"].
943        let raw0 = "g!box;";
944        let spell0 = Spell::new(raw0, &shared_spells, &scrolls, (0, 0), None)
945            .expect("parse ok")
946            .expect("not None");
947        let spells0 = spell0.scroll_spells.as_ref().expect("template spells");
948        let raw_spells0: Vec<String> = spells0.iter().map(|s| s.raw_spell.clone()).collect();
949
950        assert!(raw_spells0.contains(&"height=var(--box-height)".to_string()));
951        assert!(raw_spells0.contains(&"width=var(--box-width)".to_string()));
952        assert!(!raw_spells0.iter().any(|s| s.starts_with("padding-")));
953    }
954
955    #[test]
956    fn test_nested_scroll_invocation_inside_scroll_spells_is_flattened() {
957        let shared_spells = HashSet::new();
958
959        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
960        scrolls_map.insert(
961            "box".to_string(),
962            ScrollDefinition {
963                spells: vec![],
964                spells_by_args: Some(HashMap::from([(
965                    "2".to_string(),
966                    vec![
967                        "padding-top=$1".to_string(),
968                        "padding-bottom=$1".to_string(),
969                        "padding-left=$2".to_string(),
970                        "padding-right=$2".to_string(),
971                    ],
972                )])),
973            },
974        );
975        scrolls_map.insert(
976            "wrap".to_string(),
977            ScrollDefinition {
978                spells: vec!["box=10px_20px".to_string()],
979                spells_by_args: None,
980            },
981        );
982        let scrolls = Some(scrolls_map);
983
984        let spell = Spell::new("wrap", &shared_spells, &scrolls, (0, 0), None)
985            .expect("parse ok")
986            .expect("not None");
987        let spells = spell.scroll_spells.as_ref().expect("scroll spells");
988        let raw_spells: Vec<String> = spells.iter().map(|s| s.raw_spell.clone()).collect();
989
990        assert!(raw_spells.contains(&"padding-top=10px".to_string()));
991        assert!(raw_spells.contains(&"padding-bottom=10px".to_string()));
992        assert!(raw_spells.contains(&"padding-left=20px".to_string()));
993        assert!(raw_spells.contains(&"padding-right=20px".to_string()));
994    }
995
996    #[test]
997    fn test_nested_scroll_invocation_preserves_effects_prefix() {
998        let shared_spells = HashSet::new();
999
1000        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
1001        scrolls_map.insert(
1002            "box".to_string(),
1003            ScrollDefinition {
1004                spells: vec![],
1005                spells_by_args: Some(HashMap::from([(
1006                    "1".to_string(),
1007                    vec!["padding-top=$1".to_string()],
1008                )])),
1009            },
1010        );
1011        scrolls_map.insert(
1012            "hoverWrap".to_string(),
1013            ScrollDefinition {
1014                spells: vec!["hover:box=4px".to_string()],
1015                spells_by_args: None,
1016            },
1017        );
1018        let scrolls = Some(scrolls_map);
1019
1020        let spell = Spell::new("hoverWrap", &shared_spells, &scrolls, (0, 0), None)
1021            .expect("parse ok")
1022            .expect("not None");
1023        let spells = spell.scroll_spells.as_ref().expect("scroll spells");
1024        assert_eq!(spells.len(), 1);
1025        assert_eq!(spells[0].effects(), "hover");
1026        assert_eq!(spells[0].component(), "padding-top");
1027        assert_eq!(spells[0].component_target(), "4px");
1028    }
1029
1030    #[test]
1031    fn test_nested_scroll_invocation_inside_template_token_in_scroll_spells() {
1032        let shared_spells = HashSet::new();
1033
1034        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
1035        scrolls_map.insert(
1036            "box".to_string(),
1037            ScrollDefinition {
1038                spells: vec![],
1039                spells_by_args: Some(HashMap::from([(
1040                    "2".to_string(),
1041                    vec!["padding-top=$1".to_string(), "padding-left=$2".to_string()],
1042                )])),
1043            },
1044        );
1045        scrolls_map.insert(
1046            "templateWrap".to_string(),
1047            ScrollDefinition {
1048                spells: vec!["g!box=10px_20px;".to_string()],
1049                spells_by_args: None,
1050            },
1051        );
1052        let scrolls = Some(scrolls_map);
1053
1054        let spell = Spell::new("templateWrap", &shared_spells, &scrolls, (0, 0), None)
1055            .expect("parse ok")
1056            .expect("not None");
1057        let spells = spell.scroll_spells.as_ref().expect("scroll spells");
1058        let raw_spells: Vec<String> = spells.iter().map(|s| s.raw_spell.clone()).collect();
1059        assert!(raw_spells.contains(&"padding-top=10px".to_string()));
1060        assert!(raw_spells.contains(&"padding-left=20px".to_string()));
1061    }
1062
1063    #[test]
1064    fn test_scroll_cycle_detection_errors() {
1065        let shared_spells = HashSet::new();
1066
1067        let mut scrolls_map: HashMap<String, ScrollDefinition> = HashMap::new();
1068        scrolls_map.insert(
1069            "a".to_string(),
1070            ScrollDefinition {
1071                spells: vec!["b".to_string()],
1072                spells_by_args: None,
1073            },
1074        );
1075        scrolls_map.insert(
1076            "b".to_string(),
1077            ScrollDefinition {
1078                spells: vec!["a".to_string()],
1079                spells_by_args: None,
1080            },
1081        );
1082        let scrolls = Some(scrolls_map);
1083
1084        let err = Spell::new("a", &shared_spells, &scrolls, (0, 0), None).unwrap_err();
1085        let msg = err.to_string();
1086        assert!(msg.to_lowercase().contains("cycle"));
1087    }
1088}