jobby_derive/
lib.rs

1//! This crate provides a procedural macro for deriving job types in jobby.
2//! 
3//! The `JobType` macro allows you to define enums that represent different job types, 
4//! along with associated metadata such as client IDs and names. It also supports 
5//! attributes for marking variants as submittable via an admin UI
6//! 
7//! ## Usage
8//! 
9//! To use this macro, annotate your enum with `#[derive(JobType)]` and provide 
10//! the necessary attributes. For example:
11//! 
12//! ```
13//! #[derive(JobType)]
14//! #[job_type(u8, 1, "example", "example_module")]
15//! enum ExampleJob {
16//!     #[job_type(submittable)]
17//!     JobA = 1,
18//!     JobB = 2,
19//! }
20//! ```
21//! 
22//! This will generate implementations for converting the enum to and from 
23//! its underlying representation, as well as metadata retrieval functions.
24
25#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
26
27use proc_macro::TokenStream;
28use proc_macro2::Span;
29use quote::quote;
30use syn::{
31    parse::{Parse, ParseStream},
32    parse_macro_input, parse_quote,
33    spanned::Spanned,
34    Data, DeriveInput, Error, Expr, Ident, LitInt, LitStr, Meta, Result,
35};
36
37mod kw {
38    syn::custom_keyword!(submittable);
39}
40
41struct JobbyVariantAttributes {
42    items: syn::punctuated::Punctuated<JobbyVariantAttributeItem, syn::Token![,]>,
43}
44
45impl Parse for JobbyVariantAttributes {
46    fn parse(input: ParseStream<'_>) -> Result<Self> {
47        Ok(Self {
48            items: input.parse_terminated(JobbyVariantAttributeItem::parse)?,
49        })
50    }
51}
52
53enum JobbyVariantAttributeItem {
54    Submittable(VariantSubmittableAttribute),
55}
56
57impl Parse for JobbyVariantAttributeItem {
58    fn parse(input: ParseStream<'_>) -> Result<Self> {
59        let lookahead = input.lookahead1();
60        if lookahead.peek(kw::submittable) {
61            input.parse().map(Self::Submittable)
62        } else {
63            Err(lookahead.error())
64        }
65    }
66}
67
68struct VariantSubmittableAttribute {
69    keyword: kw::submittable,
70}
71
72impl Parse for VariantSubmittableAttribute {
73    fn parse(input: ParseStream) -> Result<Self> {
74        Ok(Self {
75            keyword: input.parse()?,
76        })
77    }
78}
79
80impl Spanned for VariantSubmittableAttribute {
81    fn span(&self) -> Span {
82        self.keyword.span()
83    }
84}
85
86struct VariantInfo {
87    ident: Ident,
88    discriminant: Expr,
89    is_submittable: bool,
90    name: LitStr,
91}
92
93struct EnumInfo {
94    name: Ident,
95    repr: Ident,
96    client_id: LitInt,
97    client_name: LitStr,
98    client_module: Ident,
99    variants: Vec<VariantInfo>,
100}
101
102impl Parse for EnumInfo {
103    fn parse(input: ParseStream) -> Result<Self> {
104        Ok({
105            let input: DeriveInput = input.parse()?;
106            let name = input.ident;
107            let data = match input.data {
108                Data::Enum(data) => data,
109                Data::Union(data) => {
110                    return Err(Error::new_spanned(
111                        data.union_token,
112                        "Expected enum but found union",
113                    ))
114                }
115                Data::Struct(data) => {
116                    return Err(Error::new_spanned(
117                        data.struct_token,
118                        "Expected enum but found struct",
119                    ))
120                }
121            };
122
123            let mut maybe_repr: Option<Ident> = None;
124            let mut maybe_client_id: Option<LitInt> = None;
125            let mut maybe_client_name: Option<LitStr> = None;
126            let mut maybe_client_module: Option<Ident> = None;
127
128            for attr in input.attrs {
129                let Ok(Meta::List(meta_list)) = attr.parse_meta() else {
130                    continue;
131                };
132                let Some(ident) = meta_list.path.get_ident() else {
133                    continue;
134                };
135                if ident == "repr" {
136                    let mut nested = meta_list.nested.iter();
137                    if nested.len() != 1 {
138                        return Err(Error::new_spanned(
139                            attr,
140                            "Expected exactly one `repr` argument",
141                        ));
142                    }
143                    let repr = nested.next().expect("We checked the length above!");
144                    let repr: Ident = parse_quote! {
145                        #repr
146                    };
147                    if repr != "u8" {
148                        return Err(Error::new_spanned(repr, "JobType must be repr(u8)"));
149                    }
150                    maybe_repr = Some(repr);
151                } else if ident == "job_type" {
152                    let mut nested = meta_list.nested.iter();
153                    if nested.len() != 3 {
154                        return Err(Error::new_spanned(
155                            attr,
156                            "Expected exactly three `job_type` arguments",
157                        ));
158                    }
159                    let client_id = nested.next().expect("We checked the length above!");
160                    let client_id: LitInt = parse_quote! {
161                        #client_id
162                    };
163                    maybe_client_id = Some(client_id);
164                    let client_name = nested.next().expect("We checked the length above!");
165                    let client_name: LitStr = parse_quote! {
166                        #client_name
167                    };
168                    maybe_client_name = Some(client_name);
169                    let client_module = nested.next().expect("We checked the length above!");
170                    let client_module: Ident = parse_quote! {
171                        #client_module
172                    };
173                    maybe_client_module = Some(client_module);
174                } else {
175                    // ignore the rest
176                }
177            }
178
179            let repr = maybe_repr.ok_or_else(|| {
180                Error::new(Span::call_site(), "Expected exactly one repr(u8) argument!")
181            })?;
182            let client_id = maybe_client_id.ok_or_else(|| {
183                Error::new(
184                    Span::call_site(),
185                    "Expected to find valid client_id argument!",
186                )
187            })?;
188            let client_name = maybe_client_name.ok_or_else(|| {
189                Error::new(
190                    Span::call_site(),
191                    "Expected to find valid client_name argument!",
192                )
193            })?;
194            let client_module = maybe_client_module.ok_or_else(|| {
195                Error::new(
196                    Span::call_site(),
197                    "Expected to find valid client_module argument!",
198                )
199            })?;
200
201            let mut variants: Vec<VariantInfo> = vec![];
202
203            for variant in data.variants {
204                let ident = variant.ident.clone();
205
206                let discriminant = variant
207                    .discriminant
208                    .as_ref()
209                    .map(|d| d.1.clone())
210                    .ok_or_else(|| {
211                        Error::new_spanned(
212                            &variant,
213                            "Variant must have a discriminant to ensure forward compatibility",
214                        )
215                    })?
216                    .clone();
217
218                let mut is_submittable = false;
219
220                for attribute in &variant.attrs {
221                    if attribute.path.is_ident("job_type") {
222                        match attribute.parse_args_with(JobbyVariantAttributes::parse) {
223                            Ok(variant_attributes) => {
224                                for variant_attribute in variant_attributes.items {
225                                    match variant_attribute {
226                                        JobbyVariantAttributeItem::Submittable(_) => {
227                                            is_submittable = true;
228                                        }
229                                    }
230                                }
231                            }
232                            Err(err) => {
233                                return Err(Error::new_spanned(
234                                    attribute,
235                                    format!("Invalid attribute: {err}"),
236                                ));
237                            }
238                        }
239                    }
240                }
241
242                let name = LitStr::new(&ident.to_string(), ident.span());
243
244                variants.push(VariantInfo {
245                    ident,
246                    discriminant,
247                    is_submittable,
248                    name,
249                });
250            }
251
252            Self {
253                name,
254                repr,
255                client_id,
256                client_name,
257                client_module,
258                variants,
259            }
260        })
261    }
262}
263
264#[proc_macro_derive(JobType, attributes(job_type, submittable))]
265pub fn derive_job_type(input: TokenStream) -> TokenStream {
266    let enum_info = parse_macro_input!(input as EnumInfo);
267    let name = &enum_info.name;
268    let repr = &enum_info.repr;
269    let client_id = &enum_info.client_id;
270    let client_name = &enum_info.client_name;
271    let client_module = &enum_info.client_module;
272    let helper_module_name = Ident::new(
273        &format!("{}_{}", "jobby_init_", &name.to_string()),
274        name.span(),
275    );
276
277    let mut from_str_arms = Vec::new();
278    let mut metadata_arms = Vec::new();
279    for variant in enum_info.variants {
280        let ident = &variant.ident;
281        let output = &variant.name;
282        let is_submittable = &variant.is_submittable;
283        let discriminant = &variant.discriminant;
284        from_str_arms.push(quote! { #name::#ident => #output });
285        metadata_arms.push(quote! {
286            jobby::JobTypeMetadata {
287                unnamespaced_id: jobby::UnnamespacedJobType::from(Self::#ident).id(),
288                base_id: #discriminant,
289                client_id: #client_id,
290                name: #output,
291                client_name: #client_name,
292                is_submittable: #is_submittable,
293            }
294        });
295    }
296
297    TokenStream::from(quote! {
298        impl From<#name> for #repr {
299            #[inline]
300            fn from(enum_value: #name) -> Self {
301                enum_value as Self
302            }
303        }
304
305        impl From<#name> for &'static str {
306            fn from(enum_value: #name) -> Self {
307                match enum_value {
308                    #(#from_str_arms),*
309                }
310            }
311        }
312
313        impl jobby::JobType for #name {
314            #[inline]
315            fn client_id() -> usize {
316                #client_id
317            }
318
319            fn list_metadata() -> Vec<jobby::JobTypeMetadata> {
320                vec![
321                    #(#metadata_arms),*
322                ]
323            }
324        }
325
326        #[allow(non_snake_case)]
327        mod #helper_module_name {
328            use jobby::JobType;
329            pub fn initialize(
330                rocket: &jobby::rocket::Rocket<jobby::rocket::Build>,
331            ) -> Result<Box<dyn jobby::Module + Send + Sync>, jobby::Error> {
332                Ok(Box::new(<super::#client_module as jobby::Module>::initialize(rocket)?))
333            }
334            pub fn list_metadata() -> Vec<jobby::JobTypeMetadata> {
335                super::#name::list_metadata()
336            }
337        }
338
339        jobby::inventory::submit! {
340            jobby::ClientModule::new(#client_id, #client_name, #helper_module_name::initialize, #helper_module_name::list_metadata)
341        }
342    })
343}