diff --git a/CHANGELOG.md b/CHANGELOG.md index c73023075..c4e67f35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.3.15] (2026-07-06) + ### Added * `reader::TextReader` — a safe wrapper over libxml2's `xmlTextReader` pull diff --git a/Cargo.toml b/Cargo.toml index 77456e977..fc962e55a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libxml" -version = "0.3.14" +version = "0.3.15" edition = "2024" authors = ["Andreas Franzén ", "Deyan Ginev ","Jan Frederik Schaefer "] description = "A Rust wrapper for libxml2 - the XML C parser and toolkit developed for the Gnome project" diff --git a/src/reader.rs b/src/reader.rs index b2f2e4ecf..986342a4e 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -101,12 +101,23 @@ impl TextReader { /// The current node's type. Returns `None` for reader events that have no /// [`NodeType`] equivalent — most usefully the *end-of-element* event, which /// lets a caller distinguish an opening `` (`Some(ElementNode)`) from a - /// closing ``. + /// closing `` (`None`). pub fn node_type(&self) -> Option { - // xmlTextReaderNodeType returns an xmlReaderTypes value; for the shared - // cases (element/text/PI/comment/cdata) it is numerically identical to the - // xmlElementType NodeType::from_int expects. - NodeType::from_int(unsafe { xmlTextReaderNodeType(self.ptr) } as xmlElementType) + // `xmlTextReaderNodeType` returns an `xmlReaderTypes` value, which coincides + // numerically with `xmlElementType` ONLY for `1..=12` (element, attribute, + // text, cdata, entity-ref, entity, PI, comment, document, doctype, + // fragment, notation). The reader-only events collide with UNRELATED + // element types — end-element `15 == XML_ELEMENT_DECL`, whitespace + // `13 == XML_HTML_DOCUMENT_NODE`, significant-whitespace `14 == XML_DTD_NODE`, + // end-entity `16`, xml-declaration `17` — so passing them through + // `NodeType::from_int` would mislabel them (e.g. a closing `` as an + // `ElementDecl`). Those have no `NodeType` equivalent, hence `None`. + let t = unsafe { xmlTextReaderNodeType(self.ptr) }; + if (1..=12).contains(&t) { + NodeType::from_int(t as xmlElementType) + } else { + None + } } /// True when positioned on an element *start* tag. @@ -318,4 +329,83 @@ mod tests { std::fs::remove_file(&path).ok(); } + + /// Opening a reader on a path that does not exist fails at construction + /// (`xmlReaderForFile` returns NULL), rather than deferring to the first read. + #[test] + fn from_file_on_missing_path_is_err() { + assert!(TextReader::from_file("/no/such/rust-libxml-reader-missing.xml", 0).is_err()); + } + + /// A well-formedness violation surfaces as `Err(())` from `read`, not a silent + /// early `Ok(false)` — so a caller streaming a truncated/corrupt file can tell + /// "document ended" apart from "document is broken". + #[test] + fn read_surfaces_parse_error_on_malformed_xml() { + // closes before the still-open — not well-formed. + let path = write_temp("malformed", ""); + let mut reader = TextReader::from_file(&path, 0).unwrap(); + let mut saw_err = false; + loop { + match reader.read() { + Ok(true) => continue, + Ok(false) => break, + Err(()) => { + saw_err = true; + break; + } + } + } + assert!( + saw_err, + "malformed XML must surface a read error, not Ok(false)" + ); + std::fs::remove_file(&path).ok(); + } + + /// `read_to_next` that never matches consumes the whole document and returns + /// `Ok(false)` at end of input (the streaming analogue of an empty node-set). + #[test] + fn read_to_next_returns_false_when_pattern_absent() { + let path = write_temp("nomatch", r#""#); + let mut reader = TextReader::from_file(&path, 0).unwrap(); + let found = reader.read_to_next(|_ns, name| name == "zzz").unwrap(); + assert!( + !found, + "no exists → read_to_next must reach EOF and return false" + ); + std::fs::remove_file(&path).ok(); + } + + /// The documented contract: an opening `` is `Some(ElementNode)` but a + /// closing `` is `None` — NOT a bogus `ElementDecl`. `xmlReaderTypes` + /// END_ELEMENT (15) collides numerically with `XML_ELEMENT_DECL`, so this + /// pins the `node_type` guard that keeps the two apart. + #[test] + fn node_type_distinguishes_open_from_close_tag() { + let path = write_temp("openclose", r#"x"#); + let mut reader = TextReader::from_file(&path, 0).unwrap(); + + assert!(reader.read().unwrap()); // open + assert_eq!(reader.node_type(), Some(NodeType::ElementNode)); + assert!(reader.is_element()); + + assert!(reader.read().unwrap()); // open + assert_eq!(reader.node_type(), Some(NodeType::ElementNode)); + + assert!(reader.read().unwrap()); // text "x" + assert_eq!(reader.node_type(), Some(NodeType::TextNode)); + assert!(!reader.is_element()); + + assert!(reader.read().unwrap()); // close + assert_eq!(reader.local_name().as_deref(), Some("a")); + assert_eq!( + reader.node_type(), + None, + "a closing tag has no NodeType equivalent — must be None, not ElementDecl" + ); + assert!(!reader.is_element()); + + std::fs::remove_file(&path).ok(); + } } diff --git a/tests/xpath_tests.rs b/tests/xpath_tests.rs index 9e3b91ee4..8625dc57c 100644 --- a/tests/xpath_tests.rs +++ b/tests/xpath_tests.rs @@ -245,3 +245,85 @@ mod compile_tests { assert!(!compiles); } } + +/// Coverage for the `*_checked` evaluators and `XPathError` — the error-surfacing +/// API added for very large documents. The `Ok` path is exercised throughout the +/// tests above (the thin `evaluate`/`node_evaluate` wrappers delegate to their +/// `_checked` variant); these pin the previously-untested `Err`/classification path. +mod checked_evaluators { + use libxml::parser::Parser; + use libxml::xpath::{Context, XPathError}; + + /// An invalid expression makes libxml2 return NULL; `evaluate_checked` turns + /// that into a populated `XPathError` (message + non-zero code), while the thin + /// `evaluate` still collapses it to `Err(())`. + #[test] + fn evaluate_checked_surfaces_invalid_expression() { + let doc = Parser::default().parse_string("").unwrap(); + let ctx = Context::new(&doc).unwrap(); + + let err = ctx + .evaluate_checked("///") + .expect_err("`///` is not a valid XPath expression"); + assert!(err.message.is_some(), "expected a libxml2 error message"); + assert_ne!(err.code, 0, "expected a non-zero libxml2 error code"); + assert!( + !err.is_nodeset_limit(), + "a syntax error is not the nodeset ceiling" + ); + + assert!(ctx.evaluate("///").is_err(), "thin wrapper still errors"); + // A valid expression still succeeds through the checked path. + assert_eq!( + ctx.evaluate_checked("//c").unwrap().get_number_of_nodes(), + 1 + ); + } + + /// `is_nodeset_limit` recognizes exactly libxml2's growing-nodeset / OOM + /// messages and nothing else. Constructed directly (the fields are public) + /// because materializing a real >10M node-set in a unit test is impractical. + #[test] + fn is_nodeset_limit_classifies_libxml2_messages() { + let mk = |m: Option<&str>| XPathError { + message: m.map(str::to_string), + code: 0, + domain: 0, + }; + assert!(mk(Some("XPath error : growing nodeset hit limit")).is_nodeset_limit()); + assert!(mk(Some("Memory allocation failed : growing nodeset")).is_nodeset_limit()); + assert!(!mk(Some("Invalid expression\n")).is_nodeset_limit()); + assert!(!mk(None).is_nodeset_limit()); + } + + /// The node-relative checked evaluators resolve against the given context node + /// (both the `&Node` and `RoNode` entry points) and surface errors the same way. + #[test] + fn node_checked_evaluators_ok_and_err() { + let doc = Parser::default() + .parse_file("tests/resources/file01.xml") + .unwrap(); + let ctx = Context::new(&doc).unwrap(); + + let root = doc.get_root_element().unwrap(); + assert_eq!( + ctx + .node_evaluate_checked("child", &root) + .unwrap() + .get_number_of_nodes(), + 2, + "two nodes are reachable from the root" + ); + assert!(ctx.node_evaluate_checked("///", &root).is_err()); + + let ro_root = doc.get_root_readonly().unwrap(); + assert_eq!( + ctx + .node_evaluate_readonly_checked("child", ro_root) + .unwrap() + .get_number_of_nodes(), + 2 + ); + assert!(ctx.node_evaluate_readonly_checked("///", ro_root).is_err()); + } +}