xso-proc: add support for extracting attributes into collections

Emmanuel Gil Peyrot created

Change summary

parsers/src/util/macro_tests.rs | 53 +++++++++++++++++++++++++++++++++++
xso-proc/src/field.rs           | 11 +++++++
xso-proc/src/meta.rs            | 14 +++++++++
xso/src/from_xml_doc.md         |  5 +++
4 files changed, 83 insertions(+)

Detailed changes

parsers/src/util/macro_tests.rs 🔗

@@ -1234,3 +1234,56 @@ fn text_extract_vec_roundtrip() {
         "<parent xmlns='urn:example:ns1'><child>hello</child><child>world</child></parent>",
     )
 }
+
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(namespace = NS1, name = "parent")]
+struct AttributeExtractVec {
+    #[xml(extract(n = .., namespace = NS1, name = "child", fields(attribute(type_ = String, name = "attr"))))]
+    contents: Vec<String>,
+}
+
+#[test]
+fn text_extract_attribute_vec_positive_nonempty() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeExtractVec>(
+        "<parent xmlns='urn:example:ns1'><child attr='hello'/><child attr='world'/></parent>",
+    ) {
+        Ok(AttributeExtractVec { contents }) => {
+            assert_eq!(contents[0], "hello");
+            assert_eq!(contents[1], "world");
+            assert_eq!(contents.len(), 2);
+        }
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn text_extract_attribute_vec_positive_empty() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    match parse_str::<AttributeExtractVec>("<parent xmlns='urn:example:ns1'/>") {
+        Ok(AttributeExtractVec { contents }) => {
+            assert_eq!(contents.len(), 0);
+        }
+        other => panic!("unexpected result: {:?}", other),
+    }
+}
+
+#[test]
+fn text_extract_attribute_vec_roundtrip() {
+    #[allow(unused_imports)]
+    use std::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<AttributeExtractVec>(
+        "<parent xmlns='urn:example:ns1'><child attr='hello'/><child attr='world'/></parent>",
+    )
+}

xso-proc/src/field.rs 🔗

@@ -239,9 +239,20 @@ impl FieldKind {
                 span,
                 qname: QNameRef { namespace, name },
                 default_,
+                type_,
             } => {
                 let xml_name = default_name(span, name, field_ident)?;
 
+                // This would've been taken via `XmlFieldMeta::take_type` if
+                // this field was within an extract where a `type_` is legal
+                // to have.
+                if let Some(type_) = type_ {
+                    return Err(Error::new_spanned(
+                        type_,
+                        "specifying `type_` on fields inside structs and enum variants is redundant and not allowed."
+                    ));
+                }
+
                 Ok(Self::Attribute {
                     xml_name,
                     xml_namespace: namespace,

xso-proc/src/meta.rs 🔗

@@ -634,6 +634,9 @@ pub(crate) enum XmlFieldMeta {
 
         /// The `default` flag.
         default_: Flag,
+
+        /// An explicit type override, only usable within extracts.
+        type_: Option<Type>,
     },
 
     /// `#[xml(text)]`
@@ -700,11 +703,13 @@ impl XmlFieldMeta {
                     namespace,
                 },
                 default_: Flag::Absent,
+                type_: None,
             })
         } else if meta.input.peek(syn::token::Paren) {
             // full syntax
             let mut qname = QNameRef::default();
             let mut default_ = Flag::Absent;
+            let mut type_ = None;
             meta.parse_nested_meta(|meta| {
                 if meta.path.is_ident("default") {
                     if default_.is_set() {
@@ -712,6 +717,12 @@ impl XmlFieldMeta {
                     }
                     default_ = (&meta.path).into();
                     Ok(())
+                } else if meta.path.is_ident("type_") {
+                    if type_.is_some() {
+                        return Err(Error::new_spanned(meta.path, "duplicate `type_` key"));
+                    }
+                    type_ = Some(meta.value()?.parse()?);
+                    Ok(())
                 } else {
                     match qname.parse_incremental_from_meta(meta)? {
                         None => Ok(()),
@@ -723,6 +734,7 @@ impl XmlFieldMeta {
                 span: meta.path.span(),
                 qname,
                 default_,
+                type_,
             })
         } else {
             // argument-less syntax
@@ -730,6 +742,7 @@ impl XmlFieldMeta {
                 span: meta.path.span(),
                 qname: QNameRef::default(),
                 default_: Flag::Absent,
+                type_: None,
             })
         }
     }
@@ -980,6 +993,7 @@ impl XmlFieldMeta {
     /// Extract an explicit type specification if it exists.
     pub(crate) fn take_type(&mut self) -> Option<Type> {
         match self {
+            Self::Attribute { ref mut type_, .. } => type_.take(),
             Self::Text { ref mut type_, .. } => type_.take(),
             _ => None,
         }

xso/src/from_xml_doc.md 🔗

@@ -166,6 +166,7 @@ The following keys can be used inside the `#[xml(attribute(..))]` meta:
 | `namespace` | *string literal* or *path* | The optional namespace of the XML attribute to match. If it is a *path*, it must point at a `&'static str`. Note that attributes, unlike elements, are unnamespaced by default. |
 | `name` | *string literal* or *path* | The name of the XML attribute to match. If it is a *path*, it must point at a `&'static NcNameStr`. |
 | `default` | flag | If present, an absent attribute will substitute the default value instead of raising an error. |
+| `type_` | *type* | Optional explicit type specification. Only allowed within `#[xml(extract(fields(..)))]`. |
 
 If the `name` key contains a namespace prefix, it must be one of the prefixes
 defined as built-in in the XML specifications. That prefix will then be
@@ -183,6 +184,10 @@ 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`.
 
+If `type_` is specified and the `text` meta is used within an
+`#[xml(extract(fields(..)))]` meta, the specified type is used instead of the
+field type on which the `extract` is declared.
+
 ##### Example
 
 ```rust