xso-proc: start making derive macros for FromXml and IntoXml

For now, these macros only support empty elements. Everything else will
be rejected with a compile-time error.
This commit is contained in:
Jonas Schäfer 2024-06-21 17:53:37 +02:00
parent 9ec9a0f0c6
commit 0adfd1218b
10 changed files with 662 additions and 1 deletions

View file

@ -7,6 +7,7 @@ members = [ # alphabetically sorted
"tokio-xmpp",
"xmpp",
"xso",
"xso-proc",
]
resolver = "2"
@ -18,3 +19,4 @@ tokio-xmpp = { path = "tokio-xmpp" }
xmpp-parsers = { path = "parsers" }
xmpp = { path = "xmpp" }
xso = { path = "xso" }
xso_proc = { path = "xso-proc" }

View file

@ -24,7 +24,7 @@ chrono = { version = "0.4.5", default-features = false, features = ["std"] }
# same repository dependencies
jid = { version = "0.10", features = ["minidom"], path = "../jid" }
minidom = { version = "0.15", path = "../minidom" }
xso = { version = "0.0.2" }
xso = { version = "0.0.2", features = ["macros", "minidom", "panicking-into-impl"] }
[features]
# Build xmpp-parsers to make components instead of clients.

View file

@ -0,0 +1,150 @@
// Copyright (c) 2024 Jonas Schäfer <jonas@zombofant.net>
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
#![deny(
non_camel_case_types,
non_snake_case,
unsafe_code,
unused_variables,
unused_mut,
dead_code
)]
mod helpers {
// we isolate the helpers into a module, because we do not want to have
// them in scope below.
// this is to ensure that the macros do not have hidden dependencies on
// any specific names being imported.
use minidom::Element;
use xso::{error::FromElementError, transform, try_from_element, FromXml, IntoXml};
pub(super) fn roundtrip_full<T: IntoXml + FromXml + PartialEq + std::fmt::Debug + Clone>(
s: &str,
) {
let initial: Element = s.parse().unwrap();
let structural: T = match try_from_element(initial.clone()) {
Ok(v) => v,
Err(e) => panic!("failed to parse from {:?}: {}", s, e),
};
let recovered =
transform(structural.clone()).expect("roundtrip did not produce an element");
assert_eq!(initial, recovered);
let structural2: T = match try_from_element(recovered) {
Ok(v) => v,
Err(e) => panic!("failed to parse from serialisation of {:?}: {}", s, e),
};
assert_eq!(structural, structural2);
}
pub(super) fn parse_str<T: FromXml>(s: &str) -> Result<T, FromElementError> {
let initial: Element = s.parse().unwrap();
try_from_element(initial)
}
}
use self::helpers::{parse_str, roundtrip_full};
use xso::{FromXml, IntoXml};
// these are adverserial local names in order to trigger any issues with
// unqualified names in the macro expansions.
#[allow(dead_code, non_snake_case)]
fn Err() {}
#[allow(dead_code, non_snake_case)]
fn Ok() {}
#[allow(dead_code, non_snake_case)]
fn Some() {}
#[allow(dead_code, non_snake_case)]
fn None() {}
#[allow(dead_code)]
type Option = ((),);
#[allow(dead_code)]
type Result = ((),);
static NS1: &str = "urn:example:ns1";
#[derive(FromXml, IntoXml, PartialEq, Debug, Clone)]
#[xml(namespace = NS1, name = "foo")]
struct Empty;
#[test]
fn empty_roundtrip() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
roundtrip_full::<Empty>("<foo xmlns='urn:example:ns1'/>");
}
#[test]
fn empty_name_mismatch() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
match parse_str::<Empty>("<bar xmlns='urn:example:ns1'/>") {
Err(xso::error::FromElementError::Mismatch(..)) => (),
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn empty_namespace_mismatch() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
match parse_str::<Empty>("<foo xmlns='urn:example:ns2'/>") {
Err(xso::error::FromElementError::Mismatch(..)) => (),
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn empty_unexpected_attribute() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
match parse_str::<Empty>("<foo xmlns='urn:example:ns1' fnord='bar'/>") {
Err(xso::error::FromElementError::Invalid(xso::error::Error::Other(e))) => {
assert_eq!(e, "Unknown attribute in foo element.");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn empty_unexpected_child() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
match parse_str::<Empty>("<foo xmlns='urn:example:ns1'><coucou/></foo>") {
Err(xso::error::FromElementError::Invalid(xso::error::Error::Other(e))) => {
assert_eq!(e, "Unknown child in foo element.");
}
other => panic!("unexpected result: {:?}", other),
}
}
#[test]
fn empty_qname_check_has_precedence_over_attr_check() {
#[allow(unused_imports)]
use std::{
option::Option::{None, Some},
result::Result::{Err, Ok},
};
match parse_str::<Empty>("<bar xmlns='urn:example:ns1' fnord='bar'/>") {
Err(xso::error::FromElementError::Mismatch(..)) => (),
other => panic!("unexpected result: {:?}", other),
}
}

View file

@ -10,3 +10,6 @@ pub(crate) mod text_node_codecs;
/// Helper macros to parse and serialise more easily.
#[macro_use]
mod macros;
#[cfg(test)]
mod macro_tests;

24
xso-proc/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "xso_proc"
version = "0.0.2"
authors = [
"Jonas Schäfer <jonas@zombofant.net>",
]
description = "Macro implementation of #[derive(FromXml, IntoXml)]"
homepage = "https://xmpp.rs"
repository = "https://gitlab.com/xmpp-rs/xmpp-rs"
keywords = ["xso", "derive", "serialization"]
license = "MPL-2.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "^1"
syn = { version = "^2", features = ["full", "extra-traits"] }
proc-macro2 = "^1"
[features]
panicking-into-impl = ["minidom"]
minidom = []

286
xso-proc/src/lib.rs Normal file
View file

@ -0,0 +1,286 @@
// Copyright (c) 2024 Jonas Schäfer <jonas@zombofant.net>
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![allow(rustdoc::private_intra_doc_links)]
/*!
# Macros for parsing XML into Rust structs, and vice versa
**If you are a user of `xso_proc` or `xso`, please
return to `xso` for more information**. The documentation of
`xso_proc` is geared toward developers of `_macros` and `_core`.
**You have been warned.**
*/
// Wondering about RawTokenStream vs. TokenStream?
// syn mostly works with proc_macro2, while the proc macros themselves use
// proc_macro.
use proc_macro::TokenStream as RawTokenStream;
use proc_macro2::TokenStream;
use quote::quote;
use syn::*;
mod meta;
/// Convert an [`syn::Item`] into the parts relevant for us.
///
/// If the item is of an unsupported variant, an appropriate error is
/// returned.
fn parse_struct(item: Item) -> Result<(Visibility, meta::XmlCompoundMeta, Ident)> {
match item {
Item::Struct(item) => {
match item.fields {
Fields::Unit => (),
other => {
return Err(Error::new_spanned(
other,
"cannot derive on non-unit struct (yet!)",
))
}
}
let meta = meta::XmlCompoundMeta::parse_from_attributes(&item.attrs)?;
Ok((item.vis, meta, item.ident))
}
other => Err(Error::new_spanned(other, "cannot derive on this item")),
}
}
/// Generate a `xso::FromXml` implementation for the given item, or fail with
/// a proper compiler error.
fn from_xml_impl(input: Item) -> Result<TokenStream> {
let (
vis,
meta::XmlCompoundMeta {
namespace,
name,
span,
},
ident,
) = parse_struct(input)?;
// we rebind to a different name here because otherwise some expressions
// inside `quote! {}` below get a bit tricky to read (such as
// `name.1 == #name`).
let Some(xml_namespace) = namespace else {
return Err(Error::new(span, "`namespace` key is required"));
};
let Some(xml_name) = name else {
return Err(Error::new(span, "`name` key is required"));
};
let from_events_builder_ty_name = quote::format_ident!("{}FromEvents", ident);
let state_ty_name = quote::format_ident!("{}FromEventsState", ident);
let unknown_attr_err = format!("Unknown attribute in {} element.", xml_name.value());
let unknown_child_err = format!("Unknown child in {} element.", xml_name.value());
let docstr = format!("Build a [`{}`] from XML events", ident);
#[cfg_attr(not(feature = "minidom"), allow(unused_mut))]
let mut result = quote! {
enum #state_ty_name {
Default,
}
#[doc = #docstr]
#vis struct #from_events_builder_ty_name(::core::option::Option<#state_ty_name>);
impl ::xso::FromEventsBuilder for #from_events_builder_ty_name {
type Output = #ident;
fn feed(
&mut self,
ev: ::xso::exports::rxml::Event
) -> ::core::result::Result<::core::option::Option<Self::Output>, ::xso::error::Error> {
match self.0 {
::core::option::Option::None => panic!("feed() called after it returned a non-None value"),
::core::option::Option::Some(#state_ty_name::Default) => match ev {
::xso::exports::rxml::Event::StartElement(..) => {
::core::result::Result::Err(::xso::error::Error::Other(#unknown_child_err))
}
::xso::exports::rxml::Event::EndElement(..) => {
self.0 = ::core::option::Option::None;
::core::result::Result::Ok(::core::option::Option::Some(#ident))
}
::xso::exports::rxml::Event::Text(..) => {
::core::result::Result::Err(::xso::error::Error::Other("Unexpected text content".into()))
}
// we ignore these: a correct parser only generates
// them at document start, and there we want to indeed
// not worry about them being in front of the first
// element.
::xso::exports::rxml::Event::XmlDeclaration(_, ::xso::exports::rxml::XmlVersion::V1_0) => ::core::result::Result::Ok(::core::option::Option::None)
}
}
}
}
impl ::xso::FromXml for #ident {
type Builder = #from_events_builder_ty_name;
fn from_events(
name: ::xso::exports::rxml::QName,
attrs: ::xso::exports::rxml::AttrMap,
) -> ::core::result::Result<Self::Builder, ::xso::error::FromEventsError> {
if name.0 != #xml_namespace || name.1 != #xml_name {
return ::core::result::Result::Err(::xso::error::FromEventsError::Mismatch { name, attrs });
}
if attrs.len() > 0 {
return ::core::result::Result::Err(::xso::error::Error::Other(#unknown_attr_err).into());
}
::core::result::Result::Ok(#from_events_builder_ty_name(::core::option::Option::Some(#state_ty_name::Default)))
}
}
};
#[cfg(feature = "minidom")]
result.extend(quote! {
impl ::std::convert::TryFrom<::xso::exports::minidom::Element> for #ident {
type Error = ::xso::error::FromElementError;
fn try_from(other: ::xso::exports::minidom::Element) -> ::core::result::Result<Self, Self::Error> {
::xso::try_from_element(other)
}
}
});
Ok(result)
}
/// Macro to derive a `xso::FromXml` implementation on a type.
///
/// The user-facing documentation for this macro lives in the `xso` crate.
#[proc_macro_derive(FromXml, attributes(xml))]
pub fn from_xml(input: RawTokenStream) -> RawTokenStream {
// Shim wrapper around `from_xml_impl` which converts any errors into
// actual compiler errors within the resulting token stream.
let item = syn::parse_macro_input!(input as Item);
match from_xml_impl(item) {
Ok(v) => v.into(),
Err(e) => e.into_compile_error().into(),
}
}
/// Generate a `xso::IntoXml` implementation for the given item, or fail with
/// a proper compiler error.
fn into_xml_impl(input: Item) -> Result<TokenStream> {
let (
vis,
meta::XmlCompoundMeta {
namespace,
name,
span,
},
ident,
) = parse_struct(input)?;
// we rebind to a different name here to stay consistent with
// `from_xml_impl`.
let Some(xml_namespace) = namespace else {
return Err(Error::new(span, "`namespace` key is required"));
};
let Some(xml_name) = name else {
return Err(Error::new(span, "`name` key is required"));
};
let into_events_iter_ty_name = quote::format_ident!("{}IntoEvents", ident);
let state_ty_name = quote::format_ident!("{}IntoEventsState", ident);
let docstr = format!("Decompose a [`{}`] into XML events", ident);
#[cfg_attr(not(feature = "minidom"), allow(unused_mut))]
let mut result = quote! {
enum #state_ty_name {
Header,
Footer,
}
#[doc = #docstr]
#vis struct #into_events_iter_ty_name(::core::option::Option<#state_ty_name>);
impl ::std::iter::Iterator for #into_events_iter_ty_name {
type Item = ::core::result::Result<::xso::exports::rxml::Event, ::xso::error::Error>;
fn next(&mut self) -> ::core::option::Option<Self::Item> {
match self.0 {
::core::option::Option::Some(#state_ty_name::Header) => {
self.0 = ::core::option::Option::Some(#state_ty_name::Footer);
::core::option::Option::Some(::core::result::Result::Ok(::xso::exports::rxml::Event::StartElement(
::xso::exports::rxml::parser::EventMetrics::zero(),
(
::xso::exports::rxml::Namespace::from_str(#xml_namespace),
match ::xso::exports::rxml::NcName::try_from(#xml_name) {
::core::result::Result::Ok(v) => v,
::core::result::Result::Err(e) => {
self.0 = ::core::option::Option::None;
return ::core::option::Option::Some(::core::result::Result::Err(e.into()));
}
}
),
::xso::exports::rxml::AttrMap::new(),
)))
}
::core::option::Option::Some(#state_ty_name::Footer) => {
self.0 = ::core::option::Option::None;
::core::option::Option::Some(::core::result::Result::Ok(::xso::exports::rxml::Event::EndElement(
::xso::exports::rxml::parser::EventMetrics::zero(),
)))
}
::core::option::Option::None => ::core::option::Option::None,
}
}
}
impl ::xso::IntoXml for #ident {
type EventIter = #into_events_iter_ty_name;
fn into_event_iter(self) -> ::core::result::Result<Self::EventIter, ::xso::error::Error> {
::core::result::Result::Ok(#into_events_iter_ty_name(::core::option::Option::Some(#state_ty_name::Header)))
}
}
};
#[cfg(all(feature = "minidom", feature = "panicking-into-impl"))]
result.extend(quote! {
impl ::std::convert::From<#ident> for ::xso::exports::minidom::Element {
fn from(other: #ident) -> Self {
::xso::transform(other).expect("seamless conversion into minidom::Element")
}
}
});
#[cfg(all(feature = "minidom", not(feature = "panicking-into-impl")))]
result.extend(quote! {
impl ::std::convert::TryFrom<#ident> for ::xso::exports::minidom::Element {
type Error = ::xso::error::Error;
fn try_from(other: #ident) -> ::core::result::Result<Self, Self::Error> {
::xso::transform(other)
}
}
});
Ok(result)
}
/// Macro to derive a `xso::IntoXml` implementation on a type.
///
/// The user-facing documentation for this macro lives in the `xso` crate.
#[proc_macro_derive(IntoXml, attributes(xml))]
pub fn into_xml(input: RawTokenStream) -> RawTokenStream {
// Shim wrapper around `into_xml_impl` which converts any errors into
// actual compiler errors within the resulting token stream.
let item = syn::parse_macro_input!(input as Item);
match into_xml_impl(item) {
Ok(v) => v.into(),
Err(e) => e.into_compile_error().into(),
}
}

120
xso-proc/src/meta.rs Normal file
View file

@ -0,0 +1,120 @@
// Copyright (c) 2024 Jonas Schäfer <jonas@zombofant.net>
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//! # Parse Rust attributes
//!
//! This module is concerned with parsing attributes from the Rust "meta"
//! annotations on structs, enums, enum variants and fields.
use proc_macro2::Span;
use syn::{spanned::Spanned, *};
/// Type alias for a `#[xml(namespace = ..)]` attribute.
///
/// This may, in the future, be replaced by an enum supporting multiple
/// ways to specify a namespace.
pub(crate) type NamespaceRef = Path;
/// Type alias for a `#[xml(name = ..)]` attribute.
///
/// This may, in the future, be replaced by an enum supporting both `Path` and
/// `LitStr`.
pub(crate) type NameRef = LitStr;
/// Contents of an `#[xml(..)]` attribute on a struct, enum variant, or enum.
#[derive(Debug)]
pub(crate) struct XmlCompoundMeta {
/// The span of the `#[xml(..)]` meta from which this was parsed.
///
/// This is useful for error messages.
pub(crate) span: Span,
/// The value assigned to `namespace` inside `#[xml(..)]`, if any.
pub(crate) namespace: Option<NamespaceRef>,
/// The value assigned to `name` inside `#[xml(..)]`, if any.
pub(crate) name: Option<NameRef>,
}
impl XmlCompoundMeta {
/// Parse the meta values from a `#[xml(..)]` attribute.
///
/// Undefined options or options with incompatible values are rejected
/// with an appropriate compile-time error.
fn parse_from_attribute(attr: &Attribute) -> Result<Self> {
let mut namespace = None;
let mut name = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
if name.is_some() {
return Err(Error::new_spanned(meta.path, "duplicate `name` key"));
}
name = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("namespace") {
if namespace.is_some() {
return Err(Error::new_spanned(meta.path, "duplicate `namespace` key"));
}
namespace = Some(meta.value()?.parse()?);
Ok(())
} else {
Err(Error::new_spanned(meta.path, "unsupported key"))
}
})?;
Ok(Self {
span: attr.span(),
namespace,
name,
})
}
/// Search through `attrs` for a single `#[xml(..)]` attribute and parse
/// it.
///
/// Undefined options or options with incompatible values are rejected
/// with an appropriate compile-time error.
///
/// If more than one `#[xml(..)]` attribute is found, an error is
/// emitted.
///
/// If no `#[xml(..)]` attribute is found, `None` is returned.
pub(crate) fn try_parse_from_attributes(attrs: &[Attribute]) -> Result<Option<Self>> {
let mut result = None;
for attr in attrs {
if !attr.path().is_ident("xml") {
continue;
}
if result.is_some() {
return Err(syn::Error::new_spanned(
attr.path(),
"only one #[xml(..)] per struct or enum variant allowed",
));
}
result = Some(Self::parse_from_attribute(attr)?);
}
Ok(result)
}
/// Search through `attrs` for a single `#[xml(..)]` attribute and parse
/// it.
///
/// Undefined options or options with incompatible values are rejected
/// with an appropriate compile-time error.
///
/// If more than one or no `#[xml(..)]` attribute is found, an error is
/// emitted.
pub(crate) fn parse_from_attributes(attrs: &[Attribute]) -> Result<Self> {
match Self::try_parse_from_attributes(attrs)? {
Some(v) => Ok(v),
None => Err(syn::Error::new(
Span::call_site(),
"#[xml(..)] attribute required on struct or enum variant",
)),
}
}
}

View file

@ -12,3 +12,9 @@ license = "MPL-2.0"
[dependencies]
rxml = { version = "0.11.0", default-features = false }
minidom = { version = "^0.15" }
xso_proc = { version = "0.0.2", optional = true }
[features]
macros = [ "dep:xso_proc" ]
minidom = [ "xso_proc/minidom"]
panicking-into-impl = ["xso_proc/panicking-into-impl"]

50
xso/src/from_xml_doc.md Normal file
View file

@ -0,0 +1,50 @@
# Make a struct or enum parseable from XML
This derives the [`FromXml`] trait on a struct or enum. It is the counterpart
to [`macro@IntoXml`].
## Example
```rust
# use xso::FromXml;
static MY_NAMESPACE: &str = "urn:example";
#[derive(FromXml, Debug, PartialEq)]
#[xml(namespace = MY_NAMESPACE, name = "foo")]
struct Foo;
let foo: Foo = xso::from_bytes(b"<foo xmlns='urn:example'/>").unwrap();
assert_eq!(foo, Foo);
```
## Attributes
The derive macros need to know which XML namespace and name the elements it
is supposed have. This must be specified via key-value pairs on the type the
derive macro is invoked on. These are specified as Rust attributes. In order
to disambiguate between XML attributes and Rust attributes, we are going to
refer to Rust attributes using the term *meta* instead, which is consistent
with the Rust language reference calling that syntax construct *meta*.
All key-value pairs interpreted by these derive macros must be wrapped in a
`#[xml( ... )]` *meta*. The following keys are defined on structs:
| Key | Value type | Description |
| --- | --- | --- |
| `namespace` | *path* | The path to a `&'static str` which holds the XML namespace to match. |
| `name` | *string literal* | The XML element name to match. |
## Limitations
Supports only empty structs currently. For example, the following will not
work:
```compile_fail
# use xso::FromXml;
# static MY_NAMESPACE: &str = "urn:example";
#[derive(FromXml, Debug, PartialEq)]
#[xml(namespace = MY_NAMESPACE, name = "foo")]
struct Foo {
some_field: String,
}
```

View file

@ -20,13 +20,32 @@ use of this library in parsing XML streams like specified in RFC 6120.
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
pub mod error;
#[cfg(feature = "minidom")]
pub mod minidom_compat;
#[doc(hidden)]
pub mod exports {
#[cfg(feature = "minidom")]
pub use minidom;
pub use rxml;
}
#[doc = include_str!("from_xml_doc.md")]
#[doc(inline)]
#[cfg(feature = "macros")]
pub use xso_proc::FromXml;
/// # Make a struct or enum serialisable to XML
///
/// This derives the [`IntoXml`] trait on a struct or enum. It is the
/// counterpart to [`macro@FromXml`].
///
/// The attributes necessary and available for the derivation to work are
/// documented on [`macro@FromXml`].
#[doc(inline)]
#[cfg(feature = "macros")]
pub use xso_proc::IntoXml;
/// Trait allowing to consume a struct and iterate its contents as
/// serialisable [`rxml::Event`] items.
///
@ -145,6 +164,7 @@ pub fn transform<T: FromXml, F: IntoXml>(from: F) -> Result<T, self::error::Erro
/// Unlike [`transform`] (which can also be used with an element), this
/// function will return the element unharmed if its element header does not
/// match the expectations of `T`.
#[cfg(feature = "minidom")]
pub fn try_from_element<T: FromXml>(
from: minidom::Element,
) -> Result<T, self::error::FromElementError> {