Skip to main content

hl7_2_xml_lite_helper/
lib.rs

1//! The small, dependency-free XML reader the `hl7-2` crates share.
2//!
3//! The name says what it is for. Nothing here is HL7-specific, but the
4//! crate is scoped to serve `hl7-2-soap`, `hl7-2-from-xml-into-er7` and
5//! `hl7-2-from-xsd-into-json-dictionary`, and every trade-off below is
6//! chosen for the documents those read.
7//!
8//! It is not a general-purpose parser and does not try to be. It reads the
9//! subset that carries meaning in a data document — elements, attributes,
10//! text, and nesting — and skips the rest: comments, processing
11//! instructions, a `DOCTYPE`, and the XML declaration. There is no
12//! validation, no schema, no DTD, no namespace resolution, and no streaming.
13//!
14//! What it is for: reading a document produced by a system you are talking
15//! to, where you know which elements you want and simply need them out. A
16//! SOAP envelope, an XML Schema, an HL7 v2.xml message. For anything where
17//! the document is untrusted, unbounded, or genuinely unknown, use a real
18//! parser.
19//!
20//! ```
21//! let root = hl7_2_xml_lite_helper::parse(
22//!     r#"<order id="7"><item qty="2">widget</item></order>"#,
23//! )?;
24//! assert_eq!(root.attribute("id"), Some("7"));
25//! let item = root.child("item").unwrap();
26//! assert_eq!(item.attribute("qty"), Some("2"));
27//! assert_eq!(item.text, "widget");
28//! # Ok::<(), hl7_2_xml_lite_helper::Error>(())
29//! ```
30//!
31//! # Namespace prefixes are ignored, not resolved
32//!
33//! Elements and attributes are matched on their **local name**, so
34//! `soapenv:Body`, `soap:Body`, `SOAP-ENV:Body` and `Body` are the same
35//! element. This is the single most important thing to understand about
36//! this crate, and it is a deliberate trade: the prefix is chosen by
37//! whoever serialized the document, and code that insists on one prefix
38//! rejects valid documents from every other tool. A document that binds the
39//! same prefix to two namespaces, or relies on the distinction between two
40//! namespaces that happen to use the same local names, will be misread.
41//! Reach for a namespace-aware parser there.
42//!
43//! See `spec/index.md` for the exact rules (source of truth).
44
45#![warn(missing_docs, clippy::pedantic)]
46
47use std::collections::BTreeMap;
48use std::fmt;
49
50/// One element: its name, attributes, text, and children.
51///
52/// An element has both `text` and `children` because some documents use
53/// both, and dropping either would make this reader useless for one of the
54/// documents it is meant to read. Whitespace-only text beside children is
55/// dropped, because it is indentation rather than content (§3.3).
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct Element {
58    /// The tag as written, prefix included (`soapenv:Body`).
59    pub name: String,
60    /// Attributes, keyed by name as written, in name order.
61    pub attributes: BTreeMap<String, String>,
62    /// Text content, entity-decoded.
63    pub text: String,
64    /// Child elements, in document order.
65    pub children: Vec<Element>,
66}
67
68impl Element {
69    /// The tag without its namespace prefix: `Body` for `soapenv:Body`.
70    #[must_use]
71    pub fn local_name(&self) -> &str {
72        local_name(&self.name)
73    }
74
75    /// An attribute's value, by local name, ignoring any prefix.
76    #[must_use]
77    pub fn attribute(&self, name: &str) -> Option<&str> {
78        self.attributes
79            .iter()
80            .find(|(key, _)| local_name(key) == name)
81            .map(|(_, value)| value.as_str())
82    }
83
84    /// This element's text, or `None` when it has none.
85    ///
86    /// The distinction between empty and absent is the same one here; an
87    /// element with no text and one with empty text are not distinguishable
88    /// in XML without preserving the difference between `<a/>` and `<a></a>`,
89    /// which no document this crate is for depends on.
90    #[must_use]
91    pub fn text_opt(&self) -> Option<&str> {
92        Some(self.text.as_str()).filter(|text| !text.is_empty())
93    }
94
95    /// The first direct child with this local name.
96    #[must_use]
97    pub fn child<'a>(&'a self, name: &str) -> Option<&'a Element> {
98        self.children
99            .iter()
100            .find(|child| child.local_name() == name)
101    }
102
103    /// Direct children with this local name.
104    pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Element> + 'a {
105        self.children
106            .iter()
107            .filter(move |child| child.local_name() == name)
108    }
109
110    /// The first descendant with this local name, this element included, in
111    /// document order.
112    #[must_use]
113    pub fn find<'a>(&'a self, name: &str) -> Option<&'a Element> {
114        if self.local_name() == name {
115            return Some(self);
116        }
117        self.children.iter().find_map(|child| child.find(name))
118    }
119
120    /// Follow a chain of local names down from here and return the first
121    /// non-blank text found at the end of it.
122    ///
123    /// Every element at each step is followed, not only the first: a
124    /// repeating field puts several elements of the same name side by side,
125    /// and the value wanted may be under any of them.
126    ///
127    /// ```
128    /// let root = hl7_2_xml_lite_helper::parse(
129    ///     "<PID><PID.3><CX.1>a</CX.1></PID.3><PID.3><CX.4>b</CX.4></PID.3></PID>",
130    /// )?;
131    /// assert_eq!(root.text_at(&["PID.3", "CX.4"]), Some("b"));
132    /// # Ok::<(), hl7_2_xml_lite_helper::Error>(())
133    /// ```
134    #[must_use]
135    pub fn text_at<'a>(&'a self, path: &[&str]) -> Option<&'a str> {
136        let mut level: Vec<&Element> = vec![self];
137        for step in path {
138            let mut next: Vec<&Element> = Vec::new();
139            for element in level {
140                next.extend(
141                    element
142                        .children
143                        .iter()
144                        .filter(|child| child.local_name() == *step),
145                );
146            }
147            if next.is_empty() {
148                return None;
149            }
150            level = next;
151        }
152        level
153            .into_iter()
154            .map(|element| element.text.trim())
155            .find(|text| !text.is_empty())
156    }
157}
158
159/// The local part of a possibly-prefixed XML name.
160#[must_use]
161pub fn local_name(name: &str) -> &str {
162    match name.split_once(':') {
163        Some((_, local)) => local,
164        None => name,
165    }
166}
167
168/// Why a document could not be read.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum Error {
171    /// The document has no root element.
172    NoRootElement,
173    /// The input ended before an open element was closed.
174    Unclosed(String),
175    /// A closing tag did not match the element it was meant to close.
176    Mismatched {
177        /// The name the open tag gave.
178        open: String,
179        /// The name the close tag gave.
180        close: String,
181    },
182    /// The input is not well-formed XML; carries a reason and a byte offset.
183    Malformed(String, usize),
184}
185
186impl fmt::Display for Error {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Error::NoRootElement => write!(f, "no root element found"),
190            Error::Unclosed(name) => write!(f, "element <{name}> is never closed"),
191            Error::Mismatched { open, close } => write!(f, "<{open}> is closed by </{close}>"),
192            Error::Malformed(reason, at) => write!(f, "{reason} at byte {at}"),
193        }
194    }
195}
196
197impl std::error::Error for Error {}
198
199/// Parse a document and return its root element.
200///
201/// Anything before the root — the XML declaration, comments, a `DOCTYPE` —
202/// is skipped, and anything after the root's closing tag is ignored, which
203/// is how every reader treats a trailing newline.
204///
205/// # Errors
206///
207/// [`Error`] when the document is not well formed: no root element, an
208/// element left open, a mismatched closing tag, or anything else, with the
209/// byte offset where reading gave up. There is no recovery; see
210/// `spec/index.md` §3.6.
211pub fn parse(xml: &str) -> Result<Element, Error> {
212    let mut cursor = Cursor::new(xml.strip_prefix('\u{feff}').unwrap_or(xml));
213    cursor.skip_prolog()?;
214    cursor.skip_whitespace();
215    if cursor.at_end() {
216        return Err(Error::NoRootElement);
217    }
218    cursor.parse_element()
219}
220
221/// Escape text for element content or an attribute value.
222///
223/// All five predefined entities, because a value may be quoted into either
224/// position and this crate does not get to choose the reader at the far end.
225#[must_use]
226pub fn escape(text: &str) -> String {
227    let mut out = String::with_capacity(text.len());
228    for c in text.chars() {
229        match c {
230            '&' => out.push_str("&amp;"),
231            '<' => out.push_str("&lt;"),
232            '>' => out.push_str("&gt;"),
233            '"' => out.push_str("&quot;"),
234            '\'' => out.push_str("&apos;"),
235            c => out.push(c),
236        }
237    }
238    out
239}
240
241/// How deeply elements may nest before reading gives up.
242///
243/// Reading is recursive, so nesting depth is stack depth: without a limit a
244/// small document of nothing but open tags aborts the process with a stack
245/// overflow, which a library must never do to its caller. The documents
246/// this crate is for nest a dozen levels at the outside — a v2.xml message
247/// reaches subcomponents in six, a SOAP envelope in about the same — so
248/// this is far above anything real and still far below the stack.
249const MAX_DEPTH: usize = 256;
250
251struct Cursor<'a> {
252    text: &'a str,
253    position: usize,
254    depth: usize,
255}
256
257impl<'a> Cursor<'a> {
258    fn new(text: &'a str) -> Cursor<'a> {
259        Cursor {
260            text,
261            position: 0,
262            depth: 0,
263        }
264    }
265
266    fn rest(&self) -> &'a str {
267        &self.text[self.position..]
268    }
269
270    fn at_end(&self) -> bool {
271        self.position >= self.text.len()
272    }
273
274    fn starts_with(&self, pattern: &str) -> bool {
275        self.rest().starts_with(pattern)
276    }
277
278    fn advance(&mut self, bytes: usize) {
279        self.position += bytes;
280    }
281
282    fn skip_whitespace(&mut self) {
283        let trimmed = self.rest().trim_start();
284        self.position = self.text.len() - trimmed.len();
285    }
286
287    fn skip_prolog(&mut self) -> Result<(), Error> {
288        loop {
289            self.skip_whitespace();
290            if self.starts_with("<?") {
291                self.skip_through("?>")?;
292            } else if self.starts_with("<!--") {
293                self.skip_through("-->")?;
294            } else if self.starts_with("<!") {
295                self.skip_through(">")?;
296            } else {
297                return Ok(());
298            }
299        }
300    }
301
302    fn skip_through(&mut self, end: &str) -> Result<(), Error> {
303        match self.rest().find(end) {
304            Some(index) => {
305                self.advance(index + end.len());
306                Ok(())
307            }
308            None => Err(Error::Malformed(
309                format!("unterminated {end:?}"),
310                self.position,
311            )),
312        }
313    }
314
315    fn parse_element(&mut self) -> Result<Element, Error> {
316        if !self.starts_with("<") {
317            return Err(Error::Malformed("expected '<'".into(), self.position));
318        }
319        if self.depth >= MAX_DEPTH {
320            return Err(Error::Malformed(
321                format!("elements nested more than {MAX_DEPTH} deep"),
322                self.position,
323            ));
324        }
325        self.advance(1);
326        let name = self.read_name()?;
327        let (attributes, self_closing) = self.read_attributes()?;
328        if self_closing {
329            return Ok(Element {
330                name,
331                attributes,
332                text: String::new(),
333                children: Vec::new(),
334            });
335        }
336        let (text, children) = self.parse_content(&name)?;
337        Ok(Element {
338            name,
339            attributes,
340            text,
341            children,
342        })
343    }
344
345    /// An XML name: everything up to whitespace, `/`, or `>`.
346    fn read_name(&mut self) -> Result<String, Error> {
347        let end = self
348            .rest()
349            .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
350            .ok_or_else(|| Error::Malformed("unterminated tag".into(), self.position))?;
351        if end == 0 {
352            return Err(Error::Malformed("empty element name".into(), self.position));
353        }
354        let name = self.rest()[..end].to_string();
355        self.advance(end);
356        Ok(name)
357    }
358
359    /// Read `name="value"` pairs up to the tag's close. Returns the
360    /// attributes and whether the tag was self-closing.
361    fn read_attributes(&mut self) -> Result<(BTreeMap<String, String>, bool), Error> {
362        let mut attributes = BTreeMap::new();
363        loop {
364            self.skip_whitespace();
365            if self.starts_with("/>") {
366                self.advance(2);
367                return Ok((attributes, true));
368            }
369            if self.starts_with(">") {
370                self.advance(1);
371                return Ok((attributes, false));
372            }
373            if self.at_end() {
374                return Err(Error::Malformed("unterminated tag".into(), self.position));
375            }
376            let name_end = self
377                .rest()
378                .find(|c: char| c.is_whitespace() || c == '=')
379                .ok_or_else(|| Error::Malformed("malformed attribute".into(), self.position))?;
380            let name = self.rest()[..name_end].to_string();
381            self.advance(name_end);
382            self.skip_whitespace();
383            if !self.starts_with("=") {
384                return Err(Error::Malformed(
385                    "attribute without a value".into(),
386                    self.position,
387                ));
388            }
389            self.advance(1);
390            self.skip_whitespace();
391            let quote = self
392                .rest()
393                .chars()
394                .next()
395                .filter(|&c| c == '"' || c == '\'')
396                .ok_or_else(|| {
397                    Error::Malformed("unquoted attribute value".into(), self.position)
398                })?;
399            self.advance(1);
400            let close = self
401                .rest()
402                .find(quote)
403                .ok_or_else(|| Error::Malformed("unterminated attribute".into(), self.position))?;
404            let value = decode(&self.rest()[..close]);
405            self.advance(close + 1);
406            attributes.insert(name, value);
407        }
408    }
409
410    fn parse_content(&mut self, open_name: &str) -> Result<(String, Vec<Element>), Error> {
411        let mut text = String::new();
412        let mut children = Vec::new();
413        loop {
414            let next = self
415                .rest()
416                .find('<')
417                .ok_or_else(|| Error::Unclosed(open_name.to_string()))?;
418            if next > 0 {
419                text.push_str(&decode(&self.rest()[..next]));
420                self.advance(next);
421            }
422            if self.starts_with("</") {
423                self.advance(2);
424                let close_name = self.read_name()?;
425                self.skip_whitespace();
426                if !self.starts_with(">") {
427                    return Err(Error::Malformed(
428                        "unterminated close tag".into(),
429                        self.position,
430                    ));
431                }
432                self.advance(1);
433                if close_name != open_name {
434                    return Err(Error::Mismatched {
435                        open: open_name.to_string(),
436                        close: close_name,
437                    });
438                }
439                // Whitespace between child elements is layout, not content.
440                if !children.is_empty() && text.trim().is_empty() {
441                    text.clear();
442                }
443                return Ok((text, children));
444            }
445            if self.starts_with("<!--") {
446                self.skip_through("-->")?;
447                continue;
448            }
449            if self.starts_with("<?") {
450                self.skip_through("?>")?;
451                continue;
452            }
453            if self.starts_with("<![CDATA[") {
454                self.advance("<![CDATA[".len());
455                let end = self
456                    .rest()
457                    .find("]]>")
458                    .ok_or_else(|| Error::Malformed("unterminated CDATA".into(), self.position))?;
459                text.push_str(&self.rest()[..end]);
460                self.advance(end + "]]>".len());
461                continue;
462            }
463            self.depth += 1;
464            let child = self.parse_element();
465            self.depth -= 1;
466            children.push(child?);
467        }
468    }
469}
470
471/// Decode the five predefined entities and numeric character references.
472/// Anything unrecognized is kept literally rather than rejected.
473fn decode(text: &str) -> String {
474    if !text.contains('&') {
475        return text.to_string();
476    }
477    let mut out = String::with_capacity(text.len());
478    let mut rest = text;
479    while let Some(index) = rest.find('&') {
480        out.push_str(&rest[..index]);
481        rest = &rest[index..];
482        let Some(end) = rest.find(';') else {
483            out.push_str(rest);
484            return out;
485        };
486        let entity = &rest[1..end];
487        let decoded = match entity {
488            "amp" => Some('&'),
489            "lt" => Some('<'),
490            "gt" => Some('>'),
491            "quot" => Some('"'),
492            "apos" => Some('\''),
493            _ => entity
494                .strip_prefix('#')
495                .and_then(|number| match number.strip_prefix(['x', 'X']) {
496                    Some(hex) => u32::from_str_radix(hex, 16).ok(),
497                    None => number.parse().ok(),
498                })
499                .and_then(char::from_u32),
500        };
501        match decoded {
502            Some(c) => out.push(c),
503            None => out.push_str(&rest[..=end]),
504        }
505        rest = &rest[end + 1..];
506    }
507    out.push_str(rest);
508    out
509}