xso: add support for attribute-switched enums

Jonas Schäfer created

skip-changelog, because enums have been introduced in the same release,
so this is merely an addition to the already changelogged enum feature.

Change summary

parsers/src/util/macro_tests.rs | 107 +++++++++
xso-proc/src/enums.rs           | 375 ++++++++++++++++++++++++++++++++++
xso-proc/src/meta.rs            |  63 +++++
xso-proc/src/structs.rs         |   4 
xso/src/from_xml_doc.md         |  70 ++++++
5 files changed, 608 insertions(+), 11 deletions(-)

Detailed changes

parsers/src/util/macro_tests.rs 🔗

@@ -2522,3 +2522,110 @@ fn language_roundtrip_nested_parse() {
         other => panic!("unexpected parse result: {:?}", other),
     }
 }
+
+#[derive(FromXml, AsXml, Debug, Clone, PartialEq)]
+#[xml(namespace = NS1, name = "foo", attribute = "bar", exhaustive)]
+enum AttributeSwitchedEnum {
+    #[xml(value = "a")]
+    A {
+        #[xml(attribute = "baz")]
+        baz: String,
+    },
+
+    #[xml(value = "b")]
+    B {
+        #[xml(text)]
+        content: String,
+    },
+}
+
+#[test]
+fn attribute_switched_enum_roundtrip_a() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns1' bar='a' baz='abc'/>");
+}
+
+#[test]
+fn attribute_switched_enum_roundtrip_b() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns1' bar='b'>abc</foo>");
+}
+
+#[test]
+fn attribute_switched_enum_negative_namespace_mismatch() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns2' bar='b'>abc</foo>") {
+        Err(xso::error::Error::TypeMismatch) => (),
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn attribute_switched_enum_negative_name_mismatch() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeSwitchedEnum>("<quux xmlns='urn:example:ns1' bar='b'>abc</quux>") {
+        Err(xso::error::Error::TypeMismatch) => (),
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn attribute_switched_enum_negative_attribute_missing() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns1'/>") {
+        Err(xso::error::Error::Other(e)) => {
+            assert_eq!(e, "Missing discriminator attribute.");
+        }
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn attribute_switched_enum_negative_attribute_mismatch() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns1' bar='quux'/>") {
+        Err(xso::error::Error::Other(e)) => {
+            assert_eq!(e, "Unknown value for discriminator attribute.");
+        }
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn attribute_switched_enum_positive_attribute_mismatch() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeSwitchedEnum>("<foo xmlns='urn:example:ns1' bar='b'>abc</foo>") {
+        Ok(AttributeSwitchedEnum::B { content }) => {
+            assert_eq!(content, "abc");
+        }
+        other => panic!("unexpected result: {:?}", other),
+    }
+}

xso-proc/src/enums.rs 🔗

@@ -8,15 +8,15 @@
 
 use std::collections::HashMap;
 
-use proc_macro2::Span;
-use quote::quote;
+use proc_macro2::{Span, TokenStream};
+use quote::{quote, ToTokens};
 use syn::*;
 
 use crate::common::{AsXmlParts, FromXmlParts, ItemDef};
 use crate::compound::Compound;
 use crate::error_message::ParentRef;
 use crate::meta::{reject_key, Flag, NameRef, NamespaceRef, QNameRef, XmlCompoundMeta};
-use crate::state::{AsItemsStateMachine, FromEventsStateMachine};
+use crate::state::{AsItemsStateMachine, FromEventsMatchMode, FromEventsStateMachine};
 use crate::structs::StructInner;
 use crate::types::{ref_ty, ty_from_ident};
 
@@ -51,6 +51,8 @@ impl NameVariant {
             transparent,
             discard,
             deserialize_callback,
+            attribute,
+            value,
         } = XmlCompoundMeta::parse_from_attributes(&decl.attrs)?;
 
         reject_key!(debug flag not on "enum variants" only on "enums and structs");
@@ -60,9 +62,14 @@ impl NameVariant {
         reject_key!(iterator not on "enum variants" only on "enums and structs");
         reject_key!(transparent flag not on "named enum variants" only on "structs");
         reject_key!(deserialize_callback not on "enum variants" only on "enums and structs");
+        reject_key!(attribute not on "enum variants" only on "attribute-switched enums");
+        reject_key!(value not on "name-switched enum variants" only on "attribute-switched enum variants");
 
         let Some(name) = name else {
-            return Err(Error::new(meta_span, "`name` is required on enum variants"));
+            return Err(Error::new(
+                meta_span,
+                "`name` is required on name-switched enum variants",
+            ));
         };
 
         Ok(Self {
@@ -252,6 +259,297 @@ impl NameSwitchedEnum {
     }
 }
 
+/// The definition of an enum variant, switched on the XML element's
+/// attribute's value, inside a [`AttributeSwitchedEnum`].
+struct ValueVariant {
+    /// The verbatim value to match.
+    value: String,
+
+    /// The identifier of the variant
+    ident: Ident,
+
+    /// The field(s) of the variant.
+    inner: Compound,
+}
+
+impl ValueVariant {
+    /// Construct a new value-selected variant from its declaration.
+    fn new(decl: &Variant, enum_namespace: &NamespaceRef) -> Result<Self> {
+        // We destructure here so that we get informed when new fields are
+        // added and can handle them, either by processing them or raising
+        // an error if they are present.
+        let XmlCompoundMeta {
+            span: meta_span,
+            qname: QNameRef { namespace, name },
+            exhaustive,
+            debug,
+            builder,
+            iterator,
+            on_unknown_attribute,
+            on_unknown_child,
+            transparent,
+            discard,
+            deserialize_callback,
+            attribute,
+            value,
+        } = XmlCompoundMeta::parse_from_attributes(&decl.attrs)?;
+
+        reject_key!(debug flag not on "enum variants" only on "enums and structs");
+        reject_key!(exhaustive flag not on "enum variants" only on "enums");
+        reject_key!(namespace not on "enum variants" only on "enums and structs");
+        reject_key!(name not on "attribute-switched enum variants" only on "enums, structs and name-switched enum variants");
+        reject_key!(builder not on "enum variants" only on "enums and structs");
+        reject_key!(iterator not on "enum variants" only on "enums and structs");
+        reject_key!(transparent flag not on "named enum variants" only on "structs");
+        reject_key!(deserialize_callback not on "enum variants" only on "enums and structs");
+        reject_key!(attribute not on "enum variants" only on "attribute-switched enums");
+
+        let Some(value) = value else {
+            return Err(Error::new(
+                meta_span,
+                "`value` is required on attribute-switched enum variants",
+            ));
+        };
+
+        Ok(Self {
+            value: value.value(),
+            ident: decl.ident.clone(),
+            inner: Compound::from_fields(
+                &decl.fields,
+                enum_namespace,
+                on_unknown_attribute,
+                on_unknown_child,
+                discard,
+            )?,
+        })
+    }
+
+    fn make_from_events_statemachine(
+        &self,
+        enum_ident: &Ident,
+        state_ty_ident: &Ident,
+    ) -> Result<FromEventsStateMachine> {
+        let value = &self.value;
+
+        Ok(self
+            .inner
+            .make_from_events_statemachine(
+                state_ty_ident,
+                &ParentRef::Named(Path {
+                    leading_colon: None,
+                    segments: [
+                        PathSegment::from(enum_ident.clone()),
+                        self.ident.clone().into(),
+                    ]
+                    .into_iter()
+                    .collect(),
+                }),
+                &self.ident.to_string(),
+            )?
+            .with_augmented_init(|init| {
+                quote! {
+                    #value => { #init },
+                }
+            })
+            .compile())
+    }
+
+    fn make_as_item_iter_statemachine(
+        &self,
+        elem_namespace: &NamespaceRef,
+        elem_name: &NameRef,
+        attr_namespace: &TokenStream,
+        attr_name: &NameRef,
+        enum_ident: &Ident,
+        state_ty_ident: &Ident,
+        item_iter_ty_lifetime: &Lifetime,
+    ) -> Result<AsItemsStateMachine> {
+        let attr_value = &self.value;
+
+        Ok(self
+            .inner
+            .make_as_item_iter_statemachine(
+                &ParentRef::Named(Path {
+                    leading_colon: None,
+                    segments: [
+                        PathSegment::from(enum_ident.clone()),
+                        self.ident.clone().into(),
+                    ]
+                    .into_iter()
+                    .collect(),
+                }),
+                state_ty_ident,
+                &self.ident.to_string(),
+                item_iter_ty_lifetime,
+            )?
+            .with_augmented_init(|init| {
+                quote! {
+                    let name = (
+                        ::xso::exports::rxml::Namespace::from(#elem_namespace),
+                        ::xso::exports::alloc::borrow::Cow::Borrowed(#elem_name),
+                    );
+                    #init
+                }
+            })
+            .with_extra_header_state(
+                // Note: we convert the identifier to a string here to prevent
+                // its Span from leaking into the new identifier.
+                quote::format_ident!("{}Discriminator", &self.ident.to_string()),
+                quote! {
+                    ::xso::Item::Attribute(
+                        #attr_namespace,
+                        ::xso::exports::alloc::borrow::Cow::Borrowed(#attr_name),
+                        ::xso::exports::alloc::borrow::Cow::Borrowed(#attr_value),
+                    )
+                },
+            )
+            .compile())
+    }
+}
+
+/// The definition of an enum where each variant represents a different value
+/// of a fixed attribute.
+struct AttributeSwitchedEnum {
+    /// The XML namespace of the element.
+    elem_namespace: NamespaceRef,
+
+    /// The XML name of the element.
+    elem_name: NameRef,
+
+    /// The XML namespace of the attribute, or None if the attribute isn't
+    /// namespaced.'
+    attr_namespace: Option<NamespaceRef>,
+
+    /// The XML name of the attribute.
+    attr_name: NameRef,
+
+    /// Enum variant definitions
+    variants: Vec<ValueVariant>,
+}
+
+impl AttributeSwitchedEnum {
+    fn new<'x, I: IntoIterator<Item = &'x Variant>>(
+        elem_namespace: NamespaceRef,
+        elem_name: NameRef,
+        attr_namespace: Option<NamespaceRef>,
+        attr_name: NameRef,
+        variant_iter: I,
+    ) -> Result<Self> {
+        let mut variants = Vec::new();
+        let mut seen_values = HashMap::new();
+        for variant in variant_iter {
+            let variant = ValueVariant::new(variant, &elem_namespace)?;
+            if let Some(other) = seen_values.get(&variant.value) {
+                return Err(Error::new_spanned(
+                    variant.value,
+                    format!(
+                        "duplicate `value` in enum: variants {} and {} have the same attribute value",
+                        other, variant.ident
+                    ),
+                ));
+            }
+            seen_values.insert(variant.value.clone(), variant.ident.clone());
+            variants.push(variant);
+        }
+
+        Ok(Self {
+            elem_namespace,
+            elem_name,
+            attr_namespace,
+            attr_name,
+            variants,
+        })
+    }
+
+    /// Build the deserialisation statemachine for the attribute-switched enum.
+    fn make_from_events_statemachine(
+        &self,
+        target_ty_ident: &Ident,
+        state_ty_ident: &Ident,
+    ) -> Result<FromEventsStateMachine> {
+        let elem_namespace = &self.elem_namespace;
+        let elem_name = &self.elem_name;
+        let attr_namespace = match self.attr_namespace.as_ref() {
+            Some(v) => v.to_token_stream(),
+            None => quote! {
+                ::xso::exports::rxml::Namespace::none()
+            },
+        };
+        let attr_name = &self.attr_name;
+
+        let mut statemachine = FromEventsStateMachine::new();
+        for variant in self.variants.iter() {
+            statemachine
+                .merge(variant.make_from_events_statemachine(target_ty_ident, state_ty_ident)?);
+        }
+
+        statemachine.set_pre_init(quote! {
+            let attr = {
+                if name.0 != #elem_namespace || name.1 != #elem_name {
+                    return ::core::result::Result::Err(
+                        ::xso::error::FromEventsError::Mismatch {
+                            name,
+                            attrs,
+                        },
+                    );
+                }
+
+                let ::core::option::Option::Some(attr) = attrs.remove(#attr_namespace, #attr_name) else {
+                    return ::core::result::Result::Err(
+                        ::xso::error::FromEventsError::Invalid(
+                            ::xso::error::Error::Other("Missing discriminator attribute.")
+                        ),
+                    );
+                };
+
+                attr
+            };
+        });
+        statemachine.set_fallback(quote! {
+            ::core::result::Result::Err(
+                ::xso::error::FromEventsError::Invalid(
+                    ::xso::error::Error::Other("Unknown value for discriminator attribute.")
+                ),
+            )
+        });
+        statemachine.set_match_mode(FromEventsMatchMode::Matched {
+            expr: quote! { attr.as_str() },
+        });
+
+        Ok(statemachine)
+    }
+
+    /// Build the serialisation statemachine for the attribute-switched enum.
+    fn make_as_item_iter_statemachine(
+        &self,
+        target_ty_ident: &Ident,
+        state_ty_ident: &Ident,
+        item_iter_ty_lifetime: &Lifetime,
+    ) -> Result<AsItemsStateMachine> {
+        let attr_namespace = match self.attr_namespace.as_ref() {
+            Some(v) => v.to_token_stream(),
+            None => quote! {
+                ::xso::exports::rxml::Namespace::NONE
+            },
+        };
+
+        let mut statemachine = AsItemsStateMachine::new();
+        for variant in self.variants.iter() {
+            statemachine.merge(variant.make_as_item_iter_statemachine(
+                &self.elem_namespace,
+                &self.elem_name,
+                &attr_namespace,
+                &self.attr_name,
+                target_ty_ident,
+                state_ty_ident,
+                item_iter_ty_lifetime,
+            )?);
+        }
+
+        Ok(statemachine)
+    }
+}
+
 /// The definition of an enum variant in a [`DynamicEnum`].
 struct DynamicVariant {
     /// The identifier of the enum variant.
@@ -281,6 +579,8 @@ impl DynamicVariant {
             transparent: _,          // used by StructInner
             discard: _,              // used by StructInner
             ref deserialize_callback,
+            ref attribute,
+            ref value,
         } = meta;
 
         reject_key!(debug flag not on "enum variants" only on "enums and structs");
@@ -288,6 +588,8 @@ impl DynamicVariant {
         reject_key!(builder not on "enum variants" only on "enums and structs");
         reject_key!(iterator not on "enum variants" only on "enums and structs");
         reject_key!(deserialize_callback not on "enum variants" only on "enums and structs");
+        reject_key!(attribute not on "enum variants" only on "attribute-switched enums");
+        reject_key!(value not on "dynamic enum variants" only on "attribute-switched enum variants");
 
         let inner = StructInner::new(meta, &variant.fields)?;
         Ok(Self { ident, inner })
@@ -376,6 +678,10 @@ enum EnumInner {
     /// namespace fixed.
     NameSwitched(NameSwitchedEnum),
 
+    /// The enum switches based on the value of an attribute of the XML
+    /// element.
+    AttributeSwitched(AttributeSwitchedEnum),
+
     /// The enum consists of variants with entirely unrelated XML structures.
     Dynamic(DynamicEnum),
 }
@@ -389,7 +695,7 @@ impl EnumInner {
         // added and can handle them, either by processing them or raising
         // an error if they are present.
         let XmlCompoundMeta {
-            span: _,
+            span,
             qname: QNameRef { namespace, name },
             exhaustive,
             debug,
@@ -400,6 +706,8 @@ impl EnumInner {
             transparent,
             discard,
             deserialize_callback,
+            attribute,
+            value,
         } = meta;
 
         // These must've been cleared by the caller. Because these being set
@@ -410,19 +718,64 @@ impl EnumInner {
         assert!(!debug.is_set());
         assert!(deserialize_callback.is_none());
 
-        reject_key!(name not on "enums" only on "their variants");
         reject_key!(transparent flag not on "enums" only on "structs");
         reject_key!(on_unknown_attribute not on "enums" only on "enum variants and structs");
         reject_key!(on_unknown_child not on "enums" only on "enum variants and structs");
         reject_key!(discard vec not on "enums" only on "enum variants and structs");
+        reject_key!(value not on "enums" only on "attribute-switched enum variants");
+
+        if let Some(attribute) = attribute {
+            let Some(attr_name) = attribute.qname.name else {
+                return Err(Error::new(
+                    attribute.span,
+                    "missing `name` for `attribute` key",
+                ));
+            };
 
-        if let Some(namespace) = namespace {
+            let attr_namespace = attribute.qname.namespace;
+
+            let Some(elem_namespace) = namespace else {
+                let mut error =
+                    Error::new(span, "missing `namespace` key on attribute-switched enum");
+                error.combine(Error::new(
+                    attribute.span,
+                    "enum is attribute-switched because of the `attribute` key here",
+                ));
+                return Err(error);
+            };
+
+            let Some(elem_name) = name else {
+                let mut error = Error::new(span, "missing `name` key on attribute-switched enum");
+                error.combine(Error::new(
+                    attribute.span,
+                    "enum is attribute-switched because of the `attribute` key here",
+                ));
+                return Err(error);
+            };
+
+            if !exhaustive.is_set() {
+                return Err(Error::new(
+                    span,
+                    "attribute-switched enums must be marked as `exhaustive`. non-exhaustive attribute-switched enums are not supported."
+                ));
+            }
+
+            Ok(Self::AttributeSwitched(AttributeSwitchedEnum::new(
+                elem_namespace,
+                elem_name,
+                attr_namespace,
+                attr_name,
+                variant_iter,
+            )?))
+        } else if let Some(namespace) = namespace {
+            reject_key!(name not on "name-switched enums" only on "their variants");
             Ok(Self::NameSwitched(NameSwitchedEnum::new(
                 namespace,
                 exhaustive,
                 variant_iter,
             )?))
         } else {
+            reject_key!(name not on "dynamic enums" only on "their variants");
             reject_key!(exhaustive flag not on "dynamic enums" only on "name-switched enums");
             Ok(Self::Dynamic(DynamicEnum::new(variant_iter)?))
         }
@@ -438,6 +791,9 @@ impl EnumInner {
             Self::NameSwitched(ref inner) => {
                 inner.make_from_events_statemachine(target_ty_ident, state_ty_ident)
             }
+            Self::AttributeSwitched(ref inner) => {
+                inner.make_from_events_statemachine(target_ty_ident, state_ty_ident)
+            }
             Self::Dynamic(ref inner) => {
                 inner.make_from_events_statemachine(target_ty_ident, state_ty_ident)
             }
@@ -457,6 +813,11 @@ impl EnumInner {
                 state_ty_ident,
                 item_iter_ty_lifetime,
             ),
+            Self::AttributeSwitched(ref inner) => inner.make_as_item_iter_statemachine(
+                target_ty_ident,
+                state_ty_ident,
+                item_iter_ty_lifetime,
+            ),
             Self::Dynamic(ref inner) => inner.make_as_item_iter_statemachine(
                 target_ty_ident,
                 state_ty_ident,

xso-proc/src/meta.rs 🔗

@@ -28,8 +28,10 @@ pub const XMLNS_XMLNS: &str = "http://www.w3.org/2000/xmlns/";
 macro_rules! reject_key {
     ($key:ident not on $not_allowed_on:literal $(only on $only_allowed_on:literal)?) => {
         if let Some(ref $key) = $key {
-            return Err(Error::new_spanned(
-                $key,
+            #[allow(unused_imports)]
+            use syn::spanned::Spanned as _;
+            return Err(Error::new(
+                $key.span(),
                 concat!(
                     "`",
                     stringify!($key),
@@ -437,6 +439,22 @@ impl TryFrom<XmlFieldMeta> for DiscardSpec {
     }
 }
 
+/// Wrapper around `QNameRef` which saves additional span information.
+#[derive(Debug)]
+pub(crate) struct SpannedQNameRef {
+    /// The span which created the (potentially empty) ref.
+    pub span: Span,
+
+    /// The ref itself.
+    pub qname: QNameRef,
+}
+
+impl SpannedQNameRef {
+    pub(crate) fn span(&self) -> Span {
+        self.span
+    }
+}
+
 /// Contents of an `#[xml(..)]` attribute on a struct, enum variant, or enum.
 #[derive(Debug)]
 pub(crate) struct XmlCompoundMeta {
@@ -477,6 +495,12 @@ pub(crate) struct XmlCompoundMeta {
 
     /// The value assigned to `deserialize_callback` inside `#[xml(..)]`, if any.
     pub(crate) deserialize_callback: Option<Path>,
+
+    /// The value assigned to `attribute` inside `#[xml(..)]`, if any.
+    pub(crate) attribute: Option<SpannedQNameRef>,
+
+    /// The value assigned to `value` inside `#[xml(..)]`, if any.
+    pub(crate) value: Option<LitStr>,
 }
 
 impl XmlCompoundMeta {
@@ -495,6 +519,8 @@ impl XmlCompoundMeta {
         let mut transparent = Flag::Absent;
         let mut discard = Vec::new();
         let mut deserialize_callback = None;
+        let mut attribute = None;
+        let mut value = None;
 
         attr.parse_nested_meta(|meta| {
             if meta.path.is_ident("debug") {
@@ -560,6 +586,37 @@ impl XmlCompoundMeta {
                 }
                 deserialize_callback = Some(meta.value()?.parse()?);
                 Ok(())
+            } else if meta.path.is_ident("attribute") {
+                if attribute.is_some() {
+                    return Err(Error::new_spanned(meta.path, "duplicate `attribute` key"));
+                }
+
+                let span = meta.path.span();
+                let qname = if meta.input.peek(Token![=]) {
+                    let (namespace, name) = parse_prefixed_name(meta.value()?)?;
+                    QNameRef {
+                        name: Some(name),
+                        namespace,
+                    }
+                } else {
+                    let mut qname = QNameRef::default();
+                    meta.parse_nested_meta(|meta| {
+                        match qname.parse_incremental_from_meta(meta)? {
+                            None => Ok(()),
+                            Some(meta) => Err(Error::new_spanned(meta.path, "unsupported key")),
+                        }
+                    })?;
+                    qname
+                };
+
+                attribute = Some(SpannedQNameRef { qname, span });
+                Ok(())
+            } else if meta.path.is_ident("value") {
+                if value.is_some() {
+                    return Err(Error::new_spanned(meta.path, "duplicate `value` key"));
+                }
+                value = Some(meta.value()?.parse()?);
+                Ok(())
             } else {
                 match qname.parse_incremental_from_meta(meta)? {
                     None => Ok(()),
@@ -580,6 +637,8 @@ impl XmlCompoundMeta {
             transparent,
             discard,
             deserialize_callback,
+            attribute,
+            value,
         })
     }
 

xso-proc/src/structs.rs 🔗

@@ -74,6 +74,8 @@ impl StructInner {
             transparent,
             discard,
             deserialize_callback,
+            attribute,
+            value,
         } = meta;
 
         // These must've been cleared by the caller. Because these being set
@@ -85,6 +87,8 @@ impl StructInner {
         assert!(deserialize_callback.is_none());
 
         reject_key!(exhaustive flag not on "structs" only on "enums");
+        reject_key!(attribute not on "structs" only on "enums");
+        reject_key!(value not on "structs" only on "attribute-switched enum variants");
 
         if let Flag::Present(_) = transparent {
             reject_key!(namespace not on "transparent structs");

xso/src/from_xml_doc.md 🔗

@@ -21,7 +21,8 @@ assert_eq!(foo, Foo);
 2. [Struct meta](#struct-meta)
 3. [Enums](#enums)
     1. [Name-switched enum meta](#name-switched-enum-meta)
-    2. [Dynamic enum meta](#dynamic-enum-meta)
+    2. [Attribute-switched enum meta](#attribute-switched-enum-meta)
+    3. [Dynamic enum meta](#dynamic-enum-meta)
 4. [Field meta](#field-meta)
     1. [`attribute` meta](#attribute-meta)
     2. [`child` meta](#child-meta)
@@ -123,7 +124,11 @@ Two different `enum` flavors are supported:
    namespace they match on and each variant corresponds to a different XML
    element name within that namespace.
 
-2. [**Dynamic enums**](#dynamic-enum-meta) have entirely unrelated variants.
+2. [**Attribute-switched enums**](#attribute-switched-enum-meta) have a fixed
+   XML element they match which must have a specific attribute. The variants
+   correspond to a value of that XML attribute.
+
+3. [**Dynamic enums**](#dynamic-enum-meta) have entirely unrelated variants.
 
 At the source-code level, they are distinguished by the meta keys which are
 present on the `enum`: The different variants have different sets of mandatory
@@ -201,6 +206,67 @@ let foo: Foo = xso::from_bytes(b"<b xmlns='urn:example' bar='hello'/>").unwrap()
 assert_eq!(foo, Foo::Variant2 { bar: "hello".to_string() });
 ```
 
+### Attribute-switched enum meta
+
+Attribute-switched enums match a fixed XML element and then select the enum
+variant based on a specific attribute on that XML element. Attribute-switched
+enums are declared by setting the `namespace`, `name` and `attribute` keys on
+a `enum` item.
+
+The following keys are defined on name-switched enums:
+
+| Key | Value type | Description |
+| --- | --- | --- |
+| `namespace` | *string literal* or *path* | The XML element namespace to match for this enum. If it is a *path*, it must point at a `&'static str`. |
+| `name` | *string literal* or *path* | The XML element name to match. If it is a *path*, it must point at a `&'static NcNameStr`. |
+| `attribute` | *string literal*, *path* or *nested* | The attribute to match. If it is a *path*, it must point at a `&'static NcNameStr`. |
+| `builder` | optional *ident* | The name to use for the generated builder type. |
+| `iterator` | optional *ident* | The name to use for the generated iterator type. |
+| `exhaustive` | *flag* | Must be set to allow future extensions. |
+| `discard` | optional *nested* | Contains field specifications of content to ignore. See the struct meta docs for details. |
+| `deserialize_callback` | optional *path* | Path to a `fn(&mut T) -> Result<(), Error>` which is called on the deserialized enum after deserialization. |
+
+`attribute` follows the same syntax and semantic as the
+[`attribute` meta](#attribute-meta), but only allows the `namespace` and
+`name` keys.
+
+For details on `builder` and `iterator`, see the [Struct meta](#struct-meta)
+documentation above.
+
+#### Attribute-switched enum variant meta
+
+| Key | Value type | Description |
+| --- | --- | --- |
+| `value` | *string literal* | The text content to match for this variant. |
+| `on_unknown_attribute` | optional *ident* | Name of an [`UnknownAttributePolicy`] member, controlling how unknown attributes are handled. |
+| `on_unknown_child` | optional *ident* | Name of an [`UnknownChildPolicy`] member, controlling how unknown children are handled. |
+
+#### Example
+
+```rust
+# use xso::FromXml;
+#[derive(FromXml, Debug, PartialEq)]
+#[xml(namespace = "urn:example", name = "foo", attribute = "version", exhaustive)]
+enum Foo {
+    #[xml(value = "a")]
+    Variant1 {
+        #[xml(attribute)]
+        foo: String,
+    },
+    #[xml(value = "b")]
+    Variant2 {
+        #[xml(attribute)]
+        bar: String,
+    },
+}
+
+let foo: Foo = xso::from_bytes(b"<foo xmlns='urn:example' version='a' foo='hello'/>").unwrap();
+assert_eq!(foo, Foo::Variant1 { foo: "hello".to_string() });
+
+let foo: Foo = xso::from_bytes(b"<foo xmlns='urn:example' version='b' bar='hello'/>").unwrap();
+assert_eq!(foo, Foo::Variant2 { bar: "hello".to_string() });
+```
+
 ### Dynamic enum meta
 
 Dynamic enums select their variants by attempting to match them in declaration