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