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;
pub(crate) struct PartitionedAttrs {
pub(crate) config: Vec<syn::Attribute>,
pub(crate) cfg: Vec<syn::Attribute>,
pub(crate) other: Vec<syn::Attribute>,
}
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 }
}
#[derive(Default)]
pub(crate) struct ItemArgs {
pub(crate) validator: Option<syn::Path>,
}
#[derive(Default)]
struct FieldArgs {
required: Option<syn::Ident>,
default_value: Option<DefaultValue>,
validator: Option<FieldValidator>,
schemars_attrs: Vec<proc_macro2::TokenStream>,
}
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)
}
fn parse_config_attribute(attr: &Attribute) -> syn::Result<FieldArgs> {
let mut args = FieldArgs::default();
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)
};
}
"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);
let attr: syn::LitStr = content.parse()?;
if let Err(err) = regex_syntax::parse(&attr.value()) {
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");
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
");
}
}