grimoire_css_transmutator_lib/
lib.rs1use std::{
2 collections::{HashMap, HashSet},
3 fs::{self},
4 path::{Path, PathBuf},
5 time::{Duration, Instant},
6};
7
8use cssparser::{Parser, ParserInput, SourcePosition, Token};
9use glob::glob;
10use grimoire_css_lib::{GrimoireCssError, Spell};
11use regex::Regex;
12use serde::Serialize;
13use serde_json::to_string_pretty;
14
15#[derive(Debug, Serialize)]
16struct Transmuted {
17 pub scrolls: Vec<TransmutedClass>,
18}
19
20#[derive(Debug, Serialize)]
21struct TransmutedClass {
22 pub name: String,
23 pub spells: Vec<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub oneliner: Option<String>,
26}
27
28type TransmutedMap = HashMap<String, HashSet<String>>;
29
30#[derive(Debug, Default)]
32struct ParserState {
33 pub raw_classes_spells_map: HashMap<String, Vec<String>>,
34 pub current_class: String,
35 pub started_media_pos: Option<SourcePosition>,
36 pub focus: Vec<String>,
37 pub component_and_component_target_map: HashSet<String>,
38 pub effects: Vec<String>,
39 pub class_started: bool,
40 pub focus_delim: String,
41 pub effect_started: bool,
42 pub colons: Vec<String>,
43 pub area: Option<String>,
44}
45
46fn read_and_clean_files(paths: &[PathBuf]) -> Result<String, GrimoireCssError> {
48 let comment_regex = Regex::new(r"(?s)/\*.*?\*/").unwrap();
49
50 let total_size: usize = paths
51 .iter()
52 .filter_map(|path| fs::metadata(path).ok())
53 .map(|metadata| metadata.len() as usize)
54 .sum();
55
56 let mut all_contents = String::with_capacity(total_size);
58
59 for path in paths {
60 let content = fs::read_to_string(path).map_err(|e| {
61 GrimoireCssError::Io(std::io::Error::new(
62 e.kind(),
63 format!("Failed to read '{}': {}", path.display(), e),
64 ))
65 })?;
66
67 all_contents.push_str(&comment_regex.replace_all(&content, "").replace('"', "'"));
69 }
70
71 if all_contents.capacity() > all_contents.len() * 2 {
73 all_contents.shrink_to_fit();
74 }
75
76 Ok(all_contents)
77}
78
79fn remove_last_char(s: &str) -> &str {
81 s.char_indices()
82 .next_back()
83 .map(|(i, _)| &s[..i])
84 .unwrap_or(s)
85}
86
87fn generate_spells_map(state: &ParserState) -> TransmutedMap {
89 let mut spells_map = HashMap::new();
90
91 for (class, prefixes) in &state.raw_classes_spells_map {
92 let mut spells = HashSet::new();
93
94 for prefix in prefixes {
95 for component in &state.component_and_component_target_map {
96 let spell = if prefix.is_empty() {
97 component.clone()
98 } else {
99 format!("{prefix}{component}")
100 };
101 spells.insert(spell);
102 }
103 }
104 spells_map.insert(class.clone(), spells);
105 }
106
107 spells_map
108}
109
110fn merge_maps(map1: &mut TransmutedMap, map2: TransmutedMap) {
112 for (key, value) in map2 {
113 if let Some(existing_value) = map1.get_mut(&key) {
114 existing_value.extend(value);
115 } else {
116 map1.insert(key, value);
117 }
118 }
119}
120
121fn process_css_into_raw_spells(
123 css_input: &str,
124 parser_state: &mut ParserState,
125) -> Result<TransmutedMap, GrimoireCssError> {
126 let mut result: TransmutedMap = HashMap::new();
127 let mut parser_input = ParserInput::new(css_input);
128 let mut parser = Parser::new(&mut parser_input);
129
130 while let Ok(token) = parser.next() {
131 match token {
132 Token::Ident(cow_rc_str) => {
133 if parser_state.class_started && parser_state.current_class.is_empty() {
134 parser_state.current_class.push_str(cow_rc_str);
135 parser_state.class_started = false;
136 } else if !parser_state.focus_delim.is_empty() {
137 let prefix = if parser_state.focus.is_empty() {
138 ""
139 } else {
140 "_"
141 };
142 parser_state.focus.push(format!(
143 "{}{}_{}",
144 prefix, &parser_state.focus_delim, &cow_rc_str
145 ));
146 parser_state.focus_delim.clear();
147 } else if parser_state.effect_started {
148 if parser_state.colons.len() > 2 {
149 parser_state.colons = vec![":".to_string(), ":".to_string()]
150 }
151 let focus_item = format!("{}{}", parser_state.colons.join(""), cow_rc_str);
152 parser_state.focus.push(focus_item.clone());
153 parser_state.effects.push(cow_rc_str.to_string());
154 parser_state.effect_started = false;
155 parser_state.colons.clear();
156
157 if parser_state.current_class.is_empty() {
158 parser_state.current_class.push_str(&focus_item);
159 }
160 } else if !parser_state.current_class.is_empty() {
161 parser_state.focus.push(format!("_{cow_rc_str}"));
162 } else {
163 parser_state.current_class.push_str(cow_rc_str);
165 }
166 }
167 Token::AtKeyword(cow_rc_str) => {
168 if cow_rc_str.as_ref() == "media" {
169 parser_state.started_media_pos = Some(parser.position());
170 }
171 }
172 Token::Delim(d) => match d.to_string().as_str() {
173 "." => {
174 parser_state.class_started = true;
175 if !parser_state.current_class.is_empty() && parser_state.focus_delim.is_empty()
176 {
177 let focus_str = parser_state.focus.join("").trim().replace(" ", "_");
178
179 let base_raw_spell = if focus_str.is_empty() {
180 String::new()
181 } else {
182 format!("{{{focus_str}}}")
183 };
184
185 parser_state
186 .raw_classes_spells_map
187 .entry(parser_state.current_class.to_owned())
188 .or_default()
189 .push(base_raw_spell.clone());
190
191 parser_state.focus.clear();
192 parser_state.effects.clear();
193 parser_state.current_class.clear();
194 parser_state.focus_delim.clear();
195 }
196 }
197 ":" | "::" | ">" | "+" | "~" => parser_state.focus_delim = d.to_string(),
198 "*" => {
199 if parser_state.focus.is_empty() {
200 parser_state.focus.push(d.to_string());
201
202 if parser_state.current_class.is_empty() {
203 parser_state.current_class.push('*');
204 }
205 } else {
206 parser_state.focus_delim = d.to_string();
207 }
208 }
209 _ => {}
210 },
211 Token::Colon => {
212 parser_state.effect_started = true;
213 parser_state.colons.push(":".to_string());
214 }
215 Token::Comma => {
216 if !parser_state.focus.is_empty() {
217 if !parser_state.focus_delim.is_empty() {
218 parser_state.focus.push(parser_state.focus_delim.clone());
219
220 parser_state.focus_delim.clear();
221 }
222
223 parser_state.focus.push(",".to_string());
224 } else {
225 let focus_str = parser_state.focus.join("").trim().replace(" ", "_");
226
227 let base_raw_spell = if focus_str.is_empty() {
228 String::new()
229 } else {
230 format!("{{{focus_str}}}")
231 };
232
233 parser_state
234 .raw_classes_spells_map
235 .entry(parser_state.current_class.to_owned())
236 .or_default()
237 .push(base_raw_spell.clone());
238
239 parser_state.focus.clear();
240 parser_state.effects.clear();
241 parser_state.current_class.clear();
242 parser_state.class_started = false;
243 parser_state.focus_delim.clear();
244 }
245 }
246 Token::SquareBracketBlock => {
247 let mut squared_focus = "[".to_string();
248 let start_pos = parser.position();
249
250 parser
251 .parse_nested_block(|input| {
252 while input.next().is_ok() {}
253 Ok::<(), cssparser::ParseError<'_, ()>>(())
254 })
255 .unwrap();
256
257 let slice = parser.slice_from(start_pos);
258 squared_focus.push_str(slice);
259
260 parser_state.focus.push(squared_focus);
261 }
262 Token::CurlyBracketBlock => {
263 if let Some(start_media_pos) = parser_state.started_media_pos {
264 let slice = parser.slice_from(start_media_pos);
265 let trimmed_slice = slice
266 .char_indices()
267 .next_back()
268 .map_or(slice, |(i, _)| &slice[..i])
269 .trim()
270 .replace(" ", "_");
271
272 parser_state.area = Some(trimmed_slice.to_owned());
273 parser_state.started_media_pos = None;
274
275 let start_nested_pos = parser.position();
276 parser
277 .parse_nested_block(|input| {
278 while input.next().is_ok() {}
279 Ok::<(), cssparser::ParseError<'_, ()>>(())
280 })
281 .unwrap();
282
283 let mut state = ParserState {
284 area: parser_state.area.clone(),
285 ..Default::default()
286 };
287
288 let res = process_css_into_raw_spells(
289 parser.slice_from(start_nested_pos),
290 &mut state,
291 )?;
292 merge_maps(&mut result, res);
293 parser_state.area = None;
294 } else {
295 let spell = Spell::new(&parser_state.current_class, &HashSet::new(), &None)?;
296
297 if spell.is_some() {
298 println!(
299 "This class is already Spell: {:#?}",
300 &parser_state.current_class
301 );
302 } else {
303 let focus_str = parser_state.focus.join("").trim().replace(" ", "_");
304
305 let mut base_raw_spell = if focus_str.is_empty() {
306 String::new()
307 } else {
308 format!("{{{focus_str}}}")
309 };
310
311 if let Some(a) = &parser_state.area {
312 base_raw_spell = format!("{a}__{base_raw_spell}");
313 }
314
315 parser_state
316 .raw_classes_spells_map
317 .entry(parser_state.current_class.to_owned())
318 .or_default()
319 .push(base_raw_spell.clone());
320
321 parser
322 .parse_nested_block(|input| {
323 let mut start_decl_pos: SourcePosition = input.position();
324 let mut colon_pos: SourcePosition = input.position();
325
326 while let Ok(inner_token) = input.next() {
327 match inner_token {
328 Token::Colon => {
329 colon_pos = input.position();
330 }
331 Token::Semicolon => {
332 let component = remove_last_char(
333 input.slice(start_decl_pos..colon_pos),
334 )
335 .trim();
336 let target =
337 remove_last_char(input.slice_from(colon_pos))
338 .trim();
339
340 parser_state.component_and_component_target_map.insert(
341 format!(
342 "{}={}",
343 component.to_owned(),
344 target.to_owned()
345 )
346 .replace(" ", "_"),
347 );
348
349 start_decl_pos = input.position();
350 }
351 _ => {}
352 }
353 }
354 Ok::<(), cssparser::ParseError<'_, ()>>(())
355 })
356 .unwrap();
357
358 merge_maps(&mut result, generate_spells_map(parser_state));
359 }
360
361 parser_state.raw_classes_spells_map.clear();
362 parser_state.current_class.clear();
363 parser_state.component_and_component_target_map.clear();
364 parser_state.effects.clear();
365 parser_state.focus.clear();
366 parser_state.class_started = false;
367 parser_state.focus_delim.clear();
368 }
369 }
370 Token::Function(t) => {
371 if parser_state.effect_started {
372 if parser_state.colons.len() > 2 {
373 parser_state.colons = vec![":".to_string(), ":".to_string()]
374 }
375
376 let fn_name = t.to_string();
377
378 let start_pos = parser.position();
379
380 parser
381 .parse_nested_block(|input| {
382 while input.next().is_ok() {}
383 Ok::<(), cssparser::ParseError<'_, ()>>(())
384 })
385 .unwrap();
386
387 let slice = parser.slice_from(start_pos);
388
389 parser_state.focus.push(format!(
390 "{}{}({}",
391 parser_state.colons.join(""),
392 &fn_name,
393 slice
394 ));
395 parser_state.effects.push(fn_name);
396 parser_state.effect_started = false;
397 parser_state.colons.clear();
398 }
399 }
400 _ => {}
401 }
402 }
403
404 Ok(result)
405}
406
407pub fn run_transmutation(
410 args: Vec<String>,
411 include_oneliner: bool,
412) -> Result<(Duration, String), GrimoireCssError> {
413 let cwd: PathBuf = std::env::current_dir().map_err(GrimoireCssError::Io)?;
415
416 if args.is_empty() {
418 return Err(GrimoireCssError::InvalidInput(
419 "No CSS file patterns provided.".into(),
420 ));
421 }
422
423 let expanded_paths = expand_file_paths(&cwd, &args)?;
425 if expanded_paths.is_empty() {
426 return Err(GrimoireCssError::InvalidPath(
427 "No files found matching the provided patterns.".into(),
428 ));
429 }
430
431 let start_time = Instant::now();
432
433 let mut parser_state = ParserState::default();
434
435 let all_css_string = read_and_clean_files(&expanded_paths)?;
437 let processed_css = process_css_into_raw_spells(&all_css_string, &mut parser_state)?;
438
439 if processed_css.is_empty() {
440 return Err(GrimoireCssError::InvalidInput(
441 "There is nothing to transmute.".into(),
442 ));
443 }
444
445 let mut transmuted = Transmuted {
447 scrolls: Vec::with_capacity(processed_css.len()),
448 };
449
450 for (name, spells) in processed_css {
451 if !name.is_empty() {
452 let spells_vec: Vec<String> = spells.into_iter().collect();
454
455 let oneliner = if include_oneliner {
456 Some(spells_vec.join(" "))
457 } else {
458 None
459 };
460
461 transmuted.scrolls.push(TransmutedClass {
462 name,
463 spells: spells_vec,
464 oneliner,
465 });
466 }
467 }
468
469 let duration = start_time.elapsed();
470
471 let json_data = to_string_pretty(&transmuted).map_err(GrimoireCssError::Serde)?;
472
473 Ok((duration, json_data))
474}
475
476pub fn transmute_from_content(
479 css_content: &str,
480 include_oneliner: bool,
481) -> Result<(f64, String), GrimoireCssError> {
482 let start_time = Instant::now();
483
484 let mut parser_state = ParserState::default();
485
486 let processed_css = process_css_into_raw_spells(css_content, &mut parser_state)?;
487
488 if processed_css.is_empty() {
489 return Err(GrimoireCssError::InvalidInput(
490 "There is nothing to transmute.".into(),
491 ));
492 }
493
494 let mut transmuted = Transmuted {
495 scrolls: Vec::with_capacity(processed_css.len()),
496 };
497
498 for (name, spells) in processed_css {
499 if !name.is_empty() {
500 let spells_vec: Vec<String> = spells.into_iter().collect();
502
503 let oneliner = if include_oneliner {
504 Some(spells_vec.join(" "))
505 } else {
506 None
507 };
508
509 transmuted.scrolls.push(TransmutedClass {
510 name,
511 spells: spells_vec,
512 oneliner,
513 });
514 }
515 }
516
517 let duration = start_time.elapsed().as_secs_f64();
518
519 let json_data = to_string_pretty(&transmuted).map_err(GrimoireCssError::Serde)?;
520
521 Ok((duration, json_data))
522}
523
524fn expand_file_paths(cwd: &Path, patterns: &[String]) -> Result<Vec<PathBuf>, GrimoireCssError> {
526 let mut paths = Vec::with_capacity(patterns.len() * 4);
527
528 for pattern in patterns {
529 let absolute_pattern = if Path::new(pattern).is_absolute() {
530 pattern.to_string()
531 } else {
532 cwd.join(pattern).to_string_lossy().into_owned()
533 };
534
535 for entry_result in glob(&absolute_pattern)
536 .map_err(|e| GrimoireCssError::GlobPatternError(e.msg.to_string()))?
537 {
538 match entry_result {
539 Ok(path) if path.is_file() => paths.push(path),
540 Ok(_) => {} Err(e) => return Err(GrimoireCssError::InvalidPath(e.to_string())),
542 }
543 }
544 }
545
546 if paths.len() < paths.capacity() / 2 {
548 paths.shrink_to_fit();
549 }
550
551 Ok(paths)
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn test_remove_last_char() {
560 assert_eq!(remove_last_char("hello"), "hell");
561 assert_eq!(remove_last_char("a"), "");
562 assert_eq!(remove_last_char(""), "");
563 }
564
565 #[test]
566 fn test_read_and_clean_files() {
567 let temp_dir = tempfile::tempdir().unwrap();
568 let file_path = temp_dir.path().join("test.css");
569 let content = r#"
570 /* Comment */
571 .test {
572 color: "red";
573 }"#;
574
575 fs::write(&file_path, content).unwrap();
576 let result = read_and_clean_files(&[file_path]).unwrap();
577 let expected = ".test { color: 'red'; }";
578
579 let actual = result.replace("\n", "").replace(" ", "");
580 let expected_normalized = expected.replace("\n", "").replace(" ", "");
581
582 assert_eq!(actual, expected_normalized);
583 }
584
585 #[test]
586 fn test_generate_spells_map() {
587 let mut state = ParserState::default();
588 state
589 .raw_classes_spells_map
590 .insert("class1".to_string(), vec!["prefix".to_string()]);
591 state
592 .component_and_component_target_map
593 .insert("color=red".to_string());
594
595 let result: HashMap<String, HashSet<String>> = generate_spells_map(&state);
596 let left_spells = result.get("class1").unwrap();
597 let left_spells_vec: Vec<String> = left_spells.iter().map(String::from).collect();
598
599 assert_eq!(left_spells_vec, vec!["prefixcolor=red".to_string()]);
600 }
601
602 #[test]
603 fn test_merge_maps() {
604 let mut map1: HashMap<String, HashSet<String>> = HashMap::new();
605 map1.insert("class1".to_string(), HashSet::from(["spell1".to_string()]));
606
607 let mut map2: HashMap<String, HashSet<String>> = HashMap::new();
608 map2.insert("class1".to_string(), HashSet::from(["spell2".to_string()]));
609 map2.insert("class2".to_string(), HashSet::from(["spell3".to_string()]));
610
611 merge_maps(&mut map1, map2);
612
613 let left_spells = map1.get("class2").unwrap();
614 let left_spells_vec: Vec<String> = left_spells.iter().map(String::from).collect();
615
616 assert_eq!(left_spells_vec, vec!["spell3".to_string()]);
617 }
618
619 #[test]
620 fn test_process_css_into_raw_spells() {
621 let css_input = ".button { color: red; }";
622 let mut parser_state = ParserState::default();
623
624 let result = process_css_into_raw_spells(css_input, &mut parser_state);
625 assert!(result.is_ok());
626 let spells_map = result.unwrap();
627 let left_spells = spells_map.get("button").unwrap();
628 let left_spells_vec: Vec<String> = left_spells.iter().map(String::from).collect();
629
630 assert_eq!(left_spells_vec, vec!["color=red".to_string()]);
631 }
632
633 #[test]
634 fn test_expand_file_paths() {
635 let temp_dir = tempfile::tempdir().unwrap();
636 let file_path = temp_dir.path().join("test.css");
637 fs::write(&file_path, ".test { color: red; }").unwrap();
638
639 let cwd = temp_dir.path().to_path_buf();
640 let result = expand_file_paths(&cwd, &["test.css".to_string()]);
641
642 assert!(result.is_ok());
643 let paths = result.unwrap();
644 assert_eq!(paths.len(), 1);
645 assert_eq!(paths[0], file_path);
646 }
647
648 #[test]
649 fn test_transmute_from_content() {
650 let css_input = ".button { color: red; }";
651 let result = transmute_from_content(css_input, false);
652 assert!(result.is_ok());
653 let (_duration, json_output) = result.unwrap();
654 assert!(json_output.contains("\"name\": \"button\""));
655 assert!(json_output.contains("\"color=red\""));
656 }
657}