oxml 0.0.3

A pure Rust XML toolkit with zero unsafe code: parsing, an ergonomic tree, and XPath
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 oxml. All rights reserved.

//! Axes, node tests and operators.
//!
//! `XPath` 1.0 defines each axis as a specific node sequence; an axis
//! that returns *some* plausible nodes is not the same as one that
//! returns the right ones, so each is pinned against a document whose
//! shape makes the difference visible.

use oxml::{XPath, parse};

const DOC: &str = "\
<library count=\"3\">\
<!-- catalogue -->\
<book id=\"1\" lang=\"en\"><title>Dune</title><year>1965</year></book>\
<book id=\"2\" lang=\"fr\"><title>Germinal</title><year>1885</year></book>\
<book id=\"3\" lang=\"en\"><title>Neuromancer</title><year>1984</year></book>\
<?render mode=\"list\"?>\
</library>";

/// Evaluate `expr` and return its string value.
fn val(expr: &str) -> String {
    let doc = parse(DOC).expect("well-formed");
    XPath::compile(expr)
        .unwrap_or_else(|e| panic!("`{expr}` failed to compile: {e}"))
        .evaluate(&doc)
        .to_str(&doc)
}

/// Evaluate `expr` and return the number of nodes it selects.
fn count(expr: &str) -> usize {
    let doc = parse(DOC).expect("well-formed");
    XPath::compile(expr)
        .unwrap_or_else(|e| panic!("`{expr}` failed to compile: {e}"))
        .evaluate(&doc)
        .nodes()
        .map_or(0, <[oxml::NodeId]>::len)
}

/// The text of every node `expr` selects.
fn texts(expr: &str) -> Vec<String> {
    let doc = parse(DOC).expect("well-formed");
    let value = XPath::compile(expr).expect("valid").evaluate(&doc);
    value
        .nodes()
        .map(|n| n.iter().map(|id| doc.text(*id)).collect())
        .unwrap_or_default()
}

#[test]
fn the_descendant_axis_excludes_the_context_node() {
    // The difference from descendant-or-self is exactly one node, and
    // it is the one most easily got wrong.
    assert_eq!(count("/library/descendant::book"), 3);
    assert_eq!(count("/library/descendant::library"), 0);
    assert_eq!(count("/library/descendant-or-self::library"), 1);
}

#[test]
fn the_ancestor_axes_walk_to_the_root() {
    assert_eq!(count("//title/ancestor::book"), 3);
    assert_eq!(count("//title[1]/ancestor::*"), 2, "book and library");
    assert_eq!(
        count("//title[1]/ancestor-or-self::*"),
        3,
        "title, book and library"
    );
    assert_eq!(count("//title[1]/ancestor::title"), 0);
}

#[test]
fn sibling_axes_exclude_the_context_node_and_respect_direction() {
    assert_eq!(
        texts("//book[1]/following-sibling::book/title"),
        ["Germinal", "Neuromancer"]
    );
    assert_eq!(
        texts("//book[3]/preceding-sibling::book/title"),
        ["Dune", "Germinal"]
    );
    assert_eq!(count("//book[1]/preceding-sibling::book"), 0);
    assert_eq!(count("//book[3]/following-sibling::book"), 0);
    assert_eq!(
        count("//book[2]/following-sibling::book"),
        1,
        "the context node is not its own sibling"
    );
}

#[test]
fn the_sibling_axes_of_the_root_are_empty_rather_than_a_panic() {
    assert_eq!(count("/library/following-sibling::*"), 0);
    assert_eq!(count("/library/preceding-sibling::*"), 0);
}

#[test]
fn the_parent_and_self_axes_select_one_node_or_none() {
    assert_eq!(val("//title[1]/parent::book/@id"), "1");
    assert_eq!(count("//title[1]/self::title"), 1);
    assert_eq!(count("//title[1]/self::book"), 0);
    assert_eq!(count("/library/parent::*"), 0, "the root has no parent");
}

#[test]
fn the_attribute_axis_selects_attributes_not_their_element() {
    assert_eq!(val("//book[1]/@lang"), "en");
    assert_eq!(count("//book/@lang"), 3);
    assert_eq!(count("//book/@*"), 6, "id and lang on each of three");
    assert_eq!(count("//book/@missing"), 0);
}

#[test]
fn node_tests_select_by_kind() {
    assert_eq!(count("//comment()"), 1);
    assert_eq!(count("//processing-instruction()"), 1);
    assert!(count("//text()") > 0);
    assert!(count("//node()") > count("//*"), "node() is wider");
}

#[test]
fn a_comment_and_a_processing_instruction_have_string_values() {
    assert!(val("//comment()").contains("catalogue"));
    assert!(val("//processing-instruction()").contains("list"));
}

#[test]
fn the_wildcard_selects_elements_only() {
    // Not attributes, comments or text, which is what distinguishes
    // `*` from `node()`.
    assert_eq!(count("/library/*"), 3);
}

#[test]
fn every_comparison_operator_is_implemented() {
    assert_eq!(val("2 < 3"), "true");
    assert_eq!(val("3 < 3"), "false");
    assert_eq!(val("3 <= 3"), "true");
    assert_eq!(val("4 > 3"), "true");
    assert_eq!(val("3 >= 4"), "false");
    assert_eq!(val("3 = 3"), "true");
    assert_eq!(val("3 != 3"), "false");
}

#[test]
fn every_arithmetic_operator_is_implemented() {
    assert_eq!(val("2 + 3"), "5");
    assert_eq!(val("5 - 2"), "3");
    assert_eq!(val("3 * 4"), "12");
    assert_eq!(val("7 div 2"), "3.5");
    assert_eq!(val("7 mod 2"), "1");
    assert_eq!(val("-3 + 1"), "-2");
}

#[test]
fn boolean_operators_combine_conditions() {
    assert_eq!(val("true() and true()"), "true");
    assert_eq!(val("true() and false()"), "false");
    assert_eq!(val("false() or true()"), "true");
    assert_eq!(val("false() or false()"), "false");
    assert_eq!(val("not(false())"), "true");
}

#[test]
fn a_node_set_compares_existentially() {
    // XPath 1.0: the comparison holds if *any* node satisfies it. This
    // is the rule most often implemented as "the first node".
    assert_eq!(val("//book/@lang = 'fr'"), "true");
    assert_eq!(val("//book/@lang = 'de'"), "false");
    assert_eq!(val("//year > 1900"), "true", "at least one year is later");
    assert_eq!(val("//year < 1000"), "false");
}

#[test]
fn two_node_sets_compare_by_any_pair() {
    assert_eq!(val("//book/@id = //book/@id"), "true");
    assert_eq!(val("//title = //year"), "false");
}

#[test]
fn a_node_set_compares_against_a_number_by_conversion() {
    assert_eq!(val("//year = 1965"), "true");
    assert_eq!(val("//year = 2000"), "false");
}

#[test]
fn a_predicate_can_filter_on_an_attribute_value() {
    assert_eq!(texts("//book[@lang='en']/title"), ["Dune", "Neuromancer"]);
    assert_eq!(count("//book[@lang='de']"), 0);
}

#[test]
fn position_and_last_are_one_based() {
    assert_eq!(texts("//book[position()=1]/title"), ["Dune"]);
    assert_eq!(texts("//book[last()]/title"), ["Neuromancer"]);
    assert_eq!(val("count(//book[position() > 1])"), "2");
}

#[test]
fn a_boolean_value_converts_to_a_number() {
    assert_eq!(val("true() + 1"), "2");
    assert_eq!(val("false() + 1"), "1");
}

#[test]
fn an_empty_node_set_is_false_and_a_non_empty_one_is_true() {
    assert_eq!(val("boolean(//book)"), "true");
    assert_eq!(val("boolean(//missing)"), "false");
}

#[test]
fn nan_is_false_and_a_non_zero_number_is_true() {
    assert_eq!(val("boolean(number('x'))"), "false", "NaN is false");
    assert_eq!(val("boolean(0)"), "false");
    assert_eq!(val("boolean(1)"), "true");
    assert_eq!(val("boolean(-1)"), "true");
}

#[test]
fn an_empty_string_is_false_and_any_other_string_is_true() {
    assert_eq!(val("boolean('')"), "false");
    assert_eq!(val("boolean('a')"), "true");
    assert_eq!(val("boolean('0')"), "true", "a non-empty string is true");
}

#[test]
fn a_processing_instruction_can_be_narrowed_to_its_target() {
    // XPath 1.0 gives this node test an optional literal argument; it
    // is the only one that takes one.
    assert_eq!(count("//processing-instruction('render')"), 1);
    assert_eq!(count("//processing-instruction('other')"), 0);
    assert_eq!(count(r#"//processing-instruction("render")"#), 1);
}

#[test]
fn an_unknown_node_test_is_still_rejected() {
    assert!(XPath::compile("//madeup()").is_err());
    // A malformed processing-instruction test must not be accepted
    // just because the name matched.
    assert!(XPath::compile("//processing-instruction(").is_err());
    assert!(XPath::compile("//processing-instruction('x'").is_err());
}

#[test]
fn abbreviated_syntax_expands_to_the_full_form() {
    // `.` is self::node(), `..` is parent::node(), `//` inserts a
    // descendant-or-self step. Each is a distinct code path from the
    // spelled-out axis.
    assert_eq!(count("//book[1]/./title"), 1);
    assert_eq!(count("//title[1]/../.."), 1, ".. twice reaches library");
    assert_eq!(count("//book[1]/..//title"), 3, "// after ..");
    assert_eq!(count(".//book"), 3, "a relative descendant path");
}

#[test]
fn an_absolute_path_starts_from_the_document() {
    assert_eq!(count("/library"), 1);
    assert_eq!(count("/library/book"), 3);
    assert_eq!(count("/book"), 0, "book is not the document element");
    assert_eq!(count("/"), 1, "the document node itself");
}

#[test]
fn the_union_operator_merges_node_sets() {
    assert_eq!(count("//title | //year"), 6);
    assert_eq!(count("//title | //title"), 3, "a union deduplicates");
    assert_eq!(count("//title | //missing"), 3);
}

#[test]
fn a_parenthesised_expression_can_be_a_whole_path() {
    assert_eq!(val("(1 + 2) * 3"), "9");
    assert_eq!(val("count((//title | //year))"), "6");
}

#[test]
fn an_unclosed_parenthesis_or_predicate_is_rejected() {
    for bad in [
        "(1 + 2",
        "count(//a",
        "//book[1",
        "//book[",
        "//book]",
        "1 +",
        "| //a",
        "//",
    ] {
        assert!(XPath::compile(bad).is_err(), "`{bad}` should not compile");
    }
}

#[test]
fn trailing_input_after_a_complete_expression_is_rejected() {
    assert!(XPath::compile("1 + 1 garbage").is_err());
    assert!(XPath::compile("//book )").is_err());
}

#[test]
fn every_axis_is_reachable_by_its_full_name() {
    for axis in [
        "child",
        "descendant",
        "descendant-or-self",
        "parent",
        "ancestor",
        "ancestor-or-self",
        "self",
        "attribute",
        "following-sibling",
        "preceding-sibling",
    ] {
        let expr = format!("//book/{axis}::*");
        assert!(XPath::compile(&expr).is_ok(), "`{expr}` failed to compile");
    }
    assert!(XPath::compile("//book/nosuchaxis::*").is_err());
}