fletch-orm-macros 1.0.0

Procedural macros for the Fletch ORM database toolkit
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, Ident, Lit, Meta, Type};

use crate::crate_path::fletch_path;

struct FieldInfo {
    ident: Ident,
    ty: Type,
    is_id: bool,
    skip: bool,
    rename: Option<String>,
    is_option: bool,
}

struct EntityOpts {
    table_name: Option<String>,
}

#[expect(
    clippy::too_many_lines,
    reason = "proc-macro expansion is inherently sequential; splitting would reduce readability"
)]
#[expect(
    clippy::needless_pass_by_value,
    reason = "DeriveInput is consumed by pattern matching and syn convention passes by value"
)]
pub fn expand_entity(input: DeriveInput) -> TokenStream {
    let fletch_orm = fletch_path();
    let struct_name = &input.ident;

    let opts = parse_struct_attrs(&input);

    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(named) => named,
            _ => {
                return syn::Error::new_spanned(struct_name, "Entity derive requires named fields")
                    .to_compile_error();
            }
        },
        _ => {
            return syn::Error::new_spanned(struct_name, "Entity derive only supports structs")
                .to_compile_error();
        }
    };

    let mut field_infos: Vec<FieldInfo> = Vec::new();
    for field in &fields.named {
        #[expect(
            clippy::unwrap_used,
            reason = "named fields are guaranteed to have idents by the match guard above"
        )]
        let ident = field.ident.clone().unwrap();
        let ty = field.ty.clone();
        let mut is_id = false;
        let mut skip = false;
        let mut rename = None;

        for attr in &field.attrs {
            if !attr.path().is_ident("fletch") {
                continue;
            }
            let nested = attr
                .parse_args_with(
                    syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
                )
                .unwrap_or_default();
            for meta in nested {
                match &meta {
                    Meta::Path(p) if p.is_ident("id") => is_id = true,
                    Meta::Path(p) if p.is_ident("skip") => skip = true,
                    Meta::NameValue(nv) if nv.path.is_ident("rename") => {
                        if let syn::Expr::Lit(lit) = &nv.value {
                            if let Lit::Str(s) = &lit.lit {
                                rename = Some(s.value());
                            }
                        }
                    }
                    _ => {}
                }
            }
        }

        let is_option = is_option_type(&ty);
        field_infos.push(FieldInfo {
            ident,
            ty,
            is_id,
            skip,
            rename,
            is_option,
        });
    }

    // Find the id field
    let Some(id_field) = field_infos.iter().find(|f| f.is_id) else {
        return syn::Error::new_spanned(
            struct_name,
            "Entity derive requires exactly one field marked with #[fletch(id)]",
        )
        .to_compile_error();
    };

    // Table name: use attribute or derive from struct name
    let table_name = opts.table_name.unwrap_or_else(|| {
        let name = struct_name.to_string();
        let snake = to_snake_case(&name);
        simple_pluralize(&snake)
    });

    let id_ident = &id_field.ident;
    let id_ty = &id_field.ty;
    let id_col_name = id_field
        .rename
        .clone()
        .unwrap_or_else(|| id_field.ident.to_string());

    // Build columns (non-skipped fields)
    let persistent_fields: Vec<&FieldInfo> = field_infos.iter().filter(|f| !f.skip).collect();

    let column_defs: Vec<TokenStream> = persistent_fields
        .iter()
        .map(|f| {
            let col_name = f.rename.clone().unwrap_or_else(|| f.ident.to_string());
            let col_type = rust_type_to_column_type(&f.ty, f.is_option, &fletch_orm);
            quote! {
                #fletch_orm::Column::new(#col_name, #col_type)
            }
        })
        .collect();

    let num_cols = column_defs.len();

    let value_exprs: Vec<TokenStream> = persistent_fields
        .iter()
        .map(|f| {
            let ident = &f.ident;
            quote! { #fletch_orm::Value::from(self.#ident.clone()) }
        })
        .collect();

    let id_value_expr = quote! { self.#id_ident.clone() };

    quote! {
        impl #fletch_orm::Entity for #struct_name {
            type Id = #id_ty;

            fn table_name() -> &'static str {
                #table_name
            }

            fn id_column() -> &'static str {
                #id_col_name
            }

            fn columns() -> &'static [#fletch_orm::Column] {
                static COLS: [#fletch_orm::Column; #num_cols] = [
                    #(#column_defs),*
                ];
                &COLS
            }

            fn id(&self) -> Self::Id {
                #id_value_expr
            }

            fn values(&self) -> Vec<#fletch_orm::Value> {
                vec![#(#value_exprs),*]
            }
        }
    }
}

fn parse_struct_attrs(input: &DeriveInput) -> EntityOpts {
    let mut table_name = None;

    for attr in &input.attrs {
        if !attr.path().is_ident("fletch") {
            continue;
        }
        let nested = attr
            .parse_args_with(syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated)
            .unwrap_or_default();
        for meta in nested {
            if let Meta::NameValue(nv) = &meta {
                if nv.path.is_ident("table") {
                    if let syn::Expr::Lit(lit) = &nv.value {
                        if let Lit::Str(s) = &lit.lit {
                            table_name = Some(s.value());
                        }
                    }
                }
            }
        }
    }

    EntityOpts { table_name }
}

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

fn rust_type_to_column_type(ty: &Type, is_option: bool, fletch_orm: &TokenStream) -> TokenStream {
    let inner_ty = if is_option {
        extract_option_inner(ty)
    } else {
        ty.clone()
    };

    let type_str = type_to_string(&inner_ty);
    match type_str.as_str() {
        "String" | "&str" => quote! { #fletch_orm::ColumnType::Text },
        "i32" | "i64" => quote! { #fletch_orm::ColumnType::Integer },
        "f32" | "f64" => quote! { #fletch_orm::ColumnType::Float },
        "bool" => quote! { #fletch_orm::ColumnType::Boolean },
        "Vec<u8>" => quote! { #fletch_orm::ColumnType::Blob },
        _ => {
            // Check for known types by last segment
            match last_segment(&inner_ty).as_str() {
                "Uuid" => quote! { #fletch_orm::ColumnType::Uuid },
                "DateTime" | "NaiveDateTime" => quote! { #fletch_orm::ColumnType::Timestamp },
                "Value" | "JsonValue" => quote! { #fletch_orm::ColumnType::Json },
                _ => quote! { #fletch_orm::ColumnType::Text },
            }
        }
    }
}

fn extract_option_inner(ty: &Type) -> Type {
    if let Type::Path(tp) = ty {
        if let Some(seg) = tp.path.segments.last() {
            if seg.ident == "Option" {
                if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
                    if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
                        return inner.clone();
                    }
                }
            }
        }
    }
    ty.clone()
}

fn type_to_string(ty: &Type) -> String {
    quote!(#ty).to_string().replace(' ', "")
}

fn last_segment(ty: &Type) -> String {
    if let Type::Path(tp) = ty {
        if let Some(seg) = tp.path.segments.last() {
            return seg.ident.to_string();
        }
    }
    String::new()
}

#[expect(
    clippy::unwrap_used,
    reason = "char::to_lowercase always yields at least one character"
)]
fn to_snake_case(s: &str) -> String {
    let mut result = String::new();
    for (i, ch) in s.chars().enumerate() {
        if ch.is_uppercase() {
            if i > 0 {
                result.push('_');
            }
            result.push(ch.to_lowercase().next().unwrap());
        } else {
            result.push(ch);
        }
    }
    result
}

fn simple_pluralize(s: &str) -> String {
    if s.ends_with('s') || s.ends_with("sh") || s.ends_with("ch") || s.ends_with('x') {
        format!("{s}es")
    } else if s.ends_with('y') && !s.ends_with("ey") && !s.ends_with("ay") && !s.ends_with("oy") {
        format!("{}ies", &s[..s.len() - 1])
    } else {
        format!("{s}s")
    }
}