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,
});
}
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();
};
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());
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 },
_ => {
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")
}
}