Skip to main content

midds_v2_codegen/
lib.rs

1// This file is part of Allfeat.
2
3// Copyright (C) 2022-2025 Allfeat.
4// SPDX-License-Identifier: GPL-3.0-or-later
5
6// This program is free software: you can redistribute it and/or modify
7// it under the terms of the GNU General Public License as published by
8// the Free Software Foundation, either version 3 of the License, or
9// (at your option) any later version.
10
11// This program is distributed in the hope that it will be useful,
12// but WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14// GNU General Public License for more details.
15
16// You should have received a copy of the GNU General Public License
17// along with this program. If not, see <https://www.gnu.org/licenses/>.
18
19//! Procedural macros for MIDDS v2 code generation.
20
21use proc_macro::TokenStream;
22use proc_macro2::Span;
23use quote::quote;
24use serde::Deserialize;
25use std::fs;
26use syn::{parse_macro_input, ItemMod, Lit, Meta};
27
28/// Structure representing the music genres JSON file
29#[derive(Deserialize, Debug)]
30struct GenreData {
31    genres: Vec<Genre>,
32}
33
34#[derive(Deserialize, Debug, Clone)]
35struct Genre {
36    id: String,
37    subgenres: Option<Vec<SubGenre>>,
38}
39
40#[derive(Deserialize, Debug, Clone)]
41struct SubGenre {
42    id: String,
43}
44
45/// Procedural macro to generate music genres enum from JSON file
46///
47/// Usage:
48/// ```rust
49/// #[midds::music_genres(path = "./music-genres.json")]
50/// pub mod genres;
51/// ```
52#[proc_macro_attribute]
53pub fn music_genres(args: TokenStream, input: TokenStream) -> TokenStream {
54    let input = parse_macro_input!(input as ItemMod);
55
56    // Parse the path argument
57    let path = parse_path_from_args(args).unwrap_or_else(|err| {
58        panic!("music_genres macro error: {}", err);
59    });
60
61    // Load and parse the JSON file
62    let genre_data = load_genre_data(&path).unwrap_or_else(|err| {
63        panic!("Failed to load genre data from '{}': {}", path, err);
64    });
65
66    // Generate the enum
67    let generated_enum = generate_genre_enum(&genre_data);
68
69    // Get the module's visibility, name, and attributes
70    let vis = &input.vis;
71    let mod_name = &input.ident;
72    let attrs = &input.attrs;
73
74    // Return the module with generated content inside
75    let expanded = quote! {
76        #(#attrs)*
77        #vis mod #mod_name {
78            #generated_enum
79        }
80    };
81
82    TokenStream::from(expanded)
83}
84
85fn parse_path_from_args(args: TokenStream) -> Result<String, String> {
86    if args.is_empty() {
87        return Err("path argument is required".to_string());
88    }
89
90    let args_parsed =
91        syn::parse::<Meta>(args).map_err(|e| format!("Failed to parse arguments: {}", e))?;
92
93    match args_parsed {
94        Meta::NameValue(nv) if nv.path.is_ident("path") => match nv.value {
95            syn::Expr::Lit(syn::ExprLit {
96                lit: Lit::Str(lit_str),
97                ..
98            }) => Ok(lit_str.value()),
99            _ => Err("path must be a string literal".to_string()),
100        },
101        _ => Err("Expected 'path = \"...\"' argument".to_string()),
102    }
103}
104
105fn load_genre_data(path: &str) -> Result<GenreData, Box<dyn std::error::Error>> {
106    // Try to resolve path relative to CARGO_MANIFEST_DIR first
107    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
108    let full_path = std::path::Path::new(&manifest_dir).join(path);
109
110    let final_path = if full_path.exists() {
111        full_path
112    } else {
113        std::path::PathBuf::from(path)
114    };
115
116    let content = fs::read_to_string(&final_path)
117        .map_err(|e| format!("Cannot read file {:?}: {}", final_path, e))?;
118    let genre_data: GenreData =
119        serde_json::from_str(&content).map_err(|e| format!("Cannot parse JSON: {}", e))?;
120    Ok(genre_data)
121}
122
123fn generate_genre_enum(genre_data: &GenreData) -> proc_macro2::TokenStream {
124    let mut variants = Vec::new();
125    let mut discriminant = 0u16;
126
127    // Sort genres by id for consistent ordering
128    let mut sorted_genres = genre_data.genres.clone();
129    sorted_genres.sort_by(|a, b| a.id.cmp(&b.id));
130
131    for genre in sorted_genres {
132        // Add the main genre using the ID as identifier
133        let main_genre_ident = format_ident(&genre.id);
134
135        variants.push(quote! {
136            #main_genre_ident = #discriminant
137        });
138        discriminant += 1;
139
140        // Add subgenres if they exist
141        if let Some(subgenres) = &genre.subgenres {
142            let mut sorted_subgenres = subgenres.clone();
143            sorted_subgenres.sort_by(|a, b| a.id.cmp(&b.id));
144
145            for subgenre in sorted_subgenres {
146                let subgenre_ident = format_ident(&subgenre.id);
147                variants.push(quote! {
148                    #subgenre_ident = #discriminant
149                });
150                discriminant += 1;
151            }
152        }
153    }
154
155    quote! {
156        use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
157        use scale_info::TypeInfo;
158
159        #[cfg(feature = "std")]
160        use ts_rs::TS;
161
162        /// Flat enum containing all main genres and subgenres.
163        /// This enum is used directly in the blockchain to identify any genre type.
164        #[derive(
165            Clone,
166            Copy,
167            PartialEq,
168            Eq,
169            PartialOrd,
170            Ord,
171            Debug,
172            Encode,
173            Decode,
174            DecodeWithMemTracking,
175            TypeInfo,
176            MaxEncodedLen,
177        )]
178        #[cfg_attr(feature = "std", derive(TS), ts(export), ts(export_to = "shared/"))]
179        #[repr(u16)]
180        pub enum GenreId {
181            #(#variants,)*
182        }
183    }
184}
185
186fn format_ident(name: &str) -> syn::Ident {
187    // Convert snake_case or kebab-case to PascalCase for enum variants
188    let formatted = name
189        .split('_')
190        .map(|word| {
191            let mut chars = word.chars();
192            match chars.next() {
193                None => String::new(),
194                Some(first) => {
195                    first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
196                }
197            }
198        })
199        .collect::<String>();
200
201    // Clean up any remaining special characters
202    let cleaned = formatted
203        .replace(" ", "")
204        .replace("/", "")
205        .replace("-", "")
206        .replace("&", "And")
207        .replace("'", "")
208        .replace("‑", "")
209        .chars()
210        .filter(|c| c.is_alphanumeric())
211        .collect::<String>();
212
213    syn::Ident::new(&cleaned, Span::call_site())
214}