notionrs_macro 0.4.0

Builder-style setter derive used by notionrs
Documentation
use quote::quote;
use syn::{Data, DeriveInput, Expr, Fields, Lit, Meta, MetaNameValue, Type};

pub fn generate_setters(input: DeriveInput) -> proc_macro::TokenStream {
    let struct_name = input.ident;
    let generics = input.generics;
    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();

    let fields = match input.data {
        Data::Struct(data) => match data.fields {
            Fields::Named(fields) => fields.named.into_iter().collect::<Vec<_>>(),
            Fields::Unnamed(unnamed) => {
                return syn::Error::new_spanned(
                    unnamed.unnamed,
                    "Setter does not support tuple structs",
                )
                .to_compile_error()
                .into();
            }
            Fields::Unit => {
                return syn::Error::new_spanned(
                    struct_name,
                    "Setter does not support unit structs",
                )
                .to_compile_error()
                .into();
            }
        },
        Data::Enum(data) => {
            return syn::Error::new_spanned(data.enum_token, "Setter can only be used with structs")
                .to_compile_error()
                .into();
        }
        Data::Union(data) => {
            return syn::Error::new_spanned(
                data.union_token,
                "Setter can only be used with structs",
            )
            .to_compile_error()
            .into();
        }
    };

    let mut errors = Vec::<syn::Error>::new();

    let field_setters = fields.iter().map(|f| {
        let field_name = &f.ident;
        let field_ty = &f.ty;
        let attribute = &f.attrs;

        let comment = generate_comment(f);

        if is_skip(attribute) {
            quote! {}
        } else if is_string_type(field_ty) {
            quote! {
                #comment
                pub fn #field_name<S>(mut self, #field_name: S) -> Self
                where
                    S: AsRef<str>,
                {
                    self.#field_name = #field_name.as_ref().to_string();
                    self
                }
            }
        } else if is_option_type(field_ty) {
            let Some(inner_ty) = option_inner_ty(field_ty) else {
                errors.push(syn::Error::new_spanned(
                    field_ty,
                    "Option type must have a generic argument",
                ));
                return quote! {};
            };

            if is_string_type(inner_ty) {
                quote! {
                    #comment
                    pub fn #field_name<S>(mut self, #field_name: S) -> Self
                    where
                        S: AsRef<str>,
                    {
                        self.#field_name = Some(#field_name.as_ref().to_string());
                        self
                    }
                }
            } else {
                quote! {
                    #comment
                    pub fn #field_name(mut self, #field_name: #inner_ty) -> Self {
                        self.#field_name = Some(#field_name);
                        self
                    }
                }
            }
        } else {
            quote! {
                #comment
                pub fn #field_name(mut self, #field_name: #field_ty) -> Self {
                    self.#field_name = #field_name;
                    self
                }
            }
        }
    });

    let setters: Vec<_> = field_setters.collect();

    if !errors.is_empty() {
        let combined = errors.into_iter().reduce(|mut acc, e| {
            acc.combine(e);
            acc
        });
        return combined.unwrap().to_compile_error().into();
    }

    let expanded = quote! {
        impl #impl_generics #struct_name #ty_generics #where_clause {
            #(#setters)*
        }
    };

    proc_macro::TokenStream::from(expanded)
}

fn generate_comment(f: &syn::Field) -> proc_macro2::TokenStream {
    let field_name = &f.ident;
    let setter_comment = format!(
        "Set the value of the `{}` field.",
        field_name.as_ref().unwrap()
    );

    let field_original_comments: Vec<_> = f
        .attrs
        .iter()
        .filter_map(|attr| {
            if !attr.path().is_ident("doc") {
                return None;
            }
            if let Meta::NameValue(MetaNameValue {
                value: Expr::Lit(comment),
                ..
            }) = &attr.meta
            {
                if let Lit::Str(comment) = &comment.lit {
                    let comment = comment.value();
                    return Some(quote! { #[doc = #comment] });
                }
            }
            None
        })
        .collect();

    if field_original_comments.is_empty() {
        quote! {
            #[doc = #setter_comment]
        }
    } else {
        quote! {
            #[doc = #setter_comment]
            #[doc = ""]
            #[doc = "---"]
            #[doc = ""]
            #(#field_original_comments)*
        }
    }
}

fn is_option_type(ty: &Type) -> bool {
    if let Type::Path(type_path) = ty {
        if let Some(segment) = type_path.path.segments.last() {
            return segment.ident == "Option";
        }
    }
    false
}

fn is_string_type(ty: &Type) -> bool {
    if let Type::Path(type_path) = ty {
        if let Some(segment) = type_path.path.segments.last() {
            return segment.ident == "String";
        }
    }
    false
}

fn option_inner_ty(ty: &Type) -> Option<&Type> {
    let Type::Path(type_path) = ty else {
        return None;
    };
    let segment = type_path.path.segments.last()?;
    let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
        return None;
    };
    let syn::GenericArgument::Type(inner) = args.args.first()? else {
        return None;
    };
    Some(inner)
}

fn is_skip(attrs: &[syn::Attribute]) -> bool {
    attrs.iter().any(|attr| {
        if !attr.path().is_ident("setter") {
            return false;
        }
        let mut found = false;
        let _ = attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("skip") {
                found = true;
            }
            Ok(())
        });
        found
    })
}