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