use crate::core::{Document, ElementData, ErrorKind, NodeKind, QName, XmlError, XmlResult};
const DEFAULT_ENCODING: &str = "UTF-8";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriterConfig {
pretty: bool,
include_declaration: bool,
encoding: String,
indent: String,
}
impl WriterConfig {
pub fn compact() -> Self {
Self {
pretty: false,
include_declaration: false,
encoding: DEFAULT_ENCODING.to_owned(),
indent: String::new(),
}
}
pub fn pretty() -> Self {
Self {
pretty: true,
include_declaration: false,
encoding: DEFAULT_ENCODING.to_owned(),
indent: " ".to_owned(),
}
}
pub fn with_xml_declaration(mut self, include_declaration: bool) -> Self {
self.include_declaration = include_declaration;
self
}
pub fn with_encoding(mut self, encoding: impl Into<String>) -> Self {
self.encoding = encoding.into();
self
}
pub fn with_indent(mut self, indent: impl Into<String>) -> Self {
self.indent = indent.into();
self
}
pub fn include_declaration(&self) -> bool {
self.include_declaration
}
pub fn encoding(&self) -> &str {
&self.encoding
}
pub fn indent(&self) -> &str {
&self.indent
}
}
impl Default for WriterConfig {
fn default() -> Self {
Self::compact()
}
}
pub fn to_string_compact(document: &Document) -> XmlResult<String> {
to_string_with_config(document, &WriterConfig::compact())
}
pub fn to_string_pretty(document: &Document, config: WriterConfig) -> XmlResult<String> {
to_string_with_config(
document,
&WriterConfig {
pretty: true,
..config
},
)
}
pub fn to_string_with_config(document: &Document, config: &WriterConfig) -> XmlResult<String> {
let root = document.root().ok_or_else(|| {
XmlError::new(
ErrorKind::InvalidOperation,
"cannot serialize a document without a root element",
)
})?;
let mut output = String::new();
if config.include_declaration {
output.push_str("<?xml version=\"1.0\" encoding=\"");
output.push_str(&escape_attribute(&config.encoding));
output.push_str("\"?>");
if config.pretty {
output.push('\n');
}
}
write_node(document, root, config, 0, &mut output)?;
Ok(output)
}
pub fn escape_text(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
_ => escaped.push(ch),
}
}
escaped
}
pub fn escape_attribute(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
_ => escaped.push(ch),
}
}
escaped
}
fn write_node(
document: &Document,
node_id: crate::core::NodeId,
config: &WriterConfig,
depth: usize,
output: &mut String,
) -> XmlResult<()> {
match document.node(node_id)?.kind() {
NodeKind::Element(element) => write_element(document, element, config, depth, output)?,
NodeKind::Text(text) => output.push_str(&escape_text(text)),
NodeKind::Comment(comment) => write_comment(comment, output)?,
NodeKind::CData(cdata) => write_cdata(cdata, output)?,
NodeKind::ProcessingInstruction { target, data } => {
write_processing_instruction(target, data.as_deref(), output)?
}
}
Ok(())
}
fn write_element(
document: &Document,
element: &ElementData,
config: &WriterConfig,
depth: usize,
output: &mut String,
) -> XmlResult<()> {
output.push('<');
write_qname(element.name(), output);
write_namespace_declarations(element, output);
write_attributes(element, output);
if element.children().is_empty() {
output.push_str("/>");
return Ok(());
}
output.push('>');
if config.pretty {
if has_textual_content(document, element)? {
write_inline_children(document, element, config, depth, output)?;
} else {
write_pretty_children(document, element, config, depth, output)?;
}
} else {
write_inline_children(document, element, config, depth, output)?;
}
output.push_str("</");
write_qname(element.name(), output);
output.push('>');
Ok(())
}
fn has_textual_content(document: &Document, element: &ElementData) -> XmlResult<bool> {
for child in element.children() {
match document.node(*child)?.kind() {
NodeKind::Text(_) | NodeKind::CData(_) => return Ok(true),
NodeKind::Element(_)
| NodeKind::Comment(_)
| NodeKind::ProcessingInstruction { .. } => {}
}
}
Ok(false)
}
fn write_inline_children(
document: &Document,
element: &ElementData,
config: &WriterConfig,
depth: usize,
output: &mut String,
) -> XmlResult<()> {
for child in element.children() {
write_node(document, *child, config, depth + 1, output)?;
}
Ok(())
}
fn write_pretty_children(
document: &Document,
element: &ElementData,
config: &WriterConfig,
depth: usize,
output: &mut String,
) -> XmlResult<()> {
for child in element.children() {
output.push('\n');
write_indent(config, depth + 1, output);
write_node(document, *child, config, depth + 1, output)?;
}
output.push('\n');
write_indent(config, depth, output);
Ok(())
}
fn write_namespace_declarations(element: &ElementData, output: &mut String) {
for declaration in element.namespace_declarations() {
output.push(' ');
match declaration.prefix() {
Some(prefix) => {
output.push_str("xmlns:");
output.push_str(prefix.as_str());
}
None => output.push_str("xmlns"),
}
output.push_str("=\"");
output.push_str(&escape_attribute(declaration.uri().as_str()));
output.push('"');
}
}
fn write_attributes(element: &ElementData, output: &mut String) {
for attribute in element.attributes() {
output.push(' ');
write_qname(attribute.name(), output);
output.push_str("=\"");
output.push_str(&escape_attribute(attribute.value()));
output.push('"');
}
}
fn write_qname(name: &QName, output: &mut String) {
output.push_str(&name.lexical_name());
}
fn write_comment(comment: &str, output: &mut String) -> XmlResult<()> {
if comment.contains("--") {
return Err(XmlError::new(
ErrorKind::InvalidOperation,
"XML comments cannot contain `--`",
));
}
output.push_str("<!--");
output.push_str(comment);
output.push_str("-->");
Ok(())
}
fn write_cdata(cdata: &str, output: &mut String) -> XmlResult<()> {
if cdata.contains("]]>") {
return Err(XmlError::new(
ErrorKind::InvalidOperation,
"CDATA sections cannot contain `]]>`",
));
}
output.push_str("<![CDATA[");
output.push_str(cdata);
output.push_str("]]>");
Ok(())
}
fn write_processing_instruction(
target: &str,
data: Option<&str>,
output: &mut String,
) -> XmlResult<()> {
if data.is_some_and(|data| data.contains("?>")) {
return Err(XmlError::new(
ErrorKind::InvalidOperation,
"processing instruction data cannot contain `?>`",
));
}
output.push_str("<?");
output.push_str(target);
if let Some(data) = data {
output.push(' ');
output.push_str(data);
}
output.push_str("?>");
Ok(())
}
fn write_indent(config: &WriterConfig, depth: usize, output: &mut String) {
for _ in 0..depth {
output.push_str(config.indent());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Attribute, NamespaceDeclaration, QName};
use crate::testing::assert_xml_eq;
const SIMPLE_GOLDEN: &str = include_str!("../../tests/golden/writer_simple.xml");
const NAMESPACES_GOLDEN: &str = include_str!("../../tests/golden/writer_namespaces.xml");
const PRETTY_GOLDEN: &str = include_str!("../../tests/golden/writer_pretty.xml");
const PRETTY_MIXED_GOLDEN: &str = include_str!("../../tests/golden/writer_pretty_mixed.xml");
const PRETTY_CDATA_GOLDEN: &str = include_str!("../../tests/golden/writer_pretty_cdata.xml");
const PRETTY_STRUCTURAL_MISC_GOLDEN: &str =
include_str!("../../tests/golden/writer_pretty_structural_misc.xml");
fn qname(local: &str) -> QName {
QName::new(local).expect("valid qname")
}
fn simple_document() -> Document {
let mut document = Document::new();
let root = document.add_root_element(qname("Root")).expect("root");
let child = document.add_element(root, qname("Child")).expect("child");
document.add_text(child, "value").expect("text");
document
}
#[test]
fn writer_serializes_compact_xml() {
let document = simple_document();
let xml = to_string_compact(&document).expect("serialized XML");
assert_eq!(xml, "<Root><Child>value</Child></Root>");
}
#[test]
fn golden_simple_xml_matches_expected_file() {
let document = simple_document();
let xml = to_string_compact(&document).expect("serialized XML");
assert_xml_eq(SIMPLE_GOLDEN, &xml);
}
#[test]
fn golden_namespaces_xml_matches_expected_file() {
let mut document = Document::new();
let root = document
.add_root_element(QName::qualified("doc", "Root", "urn:doc").expect("qname"))
.expect("root");
document
.add_namespace_declaration(
root,
NamespaceDeclaration::default("urn:default").expect("namespace"),
)
.expect("namespace declaration");
document
.add_namespace_declaration(
root,
NamespaceDeclaration::prefixed("doc", "urn:doc").expect("namespace"),
)
.expect("namespace declaration");
document
.add_attribute(
root,
Attribute::new(
QName::qualified("doc", "id", "urn:doc").expect("qname"),
"123",
),
)
.expect("attribute");
let xml = to_string_compact(&document).expect("serialized XML");
assert_xml_eq(NAMESPACES_GOLDEN, &xml);
}
#[test]
fn golden_pretty_xml_matches_expected_file() {
let document = simple_document();
let xml = to_string_pretty(&document, WriterConfig::pretty()).expect("serialized XML");
assert_xml_eq(PRETTY_GOLDEN, &xml);
}
#[test]
fn golden_pretty_preserves_mixed_content() {
let mut document = Document::new();
let root = document.add_root_element(qname("Paragraph")).expect("root");
document.add_text(root, "Hello ").expect("text");
let bold = document.add_element(root, qname("Bold")).expect("bold");
document.add_text(bold, "world").expect("bold text");
document.add_text(root, "!").expect("tail text");
let xml = to_string_pretty(&document, WriterConfig::pretty()).expect("serialized XML");
assert_xml_eq(PRETTY_MIXED_GOLDEN, &xml);
}
#[test]
fn golden_pretty_preserves_cdata_content() {
let mut document = Document::new();
let root = document.add_root_element(qname("Script")).expect("root");
document
.add_cdata(root, "if (a < b) { keep(); }")
.expect("cdata");
let xml = to_string_pretty(&document, WriterConfig::pretty()).expect("serialized XML");
assert_xml_eq(PRETTY_CDATA_GOLDEN, &xml);
}
#[test]
fn golden_pretty_indents_structural_comments_and_processing_instructions() {
let mut document = Document::new();
let root = document.add_root_element(qname("Root")).expect("root");
document.add_comment(root, "generated").expect("comment");
document
.add_processing_instruction(root, "xml-stylesheet", Some("href=\"style.xsl\""))
.expect("processing instruction");
let child = document.add_element(root, qname("Child")).expect("child");
document.add_text(child, "value").expect("text");
let xml = to_string_pretty(&document, WriterConfig::pretty()).expect("serialized XML");
assert_xml_eq(PRETTY_STRUCTURAL_MISC_GOLDEN, &xml);
}
#[test]
fn escaping_text_escapes_xml_special_characters() {
let mut document = Document::new();
let root = document.add_root_element(qname("Root")).expect("root");
document.add_text(root, "a & b < c > d").expect("text");
let xml = to_string_compact(&document).expect("serialized XML");
assert_eq!(xml, "<Root>a & b < c > d</Root>");
}
#[test]
fn escaping_attributes_escapes_xml_special_characters() {
let mut document = Document::new();
let root = document.add_root_element(qname("Root")).expect("root");
document
.add_attribute(root, Attribute::new(qname("value"), "a & b < c > \"d\""))
.expect("attribute");
let xml = to_string_compact(&document).expect("serialized XML");
assert_eq!(xml, "<Root value=\"a & b < c > "d"\"/>");
}
#[test]
fn writer_config_controls_xml_declaration_and_default_encoding() {
let document = simple_document();
let config = WriterConfig::compact().with_xml_declaration(true);
let xml = to_string_with_config(&document, &config).expect("serialized XML");
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert_eq!(config.encoding(), "UTF-8");
}
#[test]
fn writer_serializes_empty_elements_as_self_closing_tags() {
let mut document = Document::new();
document.add_root_element(qname("Empty")).expect("root");
let xml = to_string_compact(&document).expect("serialized XML");
assert_eq!(xml, "<Empty/>");
}
#[test]
fn writer_keeps_output_deterministic_and_does_not_modify_document() {
let document = simple_document();
let first = to_string_compact(&document).expect("serialized XML");
let second = to_string_compact(&document).expect("serialized XML");
assert_eq!(first, second);
assert_eq!(
document
.path(document.root().expect("root"))
.unwrap()
.to_string(),
"/Root"
);
}
}