oxml 0.0.11

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.

//! XML syntax the parser must accept, and the truncations it must
//! reject.
//!
//! Each construct here is one a real document uses — declarations,
//! doctypes, CDATA, processing instructions, entities, namespaces —
//! paired with the truncated form that must be an error rather than a
//! silently shortened document.

use oxml::{ErrorKind, NodeKind, parse};

fn text_of(src: &str, attribute: Option<&str>) -> String {
    let doc = parse(src).expect("well-formed");
    let root = doc.root_element().expect("root");
    match attribute {
        Some(name) => doc.attribute(root, name).expect("attribute").to_string(),
        None => doc.text(root),
    }
}

#[test]
fn an_xml_declaration_is_accepted_and_not_part_of_the_tree() {
    let doc = parse("<?xml version=\"1.0\" encoding=\"UTF-8\"?><a>x</a>")
        .expect("well-formed");
    let root = doc.root_element().expect("root element");
    assert_eq!(doc.element_name(root).map(|n| n.local.as_str()), Some("a"));
    assert_eq!(doc.text(root), "x");
}

#[test]
fn a_truncated_xml_declaration_is_rejected() {
    let e = parse("<?xml version=\"1.0\"").expect_err("truncated");
    assert!(
        matches!(e.kind, ErrorKind::Unterminated(w) if w.contains("XML")),
        "got {:?}",
        e.kind
    );
}

#[test]
fn a_doctype_is_skipped() {
    let doc = parse("<!DOCTYPE note SYSTEM \"note.dtd\"><note>x</note>")
        .expect("well-formed");
    assert_eq!(doc.text(doc.root_element().expect("root")), "x");
}

#[test]
fn a_truncated_doctype_is_rejected() {
    let e = parse("<!DOCTYPE note").expect_err("truncated");
    // Since the DTD is parsed rather than skipped, this now reports the
    // more specific `MalformedDtd` rather than the generic
    // `Unterminated`.
    assert!(
        matches!(e.kind, ErrorKind::MalformedDtd(w) if w.contains("doctype")),
        "got {:?}",
        e.kind
    );
}

#[test]
fn a_processing_instruction_becomes_a_node() {
    let doc = parse("<?php echo 1; ?><a/>").expect("well-formed");
    let found = doc.descendants().any(|n| {
        matches!(
            doc.kind(n),
            Some(NodeKind::ProcessingInstruction { target, .. })
                if target == "php"
        )
    });
    assert!(found, "the processing instruction was dropped");
}

#[test]
fn cdata_content_is_text_and_is_not_re_parsed() {
    // The whole point of CDATA: markup inside it is data.
    let src = "<a><![CDATA[<b> & </b>]]></a>";
    let doc = parse(src).expect("well-formed");
    let root = doc.root_element().expect("root");
    assert_eq!(doc.text(root), "<b> & </b>");
    assert_eq!(doc.children(root).len(), 1, "one text node, no elements");
}

#[test]
fn an_unterminated_cdata_section_is_rejected() {
    let e = parse("<a><![CDATA[oops</a>").expect_err("truncated");
    assert!(
        matches!(e.kind, ErrorKind::Unterminated(w) if w.contains("CDATA")),
        "got {:?}",
        e.kind
    );
}

#[test]
fn the_predefined_entities_are_resolved() {
    assert_eq!(text_of("<a>&lt;&gt;&amp;&quot;&apos;</a>", None), "<>&\"'");
}

#[test]
fn numeric_character_references_are_resolved() {
    assert_eq!(text_of("<a>&#65;&#x42;</a>", None), "AB");
    assert_eq!(text_of("<a>&#233;&#x4e2d;</a>", None), "é中");
    assert_eq!(text_of("<a>&#128512;</a>", None), "😀", "beyond the BMP");
}

#[test]
fn entities_are_resolved_in_attribute_values_too() {
    assert_eq!(text_of("<a t=\"&lt;&amp;&#65;\"/>", Some("t")), "<&A");
}

#[test]
fn an_unterminated_start_tag_is_rejected() {
    for src in ["<a", "<a href=\"x\"", "<a "] {
        let e = parse(src).expect_err("truncated");
        assert!(
            matches!(
                e.kind,
                ErrorKind::Unterminated(_) | ErrorKind::UnexpectedEof
            ),
            "`{src}` gave {:?}",
            e.kind
        );
    }
}

#[test]
fn an_unterminated_end_tag_is_rejected() {
    let e = parse("<a></a").expect_err("truncated");
    assert!(
        matches!(
            e.kind,
            ErrorKind::Unterminated(_) | ErrorKind::UnexpectedEof
        ),
        "got {:?}",
        e.kind
    );
}

#[test]
fn both_quote_styles_are_accepted_for_attributes() {
    assert_eq!(text_of("<a t='single'/>", Some("t")), "single");
    assert_eq!(text_of("<a t=\"double\"/>", Some("t")), "double");
    assert_eq!(
        text_of("<a t='has \"double\" inside'/>", Some("t")),
        "has \"double\" inside"
    );
}

#[test]
fn a_default_namespace_applies_to_unprefixed_descendants() {
    let doc = parse("<r xmlns=\"urn:d\"><c/></r>").expect("well-formed");
    for local in ["r", "c"] {
        let id = doc
            .descendants()
            .find(|n| doc.element_name(*n).is_some_and(|e| e.local == local))
            .expect("element");
        assert_eq!(
            doc.element_name(id).and_then(|n| n.namespace.as_deref()),
            Some("urn:d"),
            "{local}"
        );
    }
}

#[test]
fn a_namespace_scope_ends_with_its_element() {
    // The prefix is declared on `inner` only; using it outside must
    // fail rather than silently resolving.
    assert!(parse("<r><i xmlns:p=\"urn:p\"><p:x/></i></r>").is_ok());
    assert!(parse("<r><i xmlns:p=\"urn:p\"/><p:x/></r>").is_err());
}

#[test]
fn whitespace_only_documents_and_trailing_whitespace_are_handled() {
    assert!(parse("  <a/>  ").is_ok(), "surrounding whitespace is legal");
    assert!(parse("  ").is_err(), "whitespace alone has no root element");
}

#[test]
fn comments_are_preserved_but_are_not_text() {
    let doc = parse("<a><!-- note -->x</a>").expect("well-formed");
    let root = doc.root_element().expect("root");
    assert_eq!(doc.text(root), "x");
    assert!(
        doc.children(root)
            .iter()
            .any(|c| matches!(doc.kind(*c), Some(NodeKind::Comment(_))))
    );
}

#[test]
fn nesting_up_to_the_limit_parses() {
    let src = format!(
        "{}{}",
        "<a>".repeat(oxml::MAX_DEPTH),
        "</a>".repeat(oxml::MAX_DEPTH)
    );
    let doc = parse(&src).expect("at the limit");
    assert!(doc.len() >= oxml::MAX_DEPTH);
}

#[test]
fn nesting_past_the_limit_is_an_error_not_a_stack_overflow() {
    // Parsing descends one frame per open element. Without a limit a
    // hostile document aborts the process, which no caller can catch —
    // and every front end of this crate reads documents it did not
    // write.
    for depth in [oxml::MAX_DEPTH + 1, 10_000, 100_000] {
        let src = format!("{}{}", "<a>".repeat(depth), "</a>".repeat(depth));
        let e = parse(&src).expect_err("beyond the limit");
        assert!(
            matches!(e.kind, ErrorKind::DepthLimitExceeded),
            "depth {depth} gave {:?}",
            e.kind
        );
        // The message names the bound that was hit, not its value:
        // the value is now caller-configurable via `Limits`.
        assert!(e.to_string().contains("depth limit"), "{e}");
    }
}

#[test]
fn an_unclosed_deep_document_is_also_bounded() {
    // No closing tags at all: the limit must apply on the way down,
    // not only when the document is balanced.
    let src = "<a>".repeat(100_000);
    assert!(parse(&src).is_err());
}

#[test]
fn an_unquoted_attribute_value_is_rejected_in_every_position() {
    for src in ["<a x=1/>", "<a x=y z=\"1\"/>", "<a x= />"] {
        let e = parse(src).expect_err("unquoted");
        assert!(
            matches!(
                e.kind,
                ErrorKind::UnquotedAttributeValue | ErrorKind::InvalidName
            ),
            "`{src}` gave {:?}",
            e.kind
        );
    }
}

#[test]
fn an_unterminated_attribute_value_is_rejected() {
    let e = parse("<a x=\"unclosed").expect_err("truncated");
    assert!(
        matches!(
            e.kind,
            ErrorKind::Unterminated(_) | ErrorKind::UnexpectedEof
        ),
        "got {:?}",
        e.kind
    );
}

#[test]
fn trailing_content_after_the_root_is_rejected_in_each_form() {
    for src in ["<a/>text", "<a/><b/>", "<a/><!-- c --><b/>"] {
        assert!(parse(src).is_err(), "`{src}` should not parse");
    }
    // A comment or whitespace alone after the root is legal.
    assert!(parse("<a/><!-- trailing comment -->").is_ok());
    assert!(parse("<a/>\n").is_ok());
}

#[test]
fn names_may_use_the_full_xml_name_range() {
    // Accented and non-Latin names are valid XML; rejecting them would
    // break every non-English document.
    for src in ["<é/>", "<中文/>", "<a.b/>", "<a-b/>", "<_a/>", "<a1/>"] {
        assert!(parse(src).is_ok(), "`{src}` should parse");
    }
    for src in ["<1a/>", "<-a/>", "<.a/>", "< a/>"] {
        assert!(parse(src).is_err(), "`{src}` should not parse");
    }
}

#[test]
fn an_unterminated_comment_is_rejected() {
    let e = parse("<a><!-- unclosed").expect_err("truncated");
    assert!(
        matches!(e.kind, ErrorKind::Unterminated(w) if w.contains("comment")),
        "got {:?}",
        e.kind
    );
}

#[test]
fn a_literal_cdata_terminator_is_rejected_in_content() {
    // `]]>` must be written `]]&gt;` so that a reader can always tell
    // where a CDATA section ends.
    let e = parse("<doc>]]></doc>").expect_err("not well-formed");
    assert!(matches!(e.kind, ErrorKind::IllegalCdataEnd), "{:?}", e.kind);
    // Inside a CDATA section it is the terminator, not content.
    assert!(parse("<doc><![CDATA[a]]></doc>").is_ok());
    // Escaped, it is ordinary text.
    let doc = parse("<doc>]]&gt;</doc>").expect("well-formed");
    assert_eq!(doc.text(doc.root()), "]]>");
}

#[test]
fn reserved_namespace_prefixes_are_enforced() {
    const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
    // `xml` bound to its own namespace is the one legal case.
    assert!(parse(&format!("<a xmlns:xml=\"{XML_NS}\"/>")).is_ok());
    // Bound to anything else, it is not.
    assert!(parse("<a xmlns:xml=\"urn:other\"/>").is_err());
    // No other prefix may be bound to the XML namespace.
    assert!(parse(&format!("<a xmlns:p=\"{XML_NS}\"/>")).is_err());
    // `xmlns` may not be declared at all.
    assert!(parse("<a xmlns:xmlns=\"urn:x\"/>").is_err());
    // Nor may anything be bound to the xmlns namespace.
    assert!(parse("<a xmlns:p=\"http://www.w3.org/2000/xmlns/\"/>").is_err());
}

#[test]
fn comments_may_not_contain_a_double_hyphen() {
    assert!(parse("<a><!-- ok --></a>").is_ok());
    assert!(parse("<a><!-- no -- no --></a>").is_err());
    assert!(parse("<a><!-- trailing ---></a>").is_err());
}

#[test]
fn the_xml_declaration_must_match_its_grammar() {
    assert!(parse("<?xml version=\"1.0\"?><a/>").is_ok());
    assert!(parse("<?xml version=\"1.0\" standalone=\"yes\"?><a/>").is_ok());
    // No whitespace between pseudo-attributes.
    assert!(parse("<?xml version=\"1.0\"standalone=\"yes\"?><a/>").is_err());
    // Wrong order.
    assert!(parse("<?xml standalone=\"yes\" version=\"1.0\"?><a/>").is_err());
    // `standalone` takes only yes or no.
    assert!(parse("<?xml version=\"1.0\" standalone=\"maybe\"?><a/>").is_err());
    // `version` is required.
    assert!(parse("<?xml encoding=\"UTF-8\"?><a/>").is_err());
}

#[test]
fn xml_is_a_reserved_processing_instruction_target() {
    // A second declaration is not a misplaced declaration but an
    // illegal processing instruction.
    assert!(parse("<a/><?xml version=\"1.0\"?>").is_err());
    assert!(parse("<a><?XmL x?></a>").is_err());
    assert!(parse("<a><?xmlfoo x?></a>").is_ok(), "only exactly `xml`");
}

#[test]
fn content_models_must_be_well_formed() {
    let ok = |m: &str| {
        parse(&format!("<!DOCTYPE a [<!ELEMENT a {m}>]><a/>")).is_ok()
    };
    assert!(ok("EMPTY"));
    assert!(ok("ANY"));
    assert!(ok("(#PCDATA)"));
    assert!(ok("(#PCDATA|b)*"));
    assert!(ok("(b,c)"));
    assert!(ok("(b|c)+"));
    assert!(ok("(b,(c|d)*)?"));
    // `#PCDATA` must come first in a mixed model.
    assert!(!ok("(b|#PCDATA)*"));
    // A mixed model with alternatives must be starred.
    assert!(!ok("(#PCDATA|b)"));
    // A group may not mix separators.
    assert!(!ok("(b|c,d)"));
    assert!(!ok("(b,c|d)"));
}