tabler_icon_definer/
lib.rs

1//! Proc macro to create icon enums with embedded SVG content
2//!
3//! This macro downloads SVG icons during compilation and embeds them in the binary.
4//! Currently supports Tabler icons from unpkg.com.
5//!
6//! To find the name of the icon use [tabler](https://tabler.io/icons)
7//!
8//! all cached in target/icon_cache for 30 days
9//!
10//! # Examples
11//!
12//! Basic usage:
13//! ```rust
14//! use tabler_icon_definer::tabler_icon;
15//!
16//! tabler_icon!(
17//!     brand_github[outline, filled],
18//!     user[outline]
19//! );
20//! // Generate
21//! // - Tabler::OUTLINE_BRAND_GITHUB
22//! // - Tabler::FILLED_BRAND_GITHUB
23//! // - Tabler::OUTLINE_USER
24//! ```
25//!
26//! With custom name and attributes:
27//! ```rust
28//! use tabler_icon_definer::tabler_icon;
29//!
30//! tabler_icon!(
31//!     #[derive(serde::Serialize)]
32//!     #[name = "github"]
33//!     brand_github[outline, filled],
34//!     user[outline]
35//! );
36//! // Generate
37//! // - Tabler::OUTLINE_GITHUB
38//! // - Tabler::FILLED_GITHUB
39//! // - Tabler::OUTLINE_USER
40//! ```
41//!
42//! Using generated enum:
43//! ```rust
44//! # use tabler_icon_definer::tabler_icon;
45//! # tabler_icon!(#[name = "github"] brand_github[outline, filled]);
46//! let icon = Tabler::OUTLINE_GITHUB;
47//! let svg = icon.as_str(); // Get SVG content
48//! let name = icon.as_str_merget(); // Get icon name without style prefix
49//! let full_name = icon.to_string(); // Get icon name with style prefix
50//! ```
51//!
52//! # Limitations
53//! - first item is default item
54
55use std::{
56    env, fs,
57    path::PathBuf,
58    time::{Duration, SystemTime},
59};
60
61use proc_macro::TokenStream;
62use quote::{format_ident, quote};
63use syn::{
64    Attribute, Expr, Lit, Meta, Result, Token, bracketed,
65    parse::{Parse, ParseStream},
66    parse_macro_input,
67    punctuated::Punctuated,
68};
69
70#[derive(Debug)]
71struct IconInner {
72    display_name: Option<String>,
73    name: String,
74    styles: Vec<String>,
75}
76
77impl Parse for IconInner {
78    fn parse(input: ParseStream) -> Result<Self> {
79        let mut display_name = None;
80        let attrs = Attribute::parse_outer(input)?;
81        for attr in attrs {
82            if let Meta::NameValue(meta) = attr.meta {
83                if meta.path.is_ident("name") {
84                    if let Expr::Lit(expr_lit) = meta.value {
85                        if let Lit::Str(lit_str) = expr_lit.lit {
86                            display_name = Some(lit_str.value());
87                        }
88                    }
89                }
90            }
91        }
92
93        let name = input.parse::<syn::Ident>()?.to_string();
94
95        let mut styles = Vec::new();
96        if input.peek(syn::token::Bracket) {
97            let content;
98            bracketed!(content in input);
99            let style_list = content.parse_terminated(syn::Ident::parse, Token![,])?;
100            styles = style_list
101                .into_iter()
102                .map(|ident| ident.to_string())
103                .collect();
104        }
105        Ok(IconInner {
106            display_name,
107            name,
108            styles,
109        })
110    }
111}
112#[derive(Debug)]
113struct IconItem {
114    icons: Vec<IconInner>,
115}
116
117impl Parse for IconItem {
118    fn parse(input: ParseStream) -> Result<Self> {
119        let content = Punctuated::<IconInner, Token![,]>::parse_terminated(input)?;
120        Ok(Self {
121            icons: content.into_iter().collect(),
122        })
123    }
124}
125#[derive(Debug)]
126struct TablerEnter {
127    attrs: Vec<Attribute>,
128    icons: IconItem,
129}
130
131impl Parse for TablerEnter {
132    fn parse(input: ParseStream) -> Result<Self> {
133        let attrs = Attribute::parse_outer(input).unwrap_or_default();
134        // Парсим список иконок
135        // panic!("{:#?}", attrs);
136        let icons: IconItem = input.parse()?;
137        Ok(TablerEnter {
138            attrs,
139            icons,
140        })
141    }
142}
143
144/// Proc macro to create icon enums with embedded SVG content
145///
146/// This macro downloads SVG icons during compilation and embeds them in the binary.
147/// Currently supports Tabler icons from unpkg.com.
148///
149/// To find the name of the icon use [tabler](https://tabler.io/icons)
150///
151/// All cached in target/icon_cache for 30 days
152///
153/// # Examples
154///
155/// Basic usage:
156/// ```ignore
157/// use tabler_icon_definer::tabler_icon;
158///
159/// tabler_icon!(
160///     brand_github[outline, filled],
161///     user[outline]
162/// );
163/// // Generates:
164/// // - Tabler::OUTLINE_BRAND_GITHUB
165/// // - Tabler::FILLED_BRAND_GITHUB
166/// // - Tabler::OUTLINE_USER
167/// ```
168///
169/// With custom name:
170/// ```ignore
171/// use tabler_icon_definer::tabler_icon;
172///
173/// tabler_icon!(
174///     #[name = "github"]
175///     brand_github[outline, filled]
176/// );
177/// // Generates:
178/// // - Tabler::OUTLINE_GITHUB
179/// // - Tabler::FILLED_GITHUB
180/// ```
181///
182/// With additional derives:
183/// ```ignore
184/// use tabler_icon_definer::tabler_icon;
185///
186/// tabler_icon!(
187///     #[derive(serde::Serialize)]
188///     #[name = "github"]
189///     brand_github[outline, filled],
190///     user[outline]
191/// );
192/// ```
193/// Using generated enum:
194/// ```ignore
195/// let icon = Tabler::OUTLINE_GITHUB;
196/// let svg = icon.as_str(); // Get SVG content
197/// let name = icon.as_str_merget(); // Get icon name without style prefix
198/// let full_name = icon.to_string(); // Get icon name with style prefix
199/// ```
200#[proc_macro]
201pub fn tabler_icon(input: TokenStream) -> TokenStream {
202    let TablerEnter {
203        mut attrs,
204        icons: mut icon_set,
205    } = parse_macro_input!(input as TablerEnter);
206
207    set_first_name_icon_by_name_attribut(&mut attrs, &mut icon_set);
208
209    let default_derives = quote! {
210          #[derive(Debug, Clone, PartialEq, Default)]
211    };
212
213    let attributes = if !attrs.is_empty() {
214        quote! {
215            #(#attrs)*
216            #default_derives
217        }
218    } else {
219        default_derives
220    };
221
222    let mut formated_ident = vec![]; // Вектор для форматированных идентификаторов
223    let mut ident_from_str = vec![]; // Вектор для конвертации строки в идентификатор
224    let mut ident_display_merget = vec![]; // Вектор для конвертации строки в идентификатор
225    let mut ident_display = vec![]; // Вектор для представление идентификаторов
226    let mut download_data = vec![]; // Вектор для данных загрузки
227
228    for icon in icon_set.icons.iter() {
229        let icon_name = icon.display_name.clone().unwrap_or(
230            icon.name
231                .to_string()
232                .as_str()
233                .trim_end_matches('_')
234                .to_string(),
235        );
236
237        // Для каждого стиля иконки
238        for style in icon.styles.iter() {
239            // Добавляем пару (стиль, имя) в нижнем регистре
240            download_data.push((
241                style.to_string().to_lowercase(),
242                icon.name
243                    .to_string()
244                    .trim_end_matches("_")
245                    .to_lowercase()
246                    .replace("_", "-"),
247            ));
248
249            let variant_name = format_ident!(
250                "{}_{}",
251                style.to_string().to_uppercase(),
252                icon_name.to_uppercase()
253            );
254
255            let str_icon_name = format!(
256                "{}_{}",
257                style.to_string().to_lowercase(),
258                icon_name.to_lowercase()
259            );
260
261            ident_from_str.push(quote! {
262                #str_icon_name => Ok(Self::#variant_name),
263            });
264
265            ident_display.push(quote! {
266                Self::#variant_name => write! (f, #str_icon_name)
267            });
268
269            ident_display_merget.push(quote! {
270                Self::#variant_name => #icon_name
271            });
272
273            // Добавляем форматированный идентификатор в верхнем регистре
274            formated_ident.push(format_ident!(
275                "{}_{}",
276                style.to_string().to_uppercase(),
277                icon_name.to_uppercase()
278            ));
279        }
280    }
281
282    let as_str_match_arms: Vec<_> = formated_ident
283        .iter()
284        .enumerate()
285        .map(|(num, formated_ident)| {
286            let (style, name) = &download_data[num];
287            let icon_url = download_link(style, name);
288
289            let cache_filename = format!("{}_{}.svg", style, name)
290                .replace("/", "_")
291                .replace("\\", "_");
292            let cache_path = get_cache_dir().join(cache_filename);
293            let max_cache_age = Duration::from_secs(60 * 60 * 24 * 30); // 30 days
294
295            let icon_content = if cache_path.exists() && is_cache_fresh(&cache_path, max_cache_age)
296            {
297                // use cached
298                fs::read_to_string(&cache_path).expect("Failed to read cached icon")
299            } else {
300                // download and cache it
301                let content = reqwest::blocking::get(&icon_url)
302                    .expect("Failed to download icon")
303                    .text()
304                    .expect("Failed to read icon content");
305
306                let content = content.trim_start();
307                let content = {
308                    if let Some(position) = content.find("<svg") {
309                        &content[position..]
310                    } else {
311                        panic!(
312                            "{} is not correct name with this style: {}!\nPlease check tabler for correct name!\nGot content:{:#?}",
313                            name, style, &content
314                        );
315                    }
316                }.to_string();
317
318                fs::write(&cache_path, &content).expect("Failed to cache icon");
319                content
320            };
321
322            quote! {
323                Self::#formated_ident => {#icon_content}
324            }
325        })
326        .collect();
327
328    // Generate the final expanded code
329    let expanded = quote! {
330        /// Icon enum with embedded SVG content
331        ///
332        /// Each variant represents an icon with its specific style (outline/filled)
333        /// The SVG content is embedded in the binary at compile time.
334        #attributes
335        pub enum Tabler {
336            #[default]
337            #(#formated_ident),*
338        }
339
340        impl Tabler {
341            /// Returns the SVG content for the icon
342            pub fn as_str(&self) -> &str {
343                match self {
344                    #(#as_str_match_arms),*
345                }
346            }
347
348            /// Return merget name
349            ///
350            /// undercust prefix befor `_`name
351            pub fn as_str_merget(&self) -> &str {
352                match self {
353                    #(#ident_display_merget),*
354                }
355            }
356
357            /// Returns a vector containing all available icon variants
358            pub fn all_icons() -> Vec<Self> {
359                vec![#(Self::#formated_ident),*]
360            }
361
362            #[cfg(feature = "leptos")]
363            pub fn to_leptos(&self) -> leptos::prelude::AnyView {
364                use leptos::html::div;
365                use leptos::prelude::*;
366                match self {
367                    #(Self::#formated_ident => {
368                        let svg = Self::#formated_ident.as_str();
369                        leptos::prelude::view! {<div inner_html=svg />}.into_any()
370                    })*
371                }
372            }
373        }
374
375        impl std::str::FromStr for Tabler {
376            type Err = String;
377
378            fn from_str(s: &str) -> Result<Self, Self::Err> {
379                let normalized = s.trim().to_lowercase();
380                match normalized.as_str() {
381                    #(#ident_from_str)*
382                    _ => Err(format!("Unknown icon variant: {}", normalized))
383                }
384            }
385        }
386
387        impl std::fmt::Display for Tabler {
388            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389                match self {
390                    #(#ident_display),*
391                }
392            }
393        }
394
395
396    };
397
398    expanded.into()
399}
400
401fn get_cache_dir() -> PathBuf {
402    // Получаем различные переменные окружения Cargo
403    let target_dir = env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
404
405    // Создаём структурированный путь кэша
406    let cache_dir = PathBuf::from(target_dir).join("icon_cache");
407
408    fs::create_dir_all(&cache_dir).expect("Failed to create cache directory");
409    cache_dir
410}
411
412fn is_cache_fresh(
413    cache_path: &PathBuf,
414    max_age: Duration,
415) -> bool {
416    if let Ok(metadata) = fs::metadata(cache_path) {
417        if let Ok(modified) = metadata.modified() {
418            if let Ok(duration) = SystemTime::now().duration_since(modified) {
419                return duration < max_age;
420            }
421        }
422    }
423    false
424}
425
426/// Construct download URL for icons
427fn download_link(
428    style_variant: &str,
429    name: &str,
430) -> String {
431    format!(
432        "https://raw.githubusercontent.com/tabler/tabler-icons/refs/heads/main/icons/{}/{}.svg",
433        style_variant, name
434    )
435}
436
437fn set_first_name_icon_by_name_attribut(
438    attrs: &mut Vec<Attribute>,
439    icon_set: &mut IconItem,
440) {
441    // Проверяем наличие иконок
442    if icon_set.icons.is_empty() {
443        panic!("Icon set is empty - must define at least one icon");
444    }
445
446    // Находим индекс атрибута name
447    if let Some(name_index) = attrs
448        .iter()
449        .position(|attr| matches!(&attr.meta, Meta::NameValue(meta) if meta.path.is_ident("name")))
450    {
451        // Извлекаем атрибут по индексу
452        let name_attr = attrs.remove(name_index);
453
454        // Извлекаем значение строки из атрибута
455        let name_str = match name_attr.meta {
456            Meta::NameValue(name_value) => match name_value.value {
457                Expr::Lit(lit) => match lit.lit {
458                    Lit::Str(lit_str) => lit_str.value(),
459                    _ => panic!("Invalid literal type in name attribute - expected string literal"),
460                },
461                _ => panic!("Expected literal expression in name attribute"),
462            },
463            _ => panic!("Unexpected meta format for name attribute"),
464        };
465
466        // Устанавливаем имя для первой иконки
467        icon_set
468            .icons
469            .first_mut()
470            .expect("Failed to get first icon")
471            .display_name = Some(name_str);
472    }
473}