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="<2>"><p>t&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="<2>"><p>t&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}