caelix-macros 0.0.37

Procedural macros for the Caelix framework.
Documentation
use proc_macro::TokenStream;
use quote::quote;
use std::collections::HashSet;
use syn::{
    Data, DeriveInput, Expr, Fields, Lit, LitStr, Meta, Path, Token, parse_macro_input,
    punctuated::Punctuated, spanned::Spanned,
};

pub(crate) fn expand(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let mut errors = Vec::new();

    if !input.generics.params.is_empty() {
        errors.push(syn::Error::new_spanned(
            &input.generics,
            "#[derive(Config)] does not support generic structs",
        ));
    }

    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(fields) => &fields.named,
            _ => {
                errors.push(syn::Error::new_spanned(
                    &data.fields,
                    "#[derive(Config)] requires a named-field struct",
                ));
                return compile_errors(errors);
            }
        },
        _ => {
            errors.push(syn::Error::new_spanned(
                &input,
                "#[derive(Config)] can only be used on a struct",
            ));
            return compile_errors(errors);
        }
    };

    let mut environment_names = HashSet::new();
    let mut metadata = Vec::new();
    let mut initializers = Vec::new();

    for (index, field) in fields.iter().enumerate() {
        let field_name = field.ident.as_ref().expect("named fields have identifiers");
        let field_type = &field.ty;
        let mut env = None;
        let mut default = None;
        let config_attributes: Vec<_> = field
            .attrs
            .iter()
            .filter(|attribute| attribute.path().is_ident("config"))
            .collect();

        if config_attributes.is_empty() {
            errors.push(syn::Error::new_spanned(
                field,
                "missing #[config(env = \"NAME\")] mapping",
            ));
        }
        if config_attributes.len() > 1 {
            errors.push(syn::Error::new_spanned(
                config_attributes[1],
                "duplicate #[config(...)] attribute",
            ));
        }

        for attribute in config_attributes {
            if let Err(error) = attribute.parse_nested_meta(|meta| {
                if meta.path.is_ident("env") {
                    if env.is_some() {
                        return Err(meta.error("duplicate `env` argument"));
                    }
                    env = Some(meta.value()?.parse::<LitStr>()?);
                    return Ok(());
                }
                if meta.path.is_ident("default") {
                    if default.is_some() {
                        return Err(meta.error("duplicate `default` argument"));
                    }
                    default = Some(meta.value()?.parse::<LitStr>()?);
                    return Ok(());
                }
                Err(meta.error("unknown config argument; expected `env` or `default`"))
            }) {
                errors.push(error);
            }
        }

        let Some(env) = env else {
            continue;
        };
        if !environment_names.insert(env.value()) {
            errors.push(syn::Error::new_spanned(
                &env,
                format!("duplicate environment variable mapping `{}`", env.value()),
            ));
        }

        let default_tokens = default
            .as_ref()
            .map(|value| quote! { Some(#value) })
            .unwrap_or_else(|| quote! { None });
        let is_optional = is_option(field_type);
        metadata.push(quote! {
            caelix::ConfigField {
                field: stringify!(#field_name),
                env: #env,
                default: #default_tokens,
                optional: #is_optional,
            }
        });
        let value_index = syn::Index::from(index);
        let custom_deserializer = custom_deserializer(field);
        let conversion = if let Some(path) = custom_deserializer {
            quote! {{
                let __caelix_value = &__caelix_values[#value_index];
                let __caelix_deserializer =
                    caelix::__ConfigStrDeserializer::<caelix::__ConfigValueError>::new(
                        __caelix_value.as_str(),
                    );
                #path(__caelix_deserializer).map_err(|_| {
                    caelix::__config_conversion_error(stringify!(#field_name), #env)
                })?
            }}
        } else {
            quote! {
                caelix::__deserialize_config_field::<#field_type>(
                    stringify!(#field_name),
                    #env,
                    __caelix_values[#value_index].clone(),
                )?
            }
        };
        initializers.push(quote! { #field_name: #conversion });
    }

    if !errors.is_empty() {
        return compile_errors(errors);
    }

    let expanded = quote! {
        impl caelix::Config for #name {
            fn load() -> caelix::Result<Self> {
                Self::__caelix_load_config(None)
            }

            fn load_from(
                path: impl AsRef<std::path::Path>,
            ) -> caelix::Result<Self> {
                Self::__caelix_load_config(Some(path.as_ref()))
            }
        }

        impl #name {
            fn __caelix_load_config(
                path: Option<&std::path::Path>,
            ) -> caelix::Result<Self> {
                fn __caelix_assert_deserialize<T: caelix::__DeserializeOwned>() {}
                __caelix_assert_deserialize::<Self>();
                const __CAELIX_FIELDS: &[caelix::ConfigField] = &[#(#metadata),*];
                let __caelix_values = caelix::__load_config_values(__CAELIX_FIELDS, path)?;
                let __caelix_config = Self { #(#initializers),* };
                caelix::__Validate::validate(&__caelix_config)
                    .map_err(|errors| caelix::__config_validation_error(&errors))?;
                Ok(__caelix_config)
            }
        }

        impl caelix::Injectable for #name {
            fn create(
                _container: &caelix::Container,
            ) -> caelix::BoxFuture<'_, caelix::Result<Self>> {
                Box::pin(async { <Self as caelix::Config>::load() })
            }

            fn dependencies() -> Vec<caelix::ProviderDependency> {
                Vec::new()
            }
        }
    };
    expanded.into()
}

fn custom_deserializer(field: &syn::Field) -> Option<Path> {
    for attribute in field
        .attrs
        .iter()
        .filter(|attribute| attribute.path().is_ident("serde"))
    {
        let Meta::List(list) = &attribute.meta else {
            continue;
        };
        let Ok(items) = list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
        else {
            continue;
        };
        for item in items {
            let Meta::NameValue(name_value) = item else {
                continue;
            };
            let Expr::Lit(expression) = name_value.value else {
                continue;
            };
            let Lit::Str(path) = expression.lit else {
                continue;
            };
            if name_value.path.is_ident("deserialize_with")
                && let Ok(path) = path.parse()
            {
                return Some(path);
            }
            if name_value.path.is_ident("with")
                && let Ok(mut path) = path.parse::<Path>()
            {
                path.segments.push(syn::PathSegment::from(syn::Ident::new(
                    "deserialize",
                    path.span(),
                )));
                return Some(path);
            }
        }
    }
    None
}

fn is_option(ty: &syn::Type) -> bool {
    let syn::Type::Path(path) = ty else {
        return false;
    };
    path.path
        .segments
        .last()
        .is_some_and(|segment| segment.ident == "Option")
}

fn compile_errors(errors: Vec<syn::Error>) -> TokenStream {
    let errors = errors.into_iter().map(|error| error.to_compile_error());
    quote! { #(#errors)* }.into()
}