heroicons 0.2.0

Easily include Heroicons SVGs in Rust
Documentation
use proc_macro2::TokenStream;
use std::{
    ffi::OsStr,
    io::Write,
    path::{Path, PathBuf},
};

static ICONS_ROOT: &str = "heroicons/optimized";

struct IconFile {
    pub name: String,
    pub variant: String,
    pub svg_file: PathBuf,
}

fn main() {
    let icon_files: Vec<IconFile> = svg_files().map(to_svg_file).collect();
    icon_names::generate(&icon_files);
    from_icon_impl::generate(&icon_files);

    println!("cargo::rerun-if-changed=heroicons");
    println!("cargo::rerun-if-changed=build.rs");
}

mod parser {
    use std::{fs::read_to_string, path::Path};

    use pest::{Parser, iterators::Pairs};
    use pest_derive::Parser;

    pub struct Tag {
        pub name: String,
        pub attributes: Vec<(String, String)>,
        pub children: Vec<Tag>,
    }

    pub fn parse(file: &Path) -> Tag {
        let content = read_to_string(file).unwrap();
        let mut result = SVGParser::parse(Rule::tag, &content).unwrap();
        let svg_pair = result.next().unwrap().into_inner();
        into_tag(svg_pair)
    }

    fn into_tag(mut pair: Pairs<Rule>) -> Tag {
        let svg_identifier = pair.next().unwrap();
        assert_eq!(svg_identifier.as_rule(), Rule::identifier);
        let svg_attrs = pair.next().unwrap();
        assert_eq!(svg_attrs.as_rule(), Rule::attributes);
        let svg_children = pair.next().unwrap();
        assert_eq!(svg_children.as_rule(), Rule::children);

        let name = svg_identifier.as_str().to_owned();

        let attributes = svg_attrs
            .into_inner()
            .map(|attr| {
                assert_eq!(attr.as_rule(), Rule::attribute);
                let mut pairs = attr.into_inner();
                let attr_name = pairs.next().unwrap().as_str().to_owned();
                let attr_value = pairs
                    .next()
                    .unwrap()
                    .into_inner()
                    .next()
                    .unwrap()
                    .as_str()
                    .to_owned();
                (attr_name, attr_value)
            })
            .collect::<Vec<(String, String)>>();

        let children = svg_children
            .into_inner()
            .map(|pair| into_tag(pair.into_inner()))
            .collect();

        Tag { name, attributes, children }
    }

    #[derive(Parser)]
    #[grammar_inline = r#"
      tag = { "<" ~ PUSH(identifier) ~ attributes ~ children }
      children = { ("/>" ~ DROP) | (">" ~ tag* ~ "</" ~ POP ~ ">") }
      attributes = { attribute* }
      attribute = { identifier ~ "=" ~ string_value }
      identifier = @{ ('a'..'z' | 'A'..'Z' | "-")+ }
      string_value = { "\"" ~ string_contents ~ "\"" }
      string_contents = { (!"\"" ~ ANY)* }
      WHITESPACE = _{ " " | "\n" }
      "#]
    pub struct SVGParser;
}

// SVG search and indexing:

fn svg_files() -> impl Iterator<Item = PathBuf> {
    get_dir_entries(PathBuf::from(ICONS_ROOT))
        .flat_map(get_dir_entries)
        .flat_map(get_dir_entries)
        .filter(|svg_path| svg_path.extension() == Some(OsStr::new("svg")))
}

fn get_dir_entries(path: PathBuf) -> impl Iterator<Item = PathBuf> {
    std::fs::read_dir(path).unwrap().map(|entry| entry.unwrap().path())
}

fn to_svg_file(svg_file: PathBuf) -> IconFile {
    let name = path_to_icon_name(&svg_file);

    let components = svg_file
        .components()
        .rev()
        .map(|component| component.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<String>>();
    let type_string = &components[1];
    let size_string = &components[2];

    let variant = match (type_string.as_str(), size_string.as_str()) {
        ("outline", "24") => "Outline",
        ("solid", "24") => "Solid",
        ("solid", "20") => "Mini",
        ("solid", "16") => "Micro",
        _ => panic!("Unexpected folder structure: {svg_file:?}"),
    }
    .to_string();

    IconFile { name, variant, svg_file }
}

fn path_to_icon_name(path: &Path) -> String {
    let stem = path.file_stem().unwrap().to_string_lossy().into_owned();

    // Convert to PascalCase
    stem.split('-')
        .map(|word| {
            let (first, rest) = word.split_at(1);
            let mut fixed = first.to_ascii_uppercase();
            fixed.push_str(rest);
            fixed
        })
        .collect::<String>()
}

fn write_src_file(tokens: TokenStream, filename: &str) {
    let syntax_tree = syn::parse_file(&tokens.to_string()).unwrap();
    let formatted = prettyplease::unparse(&syntax_tree);

    let out_dir = std::env::var_os("OUT_DIR").unwrap();
    let out_filename = Path::new(&out_dir).join(filename);
    println!("{}", out_filename.display());
    let mut output_file = std::fs::File::create(out_filename).unwrap();
    output_file.write_all(formatted.as_bytes()).unwrap();
}

mod icon_names {
    use proc_macro2::TokenStream;
    use quote::{format_ident, quote};

    use crate::{IconFile, write_src_file};

    pub fn generate(icons: &[IconFile]) {
        let mut names =
            icons.iter().map(|icon| &icon.name).collect::<Vec<&String>>();
        names.sort();
        names.dedup();

        write_src_file(icon_names_code(names), "generated_icon_names.rs");
    }

    pub fn icon_names_code(enum_names: Vec<&String>) -> TokenStream {
        let names = enum_names.iter().map(|name| {
            let name_ident = format_ident!("{}", name);
            quote! {
                #[derive(Clone, Copy, Debug, Default, PartialEq)]
                pub struct #name_ident;
                impl IconName for #name_ident {}
            }
        });

        quote! {
            use crate::IconName;

            #(#names)*
        }
    }
}

mod from_icon_impl {
    use proc_macro2::{Span, TokenStream};
    use quote::{format_ident, quote};
    use syn::Ident;

    use crate::{
        IconFile,
        parser::{self, Tag},
        write_src_file,
    };

    const COMMON_ATTRS: &[(&str, &str, &str)] = &[
        ("XMLNS", "xmlns", "http://www.w3.org/2000/svg"),
        ("FILL_CURRENT", "fill", "currentColor"),
        ("FILL_NONE", "fill", "none"),
        ("STROKE_CURRENT", "stroke", "currentColor"),
        ("ARIA_HIDDEN", "aria-hidden", "true"),
        ("DATA_SLOT", "data-slot", "icon"),
        ("VIEWBOX_24", "viewBox", "0 0 24 24"),
        ("VIEWBOX_20", "viewBox", "0 0 20 20"),
        ("VIEWBOX_16", "viewBox", "0 0 16 16"),
        ("FILL_EVENODD", "fill-rule", "evenodd"),
        ("CLIP_EVENODD", "clip-rule", "evenodd"),
    ];

    pub fn generate(icons: &[IconFile]) {
        write_src_file(tokens(icons), "generated_from_icon_impl.rs");
    }

    fn tokens(icons: &[IconFile]) -> TokenStream {
        let impl_tokens = icons.iter().map(svg_code);
        let common_attr_tokens =
            COMMON_ATTRS.iter().map(|(name, attr, value)| {
                let name_ident = Ident::new(name, Span::call_site());
                quote! {
                    const #name_ident: Attribute<'static> =
                        Attribute(#attr, #value);
                }
            });

        quote! {
            /// Generated code. Do not edit.
            use crate::Icon;
            use crate::icon_name::*;
            use crate::icon_variant::*;
            #(#common_attr_tokens)*
            #(#impl_tokens)*
        }
    }

    fn svg_code(icon: &IconFile) -> TokenStream {
        let svg_tag = parser::parse(&icon.svg_file);
        assert_eq!(svg_tag.name, "svg");

        let name_ident = format_ident!("{}", icon.name);
        let variant_ident = format_ident!("{}", icon.variant);
        let attributes = svg_tag.attributes.into_iter().map(attr_code);
        let children = svg_tag.children.into_iter().map(child_code);

        quote! {
            impl ToSvg for Icon<'_, #name_ident, #variant_ident> {
                fn to_svg<'a>(&'a self) -> Svg<'a> {
                    let mut attrs = vec![#(#attributes),*];
                    if !self.id.is_empty() {
                        attrs.push(Attribute("id", self.id))
                    }
                    if !self.class.is_empty() {
                        attrs.push(Attribute("class", self.class))
                    }
                    Svg {
                        attrs,
                        children: &[#(#children),*],
                    }
                }
            }
        }
    }

    fn child_code(child: Tag) -> Option<TokenStream> {
        let tag_name = child.name;
        let attrs = child.attributes.into_iter().map(attr_code);
        Some(quote! {
            SvgChild {
                tag_name: #tag_name,
                attrs: &[
                    #(#attrs),*
                ],

            }
        })
    }

    fn attr_code((attribute, value): (String, String)) -> TokenStream {
        let common_attr = COMMON_ATTRS
            .iter()
            .find(|(_, attr, val)| attr == &attribute && val == &value);
        match common_attr {
            Some((name, _, _)) => {
                let ident = Ident::new(name, Span::call_site());
                quote! {
                    #ident
                }
            }
            None => quote! {
                Attribute(#attribute, #value)
            },
        }
    }
}