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        /// Additional attributes to extend the svg element
26        #[props(extends = GlobalAttributes)]
27        attributes: Vec<Attribute>,
28    ) -> Element {
29        rsx! {
30            svg {
31                view_box: "{data.view_box}",
32                width: "{data.width}",
33                height: "{data.height}",
34                dangerous_inner_html: "{data.body}",
35                ..attributes,
36            }
37        }
38    }
39    "#};
40
41/// Represents a generated icon constant
42#[derive(Debug, Clone)]
43struct IconConst {
44    name: String,
45    full_icon_name: String,
46    body: String,
47    view_box: String,
48    width: String,
49    height: String,
50}
51
52impl IconConst {
53    fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
54        Self {
55            name: identifier.to_const_name(),
56            full_icon_name: identifier.full_name.clone(),
57            body: icon.body.clone(),
58            view_box: icon
59                .view_box
60                .clone()
61                .unwrap_or_else(|| "0 0 24 24".to_string()),
62            width: icon.width.unwrap_or(24).to_string(),
63            height: icon.height.unwrap_or(24).to_string(),
64        }
65    }
66
67    fn to_rust_code(&self) -> String {
68        // we use non upper case to be able to switch/wrap to struct or enum i the future
69        formatdoc! { "
70            #[allow(non_upper_case_globals)]
71            pub const {}: IconData = IconData {{
72              name: \"{}\",
73              body: r#\"{}\"#,
74              view_box: \"{}\",
75              width: \"{}\",
76              height: \"{}\",
77            }};
78            ",
79            self.name,
80            self.full_icon_name,
81            self.body,
82            self.view_box,
83            self.width,
84            self.height
85        }
86    }
87}
88
89/// Icon code generator
90pub struct Generator {
91    icons_dir: PathBuf,
92}
93
94impl Generator {
95    /// Create a new generator with the specified icons directory
96    pub fn new(icons_dir: PathBuf) -> Self {
97        Self { icons_dir }
98    }
99
100    /// Initialize the icons directory with mod.rs if it doesn't exist
101    pub fn init(&self) -> Result<()> {
102        // Create icons directory if it doesn't exist
103        if !self.icons_dir.exists() {
104            fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
105        }
106
107        // Create mod.rs if it doesn't exist
108        let mod_rs_path = self.icons_dir.join("mod.rs");
109        if !mod_rs_path.exists() {
110            fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
111        }
112
113        Ok(())
114    }
115
116    /// Add icons to the generated code
117    pub fn add_icons(&self, icons: &[(IconIdentifier, IconifyIcon)]) -> Result<()> {
118        // Ensure icons directory and mod.rs exist
119        self.init()?;
120
121        // Group icons by collection
122        let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
123            HashMap::new();
124
125        for (identifier, icon) in icons {
126            icons_by_collection
127                .entry(identifier.collection.clone())
128                .or_default()
129                .push((identifier.clone(), icon.clone()));
130        }
131
132        // Generate/update each collection file
133        for (collection, collection_icons) in &icons_by_collection {
134            self.update_collection_file(collection, collection_icons)?;
135        }
136
137        // Update mod.rs with module declarations
138        self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
139
140        Ok(())
141    }
142
143    /// Update a collection file (e.g., mdi.rs) with new icons
144    fn update_collection_file(
145        &self,
146        collection: &str,
147        new_icons: &[(IconIdentifier, IconifyIcon)],
148    ) -> Result<()> {
149        let module_name = collection.replace('-', "_");
150        let file_path = self.icons_dir.join(format!("{}.rs", module_name));
151
152        // Read existing icons if file exists
153        let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
154        if file_path.exists() {
155            existing_icons = self.parse_collection_file(&file_path)?;
156        }
157
158        // Add/update new icons
159        for (identifier, icon) in new_icons {
160            let icon_const = IconConst::from_api_icon(identifier, icon);
161            existing_icons.insert(icon_const.name.clone(), icon_const);
162        }
163
164        // Generate file content
165        let content = self.generate_collection_file(collection, &existing_icons)?;
166
167        // Write to file
168        fs::write(&file_path, content)
169            .context(format!("Failed to write collection file {:?}", file_path))?;
170
171        println!(
172            "✓ Updated {}.rs with {} icon(s)",
173            module_name,
174            new_icons.len()
175        );
176
177        Ok(())
178    }
179
180    /// Parse existing icons from a collection file
181    fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
182        let content =
183            fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
184
185        let mut icons = BTreeMap::new();
186
187        // Simple regex-free parsing: look for "pub const NAME: IconData = IconData {"
188        // and extract the data between braces
189        let lines: Vec<&str> = content.lines().collect();
190        let mut i = 0;
191
192        while i < lines.len() {
193            let line = lines[i].trim();
194
195            // Look for "pub const NAME: IconData"
196            if line.starts_with("pub const ")
197                && line.contains(": IconData")
198                && let Some(name_end) = line.find(':')
199            {
200                let name = line[10..name_end].trim().to_string();
201
202                // Parse the IconData struct (next several lines)
203                if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
204                    icons.insert(name, icon_const);
205                }
206            }
207
208            i += 1;
209        }
210
211        Ok(icons)
212    }
213
214    /// Parse IconData struct from lines
215    fn parse_icon_data(
216        &self,
217        lines: &[&str],
218        index: &mut usize,
219        const_name: &str,
220    ) -> Option<IconConst> {
221        let mut full_icon_name = String::new();
222        let mut body = String::new();
223        let mut view_box = String::new();
224        let mut width = String::new();
225        let mut height = String::new();
226
227        // Look ahead to find all fields
228        let mut j = *index;
229        while j < lines.len() {
230            let line = lines[j].trim();
231
232            if line.contains("name:") {
233                full_icon_name = extract_string_value(line);
234            } else if line.contains("body:") {
235                // Body might span multiple lines in raw string
236                body = extract_raw_string_value(lines, &mut j);
237            } else if line.contains("view_box:") {
238                view_box = extract_string_value(line);
239            } else if line.contains("width:") {
240                width = extract_string_value(line);
241            } else if line.contains("height:") {
242                height = extract_string_value(line);
243            }
244
245            // End of struct
246            if line.contains("};") {
247                break;
248            }
249
250            j += 1;
251        }
252
253        *index = j;
254
255        if !full_icon_name.is_empty() && !body.is_empty() {
256            Some(IconConst {
257                name: const_name.to_string(),
258                full_icon_name,
259                body,
260                view_box,
261                width,
262                height,
263            })
264        } else {
265            None
266        }
267    }
268
269    /// Generate content for a collection file
270    fn generate_collection_file(
271        &self,
272        collection: &str,
273        icons: &BTreeMap<String, IconConst>,
274    ) -> Result<String> {
275        let mut content = format!(
276            "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
277            collection
278        );
279
280        // Add each icon const in alphabetical order (BTreeMap maintains order)
281        for icon_const in icons.values() {
282            content.push_str(&icon_const.to_rust_code());
283            content.push_str("\n\n");
284        }
285
286        Ok(content)
287    }
288
289    /// Update mod.rs with module declarations
290    fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
291        let mod_rs_path = self.icons_dir.join("mod.rs");
292
293        // Read existing mod.rs
294        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
295
296        // Extract existing module declarations
297        let mut existing_modules: HashSet<String> = HashSet::new();
298        for line in content.lines() {
299            if line.trim().starts_with("pub mod ")
300                && let Some(module_name) = line
301                    .trim()
302                    .strip_prefix("pub mod ")
303                    .and_then(|s| s.strip_suffix(';'))
304            {
305                existing_modules.insert(module_name.trim().to_string());
306            }
307        }
308
309        // Add new modules
310        let mut needs_update = false;
311        for collection in collections {
312            let module_name = collection.replace('-', "_");
313            if !existing_modules.contains(&module_name) {
314                existing_modules.insert(module_name);
315                needs_update = true;
316            }
317        }
318
319        // Regenerate mod.rs if we have new modules
320        if needs_update {
321            let mut new_content = MOD_RS_TEMPLATE.to_string();
322            new_content.push('\n');
323
324            // Add module declarations in alphabetical order
325            let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
326            sorted_modules.sort();
327
328            for module in sorted_modules {
329                new_content.push_str(&format!("pub mod {};\n", module));
330            }
331
332            fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
333        }
334
335        Ok(())
336    }
337}
338
339/// Extract a string value from a line like `name: "value",`
340fn extract_string_value(line: &str) -> String {
341    if let Some(start) = line.find('"')
342        && let Some(end) = line.rfind('"')
343        && end > start
344    {
345        return line[start + 1..end].to_string();
346    }
347    String::new()
348}
349
350/// Extract a raw string value that might span multiple lines
351fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
352    let line = lines[*index];
353
354    // Look for r#"..."#
355    if let Some(start) = line.find("r#\"") {
356        let start_pos = start + 3;
357
358        // Check if it ends on the same line
359        if let Some(end) = line[start_pos..].find("\"#") {
360            return line[start_pos..start_pos + end].to_string();
361        }
362
363        // Multi-line: collect until we find "#
364        let mut result = line[start_pos..].to_string();
365        *index += 1;
366
367        while *index < lines.len() {
368            let next_line = lines[*index];
369            if let Some(end) = next_line.find("\"#") {
370                result.push_str(&next_line[..end]);
371                break;
372            }
373            result.push_str(next_line);
374            result.push('\n');
375            *index += 1;
376        }
377
378        result
379    } else {
380        String::new()
381    }
382}