holy 0.2.1

Holy is a proc-macro library that provides helper macros.
Documentation
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, Type};

use crate::utils::get_holy_string_value;

enum FuzzStrategy {
    Ascii(usize, usize),
    AlphanumericStr(usize, usize),
    Range(String, String),
}

enum FuzzTypeKind {
    String,
    Bool,
    Integer,
    Float,
    Other(String),
}

fn classify_fuzz_type(ty: &Type) -> FuzzTypeKind {
    if let Type::Path(type_path) = ty {
        if let Some(segment) = type_path.path.segments.last() {
            let ident = segment.ident.to_string();
            return match ident.as_str() {
                "String" => FuzzTypeKind::String,
                "bool" => FuzzTypeKind::Bool,
                "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
                    FuzzTypeKind::Integer
                }
                "f32" | "f64" => FuzzTypeKind::Float,
                other => FuzzTypeKind::Other(other.to_string()),
            };
        }
    }
    FuzzTypeKind::Other("unknown".to_string())
}

fn parse_two_args(
    inner: &str,
    name: &str,
    span: proc_macro2::Span,
) -> Result<(String, String), syn::Error> {
    let parts: Vec<&str> = inner.splitn(2, ',').collect();
    if parts.len() != 2 {
        return Err(syn::Error::new(
            span,
            format!("{} requires two arguments: {}(a, b)", name, name),
        ));
    }
    Ok((parts[0].trim().to_string(), parts[1].trim().to_string()))
}

fn parse_fuzz_strategy(raw: &str, span: proc_macro2::Span) -> Result<FuzzStrategy, syn::Error> {
    let trimmed = raw.trim();

    if let Some(inner) = trimmed
        .strip_prefix("ascii(")
        .and_then(|s| s.strip_suffix(')'))
    {
        let (a, b) = parse_two_args(inner, "ascii", span)?;
        let min: usize = a
            .parse()
            .map_err(|_| syn::Error::new(span, format!("invalid ascii min length: '{}'", a)))?;
        let max: usize = b
            .parse()
            .map_err(|_| syn::Error::new(span, format!("invalid ascii max length: '{}'", b)))?;
        return Ok(FuzzStrategy::Ascii(min, max));
    }

    if let Some(inner) = trimmed
        .strip_prefix("alphanumeric(")
        .and_then(|s| s.strip_suffix(')'))
    {
        let (a, b) = parse_two_args(inner, "alphanumeric", span)?;
        let min: usize = a.parse().map_err(|_| {
            syn::Error::new(span, format!("invalid alphanumeric min length: '{}'", a))
        })?;
        let max: usize = b.parse().map_err(|_| {
            syn::Error::new(span, format!("invalid alphanumeric max length: '{}'", b))
        })?;
        return Ok(FuzzStrategy::AlphanumericStr(min, max));
    }

    if let Some(inner) = trimmed
        .strip_prefix("range(")
        .and_then(|s| s.strip_suffix(')'))
    {
        let (a, b) = parse_two_args(inner, "range", span)?;
        return Ok(FuzzStrategy::Range(a, b));
    }

    Err(syn::Error::new(
        span,
        format!("unknown fuzz strategy: '{}'", trimmed),
    ))
}

fn validate_fuzz_strategy(
    strategy: &FuzzStrategy,
    type_kind: &FuzzTypeKind,
    field_name: &syn::Ident,
    span: proc_macro2::Span,
) -> Result<(), syn::Error> {
    match strategy {
        FuzzStrategy::Ascii(_, _) | FuzzStrategy::AlphanumericStr(_, _) => {
            if !matches!(type_kind, FuzzTypeKind::String) {
                return Err(syn::Error::new(
                    span,
                    format!(
                        "fuzz strategy is only valid for String fields, but field '{}' is not a String",
                        field_name
                    ),
                ));
            }
        }
        FuzzStrategy::Range(_, _) => {
            if !matches!(type_kind, FuzzTypeKind::Integer | FuzzTypeKind::Float) {
                return Err(syn::Error::new(
                    span,
                    format!(
                        "fuzz strategy 'range' is only valid for numeric fields, but field '{}' is not numeric",
                        field_name
                    ),
                ));
            }
        }
    }
    Ok(())
}

fn generate_fuzz_default_expr(
    strategy: Option<&FuzzStrategy>,
    type_kind: &FuzzTypeKind,
    field_name: &syn::Ident,
    span: proc_macro2::Span,
) -> Result<proc_macro2::TokenStream, syn::Error> {
    match (type_kind, strategy) {
        (FuzzTypeKind::String, Some(FuzzStrategy::Ascii(min, max))) => {
            let len = (min + max) / 2;
            let s = "a".repeat(len);
            Ok(quote! { String::from(#s) })
        }
        (FuzzTypeKind::String, Some(FuzzStrategy::AlphanumericStr(min, max))) => {
            let len = (min + max) / 2;
            let s = "a".repeat(len);
            Ok(quote! { String::from(#s) })
        }
        (FuzzTypeKind::String, None) => Ok(quote! { String::from("aaaa") }),
        (FuzzTypeKind::Bool, None) => Ok(quote! { false }),
        (FuzzTypeKind::Integer, Some(FuzzStrategy::Range(min, max))) => {
            let min_lit: proc_macro2::TokenStream = min.parse().unwrap();
            let max_lit: proc_macro2::TokenStream = max.parse().unwrap();
            Ok(quote! { (#min_lit + #max_lit) / 2 })
        }
        (FuzzTypeKind::Integer, None) => Ok(quote! { 0 }),
        (FuzzTypeKind::Float, Some(FuzzStrategy::Range(min, max))) => {
            let min_lit: proc_macro2::TokenStream = min.parse().unwrap();
            let max_lit: proc_macro2::TokenStream = max.parse().unwrap();
            Ok(quote! { (#min_lit + #max_lit) / 2.0 })
        }
        (FuzzTypeKind::Float, None) => Ok(quote! { 0.0 }),
        (FuzzTypeKind::Other(type_name), _) => Err(syn::Error::new(
            span,
            format!(
                "Fuzz: unsupported type '{}' for field '{}', use a supported type (String, bool, numeric) or annotate with #[holy(fuzz = \"...\")]",
                type_name, field_name
            ),
        )),
        _ => Err(syn::Error::new(
            span,
            format!("invalid fuzz strategy for field '{}'", field_name),
        )),
    }
}

fn generate_fuzz_rng_expr(
    strategy: Option<&FuzzStrategy>,
    type_kind: &FuzzTypeKind,
    field_name: &syn::Ident,
    span: proc_macro2::Span,
) -> Result<proc_macro2::TokenStream, syn::Error> {
    match (type_kind, strategy) {
        (FuzzTypeKind::String, Some(FuzzStrategy::Ascii(min, max))) => Ok(quote! {
            {
                let len = rand::RngExt::random_range(rng, #min..=#max);
                (0..len).map(|_| rand::RngExt::random_range(rng, b' '..=b'~') as char).collect::<String>()
            }
        }),
        (FuzzTypeKind::String, Some(FuzzStrategy::AlphanumericStr(min, max))) => Ok(quote! {
            {
                let len = rand::RngExt::random_range(rng, #min..=#max);
                let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
                (0..len).map(|_| chars[rand::RngExt::random_range(rng, 0..chars.len())] as char).collect::<String>()
            }
        }),
        (FuzzTypeKind::String, None) => Ok(quote! {
            {
                let len = rand::RngExt::random_range(rng, 0usize..=64usize);
                (0..len).map(|_| rand::RngExt::random_range(rng, b' '..=b'~') as char).collect::<String>()
            }
        }),
        (FuzzTypeKind::Bool, None) => Ok(quote! { rand::RngExt::random_bool(rng, 0.5) }),
        (FuzzTypeKind::Integer, Some(FuzzStrategy::Range(min, max))) => {
            let min_lit: proc_macro2::TokenStream = min.parse().unwrap();
            let max_lit: proc_macro2::TokenStream = max.parse().unwrap();
            Ok(quote! { rand::RngExt::random_range(rng, #min_lit..=#max_lit) })
        }
        (FuzzTypeKind::Integer, None) => {
            let random_ident = syn::Ident::new("random", proc_macro2::Span::call_site());
            Ok(quote! { rand::RngExt::#random_ident(rng) })
        }
        (FuzzTypeKind::Float, Some(FuzzStrategy::Range(min, max))) => {
            let min_lit: proc_macro2::TokenStream = min.parse().unwrap();
            let max_lit: proc_macro2::TokenStream = max.parse().unwrap();
            Ok(quote! { rand::RngExt::random_range(rng, #min_lit..=#max_lit) })
        }
        (FuzzTypeKind::Float, None) => Ok(quote! {
            rand::RngExt::random_range(rng, -1_000_000.0f64..1_000_000.0f64)
        }),
        (FuzzTypeKind::Other(type_name), _) => Err(syn::Error::new(
            span,
            format!(
                "Fuzz: unsupported type '{}' for field '{}'",
                type_name, field_name
            ),
        )),
        _ => Err(syn::Error::new(
            span,
            format!("invalid fuzz strategy for field '{}'", field_name),
        )),
    }
}

pub fn impl_fuzz_macro(ast: &DeriveInput) -> Result<TokenStream, syn::Error> {
    let struct_name = &ast.ident;
    let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();

    let fields = match &ast.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(named) => &named.named,
            _ => {
                return Err(syn::Error::new_spanned(
                    ast,
                    "Fuzz macro only supports structs with named fields",
                ));
            }
        },
        _ => {
            return Err(syn::Error::new_spanned(
                ast,
                "Fuzz macro only supports structs",
            ));
        }
    };

    let mut default_field_inits = Vec::new();
    let mut rng_field_inits = Vec::new();

    for field in fields.iter() {
        let field_name = field.ident.as_ref().unwrap();
        let type_kind = classify_fuzz_type(&field.ty);

        let strategy = match get_holy_string_value(&field.attrs, "fuzz") {
            Some((raw, span)) => Some(parse_fuzz_strategy(&raw, span)?),
            None => None,
        };

        if let Some(ref strat) = strategy {
            validate_fuzz_strategy(strat, &type_kind, field_name, field_name.span())?;
        }

        let default_expr = generate_fuzz_default_expr(
            strategy.as_ref(),
            &type_kind,
            field_name,
            field_name.span(),
        )?;
        let rng_expr =
            generate_fuzz_rng_expr(strategy.as_ref(), &type_kind, field_name, field_name.span())?;

        default_field_inits.push(quote! { #field_name: #default_expr });
        rng_field_inits.push(quote! { #field_name: #rng_expr });
    }

    let expanded = quote! {
        impl #impl_generics #struct_name #ty_generics #where_clause {
            pub fn fuzz_default() -> Self {
                Self {
                    #(#default_field_inits,)*
                }
            }

            pub fn fuzz_with(rng: &mut impl rand::RngExt) -> Self {
                Self {
                    #(#rng_field_inits,)*
                }
            }
        }
    };

    Ok(TokenStream::from(expanded))
}