xso-proc: implement support for collections of children

Jonas SchΓ€fer created

Change summary

parsers/src/util/macro_tests.rs |  19 +++
xso-proc/src/field.rs           | 201 +++++++++++++++++++++++++++-------
xso-proc/src/meta.rs            |  60 ++++++++++
xso-proc/src/types.rs           | 123 +++++++++++++++++++++
xso/ChangeLog                   |   2 
xso/src/from_xml_doc.md         |  38 ++++++
6 files changed, 394 insertions(+), 49 deletions(-)

Detailed changes

parsers/src/util/macro_tests.rs πŸ”—

@@ -795,3 +795,22 @@ fn exhaustive_name_switched_enum_roundtrip_variant_2() {
     };
     roundtrip_full::<ExhaustiveNameSwitchedEnum>("<b xmlns='urn:example:ns1'>hello</b>")
 }
+
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(namespace = NS1, name = "parent")]
+struct Children {
+    #[xml(child(n = ..))]
+    foo: Vec<RequiredAttribute>,
+}
+
+#[test]
+fn children_roundtrip() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<Children>(
+        "<parent xmlns='urn:example:ns1'><attr foo='X'/><attr foo='Y'/><attr foo='Z'/></parent>",
+    )
+}

xso-proc/src/field.rs πŸ”—

@@ -13,12 +13,13 @@ use syn::{spanned::Spanned, *};
 use rxml_validation::NcName;
 
 use crate::error_message::{self, ParentRef};
-use crate::meta::{Flag, NameRef, NamespaceRef, XmlFieldMeta};
+use crate::meta::{AmountConstraint, Flag, NameRef, NamespaceRef, XmlFieldMeta};
 use crate::scope::{AsItemsScope, FromEventsScope};
 use crate::types::{
-    as_optional_xml_text_fn, as_xml_iter_fn, as_xml_text_fn, default_fn, from_events_fn,
-    from_xml_builder_ty, from_xml_text_fn, item_iter_ty, option_ty, string_ty,
-    text_codec_decode_fn, text_codec_encode_fn,
+    as_optional_xml_text_fn, as_xml_iter_fn, as_xml_text_fn, default_fn, extend_fn, from_events_fn,
+    from_xml_builder_ty, from_xml_text_fn, into_iterator_into_iter_fn, into_iterator_item_ty,
+    into_iterator_iter_ty, item_iter_ty, option_ty, ref_ty, string_ty, text_codec_decode_fn,
+    text_codec_encode_fn,
 };
 
 /// Code slices necessary for declaring and initializing a temporary variable
@@ -140,8 +141,8 @@ enum FieldKind {
         /// The XML name of the attribute.
         xml_name: NameRef,
 
-        // Flag indicating whether the value should be defaulted if the
-        // attribute is absent.
+        /// Flag indicating whether the value should be defaulted if the
+        /// attribute is absent.
         default_: Flag,
     },
 
@@ -153,9 +154,12 @@ enum FieldKind {
 
     /// The field maps to a child
     Child {
-        // Flag indicating whether the value should be defaulted if the
-        // child is absent.
+        /// Flag indicating whether the value should be defaulted if the
+        /// child is absent.
         default_: Flag,
+
+        /// Number of child elements allowed.
+        amount: AmountConstraint,
     },
 }
 
@@ -203,7 +207,26 @@ impl FieldKind {
 
             XmlFieldMeta::Text { codec } => Ok(Self::Text { codec }),
 
-            XmlFieldMeta::Child { default_ } => Ok(Self::Child { default_ }),
+            XmlFieldMeta::Child { default_, amount } => {
+                if let Some(AmountConstraint::Any(ref amount_span)) = amount {
+                    if let Flag::Present(ref flag_span) = default_ {
+                        let mut err = Error::new(
+                            *flag_span,
+                            "`default` has no meaning for child collections",
+                        );
+                        err.combine(Error::new(
+                            *amount_span,
+                            "the field is treated as a collection because of this `n` value",
+                        ));
+                        return Err(err);
+                    }
+                }
+
+                Ok(Self::Child {
+                    default_,
+                    amount: amount.unwrap_or(AmountConstraint::FixedSingle(Span::call_site())),
+                })
+            }
         }
     }
 }
@@ -345,49 +368,79 @@ impl FieldDef {
                 })
             }
 
-            FieldKind::Child { ref default_ } => {
+            FieldKind::Child {
+                ref default_,
+                ref amount,
+            } => {
                 let FromEventsScope {
                     ref substate_result,
                     ..
                 } = scope;
                 let field_access = scope.access_field(&self.member);
 
-                let missing_msg = error_message::on_missing_child(container_name, &self.member);
+                let element_ty = match amount {
+                    AmountConstraint::FixedSingle(_) => self.ty.clone(),
+                    AmountConstraint::Any(_) => into_iterator_item_ty(self.ty.clone()),
+                };
 
-                let from_events = from_events_fn(self.ty.clone());
-                let from_xml_builder = from_xml_builder_ty(self.ty.clone());
+                let from_events = from_events_fn(element_ty.clone());
+                let from_xml_builder = from_xml_builder_ty(element_ty.clone());
 
-                let on_absent = match default_ {
-                    Flag::Absent => quote! {
-                        return ::core::result::Result::Err(::xso::error::Error::Other(#missing_msg).into())
-                    },
-                    Flag::Present(_) => {
-                        let default_ = default_fn(self.ty.clone());
-                        quote! {
-                            #default_()
-                        }
-                    }
-                };
+                let matcher = quote! { #from_events(name, attrs) };
+                let builder = from_xml_builder;
 
-                Ok(FieldBuilderPart::Nested {
-                    value: FieldTempInit {
-                        init: quote! { ::std::option::Option::None },
-                        ty: option_ty(self.ty.clone()),
-                    },
-                    matcher: quote! {
-                        #from_events(name, attrs)
-                    },
-                    builder: from_xml_builder,
-                    collect: quote! {
-                        #field_access = ::std::option::Option::Some(#substate_result);
-                    },
-                    finalize: quote! {
-                        match #field_access {
-                            ::std::option::Option::Some(value) => value,
-                            ::std::option::Option::None => #on_absent,
-                        }
-                    },
-                })
+                match amount {
+                    AmountConstraint::FixedSingle(_) => {
+                        let missing_msg =
+                            error_message::on_missing_child(container_name, &self.member);
+
+                        let on_absent = match default_ {
+                            Flag::Absent => quote! {
+                                return ::core::result::Result::Err(::xso::error::Error::Other(#missing_msg).into())
+                            },
+                            Flag::Present(_) => {
+                                let default_ = default_fn(self.ty.clone());
+                                quote! {
+                                    #default_()
+                                }
+                            }
+                        };
+
+                        Ok(FieldBuilderPart::Nested {
+                            value: FieldTempInit {
+                                init: quote! { ::std::option::Option::None },
+                                ty: option_ty(self.ty.clone()),
+                            },
+                            matcher,
+                            builder,
+                            collect: quote! {
+                                #field_access = ::std::option::Option::Some(#substate_result);
+                            },
+                            finalize: quote! {
+                                match #field_access {
+                                    ::std::option::Option::Some(value) => value,
+                                    ::std::option::Option::None => #on_absent,
+                                }
+                            },
+                        })
+                    }
+                    AmountConstraint::Any(_) => {
+                        let ty_extend = extend_fn(self.ty.clone(), element_ty.clone());
+                        let ty_default = default_fn(self.ty.clone());
+                        Ok(FieldBuilderPart::Nested {
+                            value: FieldTempInit {
+                                init: quote! { #ty_default() },
+                                ty: self.ty.clone(),
+                            },
+                            matcher,
+                            builder,
+                            collect: quote! {
+                                #ty_extend(&mut #field_access, [#substate_result]);
+                            },
+                            finalize: quote! { #field_access },
+                        })
+                    }
+                }
             }
         }
     }
@@ -442,7 +495,10 @@ impl FieldDef {
                 Ok(FieldIteratorPart::Text { generator })
             }
 
-            FieldKind::Child { default_: _ } => {
+            FieldKind::Child {
+                default_: _,
+                amount: AmountConstraint::FixedSingle(_),
+            } => {
                 let AsItemsScope { ref lifetime, .. } = scope;
 
                 let as_xml_iter = as_xml_iter_fn(self.ty.clone());
@@ -460,6 +516,63 @@ impl FieldDef {
                     },
                 })
             }
+
+            FieldKind::Child {
+                default_: _,
+                amount: AmountConstraint::Any(_),
+            } => {
+                let AsItemsScope { ref lifetime, .. } = scope;
+
+                // This should give us the type of element stored in the
+                // collection.
+                let element_ty = into_iterator_item_ty(self.ty.clone());
+
+                // And this is the collection type we actually work with --
+                // as_xml_iter uses references after all.
+                let ty = ref_ty(self.ty.clone(), lifetime.clone());
+
+                // as_xml_iter is called on the bare type (not the ref type)
+                let as_xml_iter = as_xml_iter_fn(element_ty.clone());
+
+                // And thus the iterator associated with AsXml is also derived
+                // from the bare type.
+                let item_iter = item_iter_ty(element_ty.clone(), lifetime.clone());
+
+                // But the iterator for iterating over the elements inside the
+                // collection must use the ref type.
+                let element_iter = into_iterator_iter_ty(ty.clone());
+
+                // And likewise the into_iter impl.
+                let into_iter = into_iterator_into_iter_fn(ty.clone());
+
+                let state_ty = Type::Tuple(TypeTuple {
+                    paren_token: token::Paren::default(),
+                    elems: [element_iter, option_ty(item_iter)].into_iter().collect(),
+                });
+
+                Ok(FieldIteratorPart::Content {
+                    value: FieldTempInit {
+                        init: quote! {
+                            (#into_iter(#bound_name), ::core::option::Option::None)
+                        },
+                        ty: state_ty,
+                    },
+                    generator: quote! {
+                        loop {
+                            if let ::core::option::Option::Some(current) = #bound_name.1.as_mut() {
+                                if let ::core::option::Option::Some(item) = current.next() {
+                                    break ::core::option::Option::Some(item).transpose();
+                                }
+                            }
+                            if let ::core::option::Option::Some(item) = #bound_name.0.next() {
+                                #bound_name.1 = ::core::option::Option::Some(#as_xml_iter(item)?)
+                            } else {
+                                break ::core::result::Result::Ok(::core::option::Option::None)
+                            }
+                        }
+                    },
+                })
+            }
         }
     }
 

xso-proc/src/meta.rs πŸ”—

@@ -180,6 +180,53 @@ impl quote::ToTokens for NameRef {
     }
 }
 
+/// Represents the amount constraint used with child elements.
+///
+/// Currently, this only supports "one" (literal `1`) or "any amount" (`..`).
+/// In the future, we might want to add support for any range pattern for
+/// `usize` and any positive integer literal.
+#[derive(Debug)]
+pub(crate) enum AmountConstraint {
+    /// Equivalent to `1`
+    #[allow(dead_code)]
+    FixedSingle(Span),
+
+    /// Equivalent to `..`.
+    Any(Span),
+}
+
+impl syn::parse::Parse for AmountConstraint {
+    fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
+        if input.peek(LitInt) && !input.peek2(token::DotDot) && !input.peek2(token::DotDotEq) {
+            let lit: LitInt = input.parse()?;
+            let value: usize = lit.base10_parse()?;
+            if value == 1 {
+                Ok(Self::FixedSingle(lit.span()))
+            } else {
+                Err(Error::new(lit.span(), "only `1` and `..` are allowed here"))
+            }
+        } else {
+            let p: PatRange = input.parse()?;
+            if let Some(attr) = p.attrs.first() {
+                return Err(Error::new_spanned(attr, "attributes not allowed here"));
+            }
+            if let Some(start) = p.start.as_ref() {
+                return Err(Error::new_spanned(
+                    start,
+                    "only full ranges (`..`) are allowed here",
+                ));
+            }
+            if let Some(end) = p.end.as_ref() {
+                return Err(Error::new_spanned(
+                    end,
+                    "only full ranges (`..`) are allowed here",
+                ));
+            }
+            Ok(Self::Any(p.span()))
+        }
+    }
+}
+
 /// Represents a boolean flag from a `#[xml(..)]` attribute meta.
 #[derive(Clone, Copy, Debug)]
 pub(crate) enum Flag {
@@ -558,6 +605,9 @@ pub(crate) enum XmlFieldMeta {
     Child {
         /// The `default` flag.
         default_: Flag,
+
+        /// The `n` flag.
+        amount: Option<AmountConstraint>,
     },
 }
 
@@ -694,6 +744,7 @@ impl XmlFieldMeta {
     fn child_from_meta(meta: ParseNestedMeta<'_>) -> Result<Self> {
         if meta.input.peek(syn::token::Paren) {
             let mut default_ = Flag::Absent;
+            let mut amount = None;
             meta.parse_nested_meta(|meta| {
                 if meta.path.is_ident("default") {
                     if default_.is_set() {
@@ -701,14 +752,21 @@ impl XmlFieldMeta {
                     }
                     default_ = (&meta.path).into();
                     Ok(())
+                } else if meta.path.is_ident("n") {
+                    if amount.is_some() {
+                        return Err(Error::new_spanned(meta.path, "duplicate `n` key"));
+                    }
+                    amount = Some(meta.value()?.parse()?);
+                    Ok(())
                 } else {
                     Err(Error::new_spanned(meta.path, "unsupported key"))
                 }
             })?;
-            Ok(Self::Child { default_ })
+            Ok(Self::Child { default_, amount })
         } else {
             Ok(Self::Child {
                 default_: Flag::Absent,
+                amount: None,
             })
         }
     }

xso-proc/src/types.rs πŸ”—

@@ -622,3 +622,126 @@ pub(crate) fn item_iter_ty(of_ty: Type, lifetime: Lifetime) -> Type {
     });
     Type::Path(ty)
 }
+
+/// Construct a [`syn::TypePath`] referring to `<#of_ty as IntoIterator>`.
+fn into_iterator_of(of_ty: Type) -> (Span, TypePath) {
+    let span = of_ty.span();
+    (
+        span,
+        TypePath {
+            qself: Some(QSelf {
+                lt_token: syn::token::Lt { spans: [span] },
+                ty: Box::new(of_ty),
+                position: 3,
+                as_token: Some(syn::token::As { span }),
+                gt_token: syn::token::Gt { spans: [span] },
+            }),
+            path: Path {
+                leading_colon: Some(syn::token::PathSep {
+                    spans: [span, span],
+                }),
+                segments: [
+                    PathSegment {
+                        ident: Ident::new("std", span),
+                        arguments: PathArguments::None,
+                    },
+                    PathSegment {
+                        ident: Ident::new("iter", span),
+                        arguments: PathArguments::None,
+                    },
+                    PathSegment {
+                        ident: Ident::new("IntoIterator", span),
+                        arguments: PathArguments::None,
+                    },
+                ]
+                .into_iter()
+                .collect(),
+            },
+        },
+    )
+}
+
+/// Construct a [`syn::Type`] referring to
+/// `<#of_ty as IntoIterator>::IntoIter`.
+pub(crate) fn into_iterator_iter_ty(of_ty: Type) -> Type {
+    let (span, mut ty) = into_iterator_of(of_ty);
+    ty.path.segments.push(PathSegment {
+        ident: Ident::new("IntoIter", span),
+        arguments: PathArguments::None,
+    });
+    Type::Path(ty)
+}
+
+/// Construct a [`syn::Type`] referring to
+/// `<#of_ty as IntoIterator>::Item`.
+pub(crate) fn into_iterator_item_ty(of_ty: Type) -> Type {
+    let (span, mut ty) = into_iterator_of(of_ty);
+    ty.path.segments.push(PathSegment {
+        ident: Ident::new("Item", span),
+        arguments: PathArguments::None,
+    });
+    Type::Path(ty)
+}
+
+/// Construct a [`syn::Expr`] referring to
+/// `<#of_ty as IntoIterator>::into_iter`.
+pub(crate) fn into_iterator_into_iter_fn(of_ty: Type) -> Expr {
+    let (span, mut ty) = into_iterator_of(of_ty);
+    ty.path.segments.push(PathSegment {
+        ident: Ident::new("into_iter", span),
+        arguments: PathArguments::None,
+    });
+    Expr::Path(ExprPath {
+        attrs: Vec::new(),
+        qself: ty.qself,
+        path: ty.path,
+    })
+}
+
+/// Construct a [`syn::Expr`] referring to
+/// `<#of_ty as ::std::iter::Extend>::extend`.
+pub(crate) fn extend_fn(of_ty: Type, item_ty: Type) -> Expr {
+    let span = of_ty.span();
+    Expr::Path(ExprPath {
+        attrs: Vec::new(),
+        qself: Some(QSelf {
+            lt_token: syn::token::Lt { spans: [span] },
+            ty: Box::new(of_ty),
+            position: 3,
+            as_token: Some(syn::token::As { span }),
+            gt_token: syn::token::Gt { spans: [span] },
+        }),
+        path: Path {
+            leading_colon: Some(syn::token::PathSep {
+                spans: [span, span],
+            }),
+            segments: [
+                PathSegment {
+                    ident: Ident::new("std", span),
+                    arguments: PathArguments::None,
+                },
+                PathSegment {
+                    ident: Ident::new("iter", span),
+                    arguments: PathArguments::None,
+                },
+                PathSegment {
+                    ident: Ident::new("Extend", span),
+                    arguments: PathArguments::AngleBracketed(AngleBracketedGenericArguments {
+                        colon2_token: Some(syn::token::PathSep {
+                            spans: [span, span],
+                        }),
+                        lt_token: syn::token::Lt { spans: [span] },
+                        args: [GenericArgument::Type(item_ty)].into_iter().collect(),
+                        gt_token: syn::token::Gt { spans: [span] },
+                    }),
+                },
+                PathSegment {
+                    ident: Ident::new("extend", span),
+                    arguments: PathArguments::None,
+                },
+            ]
+            .into_iter()
+            .collect(),
+        },
+    })
+}

xso/ChangeLog πŸ”—

@@ -14,7 +14,7 @@ Version NEXT:
         of text codecs.
     * Added
       - Support for child elements in derive macros. Child elements may also
-        be wrapped in Option or Box.
+        be wrapped in Option or Box or in containers like Vec or HashSet.
       - Support for overriding the names of the types generated by the derive
         macros.
       - Support for deriving FromXml and AsXml on enums.

xso/src/from_xml_doc.md πŸ”—

@@ -219,17 +219,29 @@ assert_eq!(foo, Foo {
 The `child` meta causes the field to be mapped to a child element of the
 element.
 
+The following keys can be used inside the `#[xml(child(..))]` meta:
+
 | Key | Value type | Description |
 | --- | --- | --- |
 | `default` | flag | If present, an absent child will substitute the default value instead of raising an error. |
+| `n` | `1` or `..` | If `1`, a single element is parsed. If `..`, a collection is parsed. Defaults to `1`. |
 
-The field's type must implement [`FromXml`] in order to derive `FromXml` and
+When parsing a single child element (i.e. `n = 1` or no `n` value set at all),
+the field's type must implement [`FromXml`] in order to derive `FromXml` and
 [`AsXml`] in order to derive `AsXml`.
 
+When parsing a collection (with `n = ..`), the field's type must implement
+[`IntoIterator<Item = T>`][`std::iter::IntoIterator`], where `T` must
+implement [`FromXml`] in order to derive `FromXml` and [`AsXml`] in order to
+derive `AsXml`. In addition, the field's type must implement
+[`Extend<T>`][`std::iter::Extend`] to derive `FromXml` and the field's
+reference type must implement `IntoIterator<Item = &'_ T>` to derive `AsXml`.
+
 If `default` is specified and the child is absent in the source, the value
 is generated using [`std::default::Default`], requiring the field type to
 implement the `Default` trait for a `FromXml` derivation. `default` has no
-influence on `AsXml`.
+influence on `AsXml`. Combining `default` and `n` where `n` is not set to `1`
+is not supported and will cause a compile-time error.
 
 ##### Example
 
@@ -242,6 +254,13 @@ struct Child {
     some_attr: String,
 }
 
+#[derive(FromXml, Debug, PartialEq)]
+#[xml(namespace = "urn:example", name = "other-child")]
+struct OtherChild {
+    #[xml(attribute = "some-attr")]
+    some_attr: String,
+}
+
 #[derive(FromXml, Debug, PartialEq)]
 #[xml(namespace = "urn:example", name = "parent")]
 struct Parent {
@@ -250,15 +269,28 @@ struct Parent {
 
     #[xml(child)]
     bar: Child,
+
+    #[xml(child(n = ..))]
+    baz: Vec<OtherChild>,
 }
 
 let parent: Parent = xso::from_bytes(b"<parent
     xmlns='urn:example'
     foo='hello world!'
-><child some-attr='within'/></parent>").unwrap();
+><child
+    some-attr='within'
+/><other-child
+    some-attr='c1'
+/><other-child
+    some-attr='c2'
+/></parent>").unwrap();
 assert_eq!(parent, Parent {
     foo: "hello world!".to_owned(),
     bar: Child { some_attr: "within".to_owned() },
+    baz: vec! [
+        OtherChild { some_attr: "c1".to_owned() },
+        OtherChild { some_attr: "c2".to_owned() },
+    ],
 });
 ```