Skip to main content

latexml_core/common/
xml.rs

1use std::borrow::Cow;
2
3use libxml::{
4  tree::{Document, Node, NodeType},
5  xpath::Context,
6};
7use rustc_hash::FxHashMap as HashMap;
8
9use crate::common::error::Result;
10
11pub const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/";
12pub const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
13
14/// Parse a standalone markup string into its own document — the port of Perl
15/// `LaTeXML::Common::XML::Parser::parseChunk` (`Common/XML/Parser.pm:36-39`:
16/// `parse_string($string)` then `->documentElement`, "expects only a single
17/// node"). Parsing lives here, beside the rest of the `Common::XML` helpers, and
18/// NOT in `Document` — mirroring Perl, where `Document::appendTree` only ever
19/// consumes already-parsed nodes.
20///
21/// Diverges from Perl in its RETURN, deliberately: Perl hands back the
22/// `documentElement` and lets the GC keep its owner alive, which in Rust would
23/// dangle — the nodes borrow the document that owns them. So we return the owning
24/// [`Document`] and let the caller take `get_root_element()`, keeping the owner
25/// alive for exactly as long as the nodes are used.
26///
27/// The markup must be a single well-formed XML root (`parseChunk`'s contract): a
28/// bare fragment of several top-level nodes, or an undeclared HTML entity such as
29/// ` `, is a parse error. The error is returned as a rendered string for the
30/// caller to report; this layer applies no logging policy of its own. See
31/// `ill_formed_markup_hint` for why that string names the likely causes instead
32/// of quoting libxml's own diagnosis.
33///
34/// **Recovery is deliberately OFF.** libxml's default is to salvage malformed
35/// input, which here silently DESTROYS author content rather than reporting it —
36/// measured: `<b>a</b> <i>b</i>` parsed to just `<b>a</b>` (the second element
37/// dropped), `<p>a&nbsp;b</p>` to `ab` (the entity deleted), and `<p>a & b</p>`
38/// to `a  b`. Swallowing a author's markup is the silent-failure mode this
39/// project forbids, so a malformed chunk must fail loudly and insert nothing.
40/// This also matches Perl, whose `XML::LibXML->parse_string` defaults to
41/// `recover => 0`. Network access is refused too: a chunk is untrusted input and
42/// must never make the parser fetch an external DTD.
43pub fn parse_chunk(markup: &str) -> std::result::Result<Document, String> {
44  let options = libxml::parser::ParserOptions {
45    recover: false,
46    no_net: true,
47    no_def_dtd: true,
48    ..libxml::parser::ParserOptions::default()
49  };
50  libxml::parser::Parser::default()
51    .parse_string_with_options(markup, options)
52    .map_err(|e| match e {
53      libxml::parser::XmlParseError::DocumentTooLarge => String::from("markup too large to parse"),
54      _ => ill_formed_markup_hint(),
55    })
56}
57
58/// What we can honestly say when libxml refuses a chunk.
59///
60/// libxml2 diagnoses a parse failure precisely (`Premature end of data in tag p,
61/// line 1`), but rust-libxml's safe API discards that: `parse_string_with_options`
62/// collapses every failure to `XmlParseError::GotNullPointer`, which renders as
63/// "Got a Null pointer" — a message that tells a binding author nothing about
64/// their markup. Recovering the real text needs `xmlGetLastError`, i.e. raw
65/// libxml2 FFI, and this workspace deliberately keeps ALL such FFI in the
66/// rust-libxml fork (oxide is a pure consumer) — so surfacing it is a fork
67/// change, not something to smuggle in here.
68///
69/// Until then, name the causes rather than the symptom. Hand-written snippets
70/// fail for essentially three reasons, all covered below, so this is strictly
71/// more actionable than the pointer message even though it is not per-chunk.
72fn ill_formed_markup_hint() -> String {
73  String::from(
74    "not well-formed XML — check for an unclosed or mismatched tag, a bare `&` \
75     (write `&amp;`), or an HTML entity such as `&nbsp;` that XML does not define \
76     (write the numeric form, `&#160;`)",
77  )
78}
79
80/// The element we parse a fragment inside of. Never enters the document — only
81/// its children are handed back — so the name just has to not collide.
82const FRAGMENT_WRAPPER: &str = "_lxfragment";
83
84/// Parse a markup chunk that may be a document FRAGMENT — several sibling nodes,
85/// or bare text — and return its owning document together with the top-level
86/// nodes to insert. Returning the two together is deliberate: libxml `Node`s are
87/// handles into the document that owns them, so the caller must keep the
88/// [`Document`] alive for exactly as long as it uses the nodes.
89///
90/// **Intentional divergence from Perl** (OXIDIZED_DESIGN #66). Perl's
91/// `Common::XML::Parser::parseChunk` is explicitly single-node — its own comment
92/// reads *"This expects only a single node, not a document fragment"* — and
93/// LaTeXML ships no fragment parser at all, so a Perl binding has to wrap its own
94/// markup. Yet `Document::appendTree` already has an `XML_DOCUMENT_FRAG_NODE`
95/// branch (ported at `document.rs`): the INSERTION half understands fragments
96/// perfectly well, Perl simply never feeds it one. Accepting them here inserts
97/// more of the author's content correctly and can never emit fewer errors than
98/// Perl, so it is a safe extension rather than a parity break.
99///
100/// Strategy: parse as-is FIRST, so every single-root chunk — including one led by
101/// an XML declaration, which may not be preceded by anything — behaves exactly as
102/// [`parse_chunk`] always did; only if that fails do we retry inside a throwaway
103/// wrapper. Recovery stays OFF in both attempts, so genuinely malformed markup
104/// (`<p>unclosed`, a bare `&`, an undeclared `&nbsp;`) is still rejected rather
105/// than silently salvaged.
106///
107/// Namespaces are the caller's business, as in Perl: nodes that declare none land
108/// in no namespace. Markup destined for `<ltx:rawhtml>` must therefore carry its
109/// own `xmlns` (or an `xhtml:` prefix), exactly as it must in a Perl binding.
110pub fn parse_fragment(markup: &str) -> std::result::Result<ParsedFragment, String> {
111  // 1. Single well-formed root — the plain `parseChunk` case, unchanged.
112  if let Ok(doc) = parse_chunk(markup)
113    && let Some(root) = doc.get_root_element()
114  {
115    return Ok(ParsedFragment { doc, nodes: vec![root] });
116  }
117  // 2. Otherwise treat it as a fragment: parse inside a wrapper and hand back the
118  //    wrapper's children. The wrapper itself is never inserted.
119  let doc = parse_chunk(&format!(
120    "<{FRAGMENT_WRAPPER}>{markup}</{FRAGMENT_WRAPPER}>"
121  ))?;
122  let root = doc
123    .get_root_element()
124    .ok_or_else(|| String::from("markup parsed to an empty document"))?;
125  let nodes = root.get_child_nodes();
126  Ok(ParsedFragment { doc, nodes })
127}
128
129/// Is `node` an artifact of HOW a chunk was parsed, rather than part of the
130/// chunk? True for exactly the two things that can sit ABOVE a chunk's top-level
131/// nodes: the throwaway [`parse_fragment`] wrapper, and the parsed document node.
132///
133/// Callers that let someone walk a parsed tree upwards need this. Handing back
134/// the wrapper would leak an internal name into a script-facing API — and worse,
135/// let a script insert `<_lxfragment>` into the page — while the document node
136/// would serialize the whole chunk from a `parent()` call. It also removes an
137/// arbitrary inconsistency: a single-root chunk's top node sits directly under
138/// the document node, a multi-root chunk's under the wrapper, and neither is
139/// something the caller wrote.
140pub fn is_parse_artifact(node: &Node) -> bool {
141  match node.get_type() {
142    Some(NodeType::DocumentNode) => true,
143    Some(NodeType::ElementNode) => node.get_name() == FRAGMENT_WRAPPER,
144    _ => false,
145  }
146}
147
148/// A parsed markup chunk, bundled with the throwaway document that owns it.
149///
150/// The pairing is the safety property, not a convenience: a libxml `Node` is a
151/// raw pointer into its owning document, so a node that outlives its owner is a
152/// dangling FFI pointer — a failure mode this project has already taken SIGSEGVs
153/// from, and one that bypasses `catch_unwind`. `Document` is refcounted and
154/// `Clone`, so holding this handle keeps [`ParsedFragment::nodes`] valid for
155/// exactly as long as the handle lives. That is what makes it safe to hand a
156/// parsed chunk to an untrusted `.rhai` script, which controls its own lifetimes.
157#[derive(Clone)]
158pub struct ParsedFragment {
159  /// Kept solely to own the nodes below; refcounted, so cloning is cheap.
160  doc:   Document,
161  nodes: Vec<Node>,
162}
163
164/// Hand-written because libxml's `Document`/`Node` are opaque FFI handles whose
165/// derived form would print pointers, not markup. Report what a caller actually
166/// wants to see: how many top-level nodes, and their names.
167impl std::fmt::Debug for ParsedFragment {
168  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169    f.debug_struct("ParsedFragment")
170      .field("nodes", &self.nodes.len())
171      .field(
172        "names",
173        &self.nodes.iter().map(Node::get_name).collect::<Vec<_>>(),
174      )
175      .finish()
176  }
177}
178
179impl ParsedFragment {
180  /// The top-level parsed nodes, ready to hand to `Document::append_tree`.
181  pub fn nodes(&self) -> Vec<Node> { self.nodes.clone() }
182  /// How many top-level nodes the chunk parsed to (>1 means it was a fragment).
183  pub fn len(&self) -> usize { self.nodes.len() }
184  pub fn is_empty(&self) -> bool { self.nodes.is_empty() }
185  /// The owning document, for callers that need to keep it alive explicitly.
186  pub fn document(&self) -> &Document { &self.doc }
187}
188
189pub struct XPath {
190  context: Context,
191}
192
193// pub type XPathClosure = Rc<Fn(&mut Gullet, Tokens) -> bool>;
194impl XPath {
195  pub fn new(doc: &Document, _mappings: HashMap<String, String>) -> Self {
196    let context = Context::new(doc).unwrap();
197    XPath { context }
198  }
199
200  pub fn register_namespace(&mut self, codeprefix: &str, namespace: &str) -> Result<()> {
201    match self.context.register_namespace(codeprefix, namespace) {
202      Ok(()) => {},
203      Err(_) => {
204        let message = s!(
205          "Failed to register an XPath namespace: prefix {:?} and href {:?}",
206          codeprefix,
207          namespace
208        );
209        Error!("expected", "XPath", message);
210      },
211    };
212    Ok(())
213  }
214
215  pub fn findnodes(&mut self, xpath: &str, node: Option<&Node>) -> Vec<Node> {
216    match self.context.findnodes(xpath, node) {
217      Ok(nodes) => nodes,
218      Err(e) => {
219        let message = s!(
220          "XPath {xpath:?} failed (context node: {}): {e:?}",
221          node.is_some()
222        );
223        let err = || {
224          Error!("xpath", "findnodes", message);
225          Ok(())
226        };
227        err().ok();
228        // libxml2 XPath failures (invalid context node, growth limit
229        // hit, malformed expression) used to panic and abort the run.
230        // Treat as "no matches" instead — the conversion can usually
231        // recover and produce most of the document. Drivers:
232        // 2105.04174, 2304.07380, 1904.02716 all aborted here.
233        Vec::new()
234      },
235    }
236  }
237
238  pub fn findvalues(&mut self, xpath: &str, node: Option<&Node>) -> Vec<String> {
239    match self.context.findvalues(xpath, node) {
240      Ok(vals) => vals,
241      Err(e) => {
242        let message = s!(
243          "XPath {xpath:?} failed (context node: {}): {e:?}",
244          node.is_some()
245        );
246        let err = || {
247          Error!("xpath", "findvalues", message);
248          Ok(())
249        };
250        err().ok();
251        Vec::new()
252      },
253    }
254  }
255
256  pub fn findvalue(&mut self, xpath: &str, node: Option<&Node>) -> String {
257    self.context.findvalue(xpath, node).unwrap_or_default()
258  }
259}
260
261//======================================================================
262// XML Utilities
263/// gets the following `Element` sibling of `node` (skipping over non-element nodes)
264pub fn get_next_element(node_in: &Node) -> Option<Node> {
265  let mut node = Cow::Borrowed(node_in);
266  while let Some(next) = node.get_next_sibling() {
267    if next.get_type() == Some(NodeType::ElementNode) {
268      return Some(next);
269    } else {
270      node = Cow::Owned(next);
271    }
272  }
273  None
274}
275/// gets the previous `Element` sibling of `node` (skipping over non-element nodes)
276pub fn get_prev_element(node_in: &Node) -> Option<Node> {
277  let mut node = Cow::Borrowed(node_in);
278  while let Some(next) = node.get_prev_sibling() {
279    if next.get_type() == Some(NodeType::ElementNode) {
280      return Some(next);
281    } else {
282      node = Cow::Owned(next);
283    }
284  }
285  None
286}
287/// Walk `node`'s ancestor chain to the top. If the chain ends at a
288/// `Document` (or document-fragment) node the subtree is LIVE — part of a
289/// document tree — and `None` is returned; otherwise the topmost node is a
290/// DETACHED root and is returned. Callers use this to decide whether a
291/// source tree they are done with is theirs to free
292/// (`Document::discard_subtree`) or still reachable from a live document.
293pub fn detached_root(node: &Node) -> Option<Node> {
294  let mut cur = node.clone();
295  loop {
296    match cur.get_parent() {
297      None => return Some(cur),
298      Some(p)
299        if matches!(
300          p.get_type(),
301          Some(NodeType::DocumentNode) | Some(NodeType::DocumentFragNode)
302        ) =>
303      {
304        return None;
305      },
306      Some(p) => cur = p,
307    }
308  }
309}
310
311/// obtains all `Element` children of `node`, ignoring all other node types
312pub fn element_nodes(node: &Node) -> Vec<Node> {
313  node
314    .get_child_nodes()
315    .into_iter()
316    .filter(|n| matches!(n.get_type(), Some(NodeType::ElementNode)))
317    .collect()
318}
319
320/// obtains all content children of `node` (`Element` and `Text`), ignoring all other node types
321pub fn content_nodes(node: &Node) -> Vec<Node> {
322  node
323    .get_child_nodes()
324    .into_iter()
325    .filter(|n| {
326      matches!(
327        n.get_type(),
328        Some(NodeType::ElementNode) | Some(NodeType::TextNode)
329      )
330    })
331    .collect()
332}
333
334pub fn closest_element(node: &Node) -> Option<Node> {
335  if node.get_type() == Some(NodeType::ElementNode) {
336    return Some(node.clone());
337  }
338  // Walk UP. The cursor must advance: re-reading `node.get_parent()` every
339  // iteration spun forever on any non-element whose parent is also a
340  // non-element — a text node directly under the document being the reachable
341  // case (`xmlGetParent` of a top-level node is the DocumentNode, and only
342  // the document's own parent is NULL, which is what terminates this loop).
343  let mut current = node.clone();
344  while let Some(parent) = current.get_parent() {
345    if parent.get_type() == Some(NodeType::ElementNode) {
346      return Some(parent);
347    }
348    current = parent;
349  }
350  None
351}
352
353/// Is `child` the same as `parent`, or a descendent of `parent`?
354pub fn is_descendant_or_self(child: &Node, parent: &Node) -> bool {
355  let mut p = Some(child);
356  let mut parent_opt;
357  while let Some(p_node) = p {
358    // if p.is_same_node(parent) {
359    if p_node == parent {
360      return true;
361    }
362    match p_node.get_parent() {
363      Some(parent_node) => {
364        parent_opt = Some(parent_node);
365        p = parent_opt.as_ref();
366      },
367      _ => {
368        break;
369      },
370    }
371  }
372  false
373}
374
375#[cfg(test)]
376mod tests {
377  use libxml::tree::Document;
378
379  use super::*;
380
381  #[test]
382  fn namespace_constants() {
383    assert_eq!(XML_NS, "http://www.w3.org/XML/1998/namespace");
384    assert_eq!(XMLNS_NS, "http://www.w3.org/2000/xmlns/");
385  }
386
387  /// `closest_element` must TERMINATE when no element ancestor exists.
388  /// The walk used to re-read `node.get_parent()` without advancing a cursor,
389  /// so a non-element whose parent is also a non-element spun forever; a text
390  /// node parented by the DocumentNode is the reachable shape. If this
391  /// regresses the test hangs rather than fails — that is the symptom.
392  #[test]
393  fn closest_element_terminates_without_an_element_ancestor() {
394    let doc = Document::new().unwrap();
395    let mut doc_node = doc.as_node();
396    let mut stray = Node::new_text("stray", &doc).unwrap();
397    doc_node.add_child(&mut stray).unwrap();
398
399    assert_eq!(stray.get_type(), Some(NodeType::TextNode));
400    assert_ne!(
401      doc_node.get_type(),
402      Some(NodeType::ElementNode),
403      "the parent must be a non-element for this to exercise the walk"
404    );
405    assert!(
406      closest_element(&stray).is_none(),
407      "no element ancestor exists, so the walk must end at the document"
408    );
409  }
410
411  fn build_tree() -> (Document, Node) {
412    let mut doc = Document::new().unwrap();
413    let mut root = Node::new("root", None, &doc).unwrap();
414    doc.set_root_element(&root);
415    // Mix of element + text siblings:
416    //   root: <a/> "text1" <b/> "text2" <c/>
417    let mut a = Node::new("a", None, &doc).unwrap();
418    let mut t1 = Node::new_text("text1", &doc).unwrap();
419    let mut b = Node::new("b", None, &doc).unwrap();
420    let mut t2 = Node::new_text("text2", &doc).unwrap();
421    let mut c = Node::new("c", None, &doc).unwrap();
422    root.add_child(&mut a).unwrap();
423    root.add_child(&mut t1).unwrap();
424    root.add_child(&mut b).unwrap();
425    root.add_child(&mut t2).unwrap();
426    root.add_child(&mut c).unwrap();
427    (doc, root)
428  }
429
430  #[test]
431  fn element_nodes_skips_text() {
432    let (_doc, root) = build_tree();
433    let children = element_nodes(&root);
434    assert_eq!(children.len(), 3);
435    assert_eq!(children[0].get_name(), "a");
436    assert_eq!(children[1].get_name(), "b");
437    assert_eq!(children[2].get_name(), "c");
438  }
439
440  #[test]
441  fn content_nodes_includes_text() {
442    let (_doc, root) = build_tree();
443    let children = content_nodes(&root);
444    assert_eq!(children.len(), 5, "3 elements + 2 text nodes");
445  }
446
447  #[test]
448  fn get_next_element_skips_text() {
449    let (_doc, root) = build_tree();
450    let a = element_nodes(&root)[0].clone();
451    let next = get_next_element(&a).expect("a has a next element");
452    assert_eq!(
453      next.get_name(),
454      "b",
455      "<a> next element must be <b>, skipping the text node"
456    );
457  }
458
459  #[test]
460  fn get_next_element_none_at_end() {
461    let (_doc, root) = build_tree();
462    let c = element_nodes(&root)[2].clone();
463    assert!(get_next_element(&c).is_none(), "last element has no next");
464  }
465
466  #[test]
467  fn get_prev_element_skips_text() {
468    let (_doc, root) = build_tree();
469    let b = element_nodes(&root)[1].clone();
470    let prev = get_prev_element(&b).expect("b has a prev element");
471    assert_eq!(
472      prev.get_name(),
473      "a",
474      "<b> prev element must be <a>, skipping text"
475    );
476  }
477
478  #[test]
479  fn get_prev_element_none_at_start() {
480    let (_doc, root) = build_tree();
481    let a = element_nodes(&root)[0].clone();
482    assert!(get_prev_element(&a).is_none(), "first element has no prev");
483  }
484
485  #[test]
486  fn is_descendant_or_self_true_for_self() {
487    let (_doc, root) = build_tree();
488    assert!(is_descendant_or_self(&root, &root));
489  }
490
491  #[test]
492  fn is_descendant_or_self_true_for_child() {
493    let (_doc, root) = build_tree();
494    let a = element_nodes(&root)[0].clone();
495    assert!(is_descendant_or_self(&a, &root));
496  }
497
498  #[test]
499  fn is_descendant_or_self_false_for_sibling() {
500    let (_doc, root) = build_tree();
501    let kids = element_nodes(&root);
502    assert!(
503      !is_descendant_or_self(&kids[0], &kids[1]),
504      "a is not a descendant of b"
505    );
506  }
507}
508
509#[cfg(test)]
510mod parse_chunk_tests {
511  //! Pins `parse_chunk`'s contract — the limits a binding author inherits from
512  //! Perl `parseChunk`, and the degrade-don't-crash promise `Document::insert_xml`
513  //! relies on.
514  use super::*;
515
516  #[test]
517  fn a_single_well_formed_root_parses() {
518    let doc = parse_chunk(r#"<p xmlns="http://www.w3.org/1999/xhtml">hi <b>bold</b></p>"#)
519      .expect("single-root xhtml should parse");
520    let root = doc.get_root_element().expect("parsed chunk has a root");
521    assert_eq!(root.get_name(), "p");
522    assert_eq!(root.get_attribute("class"), None);
523  }
524
525  #[test]
526  fn a_multi_root_fragment_is_rejected() {
527    // Faithful to Perl `parseChunk` ("expects only a single node"): a bare
528    // fragment of several top-level nodes is NOT well-formed XML. A binding that
529    // wants to insert one must wrap it in a single container element itself.
530    assert!(parse_chunk("<b>a</b> <i>b</i>").is_err());
531    assert!(parse_chunk("bare text").is_err());
532    assert!(parse_chunk("").is_err());
533  }
534
535  #[test]
536  fn an_undefined_html_entity_is_rejected_not_crashed() {
537    // XML predefines only lt/gt/amp/quot/apos. `&nbsp;` &c. are HTML entities and
538    // are undefined without a DTD, so an (X)HTML snippet carrying one fails to
539    // parse. It must surface as a clean Err for the caller to report — never a
540    // panic — which is what lets `insert_xml` degrade the one binding.
541    assert!(parse_chunk("<p>a&nbsp;b</p>").is_err());
542    // The numeric form is fine, and is the portable way to write it.
543    assert!(parse_chunk("<p>a&#160;b</p>").is_ok());
544  }
545}
546
547#[cfg(test)]
548mod parse_fragment_tests {
549  //! `parse_fragment` accepts what `parse_chunk` cannot (OXIDIZED_DESIGN #66)
550  //! WITHOUT loosening the reject-don't-salvage rule that protects author markup.
551  use super::*;
552
553  #[test]
554  fn a_single_root_still_yields_exactly_one_node() {
555    let f = parse_fragment(r#"<p xmlns="http://www.w3.org/1999/xhtml">hi</p>"#).unwrap();
556    assert_eq!(f.len(), 1);
557    assert_eq!(f.nodes()[0].get_name(), "p");
558  }
559
560  #[test]
561  fn sibling_roots_are_kept_whole() {
562    // The case Perl's parseChunk rejects, and the one libxml's recovery mode
563    // silently truncated to `<b>a</b>` before recovery was turned off.
564    let f = parse_fragment("<b>a</b><i>b</i>").unwrap();
565    assert_eq!(f.len(), 2, "both siblings must survive");
566    assert_eq!(f.nodes()[0].get_name(), "b");
567    assert_eq!(f.nodes()[1].get_name(), "i");
568  }
569
570  #[test]
571  fn bare_text_is_a_legitimate_fragment() {
572    let f = parse_fragment("just text").unwrap();
573    assert_eq!(f.len(), 1);
574    assert_eq!(f.nodes()[0].get_content(), "just text");
575  }
576
577  #[test]
578  fn malformed_markup_is_still_rejected_not_salvaged() {
579    // Fragment support must not become a back door to the recovery behaviour:
580    // each of these silently mangled the author's content under `recover: true`.
581    assert!(parse_fragment("<p>unclosed").is_err(), "unclosed element");
582    assert!(parse_fragment("<p>a & b</p>").is_err(), "bare ampersand");
583    assert!(
584      parse_fragment("<p>a&nbsp;b</p>").is_err(),
585      "undeclared entity"
586    );
587  }
588
589  #[test]
590  fn empty_markup_parses_to_no_nodes_rather_than_failing() {
591    // Empty input is not malformed — it simply contains nothing, so parsing
592    // SUCCEEDS with an empty node list. Whether "nothing" is acceptable is the
593    // caller's policy, not the parser's: `Document::insert_xml` treats it as a
594    // clean `Error:` rather than silently inserting nothing.
595    let f = parse_fragment("").expect("empty markup is not a parse failure");
596    assert!(f.is_empty());
597    assert_eq!(f.len(), 0);
598  }
599
600  #[test]
601  fn a_rejection_says_what_to_look_for_not_got_a_null_pointer() {
602    // The message a `.rhai` binding author sees is the whole point of failing
603    // loudly. rust-libxml collapses every parse failure to `GotNullPointer`
604    // ("Got a Null pointer"), which names nothing they can act on; each of the
605    // three realistic causes must instead be findable in what we report.
606    for markup in ["<p>unclosed", "<p>a & b</p>", "<p>a&nbsp;b</p>"] {
607      let err = parse_fragment(markup).expect_err("markup should be rejected");
608      assert!(
609        !err.to_lowercase().contains("null pointer"),
610        "leaked the useless libxml error for {markup:?}: {err}"
611      );
612      assert!(
613        err.contains("unclosed") && err.contains("&amp;") && err.contains("&#160;"),
614        "rejection must point at the likely causes for {markup:?}: {err}"
615      );
616    }
617  }
618
619  #[test]
620  fn the_wrapper_never_enters_the_result() {
621    let f = parse_fragment("<b>a</b><i>b</i>").unwrap();
622    assert!(
623      !f.nodes().iter().any(|n| n.get_name() == FRAGMENT_WRAPPER),
624      "the throwaway wrapper must not be handed back"
625    );
626  }
627}