1use 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 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 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 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 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 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 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 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) }
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 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 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 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 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 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 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 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 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 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 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 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}