xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
//! Declarative XML macro surface.
//!
//! The `xml!` MVP is syntax sugar over `builder` and `component`; it does not
//! parse or serialize XML by itself. It supports XML elements, literal and
//! expression attributes, text and expression children, comments, namespace
//! declarations, prefixed names, and explicit Rust components written as
//! `<{ComponentPath}>`.
//!
//! Current limits are intentional: component props must be simple Rust field
//! identifiers, namespace declarations must be string literals, text token
//! spacing follows Rust tokenization for raw text, and compile-fail coverage is
//! limited to the MVP parser/codegen errors. Use normal Rust interpolation for
//! custom component signatures or more complex values.

use crate::builder::{DocumentBuilder, FragmentBuilder, IntoXmlFragment};
use crate::core::{Document, XmlResult};

pub use xdoc_macros::xml;

/// Runtime output produced by `xml!`.
///
/// A template can be materialized as a full `Document` when it contains exactly
/// one root element, or used as a fragment when embedded as children.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XmlTemplate {
    fragment: FragmentBuilder,
}

impl XmlTemplate {
    pub fn from_fragment(fragment: FragmentBuilder) -> Self {
        Self { fragment }
    }

    pub fn into_fragment(self) -> FragmentBuilder {
        self.fragment
    }

    pub fn into_document(self) -> XmlResult<Document> {
        let root = self.fragment.into_single_element()?;
        DocumentBuilder::new().root(root)?.build()
    }
}

impl IntoXmlFragment for XmlTemplate {
    fn into_xml_fragment(self) -> XmlResult<FragmentBuilder> {
        Ok(self.fragment)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::{element, fragment};
    use crate::component::{Children, Fragment};
    use crate::core::{ErrorKind, XmlResult};
    use crate::writer::to_string_compact;

    #[derive(Debug, Clone)]
    struct InvoiceHeaderProps {
        title: &'static str,
        issued_at: String,
    }

    #[allow(non_snake_case)]
    fn InvoiceHeader(props: InvoiceHeaderProps) -> XmlResult<crate::builder::ElementBuilder> {
        element("Header")?
            .attr("title", props.title)?
            .attr("issued_at", props.issued_at)
    }

    #[derive(Debug, Clone)]
    struct PanelProps {
        title: &'static str,
    }

    #[allow(non_snake_case)]
    fn Panel(props: PanelProps, children: Children) -> XmlResult<crate::builder::ElementBuilder> {
        element("Panel")?
            .attr("title", props.title)?
            .child(children)
    }

    #[derive(Debug, Clone)]
    struct PairProps {
        first: &'static str,
        second: &'static str,
    }

    #[allow(non_snake_case)]
    fn Pair(props: PairProps) -> XmlResult<Fragment> {
        fragment()
            .child(element("First")?.text(props.first)?)?
            .child(element("Second")?.text(props.second)?)
    }

    #[test]
    fn xml_macro_basic_serializes_self_closing_element() -> XmlResult<()> {
        let document = xml! { <Root/> }?.into_document()?;

        assert_eq!(to_string_compact(&document)?, "<Root/>");
        Ok(())
    }

    #[test]
    fn xml_macro_basic_serializes_nested_text() -> XmlResult<()> {
        let document = xml! { <Root><Child>text</Child></Root> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><Child>text</Child></Root>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_basic_serializes_text_expression() -> XmlResult<()> {
        let value = "DOC-1";
        let document = xml! { <ID>{ value }</ID> }?.into_document()?;

        assert_eq!(to_string_compact(&document)?, "<ID>DOC-1</ID>");
        Ok(())
    }

    #[test]
    fn xml_macro_basic_serializes_literal_and_dynamic_attributes() -> XmlResult<()> {
        let quantity = 3;
        let document = xml! { <Item code="A001" quantity={ quantity }/> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Item code=\"A001\" quantity=\"3\"/>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_basic_composes_interpolated_fragment() -> XmlResult<()> {
        let child = xml! { <Child>value</Child> }?;
        let document = xml! { <Root>{ child }</Root> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><Child>value</Child></Root>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_namespaces_serializes_default_namespace() -> XmlResult<()> {
        let document =
            xml! { <Root xmlns="urn:default"><Child>value</Child></Root> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root xmlns=\"urn:default\"><Child>value</Child></Root>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_namespaces_serializes_prefixed_element() -> XmlResult<()> {
        let document =
            xml! { <doc:Root xmlns:doc="urn:doc"><doc:Child>value</doc:Child></doc:Root> }?
                .into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<doc:Root xmlns:doc=\"urn:doc\"><doc:Child>value</doc:Child></doc:Root>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_namespaces_serializes_prefixed_attribute() -> XmlResult<()> {
        let id = "A001";
        let document = xml! { <Root xmlns:doc="urn:doc" doc:id={ id }/> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root xmlns:doc=\"urn:doc\" doc:id=\"A001\"/>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_comments_serializes_comment() -> XmlResult<()> {
        let document = xml! { <Root><!-- hello --><Child>value</Child></Root> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><!--hello--><Child>value</Child></Root>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_components_serializes_typed_props_component() -> XmlResult<()> {
        let issued_at = "2026-06-11".to_owned();
        let document =
            xml! { <{InvoiceHeader} title="Demo" issued_at={ issued_at }/> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Header title=\"Demo\" issued_at=\"2026-06-11\"/>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_components_serializes_children_component() -> XmlResult<()> {
        let document = xml! { <{Panel} title="Main"><ID>DOC1</ID><Name>Report</Name></{Panel}> }?
            .into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Panel title=\"Main\"><ID>DOC1</ID><Name>Report</Name></Panel>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_components_accepts_interpolated_children() -> XmlResult<()> {
        let child = xml! { <ID>DOC1</ID> }?;
        let document = xml! { <{Panel} title="Main">{ child }<Name>Report</Name></{Panel}> }?
            .into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Panel title=\"Main\"><ID>DOC1</ID><Name>Report</Name></Panel>"
        );
        Ok(())
    }

    #[test]
    fn xml_macro_components_accepts_fragment_component() -> XmlResult<()> {
        let document = xml! { <Root><{Pair} first="one" second="two"/></Root> }?.into_document()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><First>one</First><Second>two</Second></Root>"
        );
        Ok(())
    }

    #[test]
    fn macro_output_template_materializes_document() -> XmlResult<()> {
        let template = XmlTemplate::from_fragment(fragment().child(element("Root")?.text("ok")?)?);

        let document = template.into_document()?;

        assert_eq!(to_string_compact(&document)?, "<Root>ok</Root>");
        Ok(())
    }

    #[test]
    fn macro_output_template_can_be_used_as_child_fragment() -> XmlResult<()> {
        let template =
            XmlTemplate::from_fragment(fragment().child(element("Child")?.text("value")?)?);
        let document = DocumentBuilder::new()
            .root(element("Root")?.child(template)?)?
            .build()?;

        assert_eq!(
            to_string_compact(&document)?,
            "<Root><Child>value</Child></Root>"
        );
        Ok(())
    }

    #[test]
    fn macro_output_template_rejects_empty_document() {
        let error = XmlTemplate::default()
            .into_document()
            .expect_err("empty template must not build a document");

        assert_eq!(error.kind(), &ErrorKind::InvalidOperation);
        assert_eq!(
            error.message(),
            "fragment requires one root element to build a document"
        );
    }

    #[test]
    fn macro_output_template_rejects_multiple_document_roots() -> XmlResult<()> {
        let template = XmlTemplate::from_fragment(
            fragment()
                .child(element("First")?)?
                .child(element("Second")?)?,
        );

        let error = template
            .into_document()
            .expect_err("multiple roots must not build a document");

        assert_eq!(error.kind(), &ErrorKind::InvalidOperation);
        assert_eq!(
            error.message(),
            "fragment has multiple top-level nodes; document requires one root element"
        );
        Ok(())
    }
}