Skip to main content

libxml/
reader.rs

1//! Streaming pull-parser (`xmlTextReader`).
2//!
3//! Building the whole DOM for a very large document is prohibitively
4//! memory-hungry (a 600 MB file becomes a ~7 GB tree). [`TextReader`] instead
5//! streams the input node-by-node and lets callers materialize only the
6//! subtrees they care about, so peak memory is one subtree at a time rather
7//! than the entire tree.
8//!
9//! Two ways to materialize the current subtree:
10//! * [`TextReader::expand`] — a **borrowed** [`RoNode`], zero-copy, valid only
11//!   until the next [`read`](TextReader::read)/[`read_next`](TextReader::read_next).
12//!   Ideal for read-only scanning.
13//! * [`TextReader::expand_to_document`] — an **owned** [`Document`] copy
14//!   (namespaces reconciled), safe to hold, mutate, transform and free after
15//!   the reader has advanced. This is the unit the rest of the pipeline
16//!   (XSLT, serialization) consumes.
17//!
18//! ## Streaming a pattern
19//!
20//! libxml2's XPath engine is not streamable (it needs a fully-built tree). The
21//! streamable subset is "downward" name/descendant matching, which at the
22//! reader level is simply a per-element name/namespace test — see
23//! [`TextReader::read_to_next`]. Arbitrary predicates are then applied on the
24//! small owned subtree, where XPath is limit-safe.
25
26use std::ffi::{CStr, CString};
27use std::os::raw::c_char;
28use std::ptr;
29
30use crate::bindings::*;
31use crate::readonly::RoNode;
32use crate::tree::{Document, NodeType};
33
34/// A safe wrapper over libxml2's `xmlTextReader` pull parser.
35///
36/// Owns the underlying reader (and the file handle it opened); dropping the
37/// `TextReader` frees both.
38pub struct TextReader {
39  ptr: xmlTextReaderPtr,
40}
41
42/// The reader's own event vocabulary (`xmlReaderTypes`), exposed losslessly.
43///
44/// [`TextReader::node_type`] maps events `1..=12` onto [`NodeType`] and
45/// everything else to `None` — which conflates *end-element* with the two
46/// *whitespace* events (13/14). A streaming caller that reconstructs document
47/// structure needs all three distinguished; [`TextReader::event`] returns this
48/// enum instead.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ReaderEvent {
51  /// No node (before the first read / after the last).
52  None,
53  /// An element start tag (`<x>` or `<x/>` — see [`TextReader::is_empty_element`]).
54  Element,
55  /// An attribute node (only when navigating attributes explicitly).
56  Attribute,
57  /// A text node with non-whitespace content.
58  Text,
59  /// A CDATA section.
60  CData,
61  /// An entity reference (unresolved).
62  EntityReference,
63  /// An entity declaration.
64  Entity,
65  /// A processing instruction.
66  ProcessingInstruction,
67  /// A comment.
68  Comment,
69  /// The document node.
70  Document,
71  /// A DOCTYPE declaration.
72  DocumentType,
73  /// A document fragment.
74  DocumentFragment,
75  /// A notation declaration.
76  Notation,
77  /// Ignorable inter-element whitespace.
78  Whitespace,
79  /// Whitespace in mixed content (significant per the reader).
80  SignificantWhitespace,
81  /// An element end tag (`</x>`).
82  EndElement,
83  /// The end of an expanded entity.
84  EndEntity,
85  /// The `<?xml …?>` declaration.
86  XmlDeclaration,
87}
88
89impl ReaderEvent {
90  fn from_int(t: i32) -> Self {
91    match t {
92      1 => ReaderEvent::Element,
93      2 => ReaderEvent::Attribute,
94      3 => ReaderEvent::Text,
95      4 => ReaderEvent::CData,
96      5 => ReaderEvent::EntityReference,
97      6 => ReaderEvent::Entity,
98      7 => ReaderEvent::ProcessingInstruction,
99      8 => ReaderEvent::Comment,
100      9 => ReaderEvent::Document,
101      10 => ReaderEvent::DocumentType,
102      11 => ReaderEvent::DocumentFragment,
103      12 => ReaderEvent::Notation,
104      13 => ReaderEvent::Whitespace,
105      14 => ReaderEvent::SignificantWhitespace,
106      15 => ReaderEvent::EndElement,
107      16 => ReaderEvent::EndEntity,
108      17 => ReaderEvent::XmlDeclaration,
109      _ => ReaderEvent::None,
110    }
111  }
112}
113
114impl Drop for TextReader {
115  fn drop(&mut self) {
116    unsafe { xmlFreeTextReader(self.ptr) };
117  }
118}
119
120/// Borrow a reader-owned `const xmlChar*` (never freed by the caller) as an
121/// owned `String`. Returns `None` for a NULL pointer.
122fn const_xmlchar_to_string(ptr: *const xmlChar) -> Option<String> {
123  if ptr.is_null() {
124    return None;
125  }
126  Some(
127    unsafe { CStr::from_ptr(ptr as *const c_char) }
128      .to_string_lossy()
129      .into_owned(),
130  )
131}
132
133/// Map libxml2's reader-advance status (`1` = positioned on a node, `0` = end
134/// of input, negative = parse error) to a `Result`.
135fn read_status(rc: i32) -> Result<bool, ()> {
136  match rc {
137    1 => Ok(true),
138    0 => Ok(false),
139    _ => Err(()),
140  }
141}
142
143impl TextReader {
144  /// Open `path` for streaming. `options` is the libxml2 parser-option bitmask
145  /// (`0` for defaults). Fails if the reader could not be created (e.g. the
146  /// file does not exist).
147  pub fn from_file(path: &str, options: i32) -> Result<Self, ()> {
148    let c_path = CString::new(path).map_err(|_| ())?;
149    let ptr = unsafe { xmlReaderForFile(c_path.as_ptr(), ptr::null(), options) };
150    if ptr.is_null() {
151      Err(())
152    } else {
153      Ok(TextReader { ptr })
154    }
155  }
156
157  /// Advance to the next node in document order (descending into children).
158  ///
159  /// `Ok(true)` = positioned on a node, `Ok(false)` = end of input,
160  /// `Err(())` = a parse error occurred.
161  pub fn read(&mut self) -> Result<bool, ()> {
162    read_status(unsafe { xmlTextReaderRead(self.ptr) })
163  }
164
165  /// Advance to the next node that is **not** a descendant of the current node
166  /// (i.e. skip the current subtree). Use after materializing a subtree to move
167  /// past it without walking its children. Same `Ok(true/false)`/`Err`
168  /// semantics as [`read`](Self::read).
169  pub fn read_next(&mut self) -> Result<bool, ()> {
170    read_status(unsafe { xmlTextReaderNext(self.ptr) })
171  }
172
173  /// The current node's type. Returns `None` for reader events that have no
174  /// [`NodeType`] equivalent — most usefully the *end-of-element* event, which
175  /// lets a caller distinguish an opening `<x>` (`Some(ElementNode)`) from a
176  /// closing `</x>` (`None`).
177  pub fn node_type(&self) -> Option<NodeType> {
178    // `xmlTextReaderNodeType` returns an `xmlReaderTypes` value, which coincides
179    // numerically with `xmlElementType` ONLY for `1..=12` (element, attribute,
180    // text, cdata, entity-ref, entity, PI, comment, document, doctype,
181    // fragment, notation). The reader-only events collide with UNRELATED
182    // element types — end-element `15 == XML_ELEMENT_DECL`, whitespace
183    // `13 == XML_HTML_DOCUMENT_NODE`, significant-whitespace `14 == XML_DTD_NODE`,
184    // end-entity `16`, xml-declaration `17` — so passing them through
185    // `NodeType::from_int` would mislabel them (e.g. a closing `</x>` as an
186    // `ElementDecl`). Those have no `NodeType` equivalent, hence `None`.
187    let t = unsafe { xmlTextReaderNodeType(self.ptr) };
188    if (1..=12).contains(&t) {
189      NodeType::from_int(t as xmlElementType)
190    } else {
191      None
192    }
193  }
194
195  /// True when positioned on an element *start* tag.
196  pub fn is_element(&self) -> bool {
197    self.node_type() == Some(NodeType::ElementNode)
198  }
199
200  /// The current reader event, losslessly (see [`ReaderEvent`]). Unlike
201  /// [`node_type`](Self::node_type), this distinguishes a closing `</x>`
202  /// (`EndElement`) from inter-element whitespace (`Whitespace` /
203  /// `SignificantWhitespace`).
204  pub fn event(&self) -> ReaderEvent {
205    ReaderEvent::from_int(unsafe { xmlTextReaderNodeType(self.ptr) })
206  }
207
208  /// The current node's depth in the tree (root element = 0).
209  pub fn depth(&self) -> i32 {
210    unsafe { xmlTextReaderDepth(self.ptr) }
211  }
212
213  /// The current node's local name (no namespace prefix), if any.
214  pub fn local_name(&self) -> Option<String> {
215    const_xmlchar_to_string(unsafe { xmlTextReaderConstLocalName(self.ptr) })
216  }
217
218  /// The current node's namespace URI, if any.
219  pub fn namespace_uri(&self) -> Option<String> {
220    const_xmlchar_to_string(unsafe { xmlTextReaderConstNamespaceUri(self.ptr) })
221  }
222
223  /// Fully build the current node's subtree and borrow it read-only.
224  ///
225  /// Zero-copy. **The returned [`RoNode`] is owned by the reader and is
226  /// invalidated by the next [`read`](Self::read)/[`read_next`](Self::read_next)** — do
227  /// not retain it across an advance. For a subtree you can keep, use
228  /// [`expand_to_document`](Self::expand_to_document). Returns `None` at end of
229  /// input or on error.
230  pub fn expand(&self) -> Option<RoNode> {
231    self.current_subtree().map(RoNode)
232  }
233
234  /// The current node's fully-built subtree as a raw pointer, or `None` at end
235  /// of input / on error. Borrowed from the reader — invalidated by the next
236  /// advance; callers must copy (see [`expand_to_document`](Self::expand_to_document))
237  /// to outlive it.
238  fn current_subtree(&self) -> Option<xmlNodePtr> {
239    let node = unsafe { xmlTextReaderExpand(self.ptr) };
240    (!node.is_null()).then_some(node)
241  }
242
243  /// Copy the current node's subtree into a fresh, independently-owned
244  /// [`Document`] whose root element is the copy.
245  ///
246  /// Namespaces declared on un-copied ancestors (e.g. the default `xmlns` on
247  /// the real document root) are reconciled onto the copy via
248  /// `xmlDOMWrapCloneNode`, so the result is self-contained — safe to hold,
249  /// mutate, transform and serialize after the reader has advanced and freed
250  /// its own copy of the subtree. Returns `None` at end of input or on error.
251  pub fn expand_to_document(&self) -> Option<Document> {
252    let node = self.current_subtree()?;
253    unsafe {
254      let newdoc = xmlNewDoc(c"1.0".as_ptr() as *const xmlChar);
255      if newdoc.is_null() {
256        return None;
257      }
258      // xmlDOMWrapCloneNode (unlike xmlDocCopyNode) reconciles the source
259      // ancestors' in-scope namespaces onto the clone, so it doesn't dangle
260      // into the source once the reader frees it. The wrap context is
261      // required: with a NULL context the clone keeps an ns *pointer* but the
262      // `xmlns=` decl is never materialized, so serialization silently drops it.
263      let ctxt = xmlDOMWrapNewCtxt();
264      let mut cloned: xmlNodePtr = ptr::null_mut();
265      let src_doc = (*node).doc;
266      let rc = xmlDOMWrapCloneNode(
267        ctxt,
268        src_doc,
269        node,
270        &mut cloned,
271        newdoc,
272        ptr::null_mut(), // no destination parent — it becomes the root
273        1,               // deep
274        0,               // options
275      );
276      xmlDOMWrapFreeCtxt(ctxt);
277      if rc != 0 || cloned.is_null() {
278        xmlFreeDoc(newdoc);
279        return None;
280      }
281      xmlDocSetRootElement(newdoc, cloned);
282      // Belt-and-suspenders: ensure every namespace used in the detached tree
283      // is declared within it (self-contained serialization, no dangling ns).
284      xmlReconciliateNs(newdoc, cloned);
285      // …but undo `xmlNewReconciledNs`'s prefix minting: a *default* (NULL
286      // prefix) namespace declared on an un-copied ancestor comes back as
287      // `xmlns:default="…"` (then `default1`, …), so every element serializes
288      // as `<default:x>` — the classic "annoying default prefix" trap, and a
289      // real corruption for callers that re-serialize subtrees (a fragment
290      // using `default:` never re-parses into the right namespace unless that
291      // fabricated declaration travels with it). Restore each minted
292      // declaration's prefix to the SOURCE element's prefix for the same href
293      // (usually NULL), unless that prefix is already taken on the clone.
294      let mut decl = (*cloned).nsDef;
295      while !decl.is_null() {
296        let prefix = (*decl).prefix;
297        if !prefix.is_null()
298          && xmlStrncmp(prefix, c"default".as_ptr() as *const xmlChar, 7) == 0
299        {
300          let src_ns = xmlSearchNsByHref(src_doc, node, (*decl).href);
301          if !src_ns.is_null() {
302            let want = (*src_ns).prefix;
303            let mut clash = false;
304            let mut other = (*cloned).nsDef;
305            while !other.is_null() {
306              if other != decl && xmlStrEqual((*other).prefix, want) == 1 {
307                clash = true;
308                break;
309              }
310              other = (*other).next;
311            }
312            if !clash && xmlStrEqual(prefix, want) == 0 {
313              let old = (*decl).prefix as *mut ::std::os::raw::c_void;
314              (*decl).prefix = if want.is_null() {
315                ptr::null()
316              } else {
317                xmlStrdup(want)
318              };
319              // Portable free: on MSVC `xmlFree` is not a linkable data
320              // symbol (LNK2019 in 0.3.18); `bindgenFree` carries the
321              // per-target arm the crate already uses elsewhere.
322              crate::c_helpers::bindgenFree(old);
323            }
324          }
325        }
326        decl = (*decl).next;
327      }
328      Some(Document::new_ptr(newdoc))
329    }
330  }
331
332  /// The current element's attributes as `(qualified-name, value)` pairs in
333  /// document order, **including namespace declarations** (`xmlns`,
334  /// `xmlns:pfx`), without expanding the subtree.
335  ///
336  /// This is the streaming way to inspect an element *before* deciding whether
337  /// to materialize it — [`expand`](Self::expand) would build the whole
338  /// subtree, which for a large container element defeats the point of
339  /// streaming. Returns an empty vec on non-element nodes.
340  ///
341  /// Values are fully entity/charref-decoded (libxml2 reader semantics); a
342  /// caller re-serializing them must re-escape.
343  pub fn attributes_qname(&mut self) -> Vec<(String, String)> {
344    let mut out = Vec::new();
345    if unsafe { xmlTextReaderMoveToFirstAttribute(self.ptr) } != 1 {
346      return out;
347    }
348    loop {
349      let name = const_xmlchar_to_string(unsafe { xmlTextReaderConstName(self.ptr) });
350      let value = const_xmlchar_to_string(unsafe { xmlTextReaderConstValue(self.ptr) });
351      if let (Some(n), Some(v)) = (name, value) {
352        out.push((n, v));
353      }
354      if unsafe { xmlTextReaderMoveToNextAttribute(self.ptr) } != 1 {
355        break;
356      }
357    }
358    // Restore the reader to the element node so subsequent
359    // `local_name`/`expand`/`read` calls see the element, not its last
360    // attribute.
361    unsafe { xmlTextReaderMoveToElement(self.ptr) };
362    out
363  }
364
365  /// The current node's text value (text/CDATA content, comment text, or
366  /// processing-instruction body). `None` for valueless nodes (e.g. an
367  /// element start).
368  pub fn value(&self) -> Option<String> {
369    const_xmlchar_to_string(unsafe { xmlTextReaderConstValue(self.ptr) })
370  }
371
372  /// True when positioned on an empty element tag (`<x/>`), which the reader
373  /// reports as a *start* event with no matching end-element event.
374  pub fn is_empty_element(&self) -> bool {
375    (unsafe { xmlTextReaderIsEmptyElement(self.ptr) }) == 1
376  }
377
378  /// Serialize the current node's subtree exactly as it appears in the input
379  /// (no XML declaration, no added namespace declarations, attribute order
380  /// preserved). Position is unchanged; call [`read_next`](Self::read_next) to
381  /// move past the subtree. Returns `None` at end of input or on error.
382  ///
383  /// Deliberately NOT `xmlTextReaderReadOuterXml`: that API deep-copies the
384  /// expanded node *parentless* first, and for content in a *default*
385  /// namespace declared on an un-copied ancestor the copy's namespace fixup
386  /// (`xmlNewReconciledNs`) then **mints a `default:` prefix** onto every
387  /// element — `<para>` serializes as `<default:para
388  /// xmlns:default="…">`. Dumping the reader-owned node directly keeps its
389  /// ancestors (and their namespace declarations) reachable, so elements
390  /// serialize with their original prefixes and no fabricated declarations —
391  /// the fragment re-parses correctly inside any wrapper that re-declares the
392  /// same namespaces.
393  pub fn outer_xml(&self) -> Option<String> {
394    let node = self.current_subtree()?;
395    unsafe {
396      let buf = xmlBufferCreate();
397      if buf.is_null() {
398        return None;
399      }
400      let rc = xmlNodeDump(buf, (*node).doc, node, 0, 0);
401      let content = xmlBufferContent(buf);
402      let result = if rc < 0 || content.is_null() {
403        None
404      } else {
405        Some(
406          CStr::from_ptr(content as *const c_char)
407            .to_string_lossy()
408            .into_owned(),
409        )
410      };
411      xmlBufferFree(buf);
412      result
413    }
414  }
415
416  /// Advance until positioned on the next element whose `(namespace, localname)`
417  /// satisfies `want`, or the end of input.
418  ///
419  /// This is the streaming analogue of a downward `//name` XPath step: the only
420  /// XPath subset that is actually streamable. Returns `Ok(true)` when
421  /// positioned on a match (then call [`expand`](Self::expand) /
422  /// [`expand_to_document`](Self::expand_to_document), and
423  /// [`read_next`](Self::read_next) to skip past it), `Ok(false)` at end of input.
424  ///
425  /// `want` receives the namespace URI (`None` if the element is in no
426  /// namespace) and the local name.
427  pub fn read_to_next<F>(&mut self, want: F) -> Result<bool, ()>
428  where
429    F: Fn(Option<&str>, &str) -> bool,
430  {
431    while self.read()? {
432      if self.is_element()
433        && let Some(name) = self.local_name()
434        && want(self.namespace_uri().as_deref(), &name)
435      {
436        return Ok(true);
437      }
438    }
439    Ok(false)
440  }
441}
442
443#[cfg(test)]
444mod tests {
445  use super::*;
446
447  const NS: &str = "http://example.org/ns";
448
449  fn write_temp(name: &str, xml: &str) -> String {
450    let path = std::env::temp_dir().join(format!(
451      "rust-libxml-reader-{}-{name}.xml",
452      std::process::id()
453    ));
454    std::fs::write(&path, xml).unwrap();
455    path.to_string_lossy().into_owned()
456  }
457
458  /// Stream a multi-section document, collect each `<section>` as an owned
459  /// Document, and verify — crucially, *after the reader is dropped* — that the
460  /// copies are self-contained: namespaces inherited from the (un-copied) root
461  /// are reconciled onto each copy, and content survives.
462  #[test]
463  fn stream_sections_owned_and_namespace_reconciled() {
464    let xml = r#"<?xml version="1.0"?>
465<doc xmlns="http://example.org/ns" xmlns:x="http://example.org/x">
466  <meta>skip me</meta>
467  <section id="a"><title>Alpha</title><p>one</p></section>
468  <section id="b"><title>Beta</title><x:note>hi</x:note></section>
469</doc>"#;
470    let path = write_temp("sections", xml);
471
472    let mut sections = Vec::new();
473    {
474      let mut reader = TextReader::from_file(&path, 0).unwrap();
475      while reader
476        .read_to_next(|ns, name| ns == Some(NS) && name == "section")
477        .unwrap()
478      {
479        sections.push(reader.expand_to_document().unwrap());
480      }
481      // reader dropped here — its copies of the subtrees are freed.
482    }
483
484    assert_eq!(sections.len(), 2, "should stream exactly two <section>s");
485
486    // Root of each owned doc is a <section> in the reconciled default ns.
487    let root0 = sections[0].get_root_element().unwrap();
488    assert_eq!(root0.get_name(), "section");
489    assert_eq!(root0.get_attribute("id").as_deref(), Some("a"));
490    assert_eq!(
491      root0.get_namespace().map(|n| n.get_href()),
492      Some(NS.to_string()),
493      "default namespace must be reconciled onto the detached copy"
494    );
495
496    // Serialization is intact and namespace-declared (no dangling ns → no UAF).
497    let s0 = sections[0].to_string();
498    assert!(
499      s0.contains("http://example.org/ns"),
500      "ns decl missing: {s0}"
501    );
502    // The reconciliation must NOT have minted a `default:` prefix for the
503    // inherited default namespace: the copy serializes with `xmlns=`, exactly
504    // as a standalone parse of the same subtree would.
505    assert!(
506      !s0.contains("default:"),
507      "default-namespace content must keep a NULL prefix, not a minted default: — {s0}"
508    );
509    assert!(
510      s0.contains(r#"<section xmlns="http://example.org/ns""#),
511      "the default declaration must materialize on the copy root: {s0}"
512    );
513    assert!(
514      s0.contains("Alpha") && s0.contains("one"),
515      "content lost: {s0}"
516    );
517
518    // The second section keeps its prefixed namespace too.
519    let s1 = sections[1].to_string();
520    assert!(s1.contains("Beta"), "content lost: {s1}");
521    assert!(
522      s1.contains("http://example.org/x"),
523      "prefixed ns lost: {s1}"
524    );
525
526    std::fs::remove_file(&path).ok();
527  }
528
529  /// `read`/`next`/`is_element`/`local_name` walk the tree and `read_next` skips a
530  /// subtree (does not descend).
531  #[test]
532  fn read_and_next_skip_subtree() {
533    let xml = r#"<r><a><deep/></a><b/></r>"#;
534    let path = write_temp("skip", xml);
535    let mut reader = TextReader::from_file(&path, 0).unwrap();
536
537    assert!(reader.read().unwrap()); // <r>
538    assert!(reader.is_element());
539    assert_eq!(reader.local_name().as_deref(), Some("r"));
540
541    assert!(reader.read().unwrap()); // <a>
542    assert_eq!(reader.local_name().as_deref(), Some("a"));
543
544    // read_next() skips <a>'s subtree (the <deep/>) → lands on <b>.
545    assert!(reader.read_next().unwrap());
546    assert_eq!(reader.local_name().as_deref(), Some("b"));
547
548    std::fs::remove_file(&path).ok();
549  }
550
551  /// Opening a reader on a path that does not exist fails at construction
552  /// (`xmlReaderForFile` returns NULL), rather than deferring to the first read.
553  #[test]
554  fn from_file_on_missing_path_is_err() {
555    assert!(TextReader::from_file("/no/such/rust-libxml-reader-missing.xml", 0).is_err());
556  }
557
558  /// A well-formedness violation surfaces as `Err(())` from `read`, not a silent
559  /// early `Ok(false)` — so a caller streaming a truncated/corrupt file can tell
560  /// "document ended" apart from "document is broken".
561  #[test]
562  fn read_surfaces_parse_error_on_malformed_xml() {
563    // </a> closes before the still-open <b> — not well-formed.
564    let path = write_temp("malformed", "<a><b></a>");
565    let mut reader = TextReader::from_file(&path, 0).unwrap();
566    let mut saw_err = false;
567    loop {
568      match reader.read() {
569        Ok(true) => continue,
570        Ok(false) => break,
571        Err(()) => {
572          saw_err = true;
573          break;
574        }
575      }
576    }
577    assert!(
578      saw_err,
579      "malformed XML must surface a read error, not Ok(false)"
580    );
581    std::fs::remove_file(&path).ok();
582  }
583
584  /// `read_to_next` that never matches consumes the whole document and returns
585  /// `Ok(false)` at end of input (the streaming analogue of an empty node-set).
586  #[test]
587  fn read_to_next_returns_false_when_pattern_absent() {
588    let path = write_temp("nomatch", r#"<doc><a/><b/></doc>"#);
589    let mut reader = TextReader::from_file(&path, 0).unwrap();
590    let found = reader.read_to_next(|_ns, name| name == "zzz").unwrap();
591    assert!(
592      !found,
593      "no <zzz> exists → read_to_next must reach EOF and return false"
594    );
595    std::fs::remove_file(&path).ok();
596  }
597
598  /// `attributes_qname` reports qualified names + namespace declarations in
599  /// document order, without expanding, and leaves the reader positioned on
600  /// the element.
601  #[test]
602  fn attributes_qname_in_order_without_expand() {
603    let xml = r#"<doc xmlns="http://example.org/ns" xmlns:x="http://example.org/x">
604  <section xml:id="s1" class="c" x:extra="e"><p>body</p></section>
605</doc>"#;
606    let path = write_temp("attrs", xml);
607    let mut reader = TextReader::from_file(&path, 0).unwrap();
608
609    assert!(reader.read().unwrap()); // <doc>
610    let root_attrs = reader.attributes_qname();
611    assert_eq!(
612      root_attrs,
613      vec![
614        ("xmlns".to_string(), "http://example.org/ns".to_string()),
615        ("xmlns:x".to_string(), "http://example.org/x".to_string()),
616      ],
617      "namespace declarations must be reported as ordinary attributes"
618    );
619    // Reader restored to the element: name still <doc>, and streaming resumes.
620    assert_eq!(reader.local_name().as_deref(), Some("doc"));
621
622    assert!(
623      reader
624        .read_to_next(|_, name| name == "section")
625        .unwrap()
626    );
627    assert_eq!(
628      reader.attributes_qname(),
629      vec![
630        ("xml:id".to_string(), "s1".to_string()),
631        ("class".to_string(), "c".to_string()),
632        ("x:extra".to_string(), "e".to_string()),
633      ],
634      "attribute order must be document order, names fully qualified"
635    );
636    std::fs::remove_file(&path).ok();
637  }
638
639  /// `outer_xml` on default-namespace content must NOT invent a `default:`
640  /// prefix (the `xmlTextReaderReadOuterXml` + parentless-copy trap) and must
641  /// not add namespace declarations the input element does not carry.
642  #[test]
643  fn outer_xml_preserves_default_namespace_content() {
644    let xml = r#"<doc xmlns="http://example.org/ns" xmlns:x="http://example.org/x">
645  <section a="1" b="&lt;2&gt;"><p>t&amp;t</p><x:note>hi</x:note></section>
646</doc>"#;
647    let path = write_temp("outerxml", xml);
648    let mut reader = TextReader::from_file(&path, 0).unwrap();
649    assert!(
650      reader
651        .read_to_next(|_, name| name == "section")
652        .unwrap()
653    );
654    let outer = reader.outer_xml().unwrap();
655    assert_eq!(
656      outer,
657      r#"<section a="1" b="&lt;2&gt;"><p>t&amp;t</p><x:note>hi</x:note></section>"#,
658      "no default: prefix, no added xmlns decls, escaping and attr order intact"
659    );
660    // Position unchanged: the same subtree can still be skipped as a unit.
661    assert_eq!(reader.local_name().as_deref(), Some("section"));
662    assert!(reader.read_next().unwrap()); // past </section> → </doc> close
663    std::fs::remove_file(&path).ok();
664  }
665
666  /// `value` returns text/comment/PI content; `is_empty_element` distinguishes
667  /// `<x/>` from `<x></x>`.
668  #[test]
669  fn value_and_is_empty_element() {
670    let xml = r#"<r><?pi data?><!--note--><a/><b></b>text</r>"#;
671    let path = write_temp("value", xml);
672    let mut reader = TextReader::from_file(&path, 0).unwrap();
673
674    assert!(reader.read().unwrap()); // <r>
675    assert!(!reader.is_empty_element());
676
677    assert!(reader.read().unwrap()); // <?pi data?>
678    assert_eq!(reader.node_type(), Some(NodeType::PiNode));
679    assert_eq!(reader.local_name().as_deref(), Some("pi"));
680    assert_eq!(reader.value().as_deref(), Some("data"));
681
682    assert!(reader.read().unwrap()); // <!--note-->
683    assert_eq!(reader.node_type(), Some(NodeType::CommentNode));
684    assert_eq!(reader.value().as_deref(), Some("note"));
685
686    assert!(reader.read().unwrap()); // <a/>
687    assert!(reader.is_empty_element());
688
689    assert!(reader.read().unwrap()); // <b>
690    assert!(!reader.is_empty_element());
691    assert!(reader.read().unwrap()); // </b> close
692
693    assert!(reader.read().unwrap()); // text
694    assert_eq!(reader.node_type(), Some(NodeType::TextNode));
695    assert_eq!(reader.value().as_deref(), Some("text"));
696
697    std::fs::remove_file(&path).ok();
698  }
699
700  /// The documented contract: an opening `<x>` is `Some(ElementNode)` but a
701  /// closing `</x>` is `None` — NOT a bogus `ElementDecl`. `xmlReaderTypes`
702  /// END_ELEMENT (15) collides numerically with `XML_ELEMENT_DECL`, so this
703  /// pins the `node_type` guard that keeps the two apart.
704  #[test]
705  fn node_type_distinguishes_open_from_close_tag() {
706    let path = write_temp("openclose", r#"<r><a>x</a></r>"#);
707    let mut reader = TextReader::from_file(&path, 0).unwrap();
708
709    assert!(reader.read().unwrap()); // <r> open
710    assert_eq!(reader.node_type(), Some(NodeType::ElementNode));
711    assert!(reader.is_element());
712
713    assert!(reader.read().unwrap()); // <a> open
714    assert_eq!(reader.node_type(), Some(NodeType::ElementNode));
715
716    assert!(reader.read().unwrap()); // text "x"
717    assert_eq!(reader.node_type(), Some(NodeType::TextNode));
718    assert!(!reader.is_element());
719
720    assert!(reader.read().unwrap()); // </a> close
721    assert_eq!(reader.local_name().as_deref(), Some("a"));
722    assert_eq!(
723      reader.node_type(),
724      None,
725      "a closing tag has no NodeType equivalent — must be None, not ElementDecl"
726    );
727    assert!(!reader.is_element());
728
729    std::fs::remove_file(&path).ok();
730  }
731}