Skip to main content

stem_derive_core/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![cfg_attr(
4    not(test),
5    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
6)]
7
8use quote::quote;
9use syn::DeriveInput;
10
11pub fn impl_stem_newtype(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
12    let name = &input.ident;
13    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
14
15    match &input.data {
16        syn::Data::Struct(s) => match &s.fields {
17            syn::Fields::Unnamed(f) if f.unnamed.len() == 1 => {}
18            syn::Fields::Unnamed(_) | syn::Fields::Named(_) | syn::Fields::Unit => {
19                return Err(syn::Error::new_spanned(
20                    name,
21                    "requires tuple struct with one field",
22                ))
23            }
24        },
25        syn::Data::Enum(_) | syn::Data::Union(_) => {
26            return Err(syn::Error::new_spanned(
27                name,
28                "can only be applied to tuple structs",
29            ))
30        }
31    }
32
33    let mut clamp_min: Option<f64> = None;
34    let mut clamp_max: Option<f64> = None;
35    let mut default_value: f64 = 0.0;
36
37    for attr in &input.attrs {
38        if !attr.path().is_ident("stem") {
39            continue;
40        }
41        attr.parse_nested_meta(|meta| {
42            if meta.path.is_ident("clamp_min") {
43                clamp_min = Some(
44                    meta.value()?
45                        .parse::<syn::LitFloat>()?
46                        .base10_parse::<f64>()?,
47                );
48                Ok(())
49            } else if meta.path.is_ident("clamp_max") {
50                clamp_max = Some(
51                    meta.value()?
52                        .parse::<syn::LitFloat>()?
53                        .base10_parse::<f64>()?,
54                );
55                Ok(())
56            } else if meta.path.is_ident("default_value") {
57                default_value = meta
58                    .value()?
59                    .parse::<syn::LitFloat>()?
60                    .base10_parse::<f64>()?;
61                Ok(())
62            } else {
63                Err(meta.error("unknown stem attribute"))
64            }
65        })?;
66    }
67
68    let clamp_expr = match (clamp_min, clamp_max) {
69        (Some(min), Some(max)) => quote! { value.clamp(#min, #max) },
70        (Some(min), None) => quote! { value.max(#min) },
71        (None, Some(max)) => quote! { value.min(#max) },
72        (None, None) => quote! { value },
73    };
74
75    Ok(quote! {
76        impl #impl_generics #name #ty_generics #where_clause {
77            pub fn new(value: f64) -> Self { Self(#clamp_expr) }
78            pub fn value(&self) -> f64 { self.0 }
79        }
80        impl #impl_generics Default for #name #ty_generics #where_clause {
81            fn default() -> Self { Self(#default_value) }
82        }
83        impl #impl_generics From<f64> for #name #ty_generics #where_clause {
84            fn from(value: f64) -> Self { Self::new(value) }
85        }
86        impl #impl_generics From<#name #ty_generics> for f64 #where_clause {
87            fn from(val: #name #ty_generics) -> f64 { val.0 }
88        }
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use syn::parse_quote;
96
97    #[test]
98    fn test_valid_struct() {
99        let input: DeriveInput = parse_quote! { struct Prob(f64); };
100        let res = impl_stem_newtype(&input);
101        assert!(res.is_ok());
102    }
103
104    #[test]
105    fn test_clamping_attributes() {
106        let input: DeriveInput = parse_quote! {
107            #[stem(clamp_min = 0.0, clamp_max = 1.0)]
108            struct Prob(f64);
109        };
110        let res = impl_stem_newtype(&input).unwrap().to_string(); // INVARIANT: test
111        let normalized: String = res.chars().filter(|c| !c.is_whitespace()).collect();
112        assert!(normalized.contains("clamp("), "Expected clamp in: {}", res);
113    }
114}