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