dioxus_iconify/
generator.rs

1use anyhow::{Context, Result};
2use indoc::{formatdoc, indoc};
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::api::IconifyIcon;
8use crate::naming::IconIdentifier;
9
10const MOD_RS_TEMPLATE: &str = indoc! {r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
11    use dioxus::prelude::*;
12
13    #[derive(Clone, Copy, PartialEq)]
14    pub struct IconData {
15        pub name: &'static str,
16        pub body: &'static str,
17        pub view_box: &'static str,
18        pub width: &'static str,
19        pub height: &'static str,
20    }
21
22    #[component]
23    pub fn Icon(
24        data: IconData,
25        /// Optional size to set both width and height
26        #[props(default, into)]
27        size: Option<String>,
28        /// Additional attributes to extend the svg element
29        #[props(extends = GlobalAttributes)]
30        attributes: Vec<Attribute>,
31    ) -> Element {
32        let width = size.as_ref().map(|s| s.as_str()).unwrap_or(data.width);
33        let height = size.as_ref().map(|s| s.as_str()).unwrap_or(data.height);
34
35        rsx! {
36            svg {
37                view_box: "{data.view_box}",
38                width: "{width}",
39                height: "{height}",
40                dangerous_inner_html: "{data.body}",
41                ..attributes,
42            }
43        }
44    }
45    "#};
46
47/// Represents a generated icon constant
48#[derive(Debug, Clone)]
49struct IconConst {
50    name: String,
51    full_icon_name: String,
52    body: String,
53    view_box: String,
54    width: String,
55    height: String,
56}
57
58impl IconConst {
59    fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
60        Self {
61            name: identifier.to_const_name(),
62            full_icon_name: identifier.full_name.clone(),
63            body: icon.body.clone(),
64            view_box: icon
65                .view_box
66                .clone()
67                .unwrap_or_else(|| "0 0 24 24".to_string()),
68            width: icon.width.unwrap_or(24).to_string(),
69            height: icon.height.unwrap_or(24).to_string(),
70        }
71    }
72
73    fn to_rust_code(&self) -> String {
74        // we use non upper case to be able to switch/wrap to struct or enum i the future
75        formatdoc! { "
76            #[allow(non_upper_case_globals)]
77            pub const {}: IconData = IconData {{
78              name: \"{}\",
79              body: r#\"{}\"#,
80              view_box: \"{}\",
81              width: \"{}\",
82              height: \"{}\",
83            }};
84            ",
85            self.name,
86            self.full_icon_name,
87            self.body,
88            self.view_box,
89            self.width,
90            self.height
91        }
92    }
93}
94
95/// Icon code generator
96pub struct Generator {
97    icons_dir: PathBuf,
98}
99
100impl Generator {
101    /// Create a new generator with the specified icons directory
102    pub fn new(icons_dir: PathBuf) -> Self {
103        Self { icons_dir }
104    }
105
106    /// List all generated icons grouped by collection
107    pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
108        let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
109
110        // Check if icons directory exists
111        if !self.icons_dir.exists() {
112            return Ok(icons_by_collection);
113        }
114
115        // Read all .rs files in the icons directory (except mod.rs)
116        let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
117
118        for entry in entries {
119            let entry = entry.context("Failed to read directory entry")?;
120            let path = entry.path();
121
122            // Skip if not a file or if it's mod.rs
123            if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
124                continue;
125            }
126
127            // Skip if not a .rs file
128            if path.extension() != Some("rs".as_ref()) {
129                continue;
130            }
131
132            // Parse the collection file
133            let icons = self.parse_collection_file(&path)?;
134
135            // Get collection name from file name
136            if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
137                let icon_names: Vec<String> = icons
138                    .values()
139                    .map(|icon| icon.full_icon_name.clone())
140                    .collect();
141
142                if !icon_names.is_empty() {
143                    icons_by_collection.insert(collection_name.to_string(), icon_names);
144                }
145            }
146        }
147
148        Ok(icons_by_collection)
149    }
150
151    /// Get all icon identifiers from generated files
152    pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
153        let icons_by_collection = self.list_icons()?;
154        let mut all_icons = Vec::new();
155
156        for icon_names in icons_by_collection.values() {
157            all_icons.extend(icon_names.clone());
158        }
159
160        Ok(all_icons)
161    }
162
163    /// Initialize the icons directory with mod.rs if it doesn't exist
164    pub fn init(&self) -> Result<()> {
165        // Create icons directory if it doesn't exist
166        if !self.icons_dir.exists() {
167            fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
168        }
169
170        // Create mod.rs if it doesn't exist
171        let mod_rs_path = self.icons_dir.join("mod.rs");
172        if !mod_rs_path.exists() {
173            fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
174        }
175
176        Ok(())
177    }
178
179    /// Add icons to the generated code
180    pub fn add_icons(&self, icons: &[(IconIdentifier, IconifyIcon)]) -> Result<()> {
181        // Ensure icons directory and mod.rs exist
182        self.init()?;
183
184        // Group icons by collection
185        let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
186            HashMap::new();
187
188        for (identifier, icon) in icons {
189            icons_by_collection
190                .entry(identifier.collection.clone())
191                .or_default()
192                .push((identifier.clone(), icon.clone()));
193        }
194
195        // Generate/update each collection file
196        for (collection, collection_icons) in &icons_by_collection {
197            self.update_collection_file(collection, collection_icons)?;
198        }
199
200        // Update mod.rs with module declarations
201        self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
202
203        Ok(())
204    }
205
206    /// Regenerate mod.rs with the latest template
207    /// This is useful for updating the Icon component definition after CLI updates
208    pub fn regenerate_mod_rs(&self) -> Result<()> {
209        let mod_rs_path = self.icons_dir.join("mod.rs");
210
211        // If mod.rs doesn't exist, just use init
212        if !mod_rs_path.exists() {
213            return self.init();
214        }
215
216        // Read existing mod.rs to extract module declarations
217        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
218
219        // Extract existing module declarations
220        let mut existing_modules: HashSet<String> = HashSet::new();
221        for line in content.lines() {
222            if line.trim().starts_with("pub mod ")
223                && let Some(module_name) = line
224                    .trim()
225                    .strip_prefix("pub mod ")
226                    .and_then(|s| s.strip_suffix(';'))
227            {
228                existing_modules.insert(module_name.trim().to_string());
229            }
230        }
231
232        // Regenerate mod.rs with latest template
233        let mut new_content = MOD_RS_TEMPLATE.to_string();
234        new_content.push('\n');
235
236        // Add module declarations in alphabetical order
237        let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
238        sorted_modules.sort();
239
240        for module in sorted_modules {
241            new_content.push_str(&format!("pub mod {};\n", module));
242        }
243
244        fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
245
246        Ok(())
247    }
248
249    /// Update a collection file (e.g., mdi.rs) with new icons
250    fn update_collection_file(
251        &self,
252        collection: &str,
253        new_icons: &[(IconIdentifier, IconifyIcon)],
254    ) -> Result<()> {
255        let module_name = collection.replace('-', "_");
256        let file_path = self.icons_dir.join(format!("{}.rs", module_name));
257
258        // Read existing icons if file exists
259        let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
260        if file_path.exists() {
261            existing_icons = self.parse_collection_file(&file_path)?;
262        }
263
264        // Add/update new icons
265        for (identifier, icon) in new_icons {
266            let icon_const = IconConst::from_api_icon(identifier, icon);
267            existing_icons.insert(icon_const.name.clone(), icon_const);
268        }
269
270        // Generate file content
271        let content = self.generate_collection_file(collection, &existing_icons)?;
272
273        // Write to file
274        fs::write(&file_path, content)
275            .context(format!("Failed to write collection file {:?}", file_path))?;
276
277        println!(
278            "✓ Updated {}.rs with {} icon(s)",
279            module_name,
280            new_icons.len()
281        );
282
283        Ok(())
284    }
285
286    /// Parse existing icons from a collection file
287    fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
288        let content =
289            fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
290
291        let mut icons = BTreeMap::new();
292
293        // Simple regex-free parsing: look for "pub const NAME: IconData = IconData {"
294        // and extract the data between braces
295        let lines: Vec<&str> = content.lines().collect();
296        let mut i = 0;
297
298        while i < lines.len() {
299            let line = lines[i].trim();
300
301            // Look for "pub const NAME: IconData"
302            if line.starts_with("pub const ")
303                && line.contains(": IconData")
304                && let Some(name_end) = line.find(':')
305            {
306                let name = line[10..name_end].trim().to_string();
307
308                // Parse the IconData struct (next several lines)
309                if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
310                    icons.insert(name, icon_const);
311                }
312            }
313
314            i += 1;
315        }
316
317        Ok(icons)
318    }
319
320    /// Parse IconData struct from lines
321    fn parse_icon_data(
322        &self,
323        lines: &[&str],
324        index: &mut usize,
325        const_name: &str,
326    ) -> Option<IconConst> {
327        let mut full_icon_name = String::new();
328        let mut body = String::new();
329        let mut view_box = String::new();
330        let mut width = String::new();
331        let mut height = String::new();
332
333        // Look ahead to find all fields
334        let mut j = *index;
335        while j < lines.len() {
336            let line = lines[j].trim();
337
338            if line.contains("name:") {
339                full_icon_name = extract_string_value(line);
340            } else if line.contains("body:") {
341                // Body might span multiple lines in raw string
342                body = extract_raw_string_value(lines, &mut j);
343            } else if line.contains("view_box:") {
344                view_box = extract_string_value(line);
345            } else if line.contains("width:") {
346                width = extract_string_value(line);
347            } else if line.contains("height:") {
348                height = extract_string_value(line);
349            }
350
351            // End of struct
352            if line.contains("};") {
353                break;
354            }
355
356            j += 1;
357        }
358
359        *index = j;
360
361        if !full_icon_name.is_empty() && !body.is_empty() {
362            Some(IconConst {
363                name: const_name.to_string(),
364                full_icon_name,
365                body,
366                view_box,
367                width,
368                height,
369            })
370        } else {
371            None
372        }
373    }
374
375    /// Generate content for a collection file
376    fn generate_collection_file(
377        &self,
378        collection: &str,
379        icons: &BTreeMap<String, IconConst>,
380    ) -> Result<String> {
381        let mut content = format!(
382            "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
383            collection
384        );
385
386        // Add each icon const in alphabetical order (BTreeMap maintains order)
387        for icon_const in icons.values() {
388            content.push_str(&icon_const.to_rust_code());
389            content.push_str("\n\n");
390        }
391
392        Ok(content)
393    }
394
395    /// Update mod.rs with module declarations
396    fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
397        let mod_rs_path = self.icons_dir.join("mod.rs");
398
399        // Read existing mod.rs
400        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
401
402        // Extract existing module declarations
403        let mut existing_modules: HashSet<String> = HashSet::new();
404        for line in content.lines() {
405            if line.trim().starts_with("pub mod ")
406                && let Some(module_name) = line
407                    .trim()
408                    .strip_prefix("pub mod ")
409                    .and_then(|s| s.strip_suffix(';'))
410            {
411                existing_modules.insert(module_name.trim().to_string());
412            }
413        }
414
415        // Add new modules
416        let mut needs_update = false;
417        for collection in collections {
418            let module_name = collection.replace('-', "_");
419            if !existing_modules.contains(&module_name) {
420                existing_modules.insert(module_name);
421                needs_update = true;
422            }
423        }
424
425        // Regenerate mod.rs if we have new modules
426        if needs_update {
427            let mut new_content = MOD_RS_TEMPLATE.to_string();
428            new_content.push('\n');
429
430            // Add module declarations in alphabetical order
431            let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
432            sorted_modules.sort();
433
434            for module in sorted_modules {
435                new_content.push_str(&format!("pub mod {};\n", module));
436            }
437
438            fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
439        }
440
441        Ok(())
442    }
443}
444
445/// Extract a string value from a line like `name: "value",`
446fn extract_string_value(line: &str) -> String {
447    if let Some(start) = line.find('"')
448        && let Some(end) = line.rfind('"')
449        && end > start
450    {
451        return line[start + 1..end].to_string();
452    }
453    String::new()
454}
455
456/// Extract a raw string value that might span multiple lines
457fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
458    let line = lines[*index];
459
460    // Look for r#"..."#
461    if let Some(start) = line.find("r#\"") {
462        let start_pos = start + 3;
463
464        // Check if it ends on the same line
465        if let Some(end) = line[start_pos..].find("\"#") {
466            return line[start_pos..start_pos + end].to_string();
467        }
468
469        // Multi-line: collect until we find "#
470        let mut result = line[start_pos..].to_string();
471        *index += 1;
472
473        while *index < lines.len() {
474            let next_line = lines[*index];
475            if let Some(end) = next_line.find("\"#") {
476                result.push_str(&next_line[..end]);
477                break;
478            }
479            result.push_str(next_line);
480            result.push('\n');
481            *index += 1;
482        }
483
484        result
485    } else {
486        String::new()
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::api::IconifyIcon;
494    use tempfile::TempDir;
495
496    #[test]
497    fn test_list_icons_empty_directory() -> Result<()> {
498        let temp_dir = TempDir::new()?;
499        let generator = Generator::new(temp_dir.path().join("icons"));
500
501        let icons = generator.list_icons()?;
502        assert!(
503            icons.is_empty(),
504            "Should return empty map for non-existent directory"
505        );
506
507        Ok(())
508    }
509
510    #[test]
511    fn test_list_icons_with_generated_icons() -> Result<()> {
512        let temp_dir = TempDir::new()?;
513        let icons_dir = temp_dir.path().join("icons");
514        let generator = Generator::new(icons_dir.clone());
515
516        // Add some test icons
517        let test_icon1 = IconifyIcon {
518            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
519            width: Some(24),
520            height: Some(24),
521            view_box: Some("0 0 24 24".to_string()),
522        };
523
524        let test_icon2 = IconifyIcon {
525            body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
526            width: Some(24),
527            height: Some(24),
528            view_box: Some("0 0 24 24".to_string()),
529        };
530
531        let identifier1 = IconIdentifier::parse("mdi:home")?;
532        let identifier2 = IconIdentifier::parse("mdi:settings")?;
533        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
534
535        generator.add_icons(&[
536            (identifier1, test_icon1.clone()),
537            (identifier2, test_icon2.clone()),
538            (identifier3, test_icon1.clone()),
539        ])?;
540
541        // List the icons
542        let icons = generator.list_icons()?;
543
544        // Should have 2 collections
545        assert_eq!(icons.len(), 2, "Should have 2 collections");
546        assert!(icons.contains_key("mdi"), "Should have mdi collection");
547        assert!(
548            icons.contains_key("heroicons"),
549            "Should have heroicons collection"
550        );
551
552        // Check mdi collection has 2 icons
553        let mdi_icons = icons.get("mdi").unwrap();
554        assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
555        assert!(mdi_icons.contains(&"mdi:home".to_string()));
556        assert!(mdi_icons.contains(&"mdi:settings".to_string()));
557
558        // Check heroicons collection has 1 icon
559        let heroicons_icons = icons.get("heroicons").unwrap();
560        assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
561        assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
562
563        Ok(())
564    }
565
566    #[test]
567    fn test_get_all_icon_identifiers() -> Result<()> {
568        let temp_dir = TempDir::new()?;
569        let icons_dir = temp_dir.path().join("icons");
570        let generator = Generator::new(icons_dir.clone());
571
572        // Test with no icons
573        let empty_icons = generator.get_all_icon_identifiers()?;
574        assert!(
575            empty_icons.is_empty(),
576            "Should return empty vec for no icons"
577        );
578
579        // Add some test icons
580        let test_icon = IconifyIcon {
581            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
582            width: Some(24),
583            height: Some(24),
584            view_box: Some("0 0 24 24".to_string()),
585        };
586
587        let identifier1 = IconIdentifier::parse("mdi:home")?;
588        let identifier2 = IconIdentifier::parse("mdi:settings")?;
589        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
590
591        generator.add_icons(&[
592            (identifier1, test_icon.clone()),
593            (identifier2, test_icon.clone()),
594            (identifier3, test_icon.clone()),
595        ])?;
596
597        // Get all identifiers
598        let all_icons = generator.get_all_icon_identifiers()?;
599
600        // Should have 3 icons
601        assert_eq!(all_icons.len(), 3, "Should have 3 icons");
602        assert!(
603            all_icons.contains(&"mdi:home".to_string()),
604            "Should contain mdi:home"
605        );
606        assert!(
607            all_icons.contains(&"mdi:settings".to_string()),
608            "Should contain mdi:settings"
609        );
610        assert!(
611            all_icons.contains(&"heroicons:arrow-left".to_string()),
612            "Should contain heroicons:arrow-left"
613        );
614
615        Ok(())
616    }
617
618    #[test]
619    fn test_regenerate_mod_rs_updates_template() -> Result<()> {
620        let temp_dir = TempDir::new()?;
621        let icons_dir = temp_dir.path().join("icons");
622        let generator = Generator::new(icons_dir.clone());
623
624        // Add some test icons
625        let test_icon = IconifyIcon {
626            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
627            width: Some(24),
628            height: Some(24),
629            view_box: Some("0 0 24 24".to_string()),
630        };
631
632        let identifier1 = IconIdentifier::parse("mdi:home")?;
633        let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
634
635        generator.add_icons(&[
636            (identifier1, test_icon.clone()),
637            (identifier2, test_icon.clone()),
638        ])?;
639
640        let mod_rs_path = icons_dir.join("mod.rs");
641        assert!(mod_rs_path.exists(), "mod.rs should exist");
642
643        // Simulate an old version by writing outdated content
644        let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
645use dioxus::prelude::*;
646
647// OLD VERSION WITHOUT SIZE PARAMETER
648#[component]
649pub fn Icon(data: IconData) -> Element {
650    rsx! { svg {} }
651}
652
653pub mod heroicons;
654pub mod mdi;
655"#;
656        fs::write(&mod_rs_path, old_content)?;
657
658        // Verify the old content is there
659        let content_before = fs::read_to_string(&mod_rs_path)?;
660        assert!(
661            content_before.contains("OLD VERSION"),
662            "Should have old version marker"
663        );
664        assert!(
665            !content_before.contains("size: Option<String>"),
666            "Should not have size parameter yet"
667        );
668
669        // Regenerate mod.rs
670        generator.regenerate_mod_rs()?;
671
672        // Verify the new content has the latest template
673        let content_after = fs::read_to_string(&mod_rs_path)?;
674        assert!(
675            !content_after.contains("OLD VERSION"),
676            "Should not have old version marker"
677        );
678        assert!(
679            content_after.contains("size: Option<String>"),
680            "Should have size parameter from latest template"
681        );
682        assert!(
683            content_after.contains("pub mod heroicons;"),
684            "Should preserve heroicons module"
685        );
686        assert!(
687            content_after.contains("pub mod mdi;"),
688            "Should preserve mdi module"
689        );
690
691        Ok(())
692    }
693}