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