apollo-configuration-macros 0.1.1

Supporting macros for apollo-configuration (internal, do not use directly)
Documentation
mod enums;

use quote::quote;
use syn::Attribute;
use syn::Token;
use syn::parenthesized;
use syn::parse::Parser as _;
use syn::spanned::Spanned as _;

use crate::ir::DefaultValue;
use crate::ir::FieldDefinition;
use crate::ir::FieldValidator;

pub(crate) use enums::parse_variant;

/// Attributes partitioned by their role in code generation.
pub(crate) struct PartitionedAttrs {
    /// `#[config(...)]` attributes that configure the macro behavior.
    pub(crate) config: Vec<syn::Attribute>,
    /// `#[cfg(...)]` attributes that must be propagated to generated code.
    pub(crate) cfg: Vec<syn::Attribute>,
    /// All other attributes to pass through unchanged.
    pub(crate) other: Vec<syn::Attribute>,
}

/// Partition attributes by their role in code generation.
pub(crate) fn partition_attrs(attrs: Vec<syn::Attribute>) -> PartitionedAttrs {
    let mut config = Vec::new();
    let mut cfg = Vec::new();
    let mut other = Vec::new();

    for attr in attrs {
        if attr.path().is_ident("config") {
            config.push(attr);
        } else if attr.path().is_ident("cfg") {
            cfg.push(attr);
        } else {
            other.push(attr);
        }
    }

    PartitionedAttrs { config, cfg, other }
}

/// The arguments to a `#[config]` field attribute.
#[derive(Default)]
pub(crate) struct ItemArgs {
    pub(crate) validator: Option<syn::Path>,
}

/// The arguments to a `#[config]` field attribute.
#[derive(Default)]
struct FieldArgs {
    /// `Some` if the field is marked as required.
    required: Option<syn::Ident>,
    default_value: Option<DefaultValue>,
    validator: Option<FieldValidator>,
    /// Raw attributes for schemars.
    schemars_attrs: Vec<proc_macro2::TokenStream>,
}

/// Parse a `#[configuration]` struct or enum-level attribute.
pub(crate) fn parse_configuration_attribute(
    attr: proc_macro2::TokenStream,
) -> syn::Result<ItemArgs> {
    let mut args = ItemArgs::default();

    let parser = syn::meta::parser(|meta| {
        if meta.path.is_ident("validate") {
            args.validator = Some(meta.value()?.parse()?);
            Ok(())
        } else {
            Err(meta.error("unsupported `#[configuration]` property"))
        }
    });

    parser.parse2(attr)?;

    Ok(args)
}

/// Parse a `#[config]` field attribute.
///
/// Many `#[config]` options are passed through to eg. serde or schemars. In that case we don't
/// parse them fully, but write them back out as attributes so the underlying crates can handle
/// parsing.
fn parse_config_attribute(attr: &Attribute) -> syn::Result<FieldArgs> {
    let mut args = FieldArgs::default();

    // Support plain `#[config]` w/o arguments
    if let syn::Meta::Path(_) = &attr.meta {
        return Ok(args);
    }

    attr.parse_nested_meta(|meta| {
        let ident = meta.path.require_ident()?;
        match ident.to_string().as_str() {
            "required" if args.required.is_some() => {
                return Err(meta.error("duplicate #[config] attribute `required`"));
            }
            "required" if args.default_value.is_some() => {
                return Err(
                    meta.error("cannot combine #[config] attribute `required` and `default`")
                );
            }
            "required" => {
                args.required = Some(ident.clone());
            }
            "default" if args.default_value.is_some() => {
                return Err(meta.error("duplicate #[config] attribute `default`"));
            }
            "default" if args.required.is_some() => {
                return Err(
                    meta.error("cannot combine #[config] attribute `required` and `default`")
                );
            }
            "default" => {
                args.default_value = if meta.input.parse::<Token![=]>().is_ok() {
                    Some(DefaultValue::Value(meta.input.parse()?))
                } else {
                    Some(DefaultValue::Default)
                };
            }
            // validation attributes
            "validate" if args.validator.is_some() => {
                return Err(meta.error("duplicate or conflicting #[config] attribute `validate`"));
            }
            "skip_validate" if args.validator.is_some() => {
                return Err(
                    meta.error("duplicate or conflicting #[config] attribute `skip_validate`")
                );
            }
            "validate" => {
                meta.input.parse::<Token![=]>()?;
                args.validator = Some(FieldValidator::Custom(meta.input.parse()?));
            }
            "skip_validate" => {
                args.validator = Some(FieldValidator::Skip);
            }
            "pattern" => {
                let content;
                parenthesized!(content in meta.input);

                // Check the regex at compile time!
                let attr: syn::LitStr = content.parse()?;
                if let Err(err) = regex_syntax::parse(&attr.value()) {
                    // The error message from regex_syntax is pretty good
                    return Err(syn::Error::new_spanned(&attr, err));
                }

                args.schemars_attrs.push(quote! { #ident ( #attr ) });
            }
            "range" | "length" => {
                let content;
                parenthesized!(content in meta.input);
                let attr: proc_macro2::TokenStream = content.parse()?;
                args.schemars_attrs.push(quote! { #ident ( #attr ) });
            }
            _ => return Err(meta.error("unrecognized config attribute")),
        }
        Ok(())
    })?;

    Ok(args)
}

pub(crate) fn parse_named_field(input: syn::Field) -> syn::Result<FieldDefinition> {
    let syn::Field {
        attrs,
        colon_token: _,
        ident,
        mutability: _,
        ty,
        vis,
    } = input;

    let ident = ident.expect("must be a named field");

    let PartitionedAttrs {
        config: config_attrs,
        cfg: cfg_attrs,
        other: attrs,
    } = partition_attrs(attrs);

    let args = match config_attrs.as_slice() {
        [] => Default::default(),
        [attr] => parse_config_attribute(attr)?,
        [_attr, extra, ..] => {
            return Err(syn::Error::new(
                extra.span(),
                "only one #[config] attribute is allowed per field",
            ));
        }
    };

    Ok(FieldDefinition {
        name: ident,
        ty,
        vis,
        attrs,
        cfg_attrs,
        required: args.required,
        default_value: args.default_value,
        validator: args.validator,
        schemars_attrs: args.schemars_attrs,
    })
}

#[cfg(test)]
mod tests {
    use syn::parse_quote;

    use super::*;

    #[test]
    fn custom_validator() {
        let args = parse_configuration_attribute(quote!(validate = validate_fn)).unwrap();
        assert!(
            args.validator
                .is_some_and(|path| path.is_ident("validate_fn"))
        );

        parse_configuration_attribute(quote!(validate = path::to::validate_fn))
            .expect("should parse a path");

        let result = parse_configuration_attribute(quote!(validate));
        assert!(result.is_err(), "must fail parsing if no argument provided");
    }

    #[test]
    fn required_attribute() {
        let args = parse_config_attribute(&parse_quote!(#[config])).unwrap();
        assert!(args.required.is_none());

        let args = parse_config_attribute(&parse_quote!(#[config(required)])).unwrap();
        assert!(args.required.is_some());

        let Err(err) = parse_config_attribute(&parse_quote!(#[config(required, required)])) else {
            panic!("should error on duplicate required");
        };
        assert_eq!(err.to_string(), "duplicate #[config] attribute `required`");
    }

    #[test]
    fn regex_syntax_is_validated() {
        parse_config_attribute(&parse_quote!(#[config(pattern("^this.regex.is.valid$"))]))
            .expect("valid regex should be accepted");

        // "s-g" is an invalid character group, because g comes before s alphabetically
        let Err(err) =
            parse_config_attribute(&parse_quote!(#[config(pattern("^[this-group-is-invalid]$"))]))
        else {
            panic!("should error on invalid regex syntax");
        };
        insta::assert_snapshot!(err, @r"
        regex parse error:
            ^[this-group-is-invalid]$
                 ^^^
        error: invalid character class range, the start must be <= the end
        ");
    }
}