Skip to main content

bpmnkit_xml/
lib.rs

1//! XML reading and writing with the fidelity `@bpmnkit/core` (`packages/core/src/xml/xml-parser.ts`)
2//! has: qualified names keep their prefixes, attributes keep their source order, and nothing is
3//! namespace-resolved, so unknown content can be written back as it was read.
4//!
5//! Tokenising is done by `quick-xml` (borrowed events, builds for `wasm32-unknown-unknown`, and
6//! already in reebe's dependency tree). What the TS scanner decides itself is decided here too:
7//! entity decoding (the five predefined entities and character references; anything else is kept
8//! as written), CDATA kept raw, comments and processing instructions dropped, text concatenated
9//! per element, and everything after the root element ignored.
10//!
11//! ```
12//! use bpmnkit_xml::{parse_xml, serialize_xml};
13//!
14//! let root = parse_xml(r#"<a:root xmlns:a="urn:x" b="1"><a:child>t</a:child></a:root>"#).unwrap();
15//! assert_eq!(root.name, "a:root"); // prefixes are kept, not resolved
16//! assert_eq!(root.local_name(), "root");
17//! assert_eq!(root.attr("b"), Some("1"));
18//! assert!(serialize_xml(&root).contains("<a:child>t</a:child>"));
19//! ```
20
21use indexmap::IndexMap;
22use quick_xml::events::{BytesStart, Event};
23use quick_xml::Reader;
24use serde::{Deserialize, Serialize};
25use std::fmt;
26
27/// Attributes in source order. Qualified keys (`xsi:type`) are kept as written.
28pub type Attrs = IndexMap<String, String>;
29
30/// An element with namespace prefixes preserved. The JSON shape matches TS's `XmlElement`.
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
32pub struct XmlElement {
33    /// Qualified name, e.g. `zeebe:taskDefinition`.
34    pub name: String,
35    pub attributes: Attrs,
36    pub children: Vec<XmlElement>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub text: Option<String>,
39}
40
41impl XmlElement {
42    pub fn new(name: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            ..Self::default()
46        }
47    }
48
49    /// The name without its prefix.
50    pub fn local_name(&self) -> &str {
51        local_name(&self.name)
52    }
53
54    pub fn attr(&self, name: &str) -> Option<&str> {
55        self.attributes.get(name).map(String::as_str)
56    }
57}
58
59/// `name` without its prefix (`bpmn:task` → `task`).
60pub fn local_name(name: &str) -> &str {
61    match name.find(':') {
62        Some(i) => &name[i + 1..],
63        None => name,
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct XmlError(pub String);
69
70impl fmt::Display for XmlError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl std::error::Error for XmlError {}
77
78/// What a sink wants the scanner to do with an element after its start tag.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Visit {
81    /// Report child elements and character data.
82    All,
83    /// Skip the content without decoding it; `end` is not called for the element.
84    Skip,
85    /// Report child elements, drop character data.
86    ElementsOnly,
87}
88
89/// Receives the events of one document from [`scan_xml`].
90pub trait XmlSink {
91    /// `name` is qualified, `local` has the prefix removed; `end` follows a self-closing
92    /// tag immediately unless the sink returned [`Visit::Skip`].
93    fn start(&mut self, name: &str, local: &str, attributes: Attrs, self_closing: bool) -> Visit;
94    /// Character data or CDATA directly inside the current element, entities decoded.
95    fn text(&mut self, text: &str);
96    fn end(&mut self, name: &str);
97}
98
99fn err(e: impl fmt::Display) -> XmlError {
100    XmlError(e.to_string())
101}
102
103fn utf8(bytes: &[u8]) -> Result<&str, XmlError> {
104    std::str::from_utf8(bytes).map_err(err)
105}
106
107fn attributes(e: &BytesStart<'_>) -> Result<Attrs, XmlError> {
108    let mut attrs = Attrs::new();
109    // Duplicates are not an error in TS: the last value wins, at the first position.
110    for a in e.attributes().with_checks(false) {
111        let a = a.map_err(err)?;
112        let key = utf8(a.key.as_ref())?;
113        let value = decode_entities(utf8(&a.value)?);
114        attrs.insert(key.to_owned(), value);
115    }
116    Ok(attrs)
117}
118
119/// Scans `xml`, reporting the root element and everything inside it to `sink`. Returns false
120/// when the document has no root element. Content after the root element is ignored.
121pub fn scan_xml(xml: &str, sink: &mut impl XmlSink) -> Result<bool, XmlError> {
122    let mut reader = Reader::from_str(xml);
123    let config = reader.config_mut();
124    config.trim_text(false);
125    config.check_end_names = true;
126    config.expand_empty_elements = false;
127    // Whether each open element wants character data.
128    let mut want_text: Vec<bool> = Vec::new();
129    let mut seen_root = false;
130    loop {
131        let event = reader.read_event().map_err(err)?;
132        match event {
133            Event::Start(e) => {
134                let name = utf8(e.name().into_inner())?;
135                let visit = sink.start(name, local_name(name), attributes(&e)?, false);
136                seen_root = true;
137                if visit == Visit::Skip {
138                    reader.read_to_end(e.name()).map_err(err)?;
139                    if want_text.is_empty() {
140                        return Ok(true);
141                    }
142                } else {
143                    want_text.push(visit == Visit::All);
144                }
145            }
146            Event::Empty(e) => {
147                let name = utf8(e.name().into_inner())?;
148                if sink.start(name, local_name(name), attributes(&e)?, true) != Visit::Skip {
149                    sink.end(name);
150                }
151                if want_text.is_empty() {
152                    return Ok(true);
153                }
154            }
155            Event::End(e) => {
156                if want_text.pop().is_none() {
157                    return Err(XmlError(format!(
158                        "Unexpected closing tag </{}>",
159                        String::from_utf8_lossy(e.name().into_inner())
160                    )));
161                }
162                sink.end(utf8(e.name().into_inner())?);
163                if want_text.is_empty() {
164                    return Ok(true);
165                }
166            }
167            Event::Text(t) => {
168                if want_text.last() == Some(&true) {
169                    sink.text(&decode_entities(utf8(&t)?));
170                }
171            }
172            Event::CData(t) => {
173                if want_text.last() == Some(&true) {
174                    sink.text(utf8(&t)?);
175                }
176            }
177            Event::Eof => {
178                if !want_text.is_empty() {
179                    return Err(XmlError(format!(
180                        "Expected \"</\" at position {}",
181                        reader.buffer_position()
182                    )));
183                }
184                return Ok(seen_root);
185            }
186            // Comments, processing instructions, the declaration and DOCTYPE carry nothing.
187            _ => {}
188        }
189    }
190}
191
192/// Parses `xml` into an element tree.
193pub fn parse_xml(xml: &str) -> Result<XmlElement, XmlError> {
194    let mut b = TreeBuilder::default();
195    if !scan_xml(xml, &mut b)? || b.root.is_none() {
196        return Err(XmlError(
197            "Failed to parse XML: no root element found".into(),
198        ));
199    }
200    Ok(b.root.unwrap_or_default())
201}
202
203#[derive(Default)]
204struct TreeBuilder {
205    root: Option<XmlElement>,
206    stack: Vec<XmlElement>,
207}
208
209impl XmlSink for TreeBuilder {
210    fn start(&mut self, name: &str, _: &str, attributes: Attrs, _: bool) -> Visit {
211        self.stack.push(XmlElement {
212            name: name.to_owned(),
213            attributes,
214            children: vec![],
215            text: None,
216        });
217        Visit::All
218    }
219
220    fn text(&mut self, text: &str) {
221        if let Some(el) = self.stack.last_mut() {
222            el.text.get_or_insert_with(String::new).push_str(text);
223        }
224    }
225
226    fn end(&mut self, _: &str) {
227        let Some(el) = self.stack.pop() else { return };
228        match self.stack.last_mut() {
229            Some(parent) => parent.children.push(el),
230            None => self.root = Some(el),
231        }
232    }
233}
234
235/// Decodes `&amp; &lt; &gt; &quot; &apos;` and numeric character references. Other `&...;`
236/// sequences are left as written, as in TS.
237pub fn decode_entities(s: &str) -> String {
238    if !s.contains('&') {
239        return s.to_owned();
240    }
241    let mut out = String::with_capacity(s.len());
242    let mut rest = s;
243    while let Some(i) = rest.find('&') {
244        out.push_str(&rest[..i]);
245        rest = &rest[i..];
246        let decoded = rest.find(';').and_then(|end| {
247            let ent = &rest[1..end];
248            let ch = match ent {
249                "amp" => Some('&'),
250                "lt" => Some('<'),
251                "gt" => Some('>'),
252                "quot" => Some('"'),
253                "apos" => Some('\''),
254                _ => {
255                    let code = if let Some(hex) = ent.strip_prefix("#x") {
256                        (!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()))
257                            .then(|| u32::from_str_radix(hex, 16).ok())
258                            .flatten()
259                    } else if let Some(dec) = ent.strip_prefix('#') {
260                        (!dec.is_empty() && dec.bytes().all(|b| b.is_ascii_digit()))
261                            .then(|| dec.parse::<u32>().ok())
262                            .flatten()
263                    } else {
264                        None
265                    };
266                    code.map(|c| char::from_u32(c).unwrap_or('\u{FFFD}'))
267                }
268            };
269            ch.map(|c| (c, end))
270        });
271        match decoded {
272            Some((c, end)) => {
273                out.push(c);
274                rest = &rest[end + 1..];
275            }
276            None => {
277                out.push('&');
278                rest = &rest[1..];
279            }
280        }
281    }
282    out.push_str(rest);
283    out
284}
285
286/// Escapes a value for use between double quotes, including the whitespace an XML parser would
287/// normalise.
288pub fn escape_attr(value: &str, out: &mut String) {
289    for c in value.chars() {
290        match c {
291            '&' => out.push_str("&amp;"),
292            '<' => out.push_str("&lt;"),
293            '"' => out.push_str("&quot;"),
294            '\n' => out.push_str("&#10;"),
295            '\r' => out.push_str("&#13;"),
296            '\t' => out.push_str("&#9;"),
297            c => out.push(c),
298        }
299    }
300}
301
302/// Escapes a value for use as character data.
303pub fn escape_text(value: &str, out: &mut String) {
304    for c in value.chars() {
305        match c {
306            '&' => out.push_str("&amp;"),
307            '<' => out.push_str("&lt;"),
308            '>' => out.push_str("&gt;"),
309            c => out.push(c),
310        }
311    }
312}
313
314/// Writes `element` as a document with an XML declaration, two-space indentation and a
315/// trailing newline, byte-identical to TS's `serializeXml`.
316pub fn serialize_xml(element: &XmlElement) -> String {
317    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
318    write_element(element, 0, &mut out);
319    out.push('\n');
320    out
321}
322
323/// Writes one element at `depth`, ending with a newline.
324pub fn write_element(el: &XmlElement, depth: usize, out: &mut String) {
325    for _ in 0..depth {
326        out.push_str("  ");
327    }
328    out.push('<');
329    out.push_str(&el.name);
330    for (k, v) in &el.attributes {
331        out.push(' ');
332        out.push_str(k);
333        out.push_str("=\"");
334        escape_attr(v, out);
335        out.push('"');
336    }
337    if el.children.is_empty() && el.text.is_none() {
338        out.push_str("/>\n");
339        return;
340    }
341    out.push('>');
342    if let Some(t) = &el.text {
343        escape_text(t, out);
344    }
345    if !el.children.is_empty() {
346        out.push('\n');
347        for c in &el.children {
348            write_element(c, depth + 1, out);
349        }
350        for _ in 0..depth {
351            out.push_str("  ");
352        }
353    }
354    out.push_str("</");
355    out.push_str(&el.name);
356    out.push_str(">\n");
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn decodes_known_entities_only() {
365        assert_eq!(decode_entities("a &amp; b &lt;&#65;&#x42;"), "a & b <AB");
366        assert_eq!(decode_entities("&nbsp; & &;"), "&nbsp; & &;");
367    }
368
369    #[test]
370    fn keeps_prefixes_order_and_text() {
371        let x = r#"<?xml version="1.0"?><!-- c --><a:root z="1" b:y="&quot;" x='2'>
372  t<![CDATA[<raw>]]><b:child/><c>x &amp; y</c></a:root><after/>"#;
373        let el = parse_xml(x).unwrap();
374        assert_eq!(el.name, "a:root");
375        let keys: Vec<_> = el.attributes.keys().cloned().collect();
376        assert_eq!(keys, ["z", "b:y", "x"]);
377        assert_eq!(el.attr("b:y"), Some("\""));
378        assert_eq!(el.text.as_deref(), Some("\n  t<raw>"));
379        assert_eq!(el.children[1].text.as_deref(), Some("x & y"));
380    }
381
382    #[test]
383    fn writes_like_ts() {
384        let el = parse_xml(r#"<r a="x&#10;"><c>1 &lt; 2</c><e/></r>"#).unwrap();
385        assert_eq!(
386            serialize_xml(&el),
387            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<r a=\"x&#10;\">\n  <c>1 &lt; 2</c>\n  <e/>\n</r>\n\n"
388        );
389    }
390
391    #[test]
392    fn errors() {
393        assert!(parse_xml("<a><b></a>").is_err());
394        assert!(parse_xml("<a>").is_err());
395        assert!(parse_xml("  ").is_err());
396    }
397
398    struct SkipB(Vec<String>);
399    impl XmlSink for SkipB {
400        fn start(&mut self, n: &str, l: &str, _: Attrs, _: bool) -> Visit {
401            self.0.push(format!("<{n}"));
402            if l == "b" {
403                Visit::Skip
404            } else {
405                Visit::ElementsOnly
406            }
407        }
408        fn text(&mut self, t: &str) {
409            self.0.push(t.to_owned());
410        }
411        fn end(&mut self, n: &str) {
412            self.0.push(format!("</{n}"));
413        }
414    }
415
416    #[test]
417    fn skip_and_elements_only() {
418        let mut s = SkipB(vec![]);
419        assert!(scan_xml("<r>t<x:b><c/>u</x:b><d/></r>", &mut s).unwrap());
420        assert_eq!(s.0, ["<r", "<x:b", "<d", "</d", "</r"]);
421    }
422}