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    /// Update a collection file (e.g., mdi.rs) with new icons
207    fn update_collection_file(
208        &self,
209        collection: &str,
210        new_icons: &[(IconIdentifier, IconifyIcon)],
211    ) -> Result<()> {
212        let module_name = collection.replace('-', "_");
213        let file_path = self.icons_dir.join(format!("{}.rs", module_name));
214
215        // Read existing icons if file exists
216        let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
217        if file_path.exists() {
218            existing_icons = self.parse_collection_file(&file_path)?;
219        }
220
221        // Add/update new icons
222        for (identifier, icon) in new_icons {
223            let icon_const = IconConst::from_api_icon(identifier, icon);
224            existing_icons.insert(icon_const.name.clone(), icon_const);
225        }
226
227        // Generate file content
228        let content = self.generate_collection_file(collection, &existing_icons)?;
229
230        // Write to file
231        fs::write(&file_path, content)
232            .context(format!("Failed to write collection file {:?}", file_path))?;
233
234        println!(
235            "✓ Updated {}.rs with {} icon(s)",
236            module_name,
237            new_icons.len()
238        );
239
240        Ok(())
241    }
242
243    /// Parse existing icons from a collection file
244    fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
245        let content =
246            fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
247
248        let mut icons = BTreeMap::new();
249
250        // Simple regex-free parsing: look for "pub const NAME: IconData = IconData {"
251        // and extract the data between braces
252        let lines: Vec<&str> = content.lines().collect();
253        let mut i = 0;
254
255        while i < lines.len() {
256            let line = lines[i].trim();
257
258            // Look for "pub const NAME: IconData"
259            if line.starts_with("pub const ")
260                && line.contains(": IconData")
261                && let Some(name_end) = line.find(':')
262            {
263                let name = line[10..name_end].trim().to_string();
264
265                // Parse the IconData struct (next several lines)
266                if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
267                    icons.insert(name, icon_const);
268                }
269            }
270
271            i += 1;
272        }
273
274        Ok(icons)
275    }
276
277    /// Parse IconData struct from lines
278    fn parse_icon_data(
279        &self,
280        lines: &[&str],
281        index: &mut usize,
282        const_name: &str,
283    ) -> Option<IconConst> {
284        let mut full_icon_name = String::new();
285        let mut body = String::new();
286        let mut view_box = String::new();
287        let mut width = String::new();
288        let mut height = String::new();
289
290        // Look ahead to find all fields
291        let mut j = *index;
292        while j < lines.len() {
293            let line = lines[j].trim();
294
295            if line.contains("name:") {
296                full_icon_name = extract_string_value(line);
297            } else if line.contains("body:") {
298                // Body might span multiple lines in raw string
299                body = extract_raw_string_value(lines, &mut j);
300            } else if line.contains("view_box:") {
301                view_box = extract_string_value(line);
302            } else if line.contains("width:") {
303                width = extract_string_value(line);
304            } else if line.contains("height:") {
305                height = extract_string_value(line);
306            }
307
308            // End of struct
309            if line.contains("};") {
310                break;
311            }
312
313            j += 1;
314        }
315
316        *index = j;
317
318        if !full_icon_name.is_empty() && !body.is_empty() {
319            Some(IconConst {
320                name: const_name.to_string(),
321                full_icon_name,
322                body,
323                view_box,
324                width,
325                height,
326            })
327        } else {
328            None
329        }
330    }
331
332    /// Generate content for a collection file
333    fn generate_collection_file(
334        &self,
335        collection: &str,
336        icons: &BTreeMap<String, IconConst>,
337    ) -> Result<String> {
338        let mut content = format!(
339            "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
340            collection
341        );
342
343        // Add each icon const in alphabetical order (BTreeMap maintains order)
344        for icon_const in icons.values() {
345            content.push_str(&icon_const.to_rust_code());
346            content.push_str("\n\n");
347        }
348
349        Ok(content)
350    }
351
352    /// Update mod.rs with module declarations
353    fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
354        let mod_rs_path = self.icons_dir.join("mod.rs");
355
356        // Read existing mod.rs
357        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
358
359        // Extract existing module declarations
360        let mut existing_modules: HashSet<String> = HashSet::new();
361        for line in content.lines() {
362            if line.trim().starts_with("pub mod ")
363                && let Some(module_name) = line
364                    .trim()
365                    .strip_prefix("pub mod ")
366                    .and_then(|s| s.strip_suffix(';'))
367            {
368                existing_modules.insert(module_name.trim().to_string());
369            }
370        }
371
372        // Add new modules
373        let mut needs_update = false;
374        for collection in collections {
375            let module_name = collection.replace('-', "_");
376            if !existing_modules.contains(&module_name) {
377                existing_modules.insert(module_name);
378                needs_update = true;
379            }
380        }
381
382        // Regenerate mod.rs if we have new modules
383        if needs_update {
384            let mut new_content = MOD_RS_TEMPLATE.to_string();
385            new_content.push('\n');
386
387            // Add module declarations in alphabetical order
388            let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
389            sorted_modules.sort();
390
391            for module in sorted_modules {
392                new_content.push_str(&format!("pub mod {};\n", module));
393            }
394
395            fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
396        }
397
398        Ok(())
399    }
400}
401
402/// Extract a string value from a line like `name: "value",`
403fn extract_string_value(line: &str) -> String {
404    if let Some(start) = line.find('"')
405        && let Some(end) = line.rfind('"')
406        && end > start
407    {
408        return line[start + 1..end].to_string();
409    }
410    String::new()
411}
412
413/// Extract a raw string value that might span multiple lines
414fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
415    let line = lines[*index];
416
417    // Look for r#"..."#
418    if let Some(start) = line.find("r#\"") {
419        let start_pos = start + 3;
420
421        // Check if it ends on the same line
422        if let Some(end) = line[start_pos..].find("\"#") {
423            return line[start_pos..start_pos + end].to_string();
424        }
425
426        // Multi-line: collect until we find "#
427        let mut result = line[start_pos..].to_string();
428        *index += 1;
429
430        while *index < lines.len() {
431            let next_line = lines[*index];
432            if let Some(end) = next_line.find("\"#") {
433                result.push_str(&next_line[..end]);
434                break;
435            }
436            result.push_str(next_line);
437            result.push('\n');
438            *index += 1;
439        }
440
441        result
442    } else {
443        String::new()
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::api::IconifyIcon;
451    use tempfile::TempDir;
452
453    #[test]
454    fn test_list_icons_empty_directory() -> Result<()> {
455        let temp_dir = TempDir::new()?;
456        let generator = Generator::new(temp_dir.path().join("icons"));
457
458        let icons = generator.list_icons()?;
459        assert!(
460            icons.is_empty(),
461            "Should return empty map for non-existent directory"
462        );
463
464        Ok(())
465    }
466
467    #[test]
468    fn test_list_icons_with_generated_icons() -> Result<()> {
469        let temp_dir = TempDir::new()?;
470        let icons_dir = temp_dir.path().join("icons");
471        let generator = Generator::new(icons_dir.clone());
472
473        // Add some test icons
474        let test_icon1 = IconifyIcon {
475            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
476            width: Some(24),
477            height: Some(24),
478            view_box: Some("0 0 24 24".to_string()),
479        };
480
481        let test_icon2 = IconifyIcon {
482            body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
483            width: Some(24),
484            height: Some(24),
485            view_box: Some("0 0 24 24".to_string()),
486        };
487
488        let identifier1 = IconIdentifier::parse("mdi:home")?;
489        let identifier2 = IconIdentifier::parse("mdi:settings")?;
490        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
491
492        generator.add_icons(&[
493            (identifier1, test_icon1.clone()),
494            (identifier2, test_icon2.clone()),
495            (identifier3, test_icon1.clone()),
496        ])?;
497
498        // List the icons
499        let icons = generator.list_icons()?;
500
501        // Should have 2 collections
502        assert_eq!(icons.len(), 2, "Should have 2 collections");
503        assert!(icons.contains_key("mdi"), "Should have mdi collection");
504        assert!(
505            icons.contains_key("heroicons"),
506            "Should have heroicons collection"
507        );
508
509        // Check mdi collection has 2 icons
510        let mdi_icons = icons.get("mdi").unwrap();
511        assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
512        assert!(mdi_icons.contains(&"mdi:home".to_string()));
513        assert!(mdi_icons.contains(&"mdi:settings".to_string()));
514
515        // Check heroicons collection has 1 icon
516        let heroicons_icons = icons.get("heroicons").unwrap();
517        assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
518        assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
519
520        Ok(())
521    }
522
523    #[test]
524    fn test_get_all_icon_identifiers() -> Result<()> {
525        let temp_dir = TempDir::new()?;
526        let icons_dir = temp_dir.path().join("icons");
527        let generator = Generator::new(icons_dir.clone());
528
529        // Test with no icons
530        let empty_icons = generator.get_all_icon_identifiers()?;
531        assert!(
532            empty_icons.is_empty(),
533            "Should return empty vec for no icons"
534        );
535
536        // Add some test icons
537        let test_icon = IconifyIcon {
538            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
539            width: Some(24),
540            height: Some(24),
541            view_box: Some("0 0 24 24".to_string()),
542        };
543
544        let identifier1 = IconIdentifier::parse("mdi:home")?;
545        let identifier2 = IconIdentifier::parse("mdi:settings")?;
546        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
547
548        generator.add_icons(&[
549            (identifier1, test_icon.clone()),
550            (identifier2, test_icon.clone()),
551            (identifier3, test_icon.clone()),
552        ])?;
553
554        // Get all identifiers
555        let all_icons = generator.get_all_icon_identifiers()?;
556
557        // Should have 3 icons
558        assert_eq!(all_icons.len(), 3, "Should have 3 icons");
559        assert!(
560            all_icons.contains(&"mdi:home".to_string()),
561            "Should contain mdi:home"
562        );
563        assert!(
564            all_icons.contains(&"mdi:settings".to_string()),
565            "Should contain mdi:settings"
566        );
567        assert!(
568            all_icons.contains(&"heroicons:arrow-left".to_string()),
569            "Should contain heroicons:arrow-left"
570        );
571
572        Ok(())
573    }
574}