pub const DECLARATION: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
#[derive(Debug, Clone)]
enum Node {
Element(Element),
Text(String),
}
#[derive(Debug, Clone)]
pub struct Element {
name: String,
attributes: Vec<(String, String)>,
children: Vec<Node>,
}
impl Element {
#[must_use]
pub fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
attributes: Vec::new(),
children: Vec::new(),
}
}
#[must_use]
pub fn attr(mut self, name: &str, value: &str) -> Self {
self.attributes.push((name.to_owned(), value.to_owned()));
self
}
#[must_use]
pub fn text(mut self, text: &str) -> Self {
self.children.push(Node::Text(text.to_owned()));
self
}
#[must_use]
pub fn child(mut self, child: Element) -> Self {
self.children.push(Node::Element(child));
self
}
pub fn push(&mut self, child: Element) {
self.children.push(Node::Element(child));
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
self.render_into(&mut out);
out
}
#[must_use]
pub fn render_document(&self) -> String {
let mut out = String::from(DECLARATION);
self.render_into(&mut out);
out
}
#[must_use]
pub fn render_document_pretty(&self) -> String {
let mut out = String::from(DECLARATION);
self.render_pretty(&mut out, 0);
out.push('\n');
out
}
fn render_open_tag(&self, out: &mut String) {
out.push('<');
out.push_str(&self.name);
for (name, value) in &self.attributes {
out.push(' ');
out.push_str(name);
out.push_str("=\"");
escape_attribute(value, out);
out.push('"');
}
}
fn render_into(&self, out: &mut String) {
self.render_open_tag(out);
if self.children.is_empty() {
out.push_str(" />");
return;
}
out.push('>');
for child in &self.children {
match child {
Node::Element(element) => element.render_into(out),
Node::Text(text) => escape_text(text, out),
}
}
out.push_str("</");
out.push_str(&self.name);
out.push('>');
}
fn render_pretty(&self, out: &mut String, depth: usize) {
for _ in 0..depth {
out.push_str(" ");
}
self.render_open_tag(out);
if self.children.is_empty() {
out.push_str(" />");
return;
}
if !self.children.iter().any(|c| matches!(c, Node::Element(_))) {
out.push('>');
for child in &self.children {
match child {
Node::Text(text) => escape_text(text, out),
Node::Element(_) => {}
}
}
out.push_str("</");
out.push_str(&self.name);
out.push('>');
return;
}
out.push_str(">\n");
for child in &self.children {
match child {
Node::Element(element) => {
element.render_pretty(out, depth + 1);
out.push('\n');
}
Node::Text(text) => {
for _ in 0..=depth {
out.push_str(" ");
}
escape_text(text, out);
out.push('\n');
}
}
}
for _ in 0..depth {
out.push_str(" ");
}
out.push_str("</");
out.push_str(&self.name);
out.push('>');
}
}
#[must_use]
pub fn is_xml_char(ch: char) -> bool {
matches!(ch, '\t' | '\n' | '\r')
|| ('\u{20}'..='\u{d7ff}').contains(&ch)
|| ('\u{e000}'..='\u{fffd}').contains(&ch)
|| ch >= '\u{10000}'
}
pub fn escape_text(text: &str, out: &mut String) {
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
other if is_xml_char(other) => out.push(other),
_ => {}
}
}
}
pub fn escape_attribute(value: &str, out: &mut String) {
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\n' => out.push_str(" "),
'\r' => out.push_str(" "),
'\t' => out.push_str("	"),
other if is_xml_char(other) => out.push(other),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::{Element, is_xml_char};
#[test]
fn empty_element_self_closes() {
assert_eq!(Element::new("br").render(), "<br />");
}
#[test]
fn forbidden_control_chars_are_dropped_from_text_and_attributes() {
let element = Element::new("p")
.attr("data-x", "a\u{0}b\u{1}c\t")
.text("x\u{0}y\u{b}z\u{c}w\u{fffe}");
assert_eq!(element.render(), "<p data-x=\"abc	\">xyzw</p>");
}
#[test]
fn xml_char_predicate_covers_the_char_production() {
for forbidden in [
'\u{0}', '\u{1}', '\u{8}', '\u{b}', '\u{c}', '\u{1f}', '\u{fffe}', '\u{ffff}',
] {
assert!(!is_xml_char(forbidden), "{forbidden:?} must be rejected");
}
for allowed in ['\t', '\n', '\r', ' ', 'a', '\u{fffd}', '\u{10000}'] {
assert!(is_xml_char(allowed), "{allowed:?} must be accepted");
}
}
#[test]
fn attributes_keep_insertion_order_and_escape() {
let element = Element::new("a")
.attr("href", "x?q=1&y=\"2\"")
.attr("rel", "next")
.text("A & B < C");
assert_eq!(
element.render(),
"<a href=\"x?q=1&y="2"\" rel=\"next\">A & B < C</a>"
);
}
#[test]
fn nested_children_render_inline() {
let element =
Element::new("ol").child(Element::new("li").child(Element::new("b").text("bold")));
assert_eq!(element.render(), "<ol><li><b>bold</b></li></ol>");
}
#[test]
fn render_document_prepends_declaration() {
let doc = Element::new("root").render_document();
assert!(doc.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root />"));
}
#[test]
fn pretty_layout_indents_element_children_and_inlines_text() {
let doc = Element::new("root")
.child(Element::new("group").child(Element::new("item").attr("id", "1").text("hi")))
.child(Element::new("empty"))
.render_document_pretty();
assert_eq!(
doc,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<root>\n \
<group>\n \
<item id=\"1\">hi</item>\n \
</group>\n \
<empty />\n\
</root>\n"
);
}
}