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