From 2d70b7000822fcd362fe0212eee11cf54778ad85 Mon Sep 17 00:00:00 2001 From: Sebastian Walz Date: Sat, 21 Sep 2024 17:01:23 +0200 Subject: [PATCH] feat(serde_with_macros): proper handling of `cfg_attr` --- serde_with/tests/schemars_0_8.rs | 16 ++ serde_with_macros/src/lazy_bool.rs | 139 +++++++++++++++ serde_with_macros/src/lib.rs | 62 ++++--- serde_with_macros/src/utils.rs | 267 +++++++++++++++-------------- 4 files changed, 332 insertions(+), 152 deletions(-) create mode 100644 serde_with_macros/src/lazy_bool.rs diff --git a/serde_with/tests/schemars_0_8.rs b/serde_with/tests/schemars_0_8.rs index a6c923e8..d8b41113 100644 --- a/serde_with/tests/schemars_0_8.rs +++ b/serde_with/tests/schemars_0_8.rs @@ -94,6 +94,22 @@ fn schemars_basic() { expected.assert_eq(&schema); } +#[test] +fn schemars_other_cfg_attrs() { + #[serde_as] + #[derive(JsonSchema, Serialize)] + struct Test { + #[serde_as(as = "DisplayFromStr")] + #[cfg_attr(any(), arbitrary("some" |weird| syntax::()))] + #[cfg_attr(any(), schemars(with = "i32"))] + custom: i32, + } + + check_matches_schema::(&json!({ + "custom": "23", + })); +} + #[test] fn schemars_custom_with() { #[serde_as] diff --git a/serde_with_macros/src/lazy_bool.rs b/serde_with_macros/src/lazy_bool.rs new file mode 100644 index 00000000..434d58b2 --- /dev/null +++ b/serde_with_macros/src/lazy_bool.rs @@ -0,0 +1,139 @@ +use core::{ + mem, + ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Not}, +}; + +/// Not-yet evaluated boolean value. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum LazyBool { + /// Like `false`. + /// + /// This is this type’s default. + #[default] + False, + + /// Like `true`. + True, + + /// Not-yet decided. + Lazy(T), +} + +impl From for LazyBool { + fn from(value: bool) -> Self { + match value { + false => Self::False, + true => Self::True, + } + } +} + +/// Helper to implement various binary operations on [`LazyBool`]. +macro_rules! impl_op { + ( + < + $trait:ident::$method:ident, + $assign_trait:ident::$assign_method:ident + >($matching:pat_param) { + $($pattern:pat => $body:expr),+ $(,)? + } + $(where $($bound:tt)+)? + ) => { + impl $trait> for LazyBool + where + L: $trait, + LazyBool: Into>, + LazyBool: Into>, + $($($bound)+)? + { + type Output = LazyBool; + + fn $method(self, rhs: LazyBool) -> Self::Output { + match (self, rhs) { + (LazyBool::Lazy(lhs), LazyBool::Lazy(rhs)) => LazyBool::Lazy(lhs.$method(rhs)), + ($matching, rhs) => rhs.into(), + (lhs, $matching) => lhs.into(), + $($pattern => $body),+ + } + } + } + + impl<'a, L, R, T> $trait<&'a LazyBool> for LazyBool + where + L: $trait<&'a R, Output = T>, + LazyBool: Into>, + LazyBool: Into> + Clone, + $($($bound)+)? + { + type Output = LazyBool; + + fn $method(self, rhs: &'a LazyBool) -> Self::Output { + match (self, rhs) { + (LazyBool::Lazy(lhs), LazyBool::Lazy(rhs)) => LazyBool::Lazy(lhs.$method(rhs)), + ($matching, rhs) => rhs.clone().into(), + (lhs, $matching) => lhs.into(), + $($pattern => $body),+ + } + } + } + + impl<'a, L, R, T> $trait> for &'a LazyBool + where + LazyBool: $trait<&'a LazyBool, Output = LazyBool>, + { + type Output = LazyBool; + + fn $method(self, rhs: LazyBool) -> Self::Output { + rhs.$method(self) + } + } + + impl $assign_trait> for LazyBool + where + LazyBool: $trait, Output = LazyBool>, + { + fn $assign_method(&mut self, rhs: LazyBool) { + let lhs = mem::take(self); + *self = lhs.$method(rhs); + } + } + }; +} + +impl_op! { (LazyBool::True){ _ => LazyBool::False } } +impl_op! { (LazyBool::False) { _ => LazyBool::True } } +impl_op! { + (LazyBool::False) { + (LazyBool::True, rhs) => (!rhs).into(), + (lhs, LazyBool::True) => (!lhs).into(), + } + where + LazyBool: Not>, + LazyBool: Not>, +} + +impl Not for LazyBool +where + T: Not, +{ + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::False => Self::True, + Self::True => Self::False, + Self::Lazy(this) => Self::Lazy(!this), + } + } +} + +impl Not for &LazyBool +where + LazyBool: Not> + Clone, +{ + type Output = LazyBool; + + fn not(self) -> Self::Output { + !self.clone() + } +} diff --git a/serde_with_macros/src/lib.rs b/serde_with_macros/src/lib.rs index 5f4d9983..79c63c22 100644 --- a/serde_with_macros/src/lib.rs +++ b/serde_with_macros/src/lib.rs @@ -32,9 +32,13 @@ //! [`serde_with`]: https://crates.io/crates/serde_with/ mod apply; +mod lazy_bool; mod utils; -use crate::utils::{split_with_de_lifetime, DeriveOptions, IteratorExt as _, SchemaFieldConfig}; +use crate::utils::{ + split_with_de_lifetime, DeriveOptions, IteratorExt as _, SchemaFieldCondition, + SchemaFieldConfig, +}; use darling::{ ast::NestedMeta, util::{Flag, Override}, @@ -619,10 +623,9 @@ pub fn serde_as(args: TokenStream, input: TokenStream) -> TokenStream { .unwrap_or_else(|| syn::parse_quote!(::serde_with)); let schemars_config = match container_options.enable_schemars_support { - _ if cfg!(not(feature = "schemars_0_8")) => SchemaFieldConfig::Disabled, - Some(true) => SchemaFieldConfig::Unconditional, - Some(false) => SchemaFieldConfig::Disabled, - None => utils::has_derive_jsonschema(input.clone()), + _ if cfg!(not(feature = "schemars_0_8")) => SchemaFieldConfig::False, + Some(condition) => condition.into(), + None => utils::has_derive_jsonschema(input.clone()).unwrap_or_default(), }; // Convert any error message into a nice compiler error @@ -772,21 +775,40 @@ fn serde_as_add_attr_to_field( let attr = parse_quote!(#[serde(with = #attr_inner_tokens)]); field.attrs.push(attr); - if let Some(cfg) = schemars_config.cfg_expr() { - let with_cfg = utils::schemars_with_attr_if( - &field.attrs, - &["with", "serialize_with", "deserialize_with", "schema_with"], - )?; - let attr_inner_tokens = - quote!(#serde_with_crate_path::Schema::<#type_original, #replacement_type>) - .to_string(); - let attr = parse_quote! { - #[cfg_attr( - all(#cfg, not(#with_cfg)), - schemars(with = #attr_inner_tokens)) - ] - }; - field.attrs.push(attr); + match schemars_config { + SchemaFieldConfig::False => {} + lhs => { + let rhs = utils::schemars_with_attr_if( + &field.attrs, + &["with", "serialize_with", "deserialize_with", "schema_with"], + )?; + + match lhs & !rhs { + SchemaFieldConfig::False => {} + condition => { + let attr_inner_tokens = quote! { + #serde_with_crate_path::Schema::<#type_original, #replacement_type> + }; + let attr_inner_tokens = attr_inner_tokens.to_string(); + let attr = match condition { + SchemaFieldConfig::False => unreachable!(), + SchemaFieldConfig::True => { + parse_quote! { #[schemars(with = #attr_inner_tokens)] } + } + SchemaFieldConfig::Lazy(SchemaFieldCondition(condition)) => { + parse_quote! { + #[cfg_attr( + #condition, + schemars(with = #attr_inner_tokens)) + ] + } + } + }; + + field.attrs.push(attr); + } + } + } } } if let Some(type_) = &serde_as_options.deserialize_as { diff --git a/serde_with_macros/src/utils.rs b/serde_with_macros/src/utils.rs index fe0696e2..4bc08cb6 100644 --- a/serde_with_macros/src/utils.rs +++ b/serde_with_macros/src/utils.rs @@ -1,18 +1,21 @@ +use crate::lazy_bool::LazyBool; use darling::FromDeriveInput; use proc_macro::TokenStream; -use proc_macro2::{TokenStream as TokenStream2, TokenTree}; +use proc_macro2::TokenStream as TokenStream2; use quote::ToTokens; -use std::collections::BTreeSet; +use std::ops::{BitAnd, BitOr, Not}; use syn::{ - ext::IdentExt, parse::Parse, parse_quote, punctuated::Punctuated, Error, Generics, Path, - TypeGenerics, + parse::{Parse, ParseStream}, + parse_quote, + punctuated::Punctuated, + Attribute, DeriveInput, Generics, Meta, Path, PathSegment, Token, TypeGenerics, WhereClause, }; /// Merge multiple [`syn::Error`] into one. pub(crate) trait IteratorExt { - fn collect_error(self) -> Result<(), Error> + fn collect_error(self) -> syn::Result<()> where - Self: Iterator> + Sized, + Self: Iterator> + Sized, { let accu = Ok(()); self.fold(accu, |accu, error| match (accu, error) { @@ -25,7 +28,7 @@ pub(crate) trait IteratorExt { }) } } -impl IteratorExt for I where I: Iterator> + Sized {} +impl IteratorExt for I where I: Iterator> + Sized {} /// Attributes usable for derive macros #[derive(FromDeriveInput)] @@ -37,7 +40,7 @@ pub(crate) struct DeriveOptions { } impl DeriveOptions { - pub(crate) fn from_derive_input(input: &syn::DeriveInput) -> Result { + pub(crate) fn from_derive_input(input: &DeriveInput) -> Result { match ::from_derive_input(input) { Ok(v) => Ok(v), Err(e) => Err(TokenStream::from(e.write_errors())), @@ -55,11 +58,7 @@ impl DeriveOptions { // Serde is also licensed Apache 2 + MIT pub(crate) fn split_with_de_lifetime( generics: &Generics, -) -> ( - DeImplGenerics<'_>, - TypeGenerics<'_>, - Option<&syn::WhereClause>, -) { +) -> (DeImplGenerics<'_>, TypeGenerics<'_>, Option<&WhereClause>) { let de_impl_generics = DeImplGenerics(generics); let (_, ty_generics, where_clause) = generics.split_for_impl(); (de_impl_generics, ty_generics, where_clause) @@ -86,170 +85,174 @@ impl<'a> ToTokens for DeImplGenerics<'a> { /// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ /// ``` struct CfgAttr { - cfg: syn::Expr, - _comma: syn::Token![,], - meta: syn::Meta, + condition: Meta, + _comma: Token![,], + metas: Punctuated, } impl Parse for CfgAttr { - fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + fn parse(input: ParseStream<'_>) -> syn::Result { Ok(Self { - cfg: input.parse()?, + condition: input.parse()?, _comma: input.parse()?, - meta: input.parse()?, + metas: Punctuated::parse_terminated(input)?, }) } } /// Determine if there is a `#[derive(JsonSchema)]` on this struct. -pub(crate) fn has_derive_jsonschema(input: TokenStream) -> SchemaFieldConfig { - fn parse_derive_args( - input: syn::parse::ParseStream<'_>, - ) -> syn::Result> { +pub(crate) fn has_derive_jsonschema(input: TokenStream) -> syn::Result { + fn parse_derive_args(input: ParseStream<'_>) -> syn::Result> { Punctuated::parse_terminated_with(input, Path::parse_mod_style) } - fn eval_attribute(attr: &syn::Attribute) -> syn::Result { - let args: CfgAttr; - let mut meta = &attr.meta; - let mut config = SchemaFieldConfig::Unconditional; - - if meta.path().is_ident("cfg_attr") { - let list = meta.require_list()?; - args = list.parse_args()?; - - meta = &args.meta; - config = SchemaFieldConfig::Conditional(args.cfg); - } - - let list = meta.require_list()?; - if !list.path.is_ident("derive") { - return Ok(SchemaFieldConfig::Disabled); - } + fn eval_metas<'a>(metas: impl IntoIterator) -> syn::Result { + metas + .into_iter() + .map(eval_meta) + .try_fold( + SchemaFieldConfig::False, + |state, result| Ok(state | result?), + ) + } - let derives = list.parse_args_with(parse_derive_args)?; - for derive in &derives { - let segments = &derive.segments; + fn eval_meta(meta: &Meta) -> syn::Result { + match meta.path() { + path if path.is_ident("cfg_attr") => { + let CfgAttr { + condition, metas, .. + } = meta.require_list()?.parse_args()?; - // Check for $(::)? $(schemars::)? JsonSchema - match segments.len() { - 1 if segments[0].ident == "JsonSchema" => (), - 2 if segments[0].ident == "schemars" && segments[1].ident == "JsonSchema" => (), - _ => continue, + Ok(eval_metas(&metas)? & SchemaFieldConfig::Lazy(condition.into())) } + path if path.is_ident("derive") => { + let config = meta + .require_list()? + .parse_args_with(parse_derive_args)? + .into_iter() + .any(|Path { segments, .. }| { + // This matches `JsonSchema`, `schemars::JsonSchema` + // as well as any other path ending with `JsonSchema`. + // This will not match aliased `JsonSchema`s, + // but might match other `JsonSchema` not `schemars::JsonSchema`! + match segments.last() { + Some(PathSegment { ident, .. }) => ident == "JsonSchema", + _ => false, + } + }) + .then_some(SchemaFieldConfig::True) + .unwrap_or_default(); - return Ok(config); - } - - Ok(SchemaFieldConfig::Disabled) - } - - let input: syn::DeriveInput = match syn::parse(input) { - Ok(input) => input, - Err(_) => return SchemaFieldConfig::Disabled, - }; - - for attr in input.attrs { - match eval_attribute(&attr) { - Ok(SchemaFieldConfig::Disabled) => continue, - Ok(config) => return config, - Err(_) => continue, + Ok(config) + } + _ => Ok(SchemaFieldConfig::False), } } - SchemaFieldConfig::Disabled + let DeriveInput { attrs, .. } = syn::parse(input)?; + let metas = attrs.iter().map(|Attribute { meta, .. }| meta); + eval_metas(metas) } /// Enum controlling when we should emit a `#[schemars]` field attribute. -pub(crate) enum SchemaFieldConfig { - /// Emit a `#[cfg_attr(#cfg, schemars(...))]` attribute. - Conditional(syn::Expr), - /// Emit a `#[schemars(...)]` attribute (or equivalent). - Unconditional, - /// Do not emit an attribute. - Disabled, +pub(crate) type SchemaFieldConfig = LazyBool; + +impl From for SchemaFieldConfig { + fn from(meta: Meta) -> Self { + Self::Lazy(meta.into()) + } } -impl SchemaFieldConfig { - /// Get a `#[cfg]` expression suitable for emitting the `#[schemars]` attribute. - /// - /// If this config is `Unconditional` then it will just return `all()` which - /// is always true. - pub(crate) fn cfg_expr(&self) -> Option { - match self { - Self::Unconditional => Some(syn::parse_quote!(all())), - Self::Conditional(cfg) => Some(cfg.clone()), - Self::Disabled => None, - } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct SchemaFieldCondition(pub(crate) Meta); + +impl BitAnd for SchemaFieldCondition { + type Output = Self; + + fn bitand(self, Self(rhs): Self) -> Self::Output { + let Self(lhs) = self; + Self(parse_quote!(all(#lhs, #rhs))) } } -struct SchemarsAttr { - args: BTreeSet, +impl BitAnd<&SchemaFieldCondition> for SchemaFieldCondition { + type Output = Self; + + fn bitand(self, Self(rhs): &Self) -> Self::Output { + let Self(lhs) = self; + Self(parse_quote!(all(#lhs, #rhs))) + } } -impl Parse for SchemarsAttr { - fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { - let mut args = BTreeSet::new(); +impl BitOr for SchemaFieldCondition { + type Output = Self; - while !input.is_empty() { - let arg = syn::Ident::parse_any(input)?; - let _eq: syn::Token![=] = input.parse()?; + fn bitor(self, Self(rhs): Self) -> Self::Output { + let Self(lhs) = self; + Self(parse_quote!(any(#lhs, #rhs))) + } +} - args.insert(arg.to_string()); +impl Not for SchemaFieldCondition { + type Output = Self; - // Don't parse the argument value, just advance until we hit the end or a comma. - input.step(|cursor| { - let mut rest = *cursor; - loop { - match rest.token_tree() { - Some((TokenTree::Punct(punct), next)) if punct.as_char() == ',' => { - return Ok(((), next)) - } - Some((_, next)) => rest = next, - None => return Ok(((), rest)), - } - } - })?; - } + fn not(self) -> Self::Output { + let Self(condition) = self; + Self(parse_quote!(not(#condition))) + } +} - Ok(Self { args }) +impl From for SchemaFieldCondition { + fn from(meta: Meta) -> Self { + Self(meta) } } /// Get a `#[cfg]` expression under which this field has a `#[schemars]` attribute /// with a `with = ...` argument. pub(crate) fn schemars_with_attr_if( - attrs: &[syn::Attribute], + attrs: &[Attribute], filter: &[&str], -) -> syn::Result { - let mut conditions = Vec::new(); - - for attr in attrs { - let path = attr.path(); +) -> syn::Result { + fn eval_metas<'a>( + filter: &[&str], + metas: impl IntoIterator, + ) -> syn::Result { + metas + .into_iter() + .map(|meta| eval_meta(filter, meta)) + .try_fold( + SchemaFieldConfig::False, + |state, result| Ok(state | result?), + ) + } - let nested; - let (cfg, meta) = match () { - _ if path.is_ident("cfg_attr") => { - let cfg_attr = attr.parse_args_with(CfgAttr::parse)?; - nested = cfg_attr.meta; + fn eval_meta(filter: &[&str], meta: &Meta) -> syn::Result { + match meta.path() { + path if path.is_ident("cfg_attr") => { + let CfgAttr { + condition, metas, .. + } = meta.require_list()?.parse_args()?; - (cfg_attr.cfg, &nested) + Ok(eval_metas(filter, &metas)? & SchemaFieldConfig::from(condition)) } - _ if path.is_ident("schemars") => (syn::parse_quote!(all()), &attr.meta), - _ => continue, - }; - - let list = meta.require_list()?; - let schemars: SchemarsAttr = syn::parse2(list.tokens.clone())?; - let args = &schemars.args; - - if !filter.iter().copied().any(|item| args.contains(item)) { - continue; + path if path.is_ident("schemars") => { + let config = meta + .require_list()? + .parse_args_with(>::parse_terminated)? + .into_iter() + .any(|meta| match meta.path().get_ident() { + Some(ident) => filter.iter().any(|relevant| ident == relevant), + _ => false, + }) + .then_some(SchemaFieldConfig::True) + .unwrap_or_default(); + Ok(config) + } + _ => Ok(SchemaFieldConfig::False), } - - conditions.push(cfg); } - Ok(syn::parse_quote!(any(#( #conditions, )*))) + let metas = attrs.iter().map(|Attribute { meta, .. }| meta); + eval_metas(filter, metas) }