rustio-macros 0.1.2

Procedural macros for RustIO, including `#[derive(RustioAdmin)]`.
Documentation
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields, Type};

#[derive(Clone, Copy)]
enum FieldKind {
    I32,
    I64,
    String,
    Bool,
}

struct FieldInfo {
    ident: syn::Ident,
    name_str: String,
    kind: FieldKind,
    editable: bool,
}

#[proc_macro_derive(RustioAdmin)]
pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    let data = match &input.data {
        Data::Struct(d) => d,
        _ => {
            return syn::Error::new_spanned(
                &input.ident,
                "RustioAdmin only supports structs with named fields",
            )
            .to_compile_error()
            .into();
        }
    };

    let named = match &data.fields {
        Fields::Named(n) => n,
        _ => {
            return syn::Error::new_spanned(&input.ident, "RustioAdmin requires named fields")
                .to_compile_error()
                .into();
        }
    };

    let mut fields: Vec<FieldInfo> = Vec::new();
    for f in &named.named {
        let ident = f.ident.clone().expect("named field");
        let name_str = ident.to_string();
        let kind = match classify_type(&f.ty) {
            Some(k) => k,
            None => {
                return syn::Error::new_spanned(
                    &f.ty,
                    "RustioAdmin: unsupported field type (supported: i32, i64, String, bool)",
                )
                .to_compile_error()
                .into();
            }
        };
        let editable = name_str != "id";
        fields.push(FieldInfo {
            ident,
            name_str,
            kind,
            editable,
        });
    }

    let admin_name = pluralize(&name.to_string().to_lowercase());
    let display_name = pluralize(&name.to_string());

    let field_entries: Vec<TokenStream2> = fields
        .iter()
        .map(|f| {
            let n = &f.name_str;
            let kind_token = kind_token(f.kind);
            let editable = f.editable;
            quote! {
                ::rustio_core::admin::AdminField {
                    name: #n,
                    ty: #kind_token,
                    editable: #editable,
                }
            }
        })
        .collect();

    let display_arms: Vec<TokenStream2> = fields
        .iter()
        .map(|f| {
            let ident = &f.ident;
            let name_str = &f.name_str;
            quote! {
                #name_str => Some(self.#ident.to_string()),
            }
        })
        .collect();

    let from_form_assignments: Vec<TokenStream2> =
        fields.iter().map(from_form_assignment).collect();

    let expanded = quote! {
        impl ::rustio_core::admin::AdminModel for #name {
            const ADMIN_NAME: &'static str = #admin_name;
            const DISPLAY_NAME: &'static str = #display_name;
            const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
                #( #field_entries ),*
            ];

            fn field_display(&self, name: &str) -> Option<String> {
                match name {
                    #( #display_arms )*
                    _ => None,
                }
            }

            fn from_form(
                form: &::rustio_core::admin::FormData,
                id: Option<i64>,
            ) -> Result<Self, ::rustio_core::Error> {
                Ok(Self {
                    #( #from_form_assignments )*
                })
            }
        }
    };

    expanded.into()
}

fn pluralize(name: &str) -> String {
    if name.ends_with('s') {
        name.to_string()
    } else {
        format!("{name}s")
    }
}

fn classify_type(ty: &Type) -> Option<FieldKind> {
    if let Type::Path(syn::TypePath { path, .. }) = ty {
        if let Some(last) = path.segments.last() {
            let ident = last.ident.to_string();
            return match ident.as_str() {
                "i32" => Some(FieldKind::I32),
                "i64" => Some(FieldKind::I64),
                "String" => Some(FieldKind::String),
                "bool" => Some(FieldKind::Bool),
                _ => None,
            };
        }
    }
    None
}

fn kind_token(kind: FieldKind) -> TokenStream2 {
    match kind {
        FieldKind::I32 => quote! { ::rustio_core::admin::FieldType::I32 },
        FieldKind::I64 => quote! { ::rustio_core::admin::FieldType::I64 },
        FieldKind::String => quote! { ::rustio_core::admin::FieldType::String },
        FieldKind::Bool => quote! { ::rustio_core::admin::FieldType::Bool },
    }
}

fn from_form_assignment(f: &FieldInfo) -> TokenStream2 {
    let ident = &f.ident;
    let name_str = &f.name_str;
    if !f.editable {
        return quote! { #ident: id.unwrap_or(0), };
    }
    match f.kind {
        FieldKind::String => quote! {
            #ident: form.get(#name_str).unwrap_or("").to_owned(),
        },
        FieldKind::Bool => quote! {
            #ident: matches!(form.get(#name_str), Some(v) if v == "on" || v == "true"),
        },
        FieldKind::I64 => quote! {
            #ident: form
                .get(#name_str)
                .unwrap_or("0")
                .parse::<i64>()
                .map_err(|_| ::rustio_core::Error::BadRequest(
                    format!("invalid integer for field `{}`", #name_str)
                ))?,
        },
        FieldKind::I32 => quote! {
            #ident: form
                .get(#name_str)
                .unwrap_or("0")
                .parse::<i32>()
                .map_err(|_| ::rustio_core::Error::BadRequest(
                    format!("invalid integer for field `{}`", #name_str)
                ))?,
        },
    }
}