use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use serde::Deserialize;
use std::fs;
use syn::{parse_macro_input, ItemMod, Lit, Meta};
#[derive(Deserialize, Debug)]
struct GenreData {
genres: Vec<Genre>,
}
#[derive(Deserialize, Debug, Clone)]
struct Genre {
id: String,
subgenres: Option<Vec<SubGenre>>,
}
#[derive(Deserialize, Debug, Clone)]
struct SubGenre {
id: String,
}
#[proc_macro_attribute]
pub fn music_genres(args: TokenStream, input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as ItemMod);
let path = parse_path_from_args(args).unwrap_or_else(|err| {
panic!("music_genres macro error: {}", err);
});
let genre_data = load_genre_data(&path).unwrap_or_else(|err| {
panic!("Failed to load genre data from '{}': {}", path, err);
});
let generated_enum = generate_genre_enum(&genre_data);
let vis = &input.vis;
let mod_name = &input.ident;
let attrs = &input.attrs;
let expanded = quote! {
#(#attrs)*
#vis mod #mod_name {
#generated_enum
}
};
TokenStream::from(expanded)
}
fn parse_path_from_args(args: TokenStream) -> Result<String, String> {
if args.is_empty() {
return Err("path argument is required".to_string());
}
let args_parsed =
syn::parse::<Meta>(args).map_err(|e| format!("Failed to parse arguments: {}", e))?;
match args_parsed {
Meta::NameValue(nv) if nv.path.is_ident("path") => match nv.value {
syn::Expr::Lit(syn::ExprLit {
lit: Lit::Str(lit_str),
..
}) => Ok(lit_str.value()),
_ => Err("path must be a string literal".to_string()),
},
_ => Err("Expected 'path = \"...\"' argument".to_string()),
}
}
fn load_genre_data(path: &str) -> Result<GenreData, Box<dyn std::error::Error>> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let full_path = std::path::Path::new(&manifest_dir).join(path);
let final_path = if full_path.exists() {
full_path
} else {
std::path::PathBuf::from(path)
};
let content = fs::read_to_string(&final_path)
.map_err(|e| format!("Cannot read file {:?}: {}", final_path, e))?;
let genre_data: GenreData =
serde_json::from_str(&content).map_err(|e| format!("Cannot parse JSON: {}", e))?;
Ok(genre_data)
}
fn generate_genre_enum(genre_data: &GenreData) -> proc_macro2::TokenStream {
let mut variants = Vec::new();
let mut discriminant = 0u16;
let mut sorted_genres = genre_data.genres.clone();
sorted_genres.sort_by(|a, b| a.id.cmp(&b.id));
for genre in sorted_genres {
let main_genre_ident = format_ident(&genre.id);
variants.push(quote! {
#main_genre_ident = #discriminant
});
discriminant += 1;
if let Some(subgenres) = &genre.subgenres {
let mut sorted_subgenres = subgenres.clone();
sorted_subgenres.sort_by(|a, b| a.id.cmp(&b.id));
for subgenre in sorted_subgenres {
let subgenre_ident = format_ident(&subgenre.id);
variants.push(quote! {
#subgenre_ident = #discriminant
});
discriminant += 1;
}
}
}
quote! {
use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
use scale_info::TypeInfo;
#[cfg(feature = "std")]
use ts_rs::TS;
#[derive(
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Debug,
Encode,
Decode,
DecodeWithMemTracking,
TypeInfo,
MaxEncodedLen,
)]
#[cfg_attr(feature = "std", derive(TS), ts(export), ts(export_to = "shared/"))]
#[repr(u16)]
pub enum GenreId {
#(#variants,)*
}
}
}
fn format_ident(name: &str) -> syn::Ident {
let formatted = name
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
}
}
})
.collect::<String>();
let cleaned = formatted
.replace(" ", "")
.replace("/", "")
.replace("-", "")
.replace("&", "And")
.replace("'", "")
.replace("‑", "")
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
syn::Ident::new(&cleaned, Span::call_site())
}