Skip to main content

quarb_xml/
lib.rs

1//! XML adapter for Quarb.
2//!
3//! Maps a well-formed XML document onto the arbor model, parsing
4//! with `quick-xml`. Unlike the HTML adapter, parsing is strict:
5//! mismatched or unclosed tags, duplicate attributes, and unknown
6//! entity references are errors.
7//!
8//! - Elements are the nodes; text, CDATA, comments, processing
9//!   instructions, and the doctype are not navigated (text is
10//!   exposed as a projection instead).
11//! - A node's *name* is its tag exactly as written, including any
12//!   namespace prefix (`dc:title`). Prefixes are not resolved to
13//!   URIs. Since `:` is not a bare-name character in Quarb, a
14//!   prefixed name is navigated with a quoted segment
15//!   (`//'dc:title'`); `;;;local-name` and `;;;ns-prefix` metadata
16//!   allow prefix-agnostic filtering (`//*[;;;local-name =
17//!   "title"]`). The document root is unnamed; the document
18//!   element is its single child.
19//! - Nodes have no traits: XML has no universal structural
20//!   vocabulary, and leaf/container distinctions are already
21//!   available as core metadata (`:::is-leaf`, `:::n-children`).
22//! - Attributes are properties: `::pages`, `::href`. The default
23//!   projection (`::`) and `::text` are the element's text
24//!   content; `;;;tag`, `;;;id`, `;;;local-name`, `;;;ns-prefix`,
25//!   `;;;n-attrs`, and any `;;;attr` expose metadata.
26//! - An attribute holding an ID reference resolves: `::ref-->`
27//!   follows a bare IDREF value (`book="b1"`) or a fragment
28//!   (`href="#b1"`) to the element with that `id` or `xml:id`.
29//! - Input is UTF-8 text; an `encoding` declaration in the XML
30//!   prologue is ignored. Only the five predefined entities and
31//!   numeric character references are resolved.
32
33use quarb::{AstAdapter, NodeId, Value};
34use quick_xml::escape::resolve_predefined_entity;
35use quick_xml::events::{BytesStart, Event};
36use quick_xml::{Decoder, Reader};
37use std::collections::HashMap;
38
39struct Node {
40    /// The tag name as written; `None` for the document root.
41    tag: Option<String>,
42    attrs: Vec<(String, String)>,
43    /// The element's text content (all descendant text and CDATA,
44    /// concatenated in document order).
45    text: String,
46    parent: Option<NodeId>,
47    children: Vec<NodeId>,
48}
49
50impl Node {
51    fn attr(&self, name: &str) -> Option<&str> {
52        self.attrs
53            .iter()
54            .find(|(k, _)| k == name)
55            .map(|(_, v)| v.as_str())
56    }
57}
58
59/// An error building an [`XmlAdapter`] from document text.
60#[derive(Debug, thiserror::Error)]
61pub enum XmlError {
62    #[error(transparent)]
63    Syntax(#[from] quick_xml::Error),
64    #[error("unknown entity reference '&{0};'")]
65    UnknownEntity(String),
66    #[error("unclosed element '<{0}>'")]
67    Unclosed(String),
68    #[error("no root element")]
69    NoRoot,
70    #[error("content after the root element")]
71    TrailingContent,
72}
73
74/// The encoding an XML document declares in its prologue
75/// (`<?xml version="1.0" encoding="windows-1251"?>`), reported for
76/// the engine's source boundary. The adapter decodes nothing: the
77/// boundary applies the declaration after `?encoding=` and a
78/// byte-order mark have had their say. The prologue is scanned as
79/// ASCII-compatible bytes, as the XML recommendation's appendix F
80/// reads it; a document whose first bytes are not `<?xml` declares
81/// nothing.
82pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
83    quarb::source::xml_declared_encoding(bytes)
84}
85
86/// A Quarb adapter over a parsed XML document.
87pub struct XmlAdapter {
88    nodes: Vec<Node>,
89    /// `id` / `xml:id` attribute value → the element carrying it.
90    ids: HashMap<String, NodeId>,
91    root: NodeId,
92}
93
94impl XmlAdapter {
95    /// Parse `xml` and build the adapter. The document must be
96    /// well-formed: exactly one root element, matched tags, unique
97    /// attributes, and only predefined or numeric entities.
98    pub fn parse(xml: &str) -> Result<Self, XmlError> {
99        let mut nodes = vec![Node {
100            tag: None,
101            attrs: Vec::new(),
102            text: String::new(),
103            parent: None,
104            children: Vec::new(),
105        }];
106        let mut ids = HashMap::new();
107        let root = NodeId(0);
108
109        let mut reader = Reader::from_str(xml);
110        let decoder = reader.decoder();
111        // Indices of the currently open elements; the synthetic
112        // root stays at the bottom.
113        let mut stack: Vec<usize> = vec![0];
114
115        loop {
116            match reader.read_event()? {
117                Event::Start(e) => {
118                    let idx = intern(&mut nodes, &mut ids, &stack, decoder, &e)?;
119                    stack.push(idx);
120                }
121                Event::Empty(e) => {
122                    intern(&mut nodes, &mut ids, &stack, decoder, &e)?;
123                }
124                Event::End(_) => {
125                    let closed = stack.pop().expect("end event matches an open element");
126                    let text = std::mem::take(&mut nodes[closed].text);
127                    let parent = *stack.last().expect("stack holds the root");
128                    nodes[parent].text.push_str(&text);
129                    nodes[closed].text = text;
130                }
131                Event::Text(e) => {
132                    let open = *stack.last().expect("stack holds the root");
133                    let text = e.xml_content().map_err(quick_xml::Error::from)?;
134                    nodes[open].text.push_str(&text);
135                }
136                Event::CData(e) => {
137                    let open = *stack.last().expect("stack holds the root");
138                    let text = e.decode().map_err(quick_xml::Error::from)?;
139                    nodes[open].text.push_str(&text);
140                }
141                Event::GeneralRef(e) => {
142                    let open = *stack.last().expect("stack holds the root");
143                    if let Some(ch) = e.resolve_char_ref()? {
144                        nodes[open].text.push(ch);
145                    } else {
146                        let name = e.decode().map_err(quick_xml::Error::from)?;
147                        let Some(resolved) = resolve_predefined_entity(&name) else {
148                            return Err(XmlError::UnknownEntity(name.into_owned()));
149                        };
150                        nodes[open].text.push_str(resolved);
151                    }
152                }
153                Event::Decl(_) | Event::PI(_) | Event::Comment(_) | Event::DocType(_) => {}
154                Event::Eof => break,
155            }
156        }
157
158        if stack.len() > 1 {
159            let open = stack.pop().expect("checked non-root");
160            let tag = nodes[open].tag.clone().expect("open elements are tagged");
161            return Err(XmlError::Unclosed(tag));
162        }
163        match nodes[0].children.len() {
164            0 => return Err(XmlError::NoRoot),
165            1 => {}
166            _ => return Err(XmlError::TrailingContent),
167        }
168        // The synthetic root's text is the document element's, not
169        // any stray whitespace around it.
170        nodes[0].text = nodes[nodes[0].children[0].0 as usize].text.clone();
171
172        Ok(XmlAdapter { nodes, ids, root })
173    }
174
175    /// A locator path to `node`, like `/library/book[2]/author`, for
176    /// rendering. A `[n]` index is added only to disambiguate
177    /// same-tag siblings.
178    pub fn locator(&self, node: NodeId) -> String {
179        let mut segments = Vec::new();
180        let mut cur = Some(node);
181        while let Some(id) = cur {
182            let n = &self.nodes[id.0 as usize];
183            if let Some(tag) = &n.tag {
184                segments.push(self.segment(id, tag));
185            }
186            cur = n.parent;
187        }
188        segments.reverse();
189        format!("/{}", segments.join("/"))
190    }
191
192    fn segment(&self, node: NodeId, tag: &str) -> String {
193        let Some(parent) = self.nodes[node.0 as usize].parent else {
194            return tag.to_string();
195        };
196        let siblings = &self.nodes[parent.0 as usize].children;
197        let same_tag: Vec<NodeId> = siblings
198            .iter()
199            .copied()
200            .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
201            .collect();
202        if same_tag.len() > 1 {
203            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
204            format!("{tag}[{n}]")
205        } else {
206            tag.to_string()
207        }
208    }
209}
210
211/// Intern the element behind a `Start`/`Empty` event as a child of
212/// the innermost open element, returning its arena index.
213fn intern(
214    nodes: &mut Vec<Node>,
215    ids: &mut HashMap<String, NodeId>,
216    stack: &[usize],
217    decoder: Decoder,
218    e: &BytesStart,
219) -> Result<usize, XmlError> {
220    let idx = nodes.len();
221    let this = NodeId(idx as u64);
222    let tag = decoder
223        .decode(e.name().as_ref())
224        .map_err(quick_xml::Error::from)?
225        .into_owned();
226    let mut attrs = Vec::new();
227    for attr in e.attributes() {
228        let attr = attr.map_err(quick_xml::Error::from)?;
229        let key = decoder
230            .decode(attr.key.as_ref())
231            .map_err(quick_xml::Error::from)?
232            .into_owned();
233        let value = attr.decode_and_unescape_value(decoder)?.into_owned();
234        if key == "id" || key == "xml:id" {
235            ids.entry(value.clone()).or_insert(this);
236        }
237        attrs.push((key, value));
238    }
239    let parent = *stack.last().expect("stack holds the root");
240    nodes.push(Node {
241        tag: Some(tag),
242        attrs,
243        text: String::new(),
244        parent: Some(NodeId(parent as u64)),
245        children: Vec::new(),
246    });
247    nodes[parent].children.push(this);
248    Ok(idx)
249}
250
251/// The name before / after the first `:` of a qualified name.
252fn split_qualified(tag: &str) -> (Option<&str>, &str) {
253    match tag.split_once(':') {
254        Some((prefix, local)) => (Some(prefix), local),
255        None => (None, tag),
256    }
257}
258
259impl AstAdapter for XmlAdapter {
260    fn root(&self) -> NodeId {
261        self.root
262    }
263
264    /// In memory: an unscoped reverse resolution or value match may
265    /// walk the arbor.
266    fn walks_for_references(&self) -> bool {
267        true
268    }
269
270    fn children(&self, node: NodeId) -> Vec<NodeId> {
271        self.nodes[node.0 as usize].children.clone()
272    }
273
274    fn name(&self, node: NodeId) -> Option<String> {
275        self.nodes[node.0 as usize].tag.clone()
276    }
277
278    fn parent(&self, node: NodeId) -> Option<NodeId> {
279        self.nodes[node.0 as usize].parent
280    }
281
282    /// `::text` is the element's text content; any other name is an
283    /// attribute value.
284    /// Attributes, by name (`::pages`). Element text is the bare
285    /// projection (`::`) — no attribute name is shadowed.
286    /// XML's own reference syntax: XLink's `xlink:href`, else a
287    /// plain `href`. The bare arrows read it.
288    fn ref_property(&self, node: NodeId) -> Option<String> {
289        let n = &self.nodes[node.0 as usize];
290        ["xlink:href", "href"]
291            .into_iter()
292            .find(|a| n.attr(a).is_some())
293            .map(str::to_string)
294    }
295
296    /// An element's properties are its attributes.
297    fn property_names(&self, node: NodeId) -> Vec<String> {
298        self.nodes[node.0 as usize]
299            .attrs
300            .iter()
301            .map(|(k, _)| k.clone())
302            .collect()
303    }
304
305    /// Any attribute may hold a reference (`#id`, an IDREF) to any
306    /// element of the document: the format declares no narrower one.
307    fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
308        Some(true)
309    }
310
311    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
312        self.nodes[node.0 as usize]
313            .attr(name)
314            .map(|v| Value::Str(v.to_string()))
315    }
316
317    /// The default projection of an element is its text content.
318    fn default_value(&self, node: NodeId) -> Option<Value> {
319        Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
320    }
321
322    /// `;;;tag`, `;;;local-name`, `;;;ns-prefix`, and `;;;n-attrs`
323    /// — facts about the element, never its data: attributes are
324    /// properties (`::pages`), text is the bare projection.
325    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
326        let n = &self.nodes[node.0 as usize];
327        match key {
328            "tag" => n.tag.clone().map(Value::Str),
329            "local-name" => n
330                .tag
331                .as_deref()
332                .map(|t| Value::Str(split_qualified(t).1.to_string())),
333            "ns-prefix" => n
334                .tag
335                .as_deref()
336                .and_then(|t| split_qualified(t).0)
337                .map(|p| Value::Str(p.to_string())),
338            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
339            _ => None,
340        }
341    }
342
343    /// Follow an attribute that is an ID reference — a bare IDREF
344    /// value (`book="b1"`) or a fragment (`href="#b1"`) — to the
345    /// element carrying that `id` or `xml:id`.
346    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
347        let value = self.nodes[node.0 as usize].attr(property)?;
348        let target = value.strip_prefix('#').unwrap_or(value);
349        self.ids.get(target).copied()
350    }
351}
352
353#[cfg(test)]
354mod declared_tests {
355    #[test]
356    fn prologue_declaration() {
357        assert_eq!(
358            super::declared_encoding(b"<?xml version='1.0' encoding = 'Windows-1251' ?><a/>"),
359            Some("Windows-1251".into())
360        );
361        assert_eq!(
362            super::declared_encoding(b"<?xml version=\"1.0\"?><a/>"),
363            None
364        );
365        assert_eq!(super::declared_encoding(b"<a encoding=\"x\"/>"), None);
366        assert_eq!(
367            super::declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><r/>"),
368            None
369        );
370        assert_eq!(
371            super::declared_encoding(b"\xef\xbb\xbf<?xml encoding=\"utf-8\"?>"),
372            None
373        );
374    }
375}