Skip to main content

omnist/formats/
xml.rs

1//! XML codec. Ported from `~/dev/omnist/omnist/formats.py`'s
2//! `read_xml`/`write_xml`/`check_xml`.
3//!
4//! ## Structural difference from `json.rs`/`yaml.rs`/`toml.rs`
5//!
6//! Per the Python module's own top docstring: "XML uses repeated elements
7//! directly, so it preserves interleaving on read and needs a single
8//! document element on write." Unlike the other three codecs, which all
9//! project through [`crate::document::Doc::to_grouped`] (the JSON-shaped
10//! "same-label edges become an array" representation), this module goes
11//! through [`crate::document::Doc::from_raw`]/[`Doc::to_raw`] and
12//! [`crate::document::RawNode`] instead -- the same interleaving-preserving
13//! path `crate::oml` uses, and for the same reason: XML element order can
14//! interleave distinct labels arbitrarily (`<b/><c/><b/>`), which a
15//! `Value::Object`'s `IndexMap` cannot represent (see `RawNode`'s own doc
16//! comment in `document.rs`). Read builds a `RawNode` directly from the
17//! parsed element tree; write walks `Doc::to_raw()` directly, never
18//! `to_grouped()`.
19//!
20//! ## Crate choice: `quick-xml`, advisory-checked
21//!
22//! [`quick_xml`] is used for read-side tokenization only (default features,
23//! no `serde`); the omnist-specific layer -- interleaving/repetition
24//! preservation, the depth guard, all-occurrences sanitization -- is
25//! hand-written, mirroring `yaml.rs`/`toml.rs`'s
26//! crate-for-tokenization-plus-hand-written-logic pattern.
27//!
28//! Checked per the omnist-ts#38 concern (an unfixable `fast-xml-parser`
29//! GHSA advisory the TS port could not shed): `cargo audit` against this
30//! crate's full dependency tree (29 crates, including `quick-xml` 0.41.0)
31//! on 2026-07-26 against the RustSec advisory database (1169 advisories
32//! loaded) found **zero** matches -- `quick-xml` has no open advisory at
33//! this version, unlike TS's situation. Additionally, `quick-xml` has no
34//! DTD/external-entity expansion support at all (only the five predefined
35//! XML entities `&lt; &gt; &amp; &apos; &quot;` are recognized; an
36//! undefined entity reference is a parse error, not silently resolved) --
37//! so, unlike Python's `read_xml` (which specifically requires
38//! `defusedxml` instead of the stdlib `xml.etree.ElementTree`, precisely to
39//! shut off XXE/entity-expansion attacks), this port has no equivalent
40//! opt-in needed: the crate is XXE-safe by construction, not by
41//! configuration.
42//!
43//! ## Namespace handling: a disclosed simplification
44//!
45//! Python's `read_xml` uses `ElementTree`, which resolves a namespaced tag
46//! into Clark notation (`{uri}local`) and `_local()` strips the `{uri}`
47//! prefix to get the bare local name. `quick_xml` (used here in
48//! non-namespace-aware mode) does not perform namespace URI resolution;
49//! `local_name` instead strips a lexical `prefix:` (up to the last `:`)
50//! from a tag, which coincides with Python's behavior for the common case
51//! (a declared, in-scope prefix) but does not resolve prefixes through
52//! `xmlns` declarations the way real namespace-aware processing would.
53//! Namespaces are outside this issue's spec (the Python docstring never
54//! mentions them), so this is a deliberate, disclosed simplification, not
55//! a claimed parity guarantee.
56//!
57//! ## Text stays untyped until materialize (omnist-rs#86)
58//!
59//! XML's grammar carries no type information -- `<m>1</m>` and `<m>hi</m>`
60//! are syntactically identical, a bare text node. Per `docs/formats/xml.md`
61//! ("Text is untyped ... every leaf arrives as a string. Typing requires a
62//! schema in stage 2."), [`read_xml`] builds every leaf as `Scalar::Str`
63//! unconditionally, with no int/float/bool inference at parse time --
64//! confirmed against a live `~/dev/venvs/omnist` `read_xml`: `<m>1</m>`
65//! reads as `Scalar::Str("1")`, never `Scalar::Int((1).into())`.
66//!
67//! An earlier version of this module ported a `coerce()` helper that
68//! type-inferred leaf text (bool/int/float) at parse time, contradicting
69//! the spec and diverging from Python's reference `read_xml` -- filed and
70//! fixed as omnist-rs#86 (found via the conformance harness, vector
71//! `formats-xml/basic/interleaved-elements-preserve-order`). Python fixed
72//! the identical bug in its own `read_xml` as `omnist#288`
73//! (`_xml_to_node` no longer infers scalar kind from text shape); this
74//! module's fix mirrors that commit.
75//!
76//! ## Schema-guided pretyping (issue #114)
77//!
78//! Because XML text carries no native type information and `materialize`
79//! intentionally never coerces plain strings to `integer`/`number`/`boolean`
80//! scalars, [`read_xml_with_schema`] performs schema-guided pretyping of
81//! `boolean`, `integer`, and `number` fields before materialization,
82//! mirroring Python's `_xml_pretype`. Fields typed `any`, date/time/datetime,
83//! and mismatched text stay strings for normal stage-2 validation/materialization
84//! reporting.
85//!
86//! ## All-occurrences sanitization (omnist-ts#36 regression)
87//!
88//! `omnist-ts#36`: `writeXml`'s `xmlSanitize` used a **non-global** regex,
89//! so only the *first* XML-illegal character in a string was replaced,
90//! emitting malformed XML for any string with more than one. This module's
91//! `xml_sanitize` does not use a substitution regex at all -- it maps
92//! every `char` of the input through `is_xml_illegal_char` individually
93//! (`str::chars().map(...).collect()`), so there is no "first occurrence
94//! only" bug class available in the first place. See
95//! `sanitizes_every_illegal_character_not_just_the_first` for the
96//! regression test with multiple illegal characters in one string.
97//!
98//! ## Depth guard reuse
99//!
100//! [`read_xml`] checks nesting depth itself, inline, during the
101//! recursive-descent walk of `quick_xml`'s pull events (mirroring Python's
102//! own `_xml_to_node`, which inlines the identical check against the
103//! shared `_MAX_DEPTH` constant rather than routing through
104//! `document.py`'s write-side guard) -- this is necessary because,
105//! without it, an adversarially deep input could blow the native Rust call
106//! stack in `parse_content`'s own recursion *before* [`Doc::from_raw`]
107//! ever gets a chance to reject it via
108//! `crate::document::check_write_depth`. [`Doc::from_raw`] then applies
109//! its own guard a second time when building the final `Doc` -- a harmless,
110//! redundant confirmation of the same limit, not a second copy of the
111//! guard's logic (it calls the crate's one shared `check_write_depth`).
112//! [`write_xml`]/[`check_xml`] do not re-guard on the way out, matching
113//! `json.rs`'s reasoning: they walk an already-`Doc`-validated
114//! [`RawNode`], so there is nothing left to guard.
115//!
116//! ## Single document element (write-side)
117//!
118//! [`write_xml`] requires `doc.to_raw()` to be a [`RawNode::Edges`] with
119//! **exactly one** edge -- any other shape (a bare leaf-rooted `Doc`, zero
120//! edges, or more than one top-level edge) is a [`crate::error::WriteError`],
121//! matching Python's `write_xml` check
122//! (`if not (isinstance(node, list) and len(node) == 1): raise WriteError(...)`)
123//! exactly, including that it fires *outside* [`crate::report::finish_write`]
124//! -- unconditionally, even under `strict=false`, and with no
125//! [`crate::report::WriteReport`] attached (mirrors `toml.rs`'s identical
126//! "non-table root" precedent).
127
128use crate::WriteError;
129use crate::document::{Cursor, Doc, MAX_DEPTH, MAX_NODES, RawNode, Scalar};
130use crate::error::{DocumentError, OmnistError, ParseError};
131use crate::formats::float_fmt;
132use crate::formats::textpos::line_col_bytes;
133use crate::report::{Severity, WriteReport};
134use crate::schema::{FieldType, Resolved, ScalarKind, Schema};
135use indexmap::IndexMap;
136use quick_xml::Reader;
137use quick_xml::events::Event;
138
139// ============================================================== Reader
140
141static XML_INT_RE: std::sync::LazyLock<regex::Regex> =
142    std::sync::LazyLock::new(|| regex::Regex::new(r"^-?(0|[1-9]\d*)$").unwrap());
143
144static XML_NUM_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
145    regex::Regex::new(r"^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$").unwrap()
146});
147
148fn xml_pretype_scalar(node: RawNode, s: &crate::schema::Scalar) -> RawNode {
149    let RawNode::Leaf(Scalar::Str(ref val)) = node else {
150        return node;
151    };
152    match s.kind() {
153        ScalarKind::Boolean => {
154            if val == "true" {
155                RawNode::Leaf(Scalar::Bool(true))
156            } else if val == "false" {
157                RawNode::Leaf(Scalar::Bool(false))
158            } else {
159                node
160            }
161        }
162        ScalarKind::Integer => {
163            if XML_INT_RE.is_match(val) {
164                let digits = if let Some(stripped) = val.strip_prefix('-') {
165                    stripped
166                } else {
167                    val.as_str()
168                };
169                if digits.len() <= crate::formats::int_cap::MAX_INT_DIGITS {
170                    let i: num_bigint::BigInt = val
171                        .parse()
172                        .expect("XML_INT_RE guarantees valid integer literal");
173                    return RawNode::Leaf(Scalar::Int(i));
174                }
175            }
176            node
177        }
178        ScalarKind::Number => {
179            if XML_NUM_RE.is_match(val) {
180                let digits = if let Some(stripped) = val.strip_prefix('-') {
181                    stripped
182                } else {
183                    val.as_str()
184                };
185                let int_digits = digits.split(['.', 'e', 'E']).next().unwrap_or(digits);
186                if int_digits.len() <= crate::formats::int_cap::MAX_INT_DIGITS {
187                    let f: f64 = val
188                        .parse()
189                        .expect("XML_NUM_RE guarantees valid float literal");
190                    return RawNode::Leaf(Scalar::Float(f));
191                }
192            }
193            node
194        }
195        _ => node,
196    }
197}
198
199fn xml_pretype(node: RawNode, schema: &Schema, ty: &FieldType) -> RawNode {
200    let d = schema.resolve(ty);
201    match d {
202        Resolved::Any => node,
203        Resolved::Scalar(s) => xml_pretype_scalar(node, &s),
204        Resolved::Record(rec) => {
205            let RawNode::Edges(edges) = node else {
206                return node;
207            };
208            let mut out = Vec::with_capacity(edges.len());
209            for (label, child) in edges {
210                let pretyped_child = if let Some(field) = rec.field(&label) {
211                    xml_pretype(child, schema, &field.ty)
212                } else {
213                    child
214                };
215                out.push((label, pretyped_child));
216            }
217            RawNode::Edges(out)
218        }
219    }
220}
221
222fn read_xml_raw(text: &str, mut report: Option<&mut WriteReport>) -> Result<RawNode, OmnistError> {
223    let normalized = normalize_line_endings(text);
224    let mut reader = Reader::from_str(&normalized);
225    reader.config_mut().trim_text(false);
226    let mut buf = Vec::new();
227    let root_node: RawNode = loop {
228        buf.clear();
229        let ev = reader
230            .read_event_into(&mut buf)
231            .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
232        match ev {
233            Event::Start(e) => {
234                let mut node_count = 1;
235                let tag = local_name(e.name());
236                let path = crate::report::child_path("$", &tag, 0);
237                record_elem_diagnostics(&e, &path, report.as_deref_mut());
238                let content =
239                    parse_content(&mut reader, &normalized, 1, &mut node_count, &path, report)?;
240                break RawNode::Edges(vec![(tag, content)]);
241            }
242            Event::Empty(e) => {
243                let tag = local_name(e.name());
244                let path = crate::report::child_path("$", &tag, 0);
245                record_elem_diagnostics(&e, &path, report.as_deref_mut());
246                break RawNode::Edges(vec![(tag, RawNode::Leaf(Scalar::Str(String::new())))]);
247            }
248            Event::Eof => {
249                return Err(located_error(
250                    &reader,
251                    &normalized,
252                    "invalid XML: no root element found",
253                ));
254            }
255            Event::Text(t) => {
256                if !t.iter().all(|b| b.is_ascii_whitespace()) {
257                    return Err(located_error(
258                        &reader,
259                        &normalized,
260                        "invalid XML: unexpected text outside root element",
261                    ));
262                }
263            }
264            Event::CData(t) => {
265                if !t.iter().all(|b| b.is_ascii_whitespace()) {
266                    return Err(located_error(
267                        &reader,
268                        &normalized,
269                        "invalid XML: unexpected text outside root element",
270                    ));
271                }
272            }
273            Event::GeneralRef(_) => {
274                return Err(located_error(
275                    &reader,
276                    &normalized,
277                    "invalid XML: unexpected text outside root element",
278                ));
279            }
280            Event::Decl(_)
281            | Event::Comment(_)
282            | Event::PI(_)
283            | Event::DocType(_)
284            | Event::End(_) => {
285                // Legal prolog events: skip.
286            }
287        }
288    };
289
290    loop {
291        buf.clear();
292        let ev = reader
293            .read_event_into(&mut buf)
294            .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
295        match ev {
296            Event::Eof => break,
297            Event::Text(t) => {
298                if !t.iter().all(|b| b.is_ascii_whitespace()) {
299                    return Err(located_error(
300                        &reader,
301                        &normalized,
302                        "invalid XML: unexpected text after root element",
303                    ));
304                }
305            }
306            Event::CData(t) => {
307                if !t.iter().all(|b| b.is_ascii_whitespace()) {
308                    return Err(located_error(
309                        &reader,
310                        &normalized,
311                        "invalid XML: unexpected text after root element",
312                    ));
313                }
314            }
315            Event::GeneralRef(_) => {
316                return Err(located_error(
317                    &reader,
318                    &normalized,
319                    "invalid XML: unexpected text after root element",
320                ));
321            }
322            Event::Comment(_)
323            | Event::PI(_)
324            | Event::DocType(_)
325            | Event::Decl(_)
326            | Event::End(_) => {
327                // Legal epilog events: skip.
328            }
329            Event::Start(_) | Event::Empty(_) => {
330                return Err(located_error(
331                    &reader,
332                    &normalized,
333                    "invalid XML: multiple root elements found",
334                ));
335            }
336        }
337    }
338
339    Ok(root_node)
340}
341
342/// Parse XML text into a [`Doc`], preserving element order/interleaving
343/// exactly (see this module's doc comment).
344pub fn read_xml(text: &str) -> Result<Doc, OmnistError> {
345    let raw = read_xml_raw(text, None)?;
346    let doc = Doc::from_raw(raw)?;
347    Ok(doc)
348}
349
350/// Same as [`read_xml`], but also reports `format.attribute-dropped` and
351/// `format.namespace-dropped` adjustments (spec Sec8.3.8, D-3) into
352/// `report` for every element that had an attribute or a namespace prefix
353/// discarded, mirroring the write-side `report: Option<&mut WriteReport>`
354/// pattern every writer in this crate already uses -- see
355/// `crate::report`'s module doc. `report: None` behaves exactly like
356/// [`read_xml`].
357pub fn read_xml_report(text: &str, report: Option<&mut WriteReport>) -> Result<Doc, OmnistError> {
358    let raw = read_xml_raw(text, report)?;
359    let doc = Doc::from_raw(raw)?;
360    Ok(doc)
361}
362
363/// Parse XML text into a [`Doc`] with schema-guided pretyping of boolean,
364/// integer, and number scalar fields (spec ยง2.2 / issue #114).
365pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError> {
366    let raw = read_xml_raw(text, None)?;
367    let pretyped = xml_pretype(raw, schema, &FieldType::Ref(schema.root().clone()));
368    let doc = Doc::from_raw(pretyped)?;
369    Ok(doc)
370}
371
372/// Reads the content of an already-opened element (the matching `Start`
373/// event has already been consumed by the caller) up to and including its
374/// `End` event, returning either an internal node ([`RawNode::Edges`], if
375/// it has child elements) or a leaf ([`RawNode::Leaf`], its untyped text
376/// verbatim as a [`Scalar::Str`]), mirroring Python's `_xml_to_node`.
377fn parse_content(
378    reader: &mut Reader<&[u8]>,
379    source: &str,
380    depth: usize,
381    node_count: &mut usize,
382    path: &str,
383    mut report: Option<&mut WriteReport>,
384) -> Result<RawNode, OmnistError> {
385    if depth > MAX_DEPTH {
386        return Err(DocumentError::new(
387            "$",
388            format!("nesting exceeds the maximum depth ({MAX_DEPTH})"),
389        )
390        .into());
391    }
392    let mut text = String::new();
393    let mut children: Vec<(String, RawNode)> = Vec::new();
394    // Same-label occurrence counts, tracked incrementally (O(1) amortized
395    // per child) rather than by rescanning children on every element via
396    // children.iter().filter(...).count(), which is O(n) per element and
397    // made a MAX_NODES-sized sibling run O(n^2).
398    let mut label_counts: std::collections::HashMap<String, usize> =
399        std::collections::HashMap::new();
400    let mut buf = Vec::new();
401    loop {
402        buf.clear();
403        let ev = reader
404            .read_event_into(&mut buf)
405            .map_err(|e| xml_parse_error(reader, source, &e))?;
406        match ev {
407            Event::Start(e) => {
408                *node_count += 1;
409                if *node_count > MAX_NODES {
410                    return Err(DocumentError::new(
411                        "$",
412                        format!("document exceeds the maximum node count ({MAX_NODES})"),
413                    )
414                    .into());
415                }
416                let tag = local_name(e.name());
417                let index = *label_counts
418                    .entry(tag.clone())
419                    .and_modify(|n| *n += 1)
420                    .or_insert(0);
421                let child_path = crate::report::child_path(path, &tag, index);
422                record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
423                let child = parse_content(
424                    reader,
425                    source,
426                    depth + 1,
427                    node_count,
428                    &child_path,
429                    report.as_deref_mut(),
430                )?;
431                children.push((tag, child));
432            }
433            Event::Empty(e) => {
434                *node_count += 1;
435                if *node_count > MAX_NODES {
436                    return Err(DocumentError::new(
437                        "$",
438                        format!("document exceeds the maximum node count ({MAX_NODES})"),
439                    )
440                    .into());
441                }
442                let tag = local_name(e.name());
443                let index = *label_counts
444                    .entry(tag.clone())
445                    .and_modify(|n| *n += 1)
446                    .or_insert(0);
447                let child_path = crate::report::child_path(path, &tag, index);
448                record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
449                children.push((tag, RawNode::Leaf(Scalar::Str(String::new()))));
450            }
451            Event::End(_) => break,
452            Event::Text(e) => {
453                // `quick_xml` splits entity/character references out into
454                // their own `GeneralRef` events (see the arm below) --
455                // a `Text` event's content never itself contains an
456                // unresolved `&...;` sequence, so only charset decoding
457                // (never entity unescaping) is needed here. `decode()`
458                // cannot fail for a `Reader::from_str`-backed reader (the
459                // crate's own source notes the decoder is fixed to UTF-8
460                // automatically in that case -- there is no declared-
461                // encoding-vs-actual-bytes mismatch possible when the
462                // input was already a Rust `&str`), so this is an
463                // `.expect()`, not a propagated error path.
464                let decoded = e
465                    .decode()
466                    .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
467                text.push_str(&decoded);
468            }
469            Event::GeneralRef(e) => {
470                text.push(resolve_general_ref(reader, source, &e)?);
471            }
472            Event::CData(e) => {
473                text.push_str(&String::from_utf8_lossy(e.as_ref()));
474            }
475            Event::Eof => {
476                return Err(located_error(
477                    reader,
478                    source,
479                    "invalid XML: unexpected end of document",
480                ));
481            }
482            // Comments/PIs inside an element body: skip.
483            _ => {}
484        }
485    }
486    if !children.is_empty() {
487        if !text.trim().is_empty() {
488            return Err(located_error(
489                reader,
490                source,
491                "invalid XML: mixed content (text alongside child elements) is outside the \
492                 data-XML profile",
493            ));
494        }
495        Ok(RawNode::Edges(children))
496    } else {
497        Ok(RawNode::Leaf(Scalar::Str(text)))
498    }
499}
500
501/// Builds a [`ParseError`] located at the reader's current byte position
502/// (converted to a 1-based line/column via [`line_col_bytes`]), for the error
503/// conditions this module detects itself rather than receiving from
504/// `quick_xml` (unexpected EOF, mixed content).
505fn located_error(reader: &Reader<&[u8]>, source: &str, message: &str) -> OmnistError {
506    let pos = (reader.buffer_position() as usize).min(source.len());
507    let (line, col) = line_col_bytes(source, pos);
508    ParseError::new(line, col, message).into()
509}
510
511/// Records `format.attribute-dropped` and `format.namespace-dropped`
512/// (spec Sec8.3.8, D-3) for one just-opened element (`Start`/`Empty`
513/// event), at `path` -- the path of the element itself, matching the
514/// vectors' convention (the element the attribute/prefix was lost *from*,
515/// not its parent or child). A no-op when `report` is `None`, matching
516/// every other `Option<&mut WriteReport>` consumer in this crate.
517fn record_elem_diagnostics(
518    e: &quick_xml::events::BytesStart<'_>,
519    path: &str,
520    report: Option<&mut WriteReport>,
521) {
522    let Some(rep) = report else { return };
523    if e.attributes().next().is_some() {
524        rep.add(
525            path,
526            "format.attribute-dropped",
527            "an XML attribute was discarded on read",
528            Severity::Warning,
529        );
530    }
531    let name = e.name();
532    let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
533    if raw.contains(':') {
534        rep.add(
535            path,
536            "format.namespace-dropped",
537            "an XML namespace prefix was discarded on read",
538            Severity::Warning,
539        );
540    }
541}
542
543/// The local (unprefixed) part of a tag name -- see this module's doc
544/// comment on namespace handling.
545fn local_name(name: quick_xml::name::QName) -> String {
546    let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
547    match raw.rsplit_once(':') {
548        Some((_, local)) => local.to_string(),
549        None => raw.to_string(),
550    }
551}
552
553/// XML mandates line-ending normalization on parse (any of `"\r\n"`,
554/// a lone `"\r"`) to a bare `"\n"` -- live-confirmed against
555/// `defusedxml.ElementTree` (see this module's tests).
556fn normalize_line_endings(s: &str) -> String {
557    s.replace("\r\n", "\n").replace('\r', "\n")
558}
559
560fn xml_parse_error(reader: &Reader<&[u8]>, source: &str, e: &quick_xml::Error) -> OmnistError {
561    let pos = (reader.buffer_position() as usize).min(source.len());
562    let (line, col) = line_col_bytes(source, pos);
563    ParseError::new(line, col, format!("invalid XML: {e}")).into()
564}
565
566/// Resolves an `Event::GeneralRef` (a `&...;` entity or character
567/// reference `quick_xml` tokenizes as its own event, separate from
568/// `Text`) to the single `char` it denotes. Handles a numeric character
569/// reference (`&#65;`/`&#x41;`) via the crate's own [`quick_xml::events::BytesRef::resolve_char_ref`],
570/// and the five predefined XML entities by name -- `quick_xml` has no
571/// DTD support, so no other named entity can ever legitimately appear
572/// (see this module's doc comment on why that's a security feature, not
573/// a gap).
574fn resolve_general_ref(
575    reader: &Reader<&[u8]>,
576    source: &str,
577    e: &quick_xml::events::BytesRef<'_>,
578) -> Result<char, OmnistError> {
579    if let Some(ch) = e
580        .resolve_char_ref()
581        .map_err(|err| xml_parse_error(reader, source, &err))?
582    {
583        return Ok(ch);
584    }
585    let name = e
586        .decode()
587        .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
588    match name.as_ref() {
589        "lt" => Ok('<'),
590        "gt" => Ok('>'),
591        "amp" => Ok('&'),
592        "apos" => Ok('\''),
593        "quot" => Ok('"'),
594        other => {
595            let pos = (reader.buffer_position() as usize).min(source.len());
596            let (line, col) = line_col_bytes(source, pos);
597            Err(ParseError::new(
598                line,
599                col,
600                format!(
601                    "invalid XML: unrecognized entity reference '&{other};' (only the five \
602                     predefined XML entities are supported; quick_xml has no DTD support)"
603                ),
604            )
605            .into())
606        }
607    }
608}
609
610// ============================================================== Writer
611
612/// Project a [`Doc`] to XML text. See this module's doc comment for the
613/// single-document-element requirement, sanitization, and depth-guard
614/// decisions.
615pub fn write_xml(
616    doc: &Doc,
617    strict: bool,
618    report: Option<&mut WriteReport>,
619) -> Result<String, WriteError> {
620    let root = doc.root();
621    let Ok(edges) = root.internal_edges() else {
622        return Err(single_root_error());
623    };
624    if edges.len() != 1 {
625        return Err(single_root_error());
626    }
627    let mut rep = WriteReport::new();
628    scan_xml_cursor(&root, "$", &mut rep, true)?;
629    let (tag, child_id) = &edges[0];
630    let child_cursor = root.seek(*child_id);
631    let mut out = String::new();
632    write_element(tag, &child_cursor, 0, &mut out);
633    if !matches!(child_cursor.internal_edges(), Ok(e) if !e.is_empty()) && out.ends_with('\n') {
634        out.pop();
635    }
636    crate::report::finish_write(out, rep, strict, report)
637}
638
639fn single_root_error() -> WriteError {
640    WriteError::new(
641        "XML needs exactly one document element; the root node must have a single top-level \
642         edge (a single-rooted Document)",
643    )
644}
645
646/// Report what writing XML would adjust, without producing output. Unlike
647/// [`write_xml`], this does not enforce the single-document-element shape
648/// (mirrors Python's `check_xml`, which is just `_scan_xml(node, "$", rep)`
649/// with no root-shape guard of its own).
650pub fn check_xml(doc: &Doc) -> WriteReport {
651    let mut rep = WriteReport::new();
652    // `fail_fast: false` -- `scan_xml_cursor` never returns `Err` on this
653    // path, it only records the same conditions as `write.unsupported-value`
654    // `Severity::Error` adjustments for preview purposes (`check_xml` never
655    // produces output to begin with, so there is nothing to fail).
656    scan_xml_cursor(&doc.root(), "$", &mut rep, false).expect("fail_fast: false never returns Err");
657    rep
658}
659
660/// Marker type implementing [`crate::formats::Codec`] for XML -- adapts
661/// [`read_xml`]/[`write_xml`]/[`check_xml`] to the registry's uniform
662/// shape with the documented defaults (`strict: false`, no report). The
663/// single-document-element root-shape error `write_xml` raises fires from
664/// inside `write_xml` itself, outside `finish_write` and before any
665/// scanning, exactly as before -- this impl only calls `write_xml`, it
666/// doesn't reimplement it.
667pub(crate) struct Xml;
668
669impl crate::formats::Codec for Xml {
670    const NAME: &'static str = "xml";
671
672    fn read(text: &str) -> Result<Doc, OmnistError> {
673        read_xml(text)
674    }
675
676    fn write(doc: &Doc) -> Result<String, OmnistError> {
677        write_xml(doc, false, None).map_err(Into::into)
678    }
679
680    fn check(doc: &Doc) -> WriteReport {
681        check_xml(doc)
682    }
683}
684
685/// Scans a subtree for every write-time adjustment/failure XML has, mirrored
686/// against `check_xml`'s preview-only need via `fail_fast`.
687///
688/// Two conditions -- an XML-illegal label and an empty internal node --
689/// fail the write unconditionally (`write.unsupported-value`, spec
690/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) rather than sanitizing/substituting
691/// and reporting a warning; retired the `key.sanitized`/`shape.empty_ambiguous`
692/// codes (see this module's doc comment and `write_xml`'s). With
693/// `fail_fast: true` (the real [`write_xml`] path), this function returns
694/// `Err` the moment either condition is found, before any output is
695/// produced -- so `write_element`'s own `xml_name`-sanitizing branch is now
696/// unreachable in practice and has been removed; every label `write_element`
697/// ever sees has already been confirmed a valid XML name here. With
698/// `fail_fast: false` ([`check_xml`]'s preview-only path), both conditions
699/// are instead recorded as `write.unsupported-value`/`Severity::Error`
700/// adjustments and scanning continues, so `check_xml` can report every
701/// occurrence in one pass rather than just the first.
702fn scan_xml_cursor(
703    cursor: &Cursor,
704    path: &str,
705    rep: &mut WriteReport,
706    fail_fast: bool,
707) -> Result<(), WriteError> {
708    match cursor.internal_edges() {
709        Ok(edges) => {
710            if edges.is_empty() {
711                let detail = "empty internal node (no edges) has no XML representation -- it \
712                              would read back as the empty-string leaf '', indistinguishable \
713                              from a genuine empty string";
714                if fail_fast {
715                    return Err(crate::report::unsupported_value_error(path, detail));
716                }
717                rep.add(path, "write.unsupported-value", detail, Severity::Error);
718                return Ok(());
719            }
720            let mut counts: IndexMap<&str, usize> = IndexMap::new();
721            for (label, child_id) in edges {
722                let entry = counts.entry(label.as_str()).or_insert(0);
723                let i = *entry;
724                *entry += 1;
725                let p = crate::report::child_path(path, label, i);
726                if !is_valid_xml_name(label) {
727                    let detail =
728                        format!("label {label:?} is not a valid XML name and cannot be written");
729                    if fail_fast {
730                        return Err(crate::report::unsupported_value_error(&p, detail));
731                    }
732                    rep.add(
733                        p.clone(),
734                        "write.unsupported-value",
735                        detail,
736                        Severity::Error,
737                    );
738                }
739                let child = cursor.seek(*child_id);
740                scan_xml_cursor(&child, &p, rep, fail_fast)?;
741            }
742        }
743        Err(_) => {
744            let scalar = cursor.value().unwrap();
745            scan_leaf(scalar, path, rep);
746        }
747    }
748    Ok(())
749}
750
751fn scan_leaf(scalar: &Scalar, path: &str, rep: &mut WriteReport) {
752    match scalar {
753        Scalar::Null => rep.add(
754            path,
755            "null.omitted",
756            "null written as an empty element",
757            Severity::Warning,
758        ),
759        // omnist-rs#86: read_xml no longer infers scalar kind from
760        // element-text shape, so a non-string scalar written to XML (XML
761        // has no native typed literals -- everything is text) now reads
762        // back as a string, not its original type. Previously silent
763        // (the old shape-based coercion happened to undo this on read);
764        // now reported like every other type-losing write, matching
765        // Python's identical fix (`omnist#288`, `value.stringified`).
766        Scalar::Bool(_)
767        | Scalar::Int(_)
768        | Scalar::Float(_)
769        | Scalar::Date(_)
770        | Scalar::Time(_)
771        | Scalar::Datetime(_) => rep.add(
772            path,
773            "value.stringified",
774            "non-string scalar written as text (reads back as a string)",
775            Severity::Warning,
776        ),
777        Scalar::Str(_) => {}
778    }
779    // `string.cr_normalized` retired (spec Sec8.3.8, issue #162): a
780    // literal '\r' is no longer written raw and reported lossy -- it's
781    // escaped as the numeric character reference `&#13;`, which is exempt
782    // from XML's mandatory line-ending normalization on parse and
783    // round-trips losslessly (confirmed live, both a bare '\r' and a
784    // '\r\n' sequence survive intact). See `xml_escape_text`. Nothing left
785    // to report here for '\r' -- the write is now genuinely lossless; only
786    // the illegal-control-character case below still needs reporting.
787    if let Scalar::Str(v) = scalar
788        && v.chars().any(is_xml_illegal_char)
789    {
790        rep.add(
791            path,
792            "string.illegal_xml_char",
793            "string contains a character XML 1.0 cannot represent (e.g. a C0 control other \
794             than tab/LF/CR); it is replaced with U+FFFD on write so the output stays \
795             well-formed",
796            Severity::Error,
797        );
798    }
799}
800
801/// `tag` is always already a valid XML name by the time this runs -- the
802/// only two callers are `write_xml` (which fails via `scan_xml_cursor`'s
803/// `fail_fast: true` pass, before this function is ever reached, on any
804/// label that isn't) and this function's own recursive call on a child
805/// label already scanned the same way. No sanitization happens here
806/// anymore -- see `scan_xml_cursor`'s doc comment on why the write now
807/// fails unconditionally on an XML-illegal label instead.
808fn write_element(tag: &str, content: &Cursor, level: usize, out: &mut String) {
809    let indent = "  ".repeat(level);
810    out.push_str(&indent);
811    out.push('<');
812    out.push_str(tag);
813    match content.internal_edges() {
814        Ok(edges) if !edges.is_empty() => {
815            out.push_str(">\n");
816            for (label, child_id) in edges {
817                let child = content.seek(*child_id);
818                write_element(label, &child, level + 1, out);
819            }
820            out.push_str(&indent);
821            out.push_str("</");
822            out.push_str(tag);
823            out.push_str(">\n");
824        }
825        // Unreachable via the real `write_xml` path: `scan_xml_cursor`'s
826        // `fail_fast: true` pass already returned `Err` for any empty
827        // internal node anywhere in the tree before `write_element` is
828        // ever called (spec Sec8.3.8/Sec8.3.9, issue #161) -- see that
829        // function's doc comment. White-box confirmed directly below
830        // (`write_element_panics_on_empty_internal_node`), same rationale
831        // as `toml.rs`'s/`yaml.rs`'s identical `unreachable!()` precedents.
832        Ok(_) => unreachable!(
833            "write_element is never called on an empty internal node -- scan_xml_cursor \
834             already failed the write"
835        ),
836        Err(_) => {
837            let scalar = content.value().unwrap();
838            let text = xml_sanitize(&xml_text(scalar));
839            if text.is_empty() {
840                out.push_str(" />\n");
841            } else {
842                out.push('>');
843                out.push_str(&xml_escape_text(&text));
844                out.push_str("</");
845                out.push_str(tag);
846                out.push_str(">\n");
847            }
848        }
849    }
850}
851
852/// A valid XML 1.0 `Name`, simplified to the ASCII-friendly subset Python's
853/// own `_XML_NAME` regex accepts (`^[A-Za-z_][A-Za-z0-9_.\-]*$`).
854fn is_valid_xml_name(name: &str) -> bool {
855    let mut chars = name.chars();
856    match chars.next() {
857        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
858        _ => return false,
859    }
860    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
861}
862
863fn xml_text(scalar: &Scalar) -> String {
864    match scalar {
865        Scalar::Null => String::new(),
866        Scalar::Bool(b) => {
867            if *b {
868                "true".to_string()
869            } else {
870                "false".to_string()
871            }
872        }
873        Scalar::Int(i) => i.to_string(),
874        Scalar::Float(x) => write_float_text(*x),
875        Scalar::Str(s) | Scalar::Date(s) | Scalar::Time(s) | Scalar::Datetime(s) => s.clone(),
876    }
877}
878
879/// Same float-formatting convention as `toml.rs`'s `write_float`: `nan`/
880/// `inf`/`-inf` lowercase for special values, an explicit `.0` appended
881/// whenever the default `Display` rendering doesn't already contain a
882/// `.`/`e`/`E` marker (matching Python's `str(float)`, which always emits
883/// one of those markers; Rust's bare `{}` formatter does not, and for
884/// integral values >= 1e17 it doesn't even include a decimal point -- see
885/// issue #46).
886fn write_float_text(x: f64) -> String {
887    float_fmt::float_to_string(x, "nan", "inf", "-inf")
888}
889
890/// Replaces every character XML 1.0 cannot represent with U+FFFD --
891/// see this module's doc comment on the omnist-ts#36 all-occurrences fix.
892fn xml_sanitize(text: &str) -> String {
893    text.chars()
894        .map(|c| {
895            if is_xml_illegal_char(c) {
896                '\u{FFFD}'
897            } else {
898                c
899            }
900        })
901        .collect()
902}
903
904/// XML 1.0's character-data legality rule (tab/LF/CR plus U+0020-U+D7FF,
905/// U+E000-U+FFFD, U+10000-U+10FFFF are legal; everything else, including
906/// the C0 controls other than tab/LF/CR and the BMP noncharacters
907/// U+FFFE/U+FFFF, is not) -- a Rust `char` can never be a UTF-16 surrogate,
908/// so that illegal range from Python's version is unreachable here and
909/// intentionally omitted.
910fn is_xml_illegal_char(c: char) -> bool {
911    let cp = c as u32;
912    (0x00..=0x08).contains(&cp)
913        || (0x0B..=0x0C).contains(&cp)
914        || (0x0E..=0x1F).contains(&cp)
915        || (0xFFFE..=0xFFFF).contains(&cp)
916}
917
918/// Escapes the three characters XML text content requires escaped
919/// (`&`, `<`, `>`), plus (spec Sec8.3.8, issue #162) a literal carriage
920/// return as the numeric character reference `&#13;` -- matching
921/// `ElementTree.tostring`'s text-escaping for the first three (quotes are
922/// left literal; they only need escaping in attribute values, which this
923/// module never writes), and going beyond it for CR: XML mandates
924/// line-ending normalization on parse, so a raw CR byte and a raw LF byte
925/// read back identical (confirmed live) -- a numeric character reference
926/// is exempt from that normalization and round-trips intact, so a string
927/// containing a bare CR writes as `a&#13;b`, and a string containing a
928/// CRLF sequence writes as `a&#13;\nb`, not the raw bytes.
929fn xml_escape_text(text: &str) -> String {
930    let mut out = String::with_capacity(text.len());
931    for c in text.chars() {
932        match c {
933            '&' => out.push_str("&amp;"),
934            '<' => out.push_str("&lt;"),
935            '>' => out.push_str("&gt;"),
936            '\r' => out.push_str("&#13;"),
937            c => out.push(c),
938        }
939    }
940    out
941}
942
943#[cfg(test)]
944mod tests;