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