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}