Skip to main content

zerodds_xml_wire/
emitter.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Streaming-XML-Emitter — DDS-XML 1.0 §6.3.
4//!
5//! Spec §6.3: XML-Output muss XML 1.0 conform sein, mit korrekter
6//! Entity-Encoding fuer `<`, `>`, `&`, `"`, `'`.
7
8use alloc::string::String;
9use alloc::vec::Vec;
10
11/// Emitter-Fehler.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum EmitError {
14    /// `end_element` ohne entsprechendes `start_element`.
15    UnbalancedEnd,
16    /// `start_element` mit ungueltigem Tag-Namen (nicht XML-NameStartChar).
17    InvalidTagName(String),
18}
19
20impl core::fmt::Display for EmitError {
21    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22        match self {
23            Self::UnbalancedEnd => f.write_str("end_element without matching start"),
24            Self::InvalidTagName(s) => write!(f, "invalid tag name `{s}`"),
25        }
26    }
27}
28
29#[cfg(feature = "std")]
30impl std::error::Error for EmitError {}
31
32/// XML-Emitter.
33#[derive(Debug, Default, Clone, PartialEq, Eq)]
34pub struct XmlEmitter {
35    buf: String,
36    stack: Vec<String>,
37}
38
39impl XmlEmitter {
40    /// Konstruktor.
41    #[must_use]
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// `<?xml version="1.0" encoding="UTF-8"?>`.
47    pub fn declaration(&mut self) {
48        self.buf
49            .push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
50    }
51
52    /// `<name>` oder `<name attr="...">`.
53    ///
54    /// # Errors
55    /// `InvalidTagName` wenn der Tag-Name leer oder mit Zahl beginnt.
56    pub fn start_element(&mut self, name: &str, attrs: &[(&str, &str)]) -> Result<(), EmitError> {
57        if !is_valid_name(name) {
58            return Err(EmitError::InvalidTagName(name.into()));
59        }
60        self.buf.push('<');
61        self.buf.push_str(name);
62        for (k, v) in attrs {
63            self.buf.push(' ');
64            self.buf.push_str(k);
65            self.buf.push_str("=\"");
66            encode_to(&mut self.buf, v);
67            self.buf.push('"');
68        }
69        self.buf.push('>');
70        self.stack.push(name.into());
71        Ok(())
72    }
73
74    /// `</name>`.
75    ///
76    /// # Errors
77    /// `UnbalancedEnd` wenn kein offenes Element auf dem Stack.
78    pub fn end_element(&mut self) -> Result<(), EmitError> {
79        let name = self.stack.pop().ok_or(EmitError::UnbalancedEnd)?;
80        self.buf.push_str("</");
81        self.buf.push_str(&name);
82        self.buf.push('>');
83        Ok(())
84    }
85
86    /// Selbstschliessendes Element `<name attr="..." />`.
87    ///
88    /// # Errors
89    /// `InvalidTagName` wenn der Tag-Name ungueltig.
90    pub fn empty_element(&mut self, name: &str, attrs: &[(&str, &str)]) -> Result<(), EmitError> {
91        if !is_valid_name(name) {
92            return Err(EmitError::InvalidTagName(name.into()));
93        }
94        self.buf.push('<');
95        self.buf.push_str(name);
96        for (k, v) in attrs {
97            self.buf.push(' ');
98            self.buf.push_str(k);
99            self.buf.push_str("=\"");
100            encode_to(&mut self.buf, v);
101            self.buf.push('"');
102        }
103        self.buf.push_str("/>");
104        Ok(())
105    }
106
107    /// Text-Inhalt mit Entity-Encoding.
108    pub fn text(&mut self, content: &str) {
109        encode_to(&mut self.buf, content);
110    }
111
112    /// `<![CDATA[...]]>`. CDATA-End-Sequenzen `]]>` werden gesplittet.
113    pub fn cdata(&mut self, content: &str) {
114        self.buf.push_str("<![CDATA[");
115        // Spec XML 1.0 §2.7: `]]>` in CDATA splitten zu `]]]]><![CDATA[>`
116        let safe = content.replace("]]>", "]]]]><![CDATA[>");
117        self.buf.push_str(&safe);
118        self.buf.push_str("]]>");
119    }
120
121    /// Konsumiert den Emitter und gibt den XML-Output.
122    #[must_use]
123    pub fn finish(self) -> String {
124        self.buf
125    }
126
127    /// Output-Length.
128    #[must_use]
129    pub fn len(&self) -> usize {
130        self.buf.len()
131    }
132
133    /// `true` wenn keine Bytes emittiert.
134    #[must_use]
135    pub fn is_empty(&self) -> bool {
136        self.buf.is_empty()
137    }
138}
139
140fn encode_to(buf: &mut String, s: &str) {
141    for c in s.chars() {
142        match c {
143            '&' => buf.push_str("&amp;"),
144            '<' => buf.push_str("&lt;"),
145            '>' => buf.push_str("&gt;"),
146            '"' => buf.push_str("&quot;"),
147            '\'' => buf.push_str("&apos;"),
148            _ => buf.push(c),
149        }
150    }
151}
152
153fn is_valid_name(s: &str) -> bool {
154    let mut chars = s.chars();
155    match chars.next() {
156        Some(c) if c.is_alphabetic() || c == '_' => {}
157        _ => return false,
158    }
159    chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':')
160}
161
162#[cfg(test)]
163#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn declaration_emits_xml_pi() {
169        let mut e = XmlEmitter::new();
170        e.declaration();
171        assert!(e.finish().starts_with("<?xml"));
172    }
173
174    #[test]
175    fn start_text_end_round_trip() {
176        let mut e = XmlEmitter::new();
177        e.start_element("a", &[]).unwrap();
178        e.text("hello");
179        e.end_element().unwrap();
180        assert_eq!(e.finish(), "<a>hello</a>");
181    }
182
183    #[test]
184    fn attributes_get_encoded() {
185        let mut e = XmlEmitter::new();
186        e.empty_element("a", &[("k", "v&\"")]).unwrap();
187        let out = e.finish();
188        assert!(out.contains("k=\"v&amp;&quot;\""));
189    }
190
191    #[test]
192    fn text_entities_encoded() {
193        let mut e = XmlEmitter::new();
194        e.start_element("a", &[]).unwrap();
195        e.text("<b&c>");
196        e.end_element().unwrap();
197        assert_eq!(e.finish(), "<a>&lt;b&amp;c&gt;</a>");
198    }
199
200    #[test]
201    fn cdata_splits_terminator() {
202        let mut e = XmlEmitter::new();
203        e.cdata("contains ]]> within");
204        let out = e.finish();
205        // `]]>` darf nicht roh erscheinen ohne CDATA-Re-Open
206        assert!(out.contains("]]]]><![CDATA[>"));
207    }
208
209    #[test]
210    fn unbalanced_end_rejected() {
211        let mut e = XmlEmitter::new();
212        assert!(e.end_element().is_err());
213    }
214
215    #[test]
216    fn invalid_tag_name_rejected() {
217        let mut e = XmlEmitter::new();
218        assert!(matches!(
219            e.start_element("123abc", &[]),
220            Err(EmitError::InvalidTagName(_))
221        ));
222        assert!(matches!(
223            e.start_element("", &[]),
224            Err(EmitError::InvalidTagName(_))
225        ));
226    }
227
228    #[test]
229    fn empty_element_self_closes() {
230        let mut e = XmlEmitter::new();
231        e.empty_element("br", &[]).unwrap();
232        assert_eq!(e.finish(), "<br/>");
233    }
234
235    #[test]
236    fn nested_elements_emit_correctly() {
237        let mut e = XmlEmitter::new();
238        e.start_element("a", &[]).unwrap();
239        e.start_element("b", &[]).unwrap();
240        e.text("x");
241        e.end_element().unwrap();
242        e.end_element().unwrap();
243        assert_eq!(e.finish(), "<a><b>x</b></a>");
244    }
245}