path2enum/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Span, TokenStream as TokenStream2};
3use quote::{format_ident, quote};
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8use syn::{Attribute, ItemEnum, parse_macro_input};
9
10/// Trait that converts a string path into a valid Rust enum identifier
11trait ToValidIdent {
12    fn to_valid_rust_ident_with_no(&self) -> String;
13}
14
15impl ToValidIdent for str {
16    fn to_valid_rust_ident_with_no(&self) -> String {
17        let parts: Vec<&str> = self.split('/').collect();
18
19        let mut segments = Vec::new();
20
21        for part in parts {
22            let replaced = part.replace('&', "And");
23            let replaced = replaced.replace('.', "・");
24            // Convert to PascalCase
25            let pascal = replaced
26                .split(&['-', '_', '.', ' '][..])
27                .filter(|s| !s.is_empty())
28                .map(|word| {
29                    let mut chars = word.chars();
30                    if let Some(first_char) = chars.next() {
31                        let mut s = String::new();
32
33                        // Prefix digits with '_'
34                        if first_char.is_ascii_digit() {
35                            s.push('_');
36                            s.push(first_char);
37                        } else {
38                            s.push(first_char.to_ascii_uppercase());
39                        }
40
41                        s.push_str(chars.as_str());
42                        s
43                    } else {
44                        String::new()
45                    }
46                })
47                .collect::<String>();
48
49            segments.push(pascal);
50        }
51
52        // Join segments using 'ノ' instead of '/'
53        segments.join("ノ")
54    }
55}
56
57#[proc_macro_attribute]
58/// Procedural macro `magic` to generate Rust enums from filesystem files.
59///
60/// Generates enum variants whose names are derived from real file paths,
61/// transformed into valid Rust identifiers, while preserving the original path for access.
62///
63/// # Macro attributes (parameters):
64///
65/// - `path: &str`  
66///   Root directory where the macro will scan files.  
67///   Default: `"."` (project root).
68///
69/// - `ext: &str`  
70///   Allowed file extensions, comma-separated (e.g. `"rs,svg,toml"`).  
71///   Default: `"svg"`.
72///
73/// - `prefix: &str`  
74///   Optional prefix added to the returned path from `.to_str()`.  
75///   Useful for virtual namespaces or folders.
76///
77/// # Behavior
78///
79/// - Recursively scans the `path` directory.
80/// - For each file with an extension in `ext`, generates an enum variant.
81/// - Variant names are derived from the file path, transformed to valid Rust identifiers:
82///   - `/` is replaced by `ノ` (katakana no)
83///   - `-`, `_`, `.` (dot), and spaces are treated as separators for PascalCase words
84///   - The `.` character is replaced by the Japanese middle dot `・` (U+30FB) in the identifier,
85///     so file extensions appear visually separated but valid in Rust identifiers.
86///   - Invalid characters replaced (e.g., digit prefixes get a leading underscore)
87///   - `&` is replaced by `And`
88///
89/// - The `.to_str()` method returns the original file path as-is,
90///   including hyphens, underscores, dots, and prefix (if any).
91///
92/// # Examples
93///
94/// ```rust
95/// use path2enum::magic;
96///
97/// #[magic(path = "tests/assets", ext = "svg,toml")]
98/// pub enum PublicPaths {}
99///
100/// assert_eq!(PublicPaths::ArrowLeft・svg.to_str(), "arrow-left.svg");
101/// assert_eq!(PublicPaths::NestedDirノIcon・svg.to_str(), "nested_dir/icon.svg");
102/// assert_eq!(PublicPaths::NestedDirノDeepDirノDeepIcon・svg.to_str(), "nested_dir/deep_dir/deep-icon.svg");
103///
104/// #[magic(ext = "rs,svg,toml")]
105/// pub enum ProjectPaths {}
106///
107/// assert_eq!(ProjectPaths::SrcノLib・rs.to_str(), "src/lib.rs");
108/// assert_eq!(ProjectPaths::TestsノAssetsノArrowLeft・svg.to_str(), "tests/assets/arrow-left.svg");
109/// assert_eq!(ProjectPaths::Cargo・toml.to_str(), "Cargo.toml");
110///
111/// #[magic(path = "tests/assets", ext = "svg", prefix = "icons")]
112/// pub enum Icons {}
113///
114/// assert_eq!(Icons::IconsノHome・svg.to_str(), "icons/home.svg");
115/// assert_eq!(Icons::Iconsノ_11Testノ_11・svg.to_str(), "icons/11-test/11.svg");
116/// assert_eq!(Icons::IconsノNestedDirノDeepDirノDeepIcon・svg.to_str(), "icons/nested_dir/deep_dir/deep-icon.svg");
117/// ```
118///
119/// # Notes
120///
121/// - The generated enum derives common traits (`Debug, Clone, Copy, PartialEq, Eq`).
122/// - The `.to_str()` method returns the original file path for runtime usage.
123/// - Generated variant identifiers follow Rust naming rules even for special characters or digit-starting names.
124/// - The Japanese middle dot `・` improves readability of extensions inside identifiers without breaking Rust syntax.
125///
126/// # Typical use case
127///
128/// Useful for embedding static assets, config files, or resources accessible via enums at compile time,
129/// avoiding hardcoded string literals.
130///
131/// # Requirements
132///
133/// This macro depends on the `path2enum` crate which should be added as a dependency.
134///
135///
136/// ```ignore
137/// // Simplified usage example
138/// #[magic(path = "assets/icons", ext = "svg", prefix = "icons")]
139/// pub enum Icons {}
140///
141/// fn main() {
142///     println!("{}", Icons::IconsノHome・svg.to_str());
143/// }
144/// ```
145///
146pub fn magic(attr: TokenStream, item: TokenStream) -> TokenStream {
147    let input_enum = parse_macro_input!(item as ItemEnum);
148
149    let attr_ts2: TokenStream2 = attr.into();
150    let attr: Attribute = syn::parse_quote!(#[magic(#attr_ts2)]);
151
152    let mut root = None;
153    let mut ext: Option<Vec<String>> = None;
154    let mut prefix = String::new();
155
156    let _ = attr.parse_nested_meta(|meta| {
157        if meta.path.is_ident("path") {
158            let value = meta.value()?.parse::<syn::LitStr>()?;
159            root = Some(value.value());
160            Ok(())
161        } else if meta.path.is_ident("ext") {
162            let value = meta.value()?.parse::<syn::LitStr>()?;
163            let exts = value
164                .value()
165                .split(',')
166                .map(|s| s.trim().to_string())
167                .filter(|s| !s.is_empty())
168                .collect::<Vec<_>>();
169            ext = Some(exts);
170            Ok(())
171        } else if meta.path.is_ident("prefix") {
172            let value = meta.value()?.parse::<syn::LitStr>()?;
173            prefix = value.value();
174            Ok(())
175        } else {
176            Err(meta.error("Only `path`, `ext`, and `prefix` are supported"))
177        }
178    });
179
180    // Default to project root if no path is provided
181    let root = root.unwrap_or_else(|| ".".to_string());
182    let ext = ext.unwrap_or_else(|| vec!["svg".to_string()]);
183    let root_path = PathBuf::from(&root);
184
185    let enum_ident = &input_enum.ident;
186
187    let mut variants = Vec::new();
188    collect_paths(&root_path, &ext, &mut variants, "", &prefix);
189
190    variants.sort_by(|a, b| a.0.cmp(&b.0));
191
192    let variant_defs = variants.iter().map(|(ident, _)| quote! { #ident, });
193
194    let match_arms = variants.iter().map(|(ident, original_path)| {
195        let lit = syn::LitStr::new(original_path, Span::call_site());
196        quote! {
197            Self::#ident => #lit,
198        }
199    });
200
201    let expanded = quote! {
202        #[allow(mixed_script_confusables)]
203        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
204        pub enum #enum_ident {
205            #(#variant_defs)*
206        }
207
208        impl #enum_ident {
209            pub fn to_str(&self) -> &'static str {
210                match self {
211                    #(#match_arms)*
212                }
213            }
214
215            pub fn to_string(&self) -> String {
216                self.to_str().to_string()
217            }
218        }
219    };
220
221    TokenStream::from(expanded)
222}
223
224fn collect_paths(
225    dir: &Path,
226    allowed_exts: &[String],
227    variants: &mut Vec<(proc_macro2::Ident, String)>,
228    current_rel_path: &str,
229    prefix: &str,
230) {
231    let Ok(entries) = fs::read_dir(dir) else {
232        return;
233    };
234
235    for entry in entries.filter_map(Result::ok) {
236        let path = entry.path();
237        let file_name = entry.file_name();
238        let name = file_name.to_string_lossy();
239
240        // Build relative path
241        let rel_path = if current_rel_path.is_empty() {
242            name.to_string()
243        } else {
244            format!("{}/{}", current_rel_path, name)
245        };
246
247        if path.is_dir() {
248            collect_paths(&path, allowed_exts, variants, &rel_path, prefix);
249        } else if path.is_file() && has_allowed_extension(&name, allowed_exts) {
250            let logical_path = if prefix.is_empty() {
251                rel_path.clone()
252            } else {
253                format!("{}/{}", prefix, rel_path)
254            };
255
256            let variant_ident = format_ident!("{}", logical_path.to_valid_rust_ident_with_no());
257            variants.push((variant_ident, logical_path));
258        }
259    }
260}
261
262fn has_allowed_extension(file_name: &str, allowed_exts: &[String]) -> bool {
263    allowed_exts
264        .iter()
265        .any(|ext| file_name.ends_with(&format!(".{}", ext)))
266}