xso-proc: Add support for the codec field on attribute meta

Emmanuel Gil Peyrot created

This allows a custom TextCodec to be used for encoding and decoding the
attribute’s value, instead of FromXmlText and AsOptionalXmlText.

Change summary

parsers/src/util/macro_tests.rs | 51 +++++++++++++++++++++++++++++++++++
xso-proc/src/field/attribute.rs | 36 +++++++++++++++++++++---
xso-proc/src/field/mod.rs       |  2 +
xso-proc/src/meta.rs            | 24 ++++++++++++++++
xso/ChangeLog                   |  2 +
xso/src/from_xml_doc.md         |  4 ++
6 files changed, 114 insertions(+), 5 deletions(-)

Detailed changes

parsers/src/util/macro_tests.rs 🔗

@@ -388,6 +388,57 @@ fn default_attribute_roundtrip_pp() {
     roundtrip_full::<DefaultAttribute>("<attr xmlns='urn:example:ns1' foo='xyz' bar='16'/>");
 }
 
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(namespace = NS1, name = "attr")]
+struct AttributeWithCodec {
+    #[xml(attribute(default, codec = xso::text::EmptyAsNone))]
+    foo: core::option::Option<String>,
+}
+
+#[test]
+fn attribute_with_codec_is_none() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    let el = parse_str::<AttributeWithCodec>("<attr xmlns='urn:example:ns1'/>").unwrap();
+    assert_eq!(el.foo, None);
+    let el = parse_str::<AttributeWithCodec>("<attr xmlns='urn:example:ns1' foo=''/>").unwrap();
+    assert_eq!(el.foo, None);
+    let el = parse_str::<AttributeWithCodec>("<attr xmlns='urn:example:ns1' foo='bar'/>").unwrap();
+    assert_eq!(el.foo, Some(String::from("bar")));
+}
+
+#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
+#[xml(namespace = NS1, name = "attr")]
+struct AttributeWithBase64Codec {
+    #[xml(attribute(codec = xso::text::Base64))]
+    foo: Vec<u8>,
+}
+
+#[test]
+fn attribute_with_base64_codec_roundtrip() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    roundtrip_full::<AttributeWithBase64Codec>("<attr xmlns='urn:example:ns1' foo='AAAA'/>");
+}
+
+#[test]
+fn attribute_with_base64_codec_decodes() {
+    #[allow(unused_imports)]
+    use core::{
+        option::Option::{None, Some},
+        result::Result::{Err, Ok},
+    };
+    let el = parse_str::<AttributeWithBase64Codec>("<attr xmlns='urn:example:ns1' foo='AAAA'/>")
+        .unwrap();
+    assert_eq!(el.foo, [0, 0, 0]);
+}
+
 #[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
 #[xml(namespace = NS1, name = "text")]
 struct TextString {

xso-proc/src/field/attribute.rs 🔗

@@ -14,7 +14,10 @@ use syn::*;
 use crate::error_message::{self, ParentRef};
 use crate::meta::{Flag, NameRef, NamespaceRef};
 use crate::scope::{AsItemsScope, FromEventsScope};
-use crate::types::{as_optional_xml_text_fn, default_fn, from_xml_text_fn};
+use crate::types::{
+    as_optional_xml_text_fn, default_fn, from_xml_text_fn, text_codec_decode_fn,
+    text_codec_encode_fn,
+};
 
 use super::{Field, FieldBuilderPart, FieldIteratorPart, FieldTempInit};
 
@@ -29,6 +32,9 @@ pub(super) struct AttributeField {
     /// Flag indicating whether the value should be defaulted if the
     /// attribute is absent.
     pub(super) default_: Flag,
+
+    /// Optional codec to use.
+    pub(super) codec: Option<Expr>,
 }
 
 impl Field for AttributeField {
@@ -53,7 +59,18 @@ impl Field for AttributeField {
             },
         };
 
-        let from_xml_text = from_xml_text_fn(ty.clone());
+        let finalize = match self.codec {
+            Some(ref codec) => {
+                let decode = text_codec_decode_fn(ty.clone());
+                quote! {
+                    |value| #decode(&#codec, value)
+                }
+            }
+            None => {
+                let from_xml_text = from_xml_text_fn(ty.clone());
+                quote! { #from_xml_text }
+            }
+        };
 
         let on_absent = match self.default_ {
             Flag::Absent => quote! {
@@ -70,7 +87,7 @@ impl Field for AttributeField {
         Ok(FieldBuilderPart::Init {
             value: FieldTempInit {
                 init: quote! {
-                    match #attrs.remove(#xml_namespace, #xml_name).map(#from_xml_text).transpose()? {
+                    match #attrs.remove(#xml_namespace, #xml_name).map(#finalize).transpose()? {
                         ::core::option::Option::Some(v) => v,
                         ::core::option::Option::None => #on_absent,
                     }
@@ -96,11 +113,20 @@ impl Field for AttributeField {
         };
         let xml_name = &self.xml_name;
 
-        let as_optional_xml_text = as_optional_xml_text_fn(ty.clone());
+        let generator = match self.codec {
+            Some(ref codec) => {
+                let encode = text_codec_encode_fn(ty.clone());
+                quote! { #encode(&#codec, #bound_name)? }
+            }
+            None => {
+                let as_optional_xml_text = as_optional_xml_text_fn(ty.clone());
+                quote! { #as_optional_xml_text(#bound_name)? }
+            }
+        };
 
         Ok(FieldIteratorPart::Header {
             generator: quote! {
-                #as_optional_xml_text(#bound_name)?.map(|#bound_name| ::xso::Item::Attribute(
+                #generator.map(|#bound_name| ::xso::Item::Attribute(
                     #xml_namespace,
                     ::std::borrow::Cow::Borrowed(#xml_name),
                     #bound_name,

xso-proc/src/field/mod.rs 🔗

@@ -253,6 +253,7 @@ fn new_field(
             qname: QNameRef { namespace, name },
             default_,
             type_,
+            codec,
         } => {
             let xml_name = default_name(span, name, field_ident)?;
 
@@ -270,6 +271,7 @@ fn new_field(
                 xml_name,
                 xml_namespace: namespace,
                 default_,
+                codec,
             }))
         }
 

xso-proc/src/meta.rs 🔗

@@ -689,6 +689,9 @@ pub(crate) enum XmlFieldMeta {
 
         /// An explicit type override, only usable within extracts.
         type_: Option<Type>,
+
+        /// The path to the optional codec type.
+        codec: Option<Expr>,
     },
 
     /// `#[xml(text)]`
@@ -787,12 +790,14 @@ impl XmlFieldMeta {
                 },
                 default_: Flag::Absent,
                 type_: None,
+                codec: 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;
+            let mut codec = None;
             meta.parse_nested_meta(|meta| {
                 if meta.path.is_ident("default") {
                     if default_.is_set() {
@@ -806,6 +811,23 @@ impl XmlFieldMeta {
                     }
                     type_ = Some(meta.value()?.parse()?);
                     Ok(())
+                } else if meta.path.is_ident("codec") {
+                    if codec.is_some() {
+                        return Err(Error::new_spanned(meta.path, "duplicate `codec` key"));
+                    }
+                    let (new_codec, helpful_error) = parse_codec_expr(meta.value()?)?;
+                    // See the comment at the top of text_from_meta() below for why we
+                    // do this.
+                    let lookahead = meta.input.lookahead1();
+                    if !lookahead.peek(Token![,]) && !meta.input.is_empty() {
+                        if let Some(helpful_error) = helpful_error {
+                            let mut e = lookahead.error();
+                            e.combine(helpful_error);
+                            return Err(e);
+                        }
+                    }
+                    codec = Some(new_codec);
+                    Ok(())
                 } else {
                     match qname.parse_incremental_from_meta(meta)? {
                         None => Ok(()),
@@ -818,6 +840,7 @@ impl XmlFieldMeta {
                 qname,
                 default_,
                 type_,
+                codec,
             })
         } else {
             // argument-less syntax
@@ -826,6 +849,7 @@ impl XmlFieldMeta {
                 qname: QNameRef::default(),
                 default_: Flag::Absent,
                 type_: None,
+                codec: None,
             })
         }
     }

xso/ChangeLog 🔗

@@ -29,6 +29,8 @@ Version NEXT:
         translated into a boolean (`#[xml(flag)]`).
       - Generic TextCodec implementation for all base64 engines provided by
         the base64 crate (if the `base64` feature is enabled).
+      - New `codec` field on `attribute` meta, to support decoding and
+        encoding using any `TextCodec`.
 
 Version 0.1.2:
 2024-07-26 Jonas Schäfer <jonas@zombofant.net>

xso/src/from_xml_doc.md 🔗

@@ -263,6 +263,7 @@ The following keys can be used inside the `#[xml(attribute(..))]` meta:
 | `name` | optional *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(..)))]`. |
+| `codec` | optional *expression* | [`TextCodec`] implementation which is used to encode or decode the field. |
 
 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
@@ -284,6 +285,9 @@ 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.
 
+If `codec` is given, the given `codec` value must implement
+[`TextCodec<T>`][`TextCodec`] where `T` is the type of the field.
+
 #### Example
 
 ```rust