Skip to main content

gpui_component_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::parse::{Parse, ParseStream};
4
5mod crate_path;
6mod derive_into_plot;
7
8/// Input for icon_name! macro: EnumName, "path", [optional derives]
9struct IconNameInput {
10    enum_name: syn::Ident,
11    _comma: syn::Token![,],
12    path: syn::LitStr,
13    derives: Option<(
14        syn::Token![,],
15        syn::punctuated::Punctuated<syn::Path, syn::Token![,]>,
16    )>,
17}
18
19impl Parse for IconNameInput {
20    fn parse(input: ParseStream) -> syn::Result<Self> {
21        let enum_name = input.parse()?;
22        let _comma = input.parse()?;
23        let path = input.parse()?;
24
25        // Check if there's an optional derives list
26        let derives = if input.peek(syn::Token![,]) {
27            let comma = input.parse()?;
28            let content;
29            syn::bracketed!(content in input);
30            let derives = content.parse_terminated(syn::Path::parse, syn::Token![,])?;
31            Some((comma, derives))
32        } else {
33            None
34        };
35
36        Ok(IconNameInput {
37            enum_name,
38            _comma,
39            path,
40            derives,
41        })
42    }
43}
44
45#[proc_macro_derive(IntoPlot)]
46pub fn derive_into_plot(input: TokenStream) -> TokenStream {
47    derive_into_plot::derive_into_plot(input)
48}
49
50/// Convert an SVG filename to PascalCase identifier.
51///
52/// Strips `.svg` extension, splits on separators (`-`, `_`, `.`),
53/// and capitalizes each word following Rust naming conventions.
54///
55/// # Examples
56///
57/// ```ignore
58/// assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight");
59/// assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName");
60/// assert_eq!(pascal_case("icon-123.svg"), "Icon123");
61/// ```
62fn pascal_case(filename: &str) -> String {
63    filename
64        .strip_suffix(".svg")
65        .unwrap_or(filename)
66        .split(|c: char| c == '-' || c == '_' || c == '.')
67        .filter(|part| !part.is_empty())
68        .map(|word| {
69            let mut chars = word.chars();
70            match chars.next() {
71                None => String::new(),
72                Some(first) if first.is_ascii_digit() => word.to_string(),
73                Some(first) => {
74                    let mut result = String::with_capacity(word.len());
75                    result.extend(first.to_uppercase());
76                    result.push_str(&chars.as_str().to_lowercase());
77                    result
78                }
79            }
80        })
81        .collect()
82}
83
84/// Generate a custom icon enum and its `IconNamed` impl by scanning a directory of SVG files.
85///
86/// Accepts an enum name, a path, and optionally a list of additional derive traits.
87/// Each `.svg` file becomes an enum variant using PascalCase conversion.
88///
89/// The path may be either:
90///
91/// - **A literal path** (the common case), resolved relative to the calling crate's
92///   `CARGO_MANIFEST_DIR`. Use this when the icons live inside your own package.
93/// - **An env-var reference** of the form `"$NAME"`, where `NAME` names a build-time
94///   environment variable whose value is the absolute path to the icons directory.
95///   Use this when the icons live in *another* crate and the path is plumbed
96///   through cargo's `links` / `DEP_<X>_<KEY>` propagation mechanism. This avoids
97///   sibling-crate references that would break `cargo vendor` and `cargo publish`.
98///
99/// # Example
100///
101/// ```ignore
102/// // Literal path (relative to the calling crate's CARGO_MANIFEST_DIR)
103/// icon_named!(IconName, "icons");
104///
105/// // Env-var reference (resolved at macro expansion time)
106/// icon_named!(IconName, "$CUSTOM_ICONS_DIR");
107///
108/// // With custom derives
109/// icon_named!(IconName, "icons", [Debug, Copy, PartialEq, Eq]);
110/// ```
111#[proc_macro]
112pub fn icon_named(input: TokenStream) -> TokenStream {
113    let IconNameInput {
114        enum_name,
115        path,
116        derives,
117        ..
118    } = syn::parse_macro_input!(input as IconNameInput);
119
120    let raw_path = path.value();
121
122    // Resolve the path. A leading `$` switches us into env-var mode: the
123    // remainder of the string is an env var name whose value (set by the
124    // caller's `build.rs` via `cargo:rustc-env=`) is the absolute path of
125    // the icons directory. Otherwise treat the string as a path relative
126    // to the calling crate's `CARGO_MANIFEST_DIR`, the original behavior.
127    let icons_dir = if let Some(env_name) = raw_path.strip_prefix('$') {
128        let env_value = std::env::var(env_name).unwrap_or_else(|_| {
129            panic!(
130                "icon_named!: env var `{env_name}` is not set at expansion time. \
131                 Ensure the calling crate's build.rs propagates it via \
132                 `cargo:rustc-env={env_name}=<absolute path>`."
133            )
134        });
135        std::path::PathBuf::from(env_value)
136    } else {
137        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
138        std::path::Path::new(&manifest_dir).join(&raw_path)
139    };
140
141    let mut entries: Vec<(String, String)> = Vec::new();
142
143    let dir = std::fs::read_dir(&icons_dir).unwrap_or_else(|e| {
144        panic!(
145            "generate_icon_enum: failed to read '{}': {}",
146            icons_dir.display(),
147            e
148        )
149    });
150
151    for entry in dir {
152        let entry = entry.expect("failed to read directory entry");
153        let filename = entry.file_name().to_string_lossy().to_string();
154        if filename.ends_with(".svg") {
155            let variant_name = pascal_case(&filename);
156            let path = format!("icons/{}", filename);
157            entries.push((variant_name, path));
158        }
159    }
160
161    entries.sort_by(|a, b| a.0.cmp(&b.0));
162
163    let variants: Vec<proc_macro2::Ident> = entries
164        .iter()
165        .map(|(name, _)| proc_macro2::Ident::new(name, proc_macro2::Span::call_site()))
166        .collect();
167    let paths: Vec<&str> = entries.iter().map(|(_, p)| p.as_str()).collect();
168
169    // Build derive list: always include IntoElement and Clone, then add custom derives
170    let derive_attrs = if let Some((_, custom_derives)) = derives {
171        let derives_vec: Vec<_> = custom_derives.iter().collect();
172        quote! {
173            #[derive(IntoElement, Clone, #(#derives_vec),*)]
174        }
175    } else {
176        quote! {
177            #[derive(IntoElement, Clone)]
178        }
179    };
180
181    let expanded = quote! {
182        #derive_attrs
183
184        pub enum #enum_name {
185            #(#variants,)*
186        }
187
188        impl IconNamed for #enum_name {
189            fn path(self) -> SharedString {
190                match self {
191                    #(Self::#variants => #paths,)*
192                }
193                .into()
194            }
195        }
196    };
197
198    TokenStream::from(expanded)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_pascal_case_basic() {
207        assert_eq!(pascal_case("arrow-right.svg"), "ArrowRight");
208        assert_eq!(pascal_case("home.svg"), "Home");
209        assert_eq!(pascal_case("x-circle.svg"), "XCircle");
210
211        assert_eq!(pascal_case("some_icon_name.svg"), "SomeIconName");
212        assert_eq!(pascal_case("arrow_up_down.svg"), "ArrowUpDown");
213
214        assert_eq!(pascal_case("kebab-case_mixed.svg"), "KebabCaseMixed");
215        assert_eq!(pascal_case("icon-with_under.svg"), "IconWithUnder");
216
217        assert_eq!(pascal_case("icon-123.svg"), "Icon123");
218        assert_eq!(pascal_case("arrow-2x.svg"), "Arrow2x");
219        assert_eq!(pascal_case("24-hour.svg"), "24Hour");
220
221        assert_eq!(pascal_case("arrow--right.svg"), "ArrowRight");
222        assert_eq!(pascal_case("icon__name.svg"), "IconName");
223        assert_eq!(pascal_case("multiple---dash.svg"), "MultipleDash");
224
225        assert_eq!(pascal_case("a.svg"), "A");
226        assert_eq!(pascal_case("-leading.svg"), "Leading");
227        assert_eq!(pascal_case("trailing-.svg"), "Trailing");
228        assert_eq!(pascal_case("-.svg"), "");
229
230        assert_eq!(pascal_case("arrow-right"), "ArrowRight");
231        assert_eq!(pascal_case("home"), "Home");
232
233        assert_eq!(pascal_case("hello.svg"), "Hello");
234        assert_eq!(pascal_case("WORLD.svg"), "World");
235        assert_eq!(pascal_case("iOS-icon.svg"), "IosIcon");
236        assert_eq!(pascal_case("API-key.svg"), "ApiKey");
237    }
238}