1use std::collections::{HashMap, HashSet};
29use std::hash::{Hash, Hasher};
30use std::sync::Arc;
31
32use super::{
33 GrimoireCssError, component::get_css_property, source_file::SourceFile, spell_value_validator,
34};
35
36#[derive(Debug, Clone)]
37struct SpellParts {
38 area: std::ops::Range<usize>,
39 focus: std::ops::Range<usize>,
40 effects: std::ops::Range<usize>,
41 component: std::ops::Range<usize>,
42 component_target: std::ops::Range<usize>,
43}
44
45#[derive(Debug, Clone)]
46pub struct Spell {
47 pub raw_spell: String,
48 pub with_template: bool,
49 pub scroll_spells: Option<Vec<Spell>>,
50 pub span: (usize, usize),
51 pub source: Option<Arc<SourceFile>>,
52 parts: Option<SpellParts>,
53}
54
55impl PartialEq for Spell {
56 fn eq(&self, other: &Self) -> bool {
57 self.raw_spell == other.raw_spell
58 && self.with_template == other.with_template
59 && self.scroll_spells == other.scroll_spells
60 }
61}
62
63impl Eq for Spell {}
64
65impl Hash for Spell {
66 fn hash<H: Hasher>(&self, state: &mut H) {
67 self.raw_spell.hash(state);
68 self.with_template.hash(state);
69 self.scroll_spells.hash(state);
70 }
71}
72
73impl Spell {
74 pub fn area(&self) -> &str {
75 self.parts
76 .as_ref()
77 .map(|p| &self.raw_spell[p.area.clone()])
78 .unwrap_or("")
79 }
80
81 pub fn focus(&self) -> &str {
82 self.parts
83 .as_ref()
84 .map(|p| &self.raw_spell[p.focus.clone()])
85 .unwrap_or("")
86 }
87
88 pub fn effects(&self) -> &str {
89 self.parts
90 .as_ref()
91 .map(|p| &self.raw_spell[p.effects.clone()])
92 .unwrap_or("")
93 }
94
95 pub fn component(&self) -> &str {
96 self.parts
97 .as_ref()
98 .map(|p| &self.raw_spell[p.component.clone()])
99 .unwrap_or("")
100 }
101
102 pub fn component_target(&self) -> &str {
103 self.parts
104 .as_ref()
105 .map(|p| &self.raw_spell[p.component_target.clone()])
106 .unwrap_or("")
107 }
108
109 pub fn new(
111 raw_spell: &str,
112 shared_spells: &HashSet<String>,
113 scrolls: &Option<HashMap<String, Vec<String>>>,
114 span: (usize, usize),
115 source: Option<Arc<SourceFile>>,
116 ) -> Result<Option<Self>, GrimoireCssError> {
117 let with_template = Self::check_for_template(raw_spell);
118 let raw_spell_cleaned = if with_template {
119 raw_spell
120 .strip_prefix("g!")
121 .and_then(|s| s.strip_suffix(";"))
122 .unwrap_or(raw_spell)
123 } else {
124 raw_spell
125 };
126
127 let raw_spell_split: Vec<&str> = raw_spell_cleaned
128 .split("--")
129 .filter(|s| !s.is_empty())
130 .collect();
131
132 if with_template && !raw_spell_split.is_empty() {
134 let mut scroll_spells: Vec<Spell> = Vec::new();
135
136 for rs in raw_spell_split {
137 if let Some(spell) = Spell::new(rs, shared_spells, scrolls, span, source.clone())? {
138 let mut spell = spell;
139
140 let area = spell.area().to_string();
147 let focus = spell.focus().to_string();
148 let effects = spell.effects().to_string();
149
150 if let Some(inner_scroll_spells) = spell.scroll_spells.take() {
151 let has_prefix =
152 !area.is_empty() || !focus.is_empty() || !effects.is_empty();
153
154 if has_prefix {
155 let mut prefix = String::new();
156
157 if !area.is_empty() {
158 prefix.push_str(&area);
159 prefix.push_str("__");
160 }
161
162 if !focus.is_empty() {
163 prefix.push('{');
164 prefix.push_str(&focus);
165 prefix.push('}');
166 }
167
168 if !effects.is_empty() {
169 prefix.push_str(&effects);
170 prefix.push(':');
171 }
172
173 for inner in inner_scroll_spells {
174 let combined = format!("{prefix}{}", inner.raw_spell);
175 if let Some(reparsed) = Spell::new(
176 &combined,
177 shared_spells,
178 scrolls,
179 span,
180 source.clone(),
181 )? {
182 scroll_spells.push(reparsed);
183 }
184 }
185 } else {
186 scroll_spells.extend(inner_scroll_spells);
187 }
188 } else {
189 scroll_spells.push(spell);
190 }
191 }
192 }
193
194 return Ok(Some(Spell {
195 raw_spell: raw_spell_cleaned.to_string(),
196 with_template,
197 scroll_spells: Some(scroll_spells),
198 span,
199 source,
200 parts: None,
201 }));
202 }
203
204 let raw = raw_spell_cleaned.to_string();
205
206 let mut area_range = 0..0;
208 let mut focus_range = 0..0;
209 let mut effects_range = 0..0;
210
211 let mut rest_start = 0usize;
212 if let Some(pos) = raw.find("__") {
213 area_range = 0..pos;
214 rest_start = pos + 2;
215 }
216
217 let mut after_focus_start = rest_start;
218 if rest_start < raw.len()
219 && let Some(close_rel) = raw[rest_start..].find('}')
220 {
221 let focus_part_start = if raw.as_bytes().get(rest_start) == Some(&b'{') {
222 rest_start + 1
223 } else {
224 rest_start
225 };
226 focus_range = focus_part_start..(rest_start + close_rel);
227 after_focus_start = rest_start + close_rel + 1;
228 }
229
230 let mut after_effects_start = after_focus_start;
231 if after_focus_start < raw.len()
232 && let Some(colon_rel) = raw[after_focus_start..].find(':')
233 {
234 effects_range = after_focus_start..(after_focus_start + colon_rel);
235 after_effects_start = after_focus_start + colon_rel + 1;
236 }
237
238 if after_effects_start <= raw.len()
240 && let Some(eq_rel) = raw[after_effects_start..].find('=')
241 {
242 let component_range = after_effects_start..(after_effects_start + eq_rel);
243 let component_target_range = (after_effects_start + eq_rel + 1)..raw.len();
244
245 let component_target = &raw[component_target_range.clone()];
246 if let Some(err) = spell_value_validator::validate_component_target(component_target) {
247 let message = match err {
248 spell_value_validator::SpellValueValidationError::UnexpectedClosingParen => {
249 format!(
250 "Invalid value '{component_target}': unexpected ')'.\n\n\
251If you intended a CSS function (e.g. calc(...)), ensure parentheses are balanced."
252 )
253 }
254 spell_value_validator::SpellValueValidationError::UnclosedParen => {
255 format!(
256 "Invalid value '{component_target}': unclosed '('.\n\n\
257Common cause: spaces inside a class attribute split the spell into multiple tokens.\n\
258Fix: replace spaces with '_' inside the value, e.g.:\n\
259 h=calc(100vh - 50px) -> h=calc(100vh_-_50px)"
260 )
261 }
262 };
263
264 if let Some(src) = &source {
265 return Err(GrimoireCssError::CompileError {
266 message,
267 span,
268 label: "invalid spell value".to_string(),
269 help: Some(
270 "In HTML class attributes, spaces split classes.\n\
271Use '_' inside spell values to represent spaces."
272 .to_string(),
273 ),
274 source_file: Some(src.clone()),
275 });
276 }
277
278 return Err(GrimoireCssError::InvalidInput(message));
279 }
280
281 let parts = SpellParts {
282 area: area_range,
283 focus: focus_range,
284 effects: effects_range,
285 component: component_range.clone(),
286 component_target: component_target_range.clone(),
287 };
288
289 let mut spell = Spell {
290 raw_spell: raw,
291 with_template,
292 scroll_spells: None,
293 span,
294 source: source.clone(),
295 parts: Some(parts),
296 };
297
298 let component = spell.component();
299
300 if let Some(raw_scroll_spells) = Self::check_raw_scroll_spells(component, scrolls) {
301 spell.scroll_spells = Self::parse_scroll(
302 component,
303 raw_scroll_spells,
304 spell.component_target(),
305 shared_spells,
306 scrolls,
307 span,
308 source,
309 )?;
310 } else if !component.starts_with("--") && get_css_property(component).is_none() {
311 let message = format!("Unknown component or scroll: '{component}'");
312 if let Some(src) = &source {
313 return Err(GrimoireCssError::InvalidSpellFormat {
314 message,
315 span,
316 label: "Error in this spell".to_string(),
317 help: Some(
318 "Check that the component name exists (built-in CSS property alias) or that the scroll is defined in config.scrolls."
319 .to_string(),
320 ),
321 source_file: Some(src.clone()),
322 });
323 } else {
324 return Err(GrimoireCssError::InvalidInput(message));
325 }
326 }
327
328 return Ok(Some(spell));
329 }
330
331 if after_effects_start <= raw.len()
333 && let Some(raw_scroll_spells) =
334 Self::check_raw_scroll_spells(&raw[after_effects_start..], scrolls)
335 {
336 let component_range = after_effects_start..raw.len();
337 let parts = SpellParts {
338 area: area_range,
339 focus: focus_range,
340 effects: effects_range,
341 component: component_range.clone(),
342 component_target: 0..0,
343 };
344
345 let mut spell = Spell {
346 raw_spell: raw,
347 with_template,
348 scroll_spells: None,
349 span,
350 source: source.clone(),
351 parts: Some(parts),
352 };
353
354 let component = spell.component();
355 spell.scroll_spells = Self::parse_scroll(
356 component,
357 raw_scroll_spells,
358 "",
359 shared_spells,
360 scrolls,
361 span,
362 source,
363 )?;
364
365 return Ok(Some(spell));
366 }
367
368 Ok(None) }
370
371 fn check_for_template(raw_spell: &str) -> bool {
372 raw_spell.starts_with("g!") && raw_spell.ends_with(';')
373 }
374
375 fn check_raw_scroll_spells<'a>(
376 scroll_name: &str,
377 scrolls: &'a Option<HashMap<String, Vec<String>>>,
378 ) -> Option<&'a Vec<String>> {
379 scrolls.as_ref()?.get(scroll_name)
380 }
381
382 #[allow(clippy::too_many_arguments)]
383 fn parse_scroll(
384 scroll_name: &str,
385 raw_scroll_spells: &[String],
386 component_target: &str,
387 shared_spells: &HashSet<String>,
388 scrolls: &Option<HashMap<String, Vec<String>>>,
389 span: (usize, usize),
390 source: Option<Arc<SourceFile>>,
391 ) -> Result<Option<Vec<Spell>>, GrimoireCssError> {
392 if raw_scroll_spells.is_empty() {
393 return Ok(None);
394 }
395
396 let scroll_variables: Vec<&str> = component_target.split('_').collect();
397 let count_of_variables = if component_target.is_empty() {
398 0
399 } else {
400 scroll_variables.len()
401 };
402 let mut count_of_used_variables = 0;
403
404 let mut spells = Vec::with_capacity(raw_scroll_spells.len());
405
406 for raw_spell in raw_scroll_spells.iter() {
407 if raw_spell.contains("=$") {
408 if count_of_used_variables > scroll_variables.len().saturating_sub(1) {
409 break;
410 }
411
412 let variabled_raw_spell = raw_spell.replace(
413 "=$",
414 format!("={}", scroll_variables[count_of_used_variables]).as_str(),
415 );
416
417 if let Ok(Some(spell)) = Spell::new(
418 &variabled_raw_spell,
419 shared_spells,
420 scrolls,
421 span,
422 source.clone(),
423 ) {
424 spells.push(spell);
425 }
426
427 count_of_used_variables += 1;
428 } else if let Ok(Some(spell)) =
429 Spell::new(raw_spell, shared_spells, scrolls, span, source.clone())
430 {
431 spells.push(spell);
432 }
433 }
434
435 if count_of_used_variables != count_of_variables {
436 let message = format!(
437 "Variable count mismatch for scroll '{scroll_name}'. Provided {count_of_variables} arguments, but scroll definition uses {count_of_used_variables}",
438 );
439
440 if let Some(src) = &source {
441 return Err(GrimoireCssError::InvalidSpellFormat {
442 message,
443 span,
444 label: "Error in this spell".to_string(),
445 help: Some(
446 "Pass exactly N arguments separated by '_' (underscores).\n\
447Example: complex-card=arg1_arg2_arg3"
448 .to_string(),
449 ),
450 source_file: Some(src.clone()),
451 });
452 } else {
453 return Err(GrimoireCssError::InvalidInput(message));
454 }
455 }
456
457 if spells.is_empty() {
458 Ok(None)
459 } else {
460 Ok(Some(spells))
461 }
462 }
463
464 pub fn generate_spells_from_classes(
465 css_classes: Vec<(String, (usize, usize))>,
466 shared_spells: &HashSet<String>,
467 scrolls: &Option<HashMap<String, Vec<String>>>,
468 source: Option<Arc<SourceFile>>,
469 ) -> Result<Vec<Spell>, GrimoireCssError> {
470 let mut spells = Vec::with_capacity(css_classes.len());
471
472 for (cs, span) in css_classes {
473 if !shared_spells.contains(&cs)
474 && let Some(spell) = Spell::new(&cs, shared_spells, scrolls, span, source.clone())?
475 {
476 spells.push(spell);
477 }
478 }
479
480 Ok(spells)
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use crate::core::source_file::SourceFile;
487 use crate::core::spell::Spell;
488 use std::collections::{HashMap, HashSet};
489 use std::sync::Arc;
490
491 #[test]
492 fn test_multiple_raw_spells_in_template() {
493 let shared_spells = HashSet::new();
494 let scrolls: Option<HashMap<String, Vec<String>>> = None;
495 let raw = "g!color=red--display=flex;";
496 let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
497 .expect("parse ok")
498 .expect("not None");
499 assert!(spell.with_template);
500 assert!(spell.scroll_spells.is_some());
501 let spells = spell.scroll_spells.as_ref().unwrap();
502 assert_eq!(spells.len(), 2);
503 assert_eq!(spells[0].component(), "color");
504 assert_eq!(spells[0].component_target(), "red");
505 assert_eq!(spells[1].component(), "display");
506 assert_eq!(spells[1].component_target(), "flex");
507 }
508
509 #[test]
510 fn test_scroll_can_be_used_inside_template_attribute() {
511 let shared_spells = HashSet::new();
512 let mut scrolls_map: HashMap<String, Vec<String>> = HashMap::new();
513 scrolls_map.insert(
514 "complex-card".to_string(),
515 vec!["h=$".to_string(), "c=$".to_string(), "w=$".to_string()],
516 );
517 let scrolls = Some(scrolls_map);
518
519 let raw = "g!complex-card=120px_red_100px;";
522 let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None)
523 .expect("parse ok")
524 .expect("not None");
525
526 assert!(spell.with_template);
527 let spells = spell.scroll_spells.as_ref().expect("template spells");
528 assert_eq!(spells.len(), 3);
529 assert_eq!(spells[0].component(), "h");
530 assert_eq!(spells[0].component_target(), "120px");
531 assert_eq!(spells[1].component(), "c");
532 assert_eq!(spells[1].component_target(), "red");
533 assert_eq!(spells[2].component(), "w");
534 assert_eq!(spells[2].component_target(), "100px");
535 }
536
537 #[test]
538 fn test_non_grimoire_plain_class_is_ignored() {
539 let shared_spells = HashSet::new();
540 let scrolls: Option<HashMap<String, Vec<String>>> = None;
541
542 let spell = Spell::new(
544 "red",
545 &shared_spells,
546 &scrolls,
547 (12, 3),
548 Some(Arc::new(SourceFile::new(
549 None,
550 "test".to_string(),
551 "<div class=\"red primary-button\"></div>".to_string(),
552 ))),
553 )
554 .expect("parsing must not fail");
555
556 assert!(spell.is_none());
557 }
558}