Skip to main content

carta_core/container/
xml.rs

1//! A small XML emitter that always produces well-formed output.
2//!
3//! Markup is modelled as a tree of [`Element`]s carrying attributes, text, and pre-serialized raw
4//! fragments. Rendering escapes text and attribute values and self-closes empty elements, so the
5//! result is well-formed by construction. Attributes and children keep insertion order, keeping
6//! output byte-reproducible.
7
8/// The XML declaration a document part begins with.
9pub const DECLARATION: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
10
11/// A node within an [`Element`]: a child element or escaped text.
12#[derive(Debug, Clone)]
13enum Node {
14    Element(Element),
15    Text(String),
16}
17
18/// An XML element with ordered attributes and children.
19#[derive(Debug, Clone)]
20pub struct Element {
21    name: String,
22    attributes: Vec<(String, String)>,
23    children: Vec<Node>,
24}
25
26impl Element {
27    /// An empty element with the given tag name.
28    #[must_use]
29    pub fn new(name: &str) -> Self {
30        Self {
31            name: name.to_owned(),
32            attributes: Vec::new(),
33            children: Vec::new(),
34        }
35    }
36
37    /// Adds an attribute (escaped on render). Repeats are kept in order.
38    #[must_use]
39    pub fn attr(mut self, name: &str, value: &str) -> Self {
40        self.attributes.push((name.to_owned(), value.to_owned()));
41        self
42    }
43
44    /// Appends escaped text content.
45    #[must_use]
46    pub fn text(mut self, text: &str) -> Self {
47        self.children.push(Node::Text(text.to_owned()));
48        self
49    }
50
51    /// Appends a child element.
52    #[must_use]
53    pub fn child(mut self, child: Element) -> Self {
54        self.children.push(Node::Element(child));
55        self
56    }
57
58    /// Appends a child element in place.
59    pub fn push(&mut self, child: Element) {
60        self.children.push(Node::Element(child));
61    }
62
63    /// Serializes this element and its descendants. No XML declaration is prepended; a caller that
64    /// wants one writes [`DECLARATION`] first.
65    #[must_use]
66    pub fn render(&self) -> String {
67        let mut out = String::new();
68        self.render_into(&mut out);
69        out
70    }
71
72    /// Serializes a complete document part: the XML [`DECLARATION`], then this element.
73    #[must_use]
74    pub fn render_document(&self) -> String {
75        let mut out = String::from(DECLARATION);
76        self.render_into(&mut out);
77        out
78    }
79
80    /// Serializes a complete document part with two-space indentation: the XML [`DECLARATION`], this
81    /// element laid out over multiple lines, and a trailing newline. An element holding only text
82    /// stays on one line; an element with child elements puts each child on its own indented line.
83    #[must_use]
84    pub fn render_document_pretty(&self) -> String {
85        let mut out = String::from(DECLARATION);
86        self.render_pretty(&mut out, 0);
87        out.push('\n');
88        out
89    }
90
91    fn render_open_tag(&self, out: &mut String) {
92        out.push('<');
93        out.push_str(&self.name);
94        for (name, value) in &self.attributes {
95            out.push(' ');
96            out.push_str(name);
97            out.push_str("=\"");
98            escape_attribute(value, out);
99            out.push('"');
100        }
101    }
102
103    fn render_into(&self, out: &mut String) {
104        self.render_open_tag(out);
105        if self.children.is_empty() {
106            out.push_str(" />");
107            return;
108        }
109        out.push('>');
110        for child in &self.children {
111            match child {
112                Node::Element(element) => element.render_into(out),
113                Node::Text(text) => escape_text(text, out),
114            }
115        }
116        out.push_str("</");
117        out.push_str(&self.name);
118        out.push('>');
119    }
120
121    fn render_pretty(&self, out: &mut String, depth: usize) {
122        for _ in 0..depth {
123            out.push_str("  ");
124        }
125        self.render_open_tag(out);
126        if self.children.is_empty() {
127            out.push_str(" />");
128            return;
129        }
130        // An element whose content is purely text stays on one line; only child elements force the
131        // indented, multi-line layout.
132        if !self.children.iter().any(|c| matches!(c, Node::Element(_))) {
133            out.push('>');
134            for child in &self.children {
135                match child {
136                    Node::Text(text) => escape_text(text, out),
137                    Node::Element(_) => {}
138                }
139            }
140            out.push_str("</");
141            out.push_str(&self.name);
142            out.push('>');
143            return;
144        }
145        out.push_str(">\n");
146        for child in &self.children {
147            match child {
148                Node::Element(element) => {
149                    element.render_pretty(out, depth + 1);
150                    out.push('\n');
151                }
152                Node::Text(text) => {
153                    for _ in 0..=depth {
154                        out.push_str("  ");
155                    }
156                    escape_text(text, out);
157                    out.push('\n');
158                }
159            }
160        }
161        for _ in 0..depth {
162            out.push_str("  ");
163        }
164        out.push_str("</");
165        out.push_str(&self.name);
166        out.push('>');
167    }
168}
169
170/// Whether `ch` may appear in an XML 1.0 document. Tab, newline and carriage return are the only
171/// C0 controls permitted; every other control below `U+0020`, the surrogate range, and the two
172/// `U+FFFE`/`U+FFFF` noncharacters are forbidden and cannot be represented even as a character
173/// reference. Characters failing this test are dropped by the escapers so the emitted markup stays
174/// well-formed whatever the caller supplies.
175#[must_use]
176pub fn is_xml_char(ch: char) -> bool {
177    matches!(ch, '\t' | '\n' | '\r')
178        || ('\u{20}'..='\u{d7ff}').contains(&ch)
179        || ('\u{e000}'..='\u{fffd}').contains(&ch)
180        || ch >= '\u{10000}'
181}
182
183/// Escapes text content, the minimal set that keeps character data unambiguous. Characters XML
184/// forbids are dropped, so the output is well-formed regardless of the input.
185pub fn escape_text(text: &str, out: &mut String) {
186    for ch in text.chars() {
187        match ch {
188            '&' => out.push_str("&amp;"),
189            '<' => out.push_str("&lt;"),
190            '>' => out.push_str("&gt;"),
191            other if is_xml_char(other) => out.push(other),
192            _ => {}
193        }
194    }
195}
196
197/// Escapes an attribute value, additionally guarding the quote and whitespace that would break a
198/// double-quoted attribute. Characters XML forbids are dropped, so the output is well-formed
199/// regardless of the input.
200pub fn escape_attribute(value: &str, out: &mut String) {
201    for ch in value.chars() {
202        match ch {
203            '&' => out.push_str("&amp;"),
204            '<' => out.push_str("&lt;"),
205            '>' => out.push_str("&gt;"),
206            '"' => out.push_str("&quot;"),
207            '\n' => out.push_str("&#10;"),
208            '\r' => out.push_str("&#13;"),
209            '\t' => out.push_str("&#9;"),
210            other if is_xml_char(other) => out.push(other),
211            _ => {}
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{Element, is_xml_char};
219
220    #[test]
221    fn empty_element_self_closes() {
222        assert_eq!(Element::new("br").render(), "<br />");
223    }
224
225    #[test]
226    fn forbidden_control_chars_are_dropped_from_text_and_attributes() {
227        // NUL, start-of-heading, vertical tab, form feed and the U+FFFE noncharacter cannot appear
228        // in XML at all; tab, newline and carriage return are the only permitted C0 controls.
229        let element = Element::new("p")
230            .attr("data-x", "a\u{0}b\u{1}c\t")
231            .text("x\u{0}y\u{b}z\u{c}w\u{fffe}");
232        assert_eq!(element.render(), "<p data-x=\"abc&#9;\">xyzw</p>");
233    }
234
235    #[test]
236    fn xml_char_predicate_covers_the_char_production() {
237        for forbidden in [
238            '\u{0}', '\u{1}', '\u{8}', '\u{b}', '\u{c}', '\u{1f}', '\u{fffe}', '\u{ffff}',
239        ] {
240            assert!(!is_xml_char(forbidden), "{forbidden:?} must be rejected");
241        }
242        for allowed in ['\t', '\n', '\r', ' ', 'a', '\u{fffd}', '\u{10000}'] {
243            assert!(is_xml_char(allowed), "{allowed:?} must be accepted");
244        }
245    }
246
247    #[test]
248    fn attributes_keep_insertion_order_and_escape() {
249        let element = Element::new("a")
250            .attr("href", "x?q=1&y=\"2\"")
251            .attr("rel", "next")
252            .text("A & B < C");
253        assert_eq!(
254            element.render(),
255            "<a href=\"x?q=1&amp;y=&quot;2&quot;\" rel=\"next\">A &amp; B &lt; C</a>"
256        );
257    }
258
259    #[test]
260    fn nested_children_render_inline() {
261        let element =
262            Element::new("ol").child(Element::new("li").child(Element::new("b").text("bold")));
263        assert_eq!(element.render(), "<ol><li><b>bold</b></li></ol>");
264    }
265
266    #[test]
267    fn render_document_prepends_declaration() {
268        let doc = Element::new("root").render_document();
269        assert!(doc.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root />"));
270    }
271
272    #[test]
273    fn pretty_layout_indents_element_children_and_inlines_text() {
274        let doc = Element::new("root")
275            .child(Element::new("group").child(Element::new("item").attr("id", "1").text("hi")))
276            .child(Element::new("empty"))
277            .render_document_pretty();
278        assert_eq!(
279            doc,
280            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
281             <root>\n  \
282             <group>\n    \
283             <item id=\"1\">hi</item>\n  \
284             </group>\n  \
285             <empty />\n\
286             </root>\n"
287        );
288    }
289}