Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

## [0.3.15] (2026-07-06)

### Added

* `reader::TextReader` — a safe wrapper over libxml2's `xmlTextReader` pull
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "libxml"
version = "0.3.14"
version = "0.3.15"
edition = "2024"
authors = ["Andreas Franzén <andreas@devil.se>", "Deyan Ginev <deyan.ginev@gmail.com>","Jan Frederik Schaefer <j.schaefer@jacobs-university.de>"]
description = "A Rust wrapper for libxml2 - the XML C parser and toolkit developed for the Gnome project"
Expand Down
100 changes: 95 additions & 5 deletions src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<x>` (`Some(ElementNode)`) from a
/// closing `</x>`.
/// closing `</x>` (`None`).
pub fn node_type(&self) -> Option<NodeType> {
// 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 `</x>` 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.
Expand Down Expand Up @@ -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() {
// </a> closes before the still-open <b> — not well-formed.
let path = write_temp("malformed", "<a><b></a>");
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#"<doc><a/><b/></doc>"#);
let mut reader = TextReader::from_file(&path, 0).unwrap();
let found = reader.read_to_next(|_ns, name| name == "zzz").unwrap();
assert!(
!found,
"no <zzz> exists → read_to_next must reach EOF and return false"
);
std::fs::remove_file(&path).ok();
}

/// The documented contract: an opening `<x>` is `Some(ElementNode)` but a
/// closing `</x>` 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#"<r><a>x</a></r>"#);
let mut reader = TextReader::from_file(&path, 0).unwrap();

assert!(reader.read().unwrap()); // <r> open
assert_eq!(reader.node_type(), Some(NodeType::ElementNode));
assert!(reader.is_element());

assert!(reader.read().unwrap()); // <a> 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()); // </a> 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();
}
}
82 changes: 82 additions & 0 deletions tests/xpath_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<r><c/></r>").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 <child> 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());
}
}
Loading