xso: implement transparent structs

Jonas Schäfer created

Change summary

parsers/src/util/macro_tests.rs |  30 +++
xso-proc/src/enums.rs           |   4 
xso-proc/src/meta.rs            |  38 +++
xso-proc/src/structs.rs         | 350 ++++++++++++++++++++++++++++------
xso/ChangeLog                   |   1 
xso/src/from_xml_doc.md         |   7 
6 files changed, 360 insertions(+), 70 deletions(-)

Detailed changes

parsers/src/util/macro_tests.rs 🔗

@@ -1570,3 +1570,33 @@ fn element_catchall_roundtrip() {
         "<parent xmlns='urn:example:ns1'><child><deeper/></child><child xmlns='urn:example:ns2'/><more-children/><yet-another-child/><child/></parent>",
     )
 }
+
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(transparent)]
+struct TransparentStruct(RequiredAttribute);
+
+#[test]
+fn transparent_struct_roundtrip() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<TransparentStruct>("<attr xmlns='urn:example:ns1' foo='bar'/>");
+}
+
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(transparent)]
+struct TransparentStructNamed {
+    foo: RequiredAttribute,
+}
+
+#[test]
+fn transparent_struct_named_roundtrip() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<TransparentStructNamed>("<attr xmlns='urn:example:ns1' foo='bar'/>");
+}

xso-proc/src/enums.rs 🔗

@@ -44,6 +44,7 @@ impl NameVariant {
             debug,
             builder,
             iterator,
+            transparent,
         } = XmlCompoundMeta::parse_from_attributes(&decl.attrs)?;
 
         reject_key!(debug flag not on "enum variants" only on "enums and structs");
@@ -51,6 +52,7 @@ impl NameVariant {
         reject_key!(namespace not on "enum variants" only on "enums and structs");
         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");
 
         let Some(name) = name else {
             return Err(Error::new(meta_span, "`name` is required on enum variants"));
@@ -179,9 +181,11 @@ impl EnumDef {
             debug,
             builder,
             iterator,
+            transparent,
         } = meta;
 
         reject_key!(name not on "enums" only on "their variants");
+        reject_key!(transparent flag not on "enums" only on "structs");
 
         let Some(namespace) = namespace else {
             return Err(Error::new(meta_span, "`namespace` is required on enums"));

xso-proc/src/meta.rs 🔗

@@ -23,7 +23,7 @@ pub const XMLNS_XML: &str = "http://www.w3.org/XML/1998/namespace";
 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) => {
+    ($key:ident not on $not_allowed_on:literal $(only on $only_allowed_on:literal)?) => {
         if let Some($key) = $key {
             return Err(Error::new_spanned(
                 $key,
@@ -32,15 +32,17 @@ macro_rules! reject_key {
                     stringify!($key),
                     "` is not allowed on ",
                     $not_allowed_on,
-                    " (only on ",
-                    $only_allowed_on,
-                    ")"
+                    $(
+                        " (only on ",
+                        $only_allowed_on,
+                        ")",
+                    )?
                 ),
             ));
         }
     };
 
-    ($key:ident flag not on $not_allowed_on:literal only on $only_allowed_on:literal) => {
+    ($key:ident flag not on $not_allowed_on:literal $(only on $only_allowed_on:literal)?) => {
         if let Flag::Present($key) = $key {
             return Err(Error::new(
                 $key,
@@ -49,9 +51,11 @@ macro_rules! reject_key {
                     stringify!($key),
                     "` is not allowed on ",
                     $not_allowed_on,
-                    " (only on ",
-                    $only_allowed_on,
-                    ")"
+                    $(
+                        " (only on ",
+                        $only_allowed_on,
+                        ")",
+                    )?
                 ),
             ));
         }
@@ -252,6 +256,13 @@ impl Flag {
             Self::Present(_) => true,
         }
     }
+
+    /// Like `Option::take`, but for flags.
+    pub(crate) fn take(&mut self) -> Self {
+        let mut result = Flag::Absent;
+        core::mem::swap(&mut result, self);
+        result
+    }
 }
 
 impl<T: Spanned> From<T> for Flag {
@@ -340,6 +351,9 @@ pub(crate) struct XmlCompoundMeta {
 
     /// The exhaustive flag.
     pub(crate) exhaustive: Flag,
+
+    /// The transparent flag.
+    pub(crate) transparent: Flag,
 }
 
 impl XmlCompoundMeta {
@@ -353,6 +367,7 @@ impl XmlCompoundMeta {
         let mut iterator = None;
         let mut debug = Flag::Absent;
         let mut exhaustive = Flag::Absent;
+        let mut transparent = Flag::Absent;
 
         attr.parse_nested_meta(|meta| {
             if meta.path.is_ident("debug") {
@@ -379,6 +394,12 @@ impl XmlCompoundMeta {
                 }
                 exhaustive = (&meta.path).into();
                 Ok(())
+            } else if meta.path.is_ident("transparent") {
+                if transparent.is_set() {
+                    return Err(Error::new_spanned(meta.path, "duplicate `transparent` key"));
+                }
+                transparent = (&meta.path).into();
+                Ok(())
             } else {
                 match qname.parse_incremental_from_meta(meta)? {
                     None => Ok(()),
@@ -394,6 +415,7 @@ impl XmlCompoundMeta {
             builder,
             iterator,
             exhaustive,
+            transparent,
         })
     }
 

xso-proc/src/structs.rs 🔗

@@ -6,42 +6,59 @@
 
 //! Handling of structs
 
-use proc_macro2::Span;
+use proc_macro2::{Span, TokenStream};
 use quote::quote;
-use syn::*;
+use syn::{spanned::Spanned, *};
 
 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::types::{ref_ty, ty_from_ident};
+use crate::state::{AsItemsSubmachine, FromEventsSubmachine, State};
+use crate::types::{
+    as_xml_iter_fn, feed_fn, from_events_fn, from_xml_builder_ty, item_iter_ty, ref_ty,
+    ty_from_ident,
+};
 
-/// Definition of a struct and how to parse it.
-pub(crate) struct StructDef {
-    /// The XML namespace of the element to map the struct to.
-    namespace: NamespaceRef,
-
-    /// The XML name of the element to map the struct to.
-    name: NameRef,
+/// The inner parts of the struct.
+///
+/// This contains all data necessary for the matching logic.
+enum StructInner {
+    /// Single-field struct declared with `#[xml(transparent)]`.
+    ///
+    /// Transparent struct delegate all parsing and serialising to their
+    /// only field, which is why they do not need to store a lot of
+    /// information and come with extra restrictions, such as:
+    ///
+    /// - no XML namespace can be declared (it is determined by inner type)
+    /// - no XML name can be declared (it is determined by inner type)
+    /// - there must be only exactly one field
+    /// - that field has no `#[xml]` attribute
+    Transparent {
+        /// The member identifier of the only field.
+        member: Member,
 
-    /// The field(s) of this struct.
-    inner: Compound,
+        /// Type of the only field.
+        ty: Type,
+    },
 
-    /// Name of the target type.
-    target_ty_ident: Ident,
+    /// A compound of fields, *not* declared as transparent.
+    ///
+    /// This can be a unit, tuple-like, or named struct.
+    Compound {
+        /// The XML namespace of the element to map the struct to.
+        xml_namespace: NamespaceRef,
 
-    /// Name of the builder type.
-    builder_ty_ident: Ident,
-
-    /// Name of the iterator type.
-    item_iter_ty_ident: Ident,
+        /// The XML name of the element to map the struct to.
+        xml_name: NameRef,
 
-    /// Flag whether debug mode is enabled.
-    debug: bool,
+        /// The field(s) of this struct.
+        inner: Compound,
+    },
 }
 
-impl StructDef {
-    /// Create a new struct from its name, meta, and fields.
-    pub(crate) fn new(ident: &Ident, meta: XmlCompoundMeta, fields: &Fields) -> Result<Self> {
+impl StructInner {
+    fn new(meta: XmlCompoundMeta, fields: &Fields) -> 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.
@@ -52,32 +69,268 @@ impl StructDef {
             debug,
             builder,
             iterator,
+            transparent,
         } = meta;
 
+        // These must've been cleared by the caller. Because these being set
+        // is a programming error (in xso-proc) and not a usage error, we
+        // assert here instead of using reject_key!.
+        assert!(builder.is_none());
+        assert!(iterator.is_none());
+        assert!(!debug.is_set());
+
         reject_key!(exhaustive flag not on "structs" only on "enums");
 
-        let Some(namespace) = namespace else {
-            return Err(Error::new(meta_span, "`namespace` is required on structs"));
-        };
+        if let Flag::Present(_) = transparent {
+            reject_key!(namespace not on "transparent structs");
+            reject_key!(name not on "transparent structs");
 
-        let Some(name) = name else {
-            return Err(Error::new(meta_span, "`name` is required on structs"));
-        };
+            let fields_span = fields.span();
+            let fields = match fields {
+                Fields::Unit => {
+                    return Err(Error::new(
+                        fields_span,
+                        "transparent structs or enum variants must have exactly one field",
+                    ))
+                }
+                Fields::Named(FieldsNamed {
+                    named: ref fields, ..
+                })
+                | Fields::Unnamed(FieldsUnnamed {
+                    unnamed: ref fields,
+                    ..
+                }) => fields,
+            };
+
+            if fields.len() != 1 {
+                return Err(Error::new(
+                    fields_span,
+                    "transparent structs or enum variants must have exactly one field",
+                ));
+            }
+
+            let field = &fields[0];
+            for attr in field.attrs.iter() {
+                if attr.meta.path().is_ident("xml") {
+                    return Err(Error::new_spanned(
+                        attr,
+                        "#[xml(..)] attributes are not allowed inside transparent structs",
+                    ));
+                }
+            }
+            let member = match field.ident.as_ref() {
+                Some(v) => Member::Named(v.clone()),
+                None => Member::Unnamed(Index {
+                    span: field.ty.span(),
+                    index: 0,
+                }),
+            };
+            let ty = field.ty.clone();
+            Ok(Self::Transparent { ty, member })
+        } else {
+            let Some(xml_namespace) = namespace else {
+                return Err(Error::new(
+                    meta_span,
+                    "`namespace` is required on non-transparent structs",
+                ));
+            };
+
+            let Some(xml_name) = name else {
+                return Err(Error::new(
+                    meta_span,
+                    "`name` is required on non-transparent structs",
+                ));
+            };
+
+            Ok(Self::Compound {
+                inner: Compound::from_fields(fields, &xml_namespace)?,
+                xml_namespace,
+                xml_name,
+            })
+        }
+    }
+
+    fn make_from_events_statemachine(
+        &self,
+        state_ty_ident: &Ident,
+        output_name: &ParentRef,
+        state_prefix: &str,
+    ) -> Result<FromEventsSubmachine> {
+        match self {
+            Self::Transparent { ty, member } => {
+                let from_xml_builder_ty = from_xml_builder_ty(ty.clone());
+                let from_events_fn = from_events_fn(ty.clone());
+                let feed_fn = feed_fn(from_xml_builder_ty.clone());
+
+                let output_cons = match output_name {
+                    ParentRef::Named(ref path) => quote! {
+                        #path { #member: result }
+                    },
+                    ParentRef::Unnamed { .. } => quote! {
+                        ( result, )
+                    },
+                };
+
+                let state_name = quote::format_ident!("{}Default", state_prefix);
+                let builder_data_ident = quote::format_ident!("__xso_data");
+
+                // Here, we generate a partial statemachine which really only
+                // proxies the FromXmlBuilder implementation of the inner
+                // type.
+                Ok(FromEventsSubmachine {
+                    defs: TokenStream::default(),
+                    states: vec![
+                        State::new_with_builder(
+                            state_name.clone(),
+                            &builder_data_ident,
+                            &from_xml_builder_ty,
+                        )
+                            .with_impl(quote! {
+                                match #feed_fn(&mut #builder_data_ident, ev)? {
+                                    ::core::option::Option::Some(result) => {
+                                        ::core::result::Result::Ok(::core::ops::ControlFlow::Continue(#output_cons))
+                                    }
+                                    ::core::option::Option::None => {
+                                        ::core::result::Result::Ok(::core::ops::ControlFlow::Break(Self::#state_name {
+                                            #builder_data_ident,
+                                        }))
+                                    }
+                                }
+                            })
+                    ],
+                    init: quote! {
+                        #from_events_fn(name, attrs).map(|#builder_data_ident| Self::#state_name { #builder_data_ident })
+                    },
+                })
+            }
+
+            Self::Compound {
+                ref inner,
+                ref xml_namespace,
+                ref xml_name,
+            } => Ok(inner
+                .make_from_events_statemachine(state_ty_ident, output_name, state_prefix)?
+                .with_augmented_init(|init| {
+                    quote! {
+                        if name.0 != #xml_namespace || name.1 != #xml_name {
+                            ::core::result::Result::Err(::xso::error::FromEventsError::Mismatch {
+                                name,
+                                attrs,
+                            })
+                        } else {
+                            #init
+                        }
+                    }
+                })),
+        }
+    }
+
+    fn make_as_item_iter_statemachine(
+        &self,
+        input_name: &ParentRef,
+        state_ty_ident: &Ident,
+        state_prefix: &str,
+        item_iter_ty_lifetime: &Lifetime,
+    ) -> Result<AsItemsSubmachine> {
+        match self {
+            Self::Transparent { ty, member } => {
+                let item_iter_ty = item_iter_ty(ty.clone(), item_iter_ty_lifetime.clone());
+                let as_xml_iter_fn = as_xml_iter_fn(ty.clone());
+
+                let state_name = quote::format_ident!("{}Default", state_prefix);
+                let iter_ident = quote::format_ident!("__xso_data");
 
-        let builder_ty_ident = match builder {
+                let destructure = match input_name {
+                    ParentRef::Named(ref path) => quote! {
+                        #path { #member: #iter_ident }
+                    },
+                    ParentRef::Unnamed { .. } => quote! {
+                        (#iter_ident, )
+                    },
+                };
+
+                // Here, we generate a partial statemachine which really only
+                // proxies the AsXml iterator implementation from the inner
+                // type.
+                Ok(AsItemsSubmachine {
+                    defs: TokenStream::default(),
+                    states: vec![State::new_with_builder(
+                        state_name.clone(),
+                        &iter_ident,
+                        &item_iter_ty,
+                    )
+                    .with_mut(&iter_ident)
+                    .with_impl(quote! {
+                        #iter_ident.next().transpose()?
+                    })],
+                    destructure,
+                    init: quote! {
+                        #as_xml_iter_fn(#iter_ident).map(|#iter_ident| Self::#state_name { #iter_ident })?
+                    },
+                })
+            }
+
+            Self::Compound {
+                ref inner,
+                ref xml_namespace,
+                ref xml_name,
+            } => Ok(inner
+                .make_as_item_iter_statemachine(
+                    input_name,
+                    state_ty_ident,
+                    state_prefix,
+                    item_iter_ty_lifetime,
+                )?
+                .with_augmented_init(|init| {
+                    quote! {
+                        let name = (
+                            ::xso::exports::rxml::Namespace::from(#xml_namespace),
+                            ::std::borrow::Cow::Borrowed(#xml_name),
+                        );
+                        #init
+                    }
+                })),
+        }
+    }
+}
+
+/// Definition of a struct and how to parse it.
+pub(crate) struct StructDef {
+    /// Name of the target type.
+    target_ty_ident: Ident,
+
+    /// Name of the builder type.
+    builder_ty_ident: Ident,
+
+    /// Name of the iterator type.
+    item_iter_ty_ident: Ident,
+
+    /// Flag whether debug mode is enabled.
+    debug: bool,
+
+    /// The matching logic and contents of the struct.
+    inner: StructInner,
+}
+
+impl StructDef {
+    /// Create a new struct from its name, meta, and fields.
+    pub(crate) fn new(ident: &Ident, mut meta: XmlCompoundMeta, fields: &Fields) -> Result<Self> {
+        let builder_ty_ident = match meta.builder.take() {
             Some(v) => v,
             None => quote::format_ident!("{}FromXmlBuilder", ident.to_string()),
         };
 
-        let item_iter_ty_ident = match iterator {
+        let item_iter_ty_ident = match meta.iterator.take() {
             Some(v) => v,
             None => quote::format_ident!("{}AsXmlIterator", ident.to_string()),
         };
 
+        let debug = meta.debug.take();
+
+        let inner = StructInner::new(meta, fields)?;
+
         Ok(Self {
-            inner: Compound::from_fields(fields, &namespace)?,
-            namespace,
-            name,
+            inner,
             target_ty_ident: ident.clone(),
             builder_ty_ident,
             item_iter_ty_ident,
@@ -93,9 +346,6 @@ impl ItemDef for StructDef {
         name_ident: &Ident,
         attrs_ident: &Ident,
     ) -> Result<FromXmlParts> {
-        let xml_namespace = &self.namespace;
-        let xml_name = &self.name;
-
         let target_ty_ident = &self.target_ty_ident;
         let builder_ty_ident = &self.builder_ty_ident;
         let state_ty_ident = quote::format_ident!("{}State", builder_ty_ident);
@@ -107,18 +357,6 @@ impl ItemDef for StructDef {
                 &Path::from(target_ty_ident.clone()).into(),
                 "Struct",
             )?
-            .with_augmented_init(|init| {
-                quote! {
-                    if name.0 != #xml_namespace || name.1 != #xml_name {
-                        ::core::result::Result::Err(::xso::error::FromEventsError::Mismatch {
-                            name,
-                            attrs,
-                        })
-                    } else {
-                        #init
-                    }
-                }
-            })
             .compile()
             .render(
                 vis,
@@ -141,9 +379,6 @@ impl ItemDef for StructDef {
     }
 
     fn make_as_xml_iter(&self, vis: &Visibility) -> Result<AsXmlParts> {
-        let xml_namespace = &self.namespace;
-        let xml_name = &self.name;
-
         let target_ty_ident = &self.target_ty_ident;
         let item_iter_ty_ident = &self.item_iter_ty_ident;
         let item_iter_ty_lifetime = Lifetime {
@@ -183,15 +418,6 @@ impl ItemDef for StructDef {
                 "Struct",
                 &item_iter_ty_lifetime,
             )?
-            .with_augmented_init(|init| {
-                quote! {
-                    let name = (
-                        ::xso::exports::rxml::Namespace::from(#xml_namespace),
-                        ::std::borrow::Cow::Borrowed(#xml_name),
-                    );
-                    #init
-                }
-            })
             .compile()
             .render(
                 vis,

xso/ChangeLog 🔗

@@ -22,6 +22,7 @@ Version NEXT:
         structs.
       - Support for collecting all unknown children in a single field as
         collection of `minidom::Element`.
+      - Support for "transparent" structs (newtype-like patterns for XSO).
 
 Version 0.1.2:
 2024-07-26 Jonas Schäfer <jonas@zombofant.net>

xso/src/from_xml_doc.md 🔗

@@ -50,6 +50,7 @@ The following keys are defined on structs:
 | --- | --- | --- |
 | `namespace` | *string literal* or *path* | The XML element namespace to match. 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`. |
+| `transparent` | *flag* | If present, declares the struct as *transparent* struct (see below) |
 | `builder` | optional *ident* | The name to use for the generated builder type. |
 | `iterator` | optional *ident* | The name to use for the generated iterator type. |
 
@@ -74,6 +75,12 @@ By default, the builder type uses the type's name suffixed with
 `FromXmlBuilder` and the iterator type uses the type's name suffixed with
 `AsXmlIterator`.
 
+If the struct is marked as `transparent`, it must not have a `namespace` or
+`name` set and it must have exactly one field. That field's type must
+implement [`FromXml`] in order to derive `FromXml` and [`AsXml`] in order to
+derive `AsXml`. The struct will be (de-)serialised exactly like the type of
+that single field. This allows a newtype-like pattern for XSO structs.
+
 ### Enum meta
 
 The following keys are defined on enums: