Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

XML

omnist::formats::xml::{read_xml, read_xml_with_schema, write_xml, check_xml}. Ported from ~/dev/omnist/omnist/formats.py’s read_xml/write_xml/check_xml; see omnist/src/formats/xml.rs’s module doc for the full detail.

XML needs exactly one document element, so (unlike JSON/YAML/TOML) an example document must be wrapped under a single top-level element.

#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::xml::{read_xml, write_xml};

// `age` is a `Value::Str`, not `Value::Int`: XML text carries no type
// information (see this doc's "Text stays untyped" note below) -- keeping
// it a string in the first place is what makes this round trip lossless.
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Str("37".to_string()));
let mut root = IndexMap::new();
root.insert("person".to_string(), Value::Object(fields));
let doc = Doc::of(&Value::Object(root)).unwrap();

let text = write_xml(&doc, true, None).unwrap();
let doc2 = read_xml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}

Structural difference: interleaving-preserving, not grouped

Unlike the other three codecs, this module goes through Doc::from_raw/ Doc::to_raw (RawNode), not Doc::to_grouped – XML element order can interleave distinct labels arbitrarily (<b/><c/><b/>), which a JSON-shaped IndexMap can’t represent.

quick-xml: no advisory, no DTD/entity-expansion at all

cargo audit against this crate’s full dependency tree (29 crates, including quick-xml 0.41.0) on 2026-07-26 against the RustSec advisory database found zero matches – unlike TS’s port, which carried an unfixable fast-xml-parser advisory (omnist-ts#38). quick-xml also has no DTD/external-entity expansion support at all (only the five predefined XML entities are recognized) – XXE-safe by construction, not by configuration (Python’s read_xml needs defusedxml instead of the stdlib ElementTree for the same protection).

The data-XML profile: what the reader refuses

read_xml reads a deliberately narrow subset (omnist-spec’s data-XML profile) and refuses the rest, rather than ignoring it. Each refusal is a DocumentError at path $ whose code is:

Constructcode
any DOCTYPE declaration (refused on sight, even if nothing uses it)format.dtd-forbidden
an entity reference other than the five predefined ones, in text or in an attribute valueformat.entity-forbidden
text alongside child elementsformat.mixed-content

These are refusals, not syntax errors: the input is well-formed XML. The refusal is raised only after the whole document has proved well-formed, so malformed XML is always parse.codec-syntax (never misreported as a refusal). Numeric character references and the five predefined entities stay legal; comments, CDATA sections and processing instructions are inert.

Characters and character references on read (C-15, C-16)

XML 1.0’s Char production admits U+0009, U+000A, U+000D, U+0020 to U+D7FF, U+E000 to U+FFFD and U+10000 to U+10FFFF. A character outside it, written directly anywhere in the input (text, a CDATA section, an attribute value, a comment, a processing instruction, a name), is parse.codec-syntax, as is a numeric character reference that is malformed (&#;, &#x;, &#65 without its ;, &#X41;, a sign, a non-digit, a value too large for an integer) or names a code point outside Char (&#0;, &#1;, &#xB;, a surrogate, &#xFFFE;, &#xFFFF;, &#x110000; and above). The check covers an attribute value’s references too, although attributes are otherwise dropped. A valid reference reads as the one character it names. An encoding= pseudo-attribute in the XML declaration is ignored whatever it names (C-15): the bytes are read as UTF-8.

Illegal characters fail the write

XML 1.0 cannot represent a raw C0 control character other than tab, LF and CR (nor U+FFFE/U+FFFF). A string holding one fails write_xml with write.unsupported-value at that string’s path, strict or not; it is no longer replaced with U+FFFD.

Namespaces: a disclosed simplification

quick_xml runs in non-namespace-aware mode here; a namespaced tag’s local name is taken by stripping a lexical prefix: up to the last :, which coincides with Python’s ElementTree-based behavior for the common case (a declared, in-scope prefix) but does not resolve prefixes through xmlns declarations. Namespaces are outside this issue’s spec.

Text stays untyped until materialize

XML’s grammar carries no type information – <m>1</m> and <m>hi</m> are syntactically identical, a bare text node. read_xml builds every leaf as a plain string unconditionally; no int/float/bool inference happens at parse time.

Writing a non-string scalar (bool/int/float) to XML now honestly reports it: XML has no native typed literals, so it reads back as a string, not its original type (check_xml’s format.value-stringified adjustment; named value.stringified through 0.7.0-alpha, renamed to the code the spec’s section 8.3.8 names).

A date, time or datetime leaf is reported as format.temporal-stringified instead, never as format.value-stringified as well (E-5): a leaf takes one of the two codes, once, at the leaf’s own path (indexed when the label repeats). The text is the value’s ISO-8601 spelling with its seconds always written (12:00 is written 12:00:00, E-37).

null has no XML form

XML has no null token, and a childless element reads back as the empty string (<root><s/></root> is s holding "", not a null). A written null would therefore be indistinguishable from the different, valid Document holding the empty string, so the writer fails (spec C-10): write_xml returns a WriteError with code write.unsupported-value and the Document path of the null leaf, E-10 indexed ($.root.item[1] for the second of three item edges), whatever strict says. check_xml previews the same refusal as an error-severity write.unsupported-value entry.

Through 0.8.0-alpha this port wrote an empty element and recorded a null.omitted warning, a code the spec’s table never had. The code is gone with the behaviour (0.9.0-alpha); a script that matched null.omitted, or relied on a null becoming <a />, must change. TOML’s null fails the same way.

Schema-guided pretyping (spec §2.2 / issue #114)

Because XML text is untyped and materialize strictly rejects coercing plain strings to boolean/integer/number, [read_xml_with_schema] performs schema-guided pretyping on the raw XML tree before materialization (spec §2.2 / issue #114).

An integer leaf with more decimal digits than the D-9 limit (Limits::max_int_digits, default 4,300; D-28 counts the digits of the value) is refused with document.limit.int-digits at the leaf’s Document path ($.r.n, or $.r.n[1] when the label repeats, E-10), not left a string for materialize to reject as materialize.inexact-conversion (omnist-rs#204). A number leaf is exempt from the limit however long its integer part (D-28): past binary64 it reads as +Infinity/-Infinity (or a zero on underflow, D-29), never as a string and never as materialize.inexact-conversion (through 0.11.1-alpha a number leaf over the limit stayed a string).

#![allow(unused)]
fn main() {
use omnist::formats::xml::read_xml_with_schema;
use omnist::osd::parse_schema;

let schema_osd = r#"
record Address  { "street": string, "city": string }
record LineItem { "sku": string, "qty": integer, "price": number }

record Order {
    "id":           string,
    "status":       string,
    "total":        number,
    "address":      Address,
    "items" [1,]:   LineItem,
    "coupon" [0,1]: string,
}

record Root { "order": Order }
root Root
"#;
let schema = parse_schema(schema_osd).unwrap();

let xml = r#"<order>
  <id>A1</id>
  <status>shipped</status>
  <total>29.97</total>
  <address><street>1 Main</street><city>London</city></address>
  <items><sku>W</sku><qty>3</qty><price>9.99</price></items>
  <items><sku>G</sku><qty>1</qty><price>9.99</price></items>
</order>"#;

let doc = read_xml_with_schema(xml, &schema).unwrap();
assert!(schema.validate(&doc.root()).ok());
}