diff --git a/Cargo.toml b/Cargo.toml index 67b32a3e..8cde5aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/parsers/Cargo.toml b/parsers/Cargo.toml index c1922438..cb5f88ef 100644 --- a/parsers/Cargo.toml +++ b/parsers/Cargo.toml @@ -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. diff --git a/parsers/src/util/macro_tests.rs b/parsers/src/util/macro_tests.rs new file mode 100644 index 00000000..6d8ecde7 --- /dev/null +++ b/parsers/src/util/macro_tests.rs @@ -0,0 +1,150 @@ +// Copyright (c) 2024 Jonas Schäfer +// +// 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( + 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(s: &str) -> Result { + 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::(""); +} + +#[test] +fn empty_name_mismatch() { + #[allow(unused_imports)] + use std::{ + option::Option::{None, Some}, + result::Result::{Err, Ok}, + }; + match parse_str::("") { + 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::("") { + 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::("") { + 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::("") { + 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::("") { + Err(xso::error::FromElementError::Mismatch(..)) => (), + other => panic!("unexpected result: {:?}", other), + } +} diff --git a/parsers/src/util/mod.rs b/parsers/src/util/mod.rs index 75e1fb86..2f7deecf 100644 --- a/parsers/src/util/mod.rs +++ b/parsers/src/util/mod.rs @@ -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; diff --git a/xso-proc/Cargo.toml b/xso-proc/Cargo.toml new file mode 100644 index 00000000..8aab8947 --- /dev/null +++ b/xso-proc/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "xso_proc" +version = "0.0.2" +authors = [ + "Jonas Schäfer ", +] +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 = [] diff --git a/xso-proc/src/lib.rs b/xso-proc/src/lib.rs new file mode 100644 index 00000000..8c363b92 --- /dev/null +++ b/xso-proc/src/lib.rs @@ -0,0 +1,286 @@ +// Copyright (c) 2024 Jonas Schäfer +// +// 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 { + 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, ::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 { + 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 { + ::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 { + 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 { + 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 { + ::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 { + ::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(), + } +} diff --git a/xso-proc/src/meta.rs b/xso-proc/src/meta.rs new file mode 100644 index 00000000..17401d4a --- /dev/null +++ b/xso-proc/src/meta.rs @@ -0,0 +1,120 @@ +// Copyright (c) 2024 Jonas Schäfer +// +// 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, + + /// The value assigned to `name` inside `#[xml(..)]`, if any. + pub(crate) name: Option, +} + +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 { + 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> { + 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 { + 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", + )), + } + } +} diff --git a/xso/Cargo.toml b/xso/Cargo.toml index 944a636f..bc0036e8 100644 --- a/xso/Cargo.toml +++ b/xso/Cargo.toml @@ -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"] diff --git a/xso/src/from_xml_doc.md b/xso/src/from_xml_doc.md new file mode 100644 index 00000000..a4c8a814 --- /dev/null +++ b/xso/src/from_xml_doc.md @@ -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"").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, +} +``` diff --git a/xso/src/lib.rs b/xso/src/lib.rs index 8b68df9c..b6874b35 100644 --- a/xso/src/lib.rs +++ b/xso/src/lib.rs @@ -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(from: F) -> Result( from: minidom::Element, ) -> Result {