diff --git a/parsers-core/src/lib.rs b/parsers-core/src/lib.rs index b3baa68a..6a370006 100644 --- a/parsers-core/src/lib.rs +++ b/parsers-core/src/lib.rs @@ -114,6 +114,11 @@ The following flavors exist: then specify the name. The variant is picked based on the name of the XML element. +- XML attribute matched: The enum itself defines a namespace, a name and an + attribute name. Each variant must specify the attribute value. The variant + is picked based on the value of the given attribute, provided that XML name + and namespace of the element itself match. + The flavor is determined based on the attributes of the enum declaration. ### Dynamic enums @@ -154,6 +159,42 @@ XML name matched enum variants support the following attributes: declared in the `name` attribute of the variant; the original XML name is lost. +### XML attribute matched enums + +XML attribute matched enums support the following attributes: + +- `namespace = ..` (required): This must be a path to a `&'static str`. It is + the namespace of the enumeration. + +- `name = ..` (required): This must be a string literal containing the XML + name to match against. + +- `attribute = ..` (required): This must be a string literal with the name of + the XML attribute to match against. + +- `exhaustive` (flag): Must currently be set unless a variant is marked as + `fallback`. Support for non-exhaustive attribute-matched enums is not + implemented yet. + + This cannot be used if a variant is set as `fallback`. + + This attribute has no relation to the Rust standard `#[non_exhaustive]` + attribute. + +#### XML attribute matched enum variants + +XML attribute matched enum variants support the following attributes: + +- `value = ..` (required): String literal with the attribute value to match + against. + +- `fallback` (flag): If present, the variant is parsed when no other variant + matches. + + *Note:* When the enum is reserialized to XML, the attribute value will be + the one declared in the `value` attribute of the variant; the original value + is lost. + ## Field attributes Field attributes are composed of a field kind, followed by a value or a list diff --git a/parsers-macros/src/compound.rs b/parsers-macros/src/compound.rs index 30e2149f..6b3e1a7a 100644 --- a/parsers-macros/src/compound.rs +++ b/parsers-macros/src/compound.rs @@ -118,17 +118,28 @@ impl Compound { /// `std::cmp::PartialEq` context. /// /// - `residual` must be the identifier at which the element is found. + /// - `forgive_attributes` must be a (potentially empty) slice of XML + /// attribute names to ignore during the unknown attribute check. This + /// can be used to ignore attributes which have been used in element + /// matching (e.g. enum discriminators). pub(crate) fn build_try_from_element( self, container_name: &ParentRef, container_namespace_expr: &Expr, residual: &Ident, + forgive_attributes: &[&str], ) -> Result { let readable_name = container_name.to_string(); let mut init = quote! {}; let mut tupinit = quote! {}; - let mut attrcheck = quote! {}; + let mut attrcheck = quote! { + #( + if key == #forgive_attributes { + continue; + } + )* + }; let mut tempinit = quote! {}; let mut childiter = quote! {}; let mut childfallback = quote! { diff --git a/parsers-macros/src/enums.rs b/parsers-macros/src/enums.rs index 3be71bd7..da16f90d 100644 --- a/parsers-macros/src/enums.rs +++ b/parsers-macros/src/enums.rs @@ -52,11 +52,15 @@ fn build_ident_mapping>( (orig_names, mapped_names, Box::new(map_ident)) } -/// Variant of an enum which switches on the XML element's name. +/// Variant of an enum which switches on a string value extracted from the XML +/// element. +/// +/// The caller of the respective methods decides what string the variant +/// matches against and how that match is carried out. #[cfg_attr(feature = "debug", derive(Debug))] -struct XmlNameVariant { - /// The XML name to match against. - xml_name: Name, +struct StrMatchedVariant { + /// The string to match against. + value: LitStr, /// The identifier of the enum variant. ident: Ident, @@ -68,18 +72,69 @@ struct XmlNameVariant { inner: Compound, } -impl XmlNameVariant { - /// Parse a [`syn::Variant`] as XML name switched variant. - fn new(variant: &Variant) -> Result { - let meta = XmlCompoundMeta::parse_from_attributes(&variant.attrs)?; - - if let Some(namespace) = meta.namespace { +impl StrMatchedVariant { + fn prevalidate(meta: &mut XmlCompoundMeta) -> Result<()> { + if let Some(namespace) = meta.namespace.take() { return Err(Error::new_spanned( namespace, "`namespace` not allowed on enum variants.", )); } + if let Some(attribute) = meta.attribute.take() { + return Err(Error::new_spanned( + attribute, + "`attribute` not allowed on enum variants.", + )); + } + + if let Flag::Present(transparent) = meta.transparent.take() { + return Err(Error::new( + transparent, + "`transparent` not allowed on enum variants in enums with a fixed namespace.", + )); + } + + if let Flag::Present(exhaustive) = meta.exhaustive.take() { + return Err(Error::new( + exhaustive, + "`exhaustive` not allowed on enum variants.", + )); + } + + if let Some(validate) = meta.validate.take() { + return Err(Error::new_spanned( + validate, + "`validate` not allowed on enum variants.", + )); + } + + if let Some(prepare) = meta.prepare.take() { + return Err(Error::new_spanned( + prepare, + "`validate` not allowed on enum variants.", + )); + } + + if let Flag::Present(debug) = meta.debug.take() { + return Err(Error::new(debug, "`debug` not allowed on enum variants.")); + } + + Ok(()) + } + + /// Parse a [`syn::Variant`] as XML name switched variant. + fn new_xml_name_switched(variant: &Variant) -> Result { + let mut meta = XmlCompoundMeta::parse_from_attributes(&variant.attrs)?; + Self::prevalidate(&mut meta)?; + + if let Some(value) = meta.value { + return Err(Error::new_spanned( + value, + "`value` not allowed on enum variants inside enums matching on XML name.", + )); + } + let name = if let Some(name) = meta.name { name.into() } else { @@ -91,40 +146,37 @@ impl XmlNameVariant { let fallback = meta.fallback; - if let Flag::Present(transparent) = meta.transparent { - return Err(Error::new( - transparent, - "`transparent` not allowed on enum variants in enums with a fixed namespace.", - )); - } + Ok(Self { + value: name, + ident: variant.ident.clone(), + fallback, + inner: Compound::from_fields(&variant.fields)?, + }) + } - if let Flag::Present(exhaustive) = meta.exhaustive { - return Err(Error::new( - exhaustive, - "`exhaustive` not allowed on enum variants.", - )); - } + /// Parse a [`syn::Variant`] as XML attribute value switched variant. + fn new_xml_attribute_switched(variant: &Variant) -> Result { + let mut meta = XmlCompoundMeta::parse_from_attributes(&variant.attrs)?; + Self::prevalidate(&mut meta)?; - if let Some(validate) = meta.validate { + if let Some(name) = meta.name { return Err(Error::new_spanned( - validate, - "`validate` not allowed on enum variants.", + name, + "`name` not allowed on enum variants inside enums matching on attribute values.", )); } - if let Some(prepare) = meta.prepare { - return Err(Error::new_spanned( - prepare, - "`validate` not allowed on enum variants.", + let Some(value) = meta.value else { + return Err(Error::new( + meta.span, + "`value` is required on enum variants in enums matching on attribute values.", )); - } + }; - if let Flag::Present(debug) = meta.debug { - return Err(Error::new(debug, "`debug` not allowed on enum variants.")); - } + let fallback = meta.fallback; Ok(Self { - xml_name: name, + value, ident: variant.ident.clone(), fallback, inner: Compound::from_fields(&variant.fields)?, @@ -207,7 +259,7 @@ struct XmlNameSwitched { namespace: StaticNamespace, /// The enum's variants. - variants: Vec, + variants: Vec, } impl XmlNameSwitched { @@ -228,7 +280,7 @@ impl XmlNameSwitched { let mut variants = Vec::with_capacity(input.size_hint().1.unwrap_or(0)); let mut had_fallback = false; for variant in input { - let variant = XmlNameVariant::new(variant)?; + let variant = StrMatchedVariant::new_xml_name_switched(variant)?; if let Flag::Present(fallback) = variant.fallback { if had_fallback { return Err(syn::Error::new( @@ -282,7 +334,7 @@ impl XmlNameSwitched { for variant in self.variants { let ident = variant.ident; - let xml_name = variant.xml_name; + let xml_name = variant.value; let variant_impl = variant.inner.build_try_from_element( &(Path { @@ -297,6 +349,7 @@ impl XmlNameSwitched { .into()), &namespace_expr, residual, + &[], )?; let variant_impl = quote! { @@ -351,7 +404,7 @@ impl XmlNameSwitched { let builder = Ident::new("builder", Span::call_site()); let mut matchers = quote! {}; for variant in self.variants { - let xml_name = variant.xml_name; + let xml_name = variant.value; let path = Path { leading_colon: None, segments: [ @@ -388,6 +441,218 @@ impl XmlNameSwitched { } } +/// An enum which switches on the value of an attribute of the XML element. +#[cfg_attr(feature = "debug", derive(Debug))] +struct XmlAttributeSwitched { + /// The namespace the enum's XML element resides in. + namespace: StaticNamespace, + + /// The XML name of the element. + name: Name, + + /// The name of the XML attribute to read. + attribute_name: LitStr, + + /// The enum's variants. + variants: Vec, +} + +impl XmlAttributeSwitched { + /// Construct a new XML name switched enum from parts. + /// + /// - `exhaustive` must be the exhaustive flag. Currently, this must be + /// set on [`XmlAttributeSwitched`] enums. + /// + /// - `namespace` must be the XML namespace of the enum. + /// + /// - `input` must be an iterator emitting borrowed [`syn::Variant`] + /// structs to process. + fn new<'x, I: Iterator>( + exhaustive: Flag, + namespace: StaticNamespace, + name: Name, + attribute_name: LitStr, + input: I, + ) -> Result { + let mut variants = Vec::with_capacity(input.size_hint().1.unwrap_or(0)); + let mut had_fallback = false; + for variant in input { + let variant = StrMatchedVariant::new_xml_attribute_switched(variant)?; + if let Flag::Present(fallback) = variant.fallback { + if had_fallback { + return Err(syn::Error::new( + fallback, + "only one variant may be a fallback variant", + )); + } + had_fallback = true; + } + variants.push(variant); + } + + if let Flag::Present(exhaustive) = exhaustive { + if had_fallback { + return Err(syn::Error::new(exhaustive, "exhaustive cannot be sensibly combined with a fallback variant. choose one or the other.")); + } + } else { + if !had_fallback { + return Err(syn::Error::new_spanned( + attribute_name, + "enums switching on an attribute must be marked exhaustive or have a fallback variant." + )); + } + } + + Ok(Self { + namespace, + name, + attribute_name, + variants, + }) + } + + /// Construct an expression which consumes the `minidom::Element` at + /// `residual` and returns a `Result`. + /// + /// - `enum_ident` must be the identifier of the enum's type. + /// - `validate` must be a statement which takes a mutable reference to + /// the identifier `result` and `return`'s with an error if it is not + /// acceptable. + /// - `residual` must be the identifier at which the element is found. + fn build_try_from_element( + self, + enum_ident: &Ident, + validate: Stmt, + residual: &Ident, + ) -> Result { + let xml_namespace = self.namespace; + let xml_name = self.name; + let attribute_name = self.attribute_name.value(); + let namespace_expr = Expr::Path(ExprPath { + attrs: Vec::new(), + qself: None, + path: xml_namespace.clone(), + }); + + let mut fallback: Option = None; + let mut iter = quote! {}; + + for variant in self.variants { + let ident = variant.ident; + let xml_name = variant.value; + + let variant_impl = variant.inner.build_try_from_element( + &(Path { + leading_colon: None, + segments: [ + PathSegment::from(enum_ident.clone()), + PathSegment::from(ident.clone()), + ] + .into_iter() + .collect(), + } + .into()), + &namespace_expr, + residual, + &[attribute_name.as_str()], + )?; + + let variant_impl = quote! { + let mut result = #variant_impl; + #validate + Ok(result) + }; + + if variant.fallback.is_set() { + fallback = Some(quote! { + _ => { #variant_impl }, + }) + } + + iter = quote! { + #iter + #xml_name => { #variant_impl }, + }; + } + + let fallback = fallback.unwrap_or_else(|| quote! { + _ => Err(::xmpp_parsers_core::error::Error::ParseError(concat!("This is not a ", stringify!(#enum_ident), " element."))), + }); + + let on_missing = format!( + "Required discriminator attribute '{}' on enum {} element missing.", + attribute_name, enum_ident, + ); + + Ok(quote! { + if #residual.is(#xml_name, #xml_namespace) { + match #residual.attr(#attribute_name) { + Some(v) => match v { + #iter + #fallback + } + None => Err(::xmpp_parsers_core::error::Error::ParseError(#on_missing)), + } + } else { + Err(::xmpp_parsers_core::error::Error::TypeMismatch("", "", #residual)) + } + }) + } + + /// Construct a token stream which contains the arms of a `match` + /// expression dissecting the enum and building `minidom::Element` objects + /// for each variant. + /// + /// `ty_ident` must be the identifier of the enum's type. + fn build_into_element(self, ty_ident: &Ident) -> Result { + let xml_namespace = self.namespace; + let xml_name = self.name; + let attribute_name = self.attribute_name; + let namespace_expr = Expr::Path(ExprPath { + attrs: Vec::new(), + qself: None, + path: xml_namespace.clone(), + }); + let builder = Ident::new("builder", Span::call_site()); + let mut matchers = quote! {}; + for variant in self.variants { + let attribute_value = variant.value; + let path = Path { + leading_colon: None, + segments: [ + PathSegment::from(ty_ident.clone()), + PathSegment::from(variant.ident), + ] + .into_iter() + .collect(), + }; + + let (orig_names, mapped_names, map_ident) = + build_ident_mapping(variant.inner.iter_members()); + + let into_element = variant.inner.build_into_element( + &(Path::from(ty_ident.clone()).into()), + &namespace_expr, + &builder, + map_ident, + )?; + + matchers = quote! { + #matchers + #path { #( #orig_names: #mapped_names ),* } => { + let #builder = ::xmpp_parsers_core::exports::minidom::Element::builder( + #xml_name, + #namespace_expr, + ).attr(#attribute_name, #attribute_value); + let #builder = #into_element; + #builder.build() + }, + }; + } + Ok(matchers) + } +} + /// An enum where each variant has completely independent matching. #[cfg_attr(feature = "debug", derive(Debug))] struct Dynamic { @@ -501,6 +766,10 @@ enum EnumInner { /// Enum item where the variants switch on the XML element's name. XmlNameSwitched(XmlNameSwitched), + /// Enum item where the variants switch on the value of an attribute on + /// the XML element. + XmlAttributeSwitched(XmlAttributeSwitched), + /// Enum item which has no matcher on the enum itself, where each variant /// may be an entirely different XML element. Dynamic(Dynamic), @@ -525,6 +794,9 @@ impl EnumInner { Self::XmlNameSwitched(inner) => { inner.build_try_from_element(enum_ident, validate, residual) } + Self::XmlAttributeSwitched(inner) => { + inner.build_try_from_element(enum_ident, validate, residual) + } Self::Dynamic(inner) => inner.build_try_from_element(enum_ident, validate, residual), } } @@ -537,6 +809,7 @@ impl EnumInner { fn build_into_element(self, ty_ident: &Ident) -> Result { match self { Self::XmlNameSwitched(inner) => inner.build_into_element(ty_ident), + Self::XmlAttributeSwitched(inner) => inner.build_into_element(ty_ident), Self::Dynamic(inner) => inner.build_into_element(ty_ident), } } @@ -588,6 +861,13 @@ impl EnumDef { )); } + if let Some(value) = meta.value { + return Err(syn::Error::new_spanned( + value, + "`value` is not allowed on enums", + )); + } + let namespace = match meta.namespace { None => { // no namespace -> must be dynamic variant. ensure the other @@ -628,20 +908,40 @@ impl EnumDef { } }; - if let Some(name) = meta.name { - return Err(syn::Error::new_spanned( - name, - "`name` cannot be set on enums (only on variants).", - )); - } - let exhaustive = meta.exhaustive; + let name = match meta.name { + None => { + return Ok(Self { + prepare, + validate, + debug, + inner: EnumInner::XmlNameSwitched(XmlNameSwitched::new( + exhaustive, namespace, input, + )?), + }); + } + Some(name) => name, + }; + + let Some(attribute_name) = meta.attribute else { + return Err(syn::Error::new( + meta.span, + "`attribute` option with the name of the XML attribute to match is required for enums matching on attribute value", + )); + }; + Ok(Self { prepare, validate, debug, - inner: EnumInner::XmlNameSwitched(XmlNameSwitched::new(exhaustive, namespace, input)?), + inner: EnumInner::XmlAttributeSwitched(XmlAttributeSwitched::new( + exhaustive, + namespace, + name.into(), + attribute_name, + input, + )?), }) } diff --git a/parsers-macros/src/field/child.rs b/parsers-macros/src/field/child.rs index bc2eef99..29dd6247 100644 --- a/parsers-macros/src/field/child.rs +++ b/parsers-macros/src/field/child.rs @@ -112,9 +112,9 @@ impl ExtractDef { quote! { data } }; - let parse = self - .parts - .build_try_from_element(container_name, &namespace_expr, residual)?; + let parse = + self.parts + .build_try_from_element(container_name, &namespace_expr, residual, &[])?; let test_expr = match self.namespace { FieldNamespace::Static(xml_namespace) => quote! { diff --git a/parsers-macros/src/meta.rs b/parsers-macros/src/meta.rs index ef3227f0..fc18c567 100644 --- a/parsers-macros/src/meta.rs +++ b/parsers-macros/src/meta.rs @@ -330,6 +330,12 @@ pub(crate) struct XmlCompoundMeta { /// The value assigned to `name` inside `#[xml(..)]`, if any. pub(crate) name: Option, + /// The value assigned to `attribute` inside `#[xml(..)]`, if any. + pub(crate) attribute: Option, + + /// The value assigned to `value` inside `#[xml(..)]`, if any. + pub(crate) value: Option, + /// Flag indicating the presence of `fallback` inside `#[xml(..)]. pub(crate) fallback: Flag, @@ -357,6 +363,8 @@ impl XmlCompoundMeta { fn parse_from_attribute(attr: &Attribute) -> Result { let mut name: Option = None; let mut namespace: Option = None; + let mut attribute: Option = None; + let mut value: Option = None; let mut fallback = Flag::Absent; let mut transparent = Flag::Absent; let mut exhaustive = Flag::Absent; @@ -369,6 +377,8 @@ impl XmlCompoundMeta { meta, ParseValue("name", &mut name), ParseValue("namespace", &mut namespace), + ParseValue("attribute", &mut attribute), + ParseValue("value", &mut value), ParseFlag("fallback", &mut fallback), ParseFlag("transparent", &mut transparent), ParseFlag("exhaustive", &mut exhaustive), @@ -390,6 +400,8 @@ impl XmlCompoundMeta { span: attr.span(), namespace, name, + attribute, + value, fallback, transparent, validate, diff --git a/parsers-macros/src/structs.rs b/parsers-macros/src/structs.rs index 3d86f9ea..4d610e79 100644 --- a/parsers-macros/src/structs.rs +++ b/parsers-macros/src/structs.rs @@ -93,6 +93,8 @@ impl StructInner { assert!(meta.prepare.is_none()); assert!(!meta.debug.is_set()); assert!(!meta.fallback.is_set()); + assert!(meta.attribute.is_none()); + assert!(meta.value.is_none()); if let Flag::Present(_) = meta.transparent { if let Some(namespace) = meta.namespace { @@ -264,7 +266,8 @@ impl StructInner { path: namespace_tempname.clone().into(), }); - let body = inner.build_try_from_element(struct_name, &namespace_expr, residual)?; + let body = + inner.build_try_from_element(struct_name, &namespace_expr, residual, &[])?; match namespace { StructNamespace::Dyn { ty, .. } => Ok(quote! { @@ -421,13 +424,28 @@ impl StructDef { if let Flag::Present(fallback) = meta.fallback.take() { return Err(syn::Error::new( fallback, - "fallback is not allowed on structs", + "`fallback` is not allowed on structs", )); } + if let Flag::Present(exhaustive) = meta.exhaustive.take() { return Err(syn::Error::new( exhaustive, - "exhaustive is not allowed on structs", + "`exhaustive` is not allowed on structs", + )); + } + + if let Some(attribute) = meta.attribute.take() { + return Err(syn::Error::new_spanned( + attribute, + "`attribute` is not allowed on structs", + )); + } + + if let Some(value) = meta.value.take() { + return Err(syn::Error::new_spanned( + value, + "`value` is not allowed on structs", )); } diff --git a/parsers/src/macro_tests/mod.rs b/parsers/src/macro_tests/mod.rs index cd027f00..ade49161 100644 --- a/parsers/src/macro_tests/mod.rs +++ b/parsers/src/macro_tests/mod.rs @@ -729,3 +729,111 @@ fn extract_field_with_super_negative_wrong_name() { other => panic!("unexpected result: {:?}", other), } } + +#[derive(FromXml, IntoXml, PartialEq, Clone, Debug)] +#[xml(namespace = self::TEST_NS1, name = "attr-switched-enum", attribute = "key", exhaustive)] +pub enum AttributeSwitchedEnum { + #[xml(value = "variant-1")] + Variant1 { + #[xml(attribute(name = "variant-1-attr"))] + data: String, + }, + #[xml(value = "variant-2")] + Variant2 { + #[xml(attribute(name = "variant-2-attr"))] + data: String, + }, +} + +#[test] +fn attribute_switched_enum_roundtrip_variant_1() { + crate::util::test::roundtrip_full::( + "", + ); +} + +#[test] +fn attribute_switched_enum_roundtrip_variant_2() { + crate::util::test::roundtrip_full::( + "", + ); +} + +#[test] +fn attribute_switched_enum_matches_namespace() { + match crate::util::test::parse_str::( + "", + ) { + Err(Error::TypeMismatch(_, _, _)) => (), + other => panic!("unexpected result: {:?}", other), + } +} + +#[test] +fn attribute_switched_enum_matches_name() { + match crate::util::test::parse_str::( + "", + ) { + Err(Error::TypeMismatch(_, _, _)) => (), + other => panic!("unexpected result: {:?}", other), + } +} + +#[test] +fn attribute_switched_enum_rejects_unknown_value() { + match crate::util::test::parse_str::( + "", + ) { + Err(Error::ParseError(msg)) if msg.find("This is not a").is_some() => (), + other => panic!("unexpected result: {:?}", other), + } +} + +#[test] +fn attribute_switched_enum_rejects_missing_attribute() { + match crate::util::test::parse_str::( + "", + ) { + Err(Error::ParseError(msg)) if msg.find("discriminator attribute").is_some() => (), + other => panic!("unexpected result: {:?}", other), + } +} + +#[derive(FromXml, IntoXml, PartialEq, Clone, Debug)] +#[xml(namespace = self::TEST_NS1, name = "attr-switched-enum-fallback", attribute = "key")] +pub enum AttributeSwitchedEnumFallback { + #[xml(value = "variant-1")] + Variant1 { + #[xml(attribute(name = "variant-1-attr"))] + data: String, + }, + #[xml(value = "variant-2", fallback)] + Variant2 { + #[xml(attribute(name = "variant-2-attr"))] + data: String, + }, +} + +#[test] +fn attribute_switched_enum_fallback_roundtrip_variant_1() { + crate::util::test::roundtrip_full::( + "", + ); +} + +#[test] +fn attribute_switched_enum_fallback_roundtrip_variant_2() { + crate::util::test::roundtrip_full::( + "", + ); +} + +#[test] +fn attribute_switched_enum_fallback() { + match crate::util::test::parse_str::( + "", + ) { + Ok(v) => assert_eq!(v, AttributeSwitchedEnumFallback::Variant2 { data: "data".to_string() }), + other => panic!("unexpected result: {:?}", other), + } +}