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