grimoire_css_transmutator_lib/
lib.rs

1use 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/// Represents the state during CSS parsing.
31#[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
46/// Reads and cleans multiple CSS files (paths mode).
47fn 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    // Allocate with the estimated capacity
57    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        // Process and append in one go to minimize intermediate allocations
68        all_contents.push_str(&comment_regex.replace_all(&content, "").replace('"', "'"));
69    }
70
71    // Release excess capacity if significant
72    if all_contents.capacity() > all_contents.len() * 2 {
73        all_contents.shrink_to_fit();
74    }
75
76    Ok(all_contents)
77}
78
79/// Removes the last character of a string.
80fn remove_last_char(s: &str) -> &str {
81    s.char_indices()
82        .next_back()
83        .map(|(i, _)| &s[..i])
84        .unwrap_or(s)
85}
86
87/// Generates a map of spells based on parser state.
88fn 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
110/// Merges two HashMaps, concatenating values for duplicate keys.
111fn 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
121/// Processes CSS input and generates raw spells.
122fn 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                    // This is a tag selector
164                    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
407/// Run the transmutation process on multiple CSS files.
408/// This is the main entry point for the paths mode.
409pub fn run_transmutation(
410    args: Vec<String>,
411    include_oneliner: bool,
412) -> Result<(Duration, String), GrimoireCssError> {
413    // Get current directory
414    let cwd: PathBuf = std::env::current_dir().map_err(GrimoireCssError::Io)?;
415
416    // Validate input
417    if args.is_empty() {
418        return Err(GrimoireCssError::InvalidInput(
419            "No CSS file patterns provided.".into(),
420        ));
421    }
422
423    // Expand file paths based on glob patterns
424    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    // Read and process CSS files
436    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    // Build the transmuted output structure
446    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            // Convert HashSet to Vec to preserve JSON ordering
453            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
476/// Transmutes CSS content to Grimoire CSS format.
477/// This is the main entry point for the content mode.
478pub 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            // Convert HashSet to Vec to preserve JSON ordering
501            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
524/// Expands glob patterns into a list of file paths.
525fn 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(_) => {} // Skip directories
541                Err(e) => return Err(GrimoireCssError::InvalidPath(e.to_string())),
542            }
543        }
544    }
545
546    // If no memory waste, return as is; otherwise, shrink to fit
547    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}