carta_core/container/
xml.rs1pub const DECLARATION: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
10
11#[derive(Debug, Clone)]
13enum Node {
14 Element(Element),
15 Text(String),
16}
17
18#[derive(Debug, Clone)]
20pub struct Element {
21 name: String,
22 attributes: Vec<(String, String)>,
23 children: Vec<Node>,
24}
25
26impl Element {
27 #[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 #[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 #[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 #[must_use]
53 pub fn child(mut self, child: Element) -> Self {
54 self.children.push(Node::Element(child));
55 self
56 }
57
58 pub fn push(&mut self, child: Element) {
60 self.children.push(Node::Element(child));
61 }
62
63 #[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 #[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 #[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 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#[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
183pub fn escape_text(text: &str, out: &mut String) {
186 for ch in text.chars() {
187 match ch {
188 '&' => out.push_str("&"),
189 '<' => out.push_str("<"),
190 '>' => out.push_str(">"),
191 other if is_xml_char(other) => out.push(other),
192 _ => {}
193 }
194 }
195}
196
197pub fn escape_attribute(value: &str, out: &mut String) {
201 for ch in value.chars() {
202 match ch {
203 '&' => out.push_str("&"),
204 '<' => out.push_str("<"),
205 '>' => out.push_str(">"),
206 '"' => out.push_str("""),
207 '\n' => out.push_str(" "),
208 '\r' => out.push_str(" "),
209 '\t' => out.push_str("	"),
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 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	\">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&y="2"\" rel=\"next\">A & B < 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}