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//! ## Illegal characters fail the write (no substitution)
87//!
88//! XML 1.0 cannot represent a raw C0 control character other than tab, LF and
89//! CR (nor U+FFFE/U+FFFF), and there is no substitute spelling, so a string
90//! holding one fails the write unconditionally with `write.unsupported-value`
91//! at that string's path (spec section 8.3.8) -- it is never dropped or
92//! replaced. `scan_xml_cursor` checks every leaf through
93//! `is_xml_illegal_char` before any output exists.
94//!
95//! ## Depth guard reuse
96//!
97//! [`read_xml`] checks nesting depth itself, inline, during the
98//! recursive-descent walk of `quick_xml`'s pull events (mirroring Python's
99//! own `_xml_to_node`, which inlines the identical check against the
100//! shared `_MAX_DEPTH` constant rather than routing through
101//! `document.py`'s write-side guard) -- this is necessary because,
102//! without it, an adversarially deep input could blow the native Rust call
103//! stack in `parse_content`'s own recursion *before* [`Doc::from_raw`]
104//! ever gets a chance to reject it via
105//! `crate::document::check_write_depth`. [`Doc::from_raw`] then applies
106//! its own guard a second time when building the final `Doc` -- a harmless,
107//! redundant confirmation of the same limit, not a second copy of the
108//! guard's logic (it calls the crate's one shared `check_write_depth`).
109//! [`write_xml`]/[`check_xml`] do not re-guard on the way out, matching
110//! `json.rs`'s reasoning: they walk an already-`Doc`-validated
111//! [`RawNode`], so there is nothing left to guard.
112//!
113//! ## Single document element (write-side)
114//!
115//! [`write_xml`] requires `doc.to_raw()` to be a [`RawNode::Edges`] with
116//! **exactly one** edge -- any other shape (a bare leaf-rooted `Doc`, zero
117//! edges, or more than one top-level edge) is a [`crate::error::WriteError`],
118//! matching Python's `write_xml` check
119//! (`if not (isinstance(node, list) and len(node) == 1): raise WriteError(...)`)
120//! exactly, including that it fires *outside* [`crate::report::finish_write`]
121//! -- unconditionally, even under `strict=false`, and with no
122//! [`crate::report::WriteReport`] attached (mirrors `toml.rs`'s identical
123//! "non-table root" precedent).
124
125use crate::WriteError;
126use crate::document::{Cursor, Doc, RawNode, Scalar};
127use crate::error::{DocumentError, OmnistError, ParseError};
128use crate::formats::float_fmt;
129use crate::formats::textpos::line_col_bytes;
130use crate::limits::{Limits, Resolved as ResolvedLimits};
131use crate::report::{Severity, WriteReport};
132use crate::schema::{FieldType, Resolved, ScalarKind, Schema};
133use indexmap::IndexMap;
134use quick_xml::Reader;
135use quick_xml::events::Event;
136
137// ============================================================== Reader
138
139static XML_INT_RE: std::sync::LazyLock<regex::Regex> =
140    std::sync::LazyLock::new(|| regex::Regex::new(r"^-?(0|[1-9]\d*)$").unwrap());
141
142static XML_NUM_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
143    regex::Regex::new(r"^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$").unwrap()
144});
145
146fn xml_pretype_scalar(
147    node: RawNode,
148    s: &crate::schema::Scalar,
149    guard: &mut crate::limits::IntGuard,
150) -> RawNode {
151    let RawNode::Leaf(Scalar::Str(ref val)) = node else {
152        return node;
153    };
154    match s.kind() {
155        ScalarKind::Boolean => {
156            if val == "true" {
157                RawNode::Leaf(Scalar::Bool(true))
158            } else if val == "false" {
159                RawNode::Leaf(Scalar::Bool(false))
160            } else {
161                node
162            }
163        }
164        ScalarKind::Integer => {
165            if XML_INT_RE.is_match(val) {
166                let digits = if let Some(stripped) = val.strip_prefix('-') {
167                    stripped
168                } else {
169                    val.as_str()
170                };
171                if digits.len() <= guard.max() {
172                    let i: num_bigint::BigInt = val
173                        .parse()
174                        .expect("XML_INT_RE guarantees valid integer literal");
175                    return RawNode::Leaf(Scalar::Int(i));
176                }
177                // Over the D-9 digit limit (counted on the decimal digits of
178                // the value, D-28): the placeholder is itself over the cap, so
179                // the Document constructor refuses it with
180                // `document.limit.int-digits` at this leaf's Document path
181                // (E-10 index included), without the superlinear conversion.
182                return RawNode::Leaf(Scalar::Int(guard.placeholder()));
183            }
184            node
185        }
186        ScalarKind::Number => {
187            // D-28: a `number` is exempt from the integer digit limit however
188            // long its integer part; D-29: past binary64 it reads as an
189            // infinity (underflow as a zero), so the parse never fails.
190            if XML_NUM_RE.is_match(val) {
191                let f: f64 = val
192                    .parse()
193                    .expect("XML_NUM_RE guarantees valid float literal");
194                return RawNode::Leaf(Scalar::Float(f));
195            }
196            node
197        }
198        _ => node,
199    }
200}
201
202fn xml_pretype(
203    node: RawNode,
204    schema: &Schema,
205    ty: &FieldType,
206    guard: &mut crate::limits::IntGuard,
207) -> RawNode {
208    let d = schema.resolve(ty);
209    match d {
210        Resolved::Any => node,
211        Resolved::Scalar(s) => xml_pretype_scalar(node, &s, guard),
212        Resolved::Record(rec) => {
213            let RawNode::Edges(edges) = node else {
214                return node;
215            };
216            let mut out = Vec::with_capacity(edges.len());
217            for (label, child) in edges {
218                let pretyped_child = if let Some(field) = rec.field(&label) {
219                    xml_pretype(child, schema, &field.ty, guard)
220                } else {
221                    child
222                };
223                out.push((label, pretyped_child));
224            }
225            RawNode::Edges(out)
226        }
227    }
228}
229
230/// A data-XML profile refusal (`docs/formats/xml.md`, "The data-XML
231/// profile"): the input is well-formed XML that Omnist declines to read.
232///
233/// A refusal is only RECORDED when met (the first one in document order
234/// wins) and raised once the whole document has proved well-formed, so
235/// malformed XML is always `parse.codec-syntax` and never misreported as a
236/// profile refusal (E-24).
237type Refusal = Option<(&'static str, String)>;
238
239fn note_refusal(slot: &mut Refusal, code: &'static str, message: impl Into<String>) {
240    if slot.is_none() {
241        *slot = Some((code, message.into()));
242    }
243}
244
245fn read_xml_raw(
246    text: &str,
247    mut report: Option<&mut WriteReport>,
248    limits: &ResolvedLimits,
249) -> Result<RawNode, OmnistError> {
250    // D-23: first, on the input as received (BOM counted).
251    limits.check_input_size(text.len())?;
252    // D-15/D-21: one leading BOM is stripped, a second is rejected at 1:1
253    // (`parse.codec-syntax`, E-24). XML 1.0 admits a leading BOM, so
254    // quick_xml would otherwise discard a second one silently.
255    let text = crate::bom::strip_leading_bom(text)
256        .map_err(|_| ParseError::codec_syntax(1, 1, crate::bom::DOUBLED_BOM_MESSAGE))?;
257    let mut refusal: Refusal = None;
258    let normalized = normalize_line_endings(text);
259    // C-16: XML 1.0 `Char` only. A character outside it, anywhere in the
260    // document (text, CDATA, an attribute value, a comment, a name), is
261    // `parse.codec-syntax` at its position.
262    if let Some((at, c)) = normalized
263        .char_indices()
264        .find(|&(_, c)| is_xml_illegal_char(c))
265    {
266        let (line, col) = line_col_bytes(&normalized, at);
267        return Err(ParseError::codec_syntax(
268            line,
269            col,
270            format!(
271                "invalid XML: character U+{:04X} is not allowed in XML 1.0",
272                c as u32
273            ),
274        )
275        .into());
276    }
277    let mut reader = Reader::from_str(&normalized);
278    reader.config_mut().trim_text(false);
279    let mut buf = Vec::new();
280    let root_node: RawNode = loop {
281        buf.clear();
282        let ev = reader
283            .read_event_into(&mut buf)
284            .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
285        match ev {
286            Event::Start(e) => {
287                // The document root is the first node; an element becomes a
288                // node (a container) once it has a child element.
289                let mut budget = NodeBudget {
290                    limits,
291                    node_count: 1,
292                };
293                let tag = local_name(e.name());
294                let path = crate::report::child_path("$", &tag, None);
295                check_attribute_char_refs(&reader, &normalized, &e)?;
296                refuse_attribute_entities(&e, &mut refusal);
297                record_elem_diagnostics(&e, &path, report.as_deref_mut());
298                let content = parse_content(
299                    &mut reader,
300                    &normalized,
301                    1,
302                    &mut budget,
303                    &path,
304                    &mut refusal,
305                    report,
306                )?;
307                break RawNode::Edges(vec![(tag, content)]);
308            }
309            Event::Empty(e) => {
310                let tag = local_name(e.name());
311                let path = crate::report::child_path("$", &tag, None);
312                check_attribute_char_refs(&reader, &normalized, &e)?;
313                refuse_attribute_entities(&e, &mut refusal);
314                record_elem_diagnostics(&e, &path, report.as_deref_mut());
315                break RawNode::Edges(vec![(tag, RawNode::Leaf(Scalar::Str(String::new())))]);
316            }
317            Event::Eof => {
318                return Err(located_error(
319                    &reader,
320                    &normalized,
321                    "invalid XML: no root element found",
322                ));
323            }
324            Event::Text(t) => {
325                if !t.iter().all(|b| b.is_ascii_whitespace()) {
326                    return Err(located_error(
327                        &reader,
328                        &normalized,
329                        "invalid XML: unexpected text outside root element",
330                    ));
331                }
332            }
333            Event::CData(t) => {
334                if !t.iter().all(|b| b.is_ascii_whitespace()) {
335                    return Err(located_error(
336                        &reader,
337                        &normalized,
338                        "invalid XML: unexpected text outside root element",
339                    ));
340                }
341            }
342            Event::GeneralRef(_) => {
343                return Err(located_error(
344                    &reader,
345                    &normalized,
346                    "invalid XML: unexpected text outside root element",
347                ));
348            }
349            Event::DocType(_) => note_refusal(
350                &mut refusal,
351                "format.dtd-forbidden",
352                "a DOCTYPE declaration is outside the data-XML profile (refused on sight)",
353            ),
354            Event::Decl(_) | Event::Comment(_) | Event::PI(_) | Event::End(_) => {
355                // Legal prolog events: skip.
356            }
357        }
358    };
359
360    loop {
361        buf.clear();
362        let ev = reader
363            .read_event_into(&mut buf)
364            .map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
365        match ev {
366            Event::Eof => break,
367            Event::Text(t) => {
368                if !t.iter().all(|b| b.is_ascii_whitespace()) {
369                    return Err(located_error(
370                        &reader,
371                        &normalized,
372                        "invalid XML: unexpected text after root element",
373                    ));
374                }
375            }
376            Event::CData(t) => {
377                if !t.iter().all(|b| b.is_ascii_whitespace()) {
378                    return Err(located_error(
379                        &reader,
380                        &normalized,
381                        "invalid XML: unexpected text after root element",
382                    ));
383                }
384            }
385            Event::GeneralRef(_) => {
386                return Err(located_error(
387                    &reader,
388                    &normalized,
389                    "invalid XML: unexpected text after root element",
390                ));
391            }
392            Event::DocType(_) => note_refusal(
393                &mut refusal,
394                "format.dtd-forbidden",
395                "a DOCTYPE declaration is outside the data-XML profile (refused on sight)",
396            ),
397            Event::Comment(_) | Event::PI(_) | Event::Decl(_) | Event::End(_) => {
398                // Legal epilog events: skip.
399            }
400            Event::Start(_) | Event::Empty(_) => {
401                return Err(located_error(
402                    &reader,
403                    &normalized,
404                    "invalid XML: multiple root elements found",
405                ));
406            }
407        }
408    }
409
410    // Well-formedness has been established for the whole document; only now
411    // is a recorded profile refusal raised. Path `$`: the refusal is about
412    // the input as a whole, not one edge (spec, xml profile vectors).
413    if let Some((code, message)) = refusal {
414        return Err(DocumentError::with_code("$", code, message).into());
415    }
416    Ok(root_node)
417}
418
419/// Parse XML text into a [`Doc`], preserving element order/interleaving
420/// exactly (see this module's doc comment).
421pub fn read_xml(text: &str) -> Result<Doc, OmnistError> {
422    let raw = read_xml_raw(text, None, &ResolvedLimits::DEFAULT)?;
423    Doc::from_raw(raw).map_err(Into::into)
424}
425
426/// [`read_xml`] under explicit [`Limits`].
427///
428/// Returns the [`Limits::validate`] error unchanged when the limits are out
429/// of range. A limit crossed is a [`crate::error::DocumentError`]
430/// (`document.limit.depth` / `document.limit.nodes` at `$`). An element's
431/// text is never an integer literal here (XML is untyped), so
432/// `max_int_digits` only governs the schema-guided pretyping of
433/// [`read_xml_with_schema_and_limits`]: a schema-typed `integer` leaf over
434/// it is refused with `document.limit.int-digits` at the leaf's path.
435pub fn read_xml_with(text: &str, limits: &Limits) -> Result<Doc, OmnistError> {
436    let resolved = limits.resolve()?;
437    let raw = read_xml_raw(text, None, &resolved)?;
438    Doc::from_raw_resolved(raw, resolved).map_err(Into::into)
439}
440
441/// Same as [`read_xml`], but also reports `format.attribute-dropped` and
442/// `format.namespace-dropped` adjustments (spec Sec8.3.8, D-3) into
443/// `report` for every element that had an attribute or a namespace prefix
444/// discarded, mirroring the write-side `report: Option<&mut WriteReport>`
445/// pattern every writer in this crate already uses -- see
446/// `crate::report`'s module doc. `report: None` behaves exactly like
447/// [`read_xml`].
448pub fn read_xml_report(text: &str, report: Option<&mut WriteReport>) -> Result<Doc, OmnistError> {
449    let raw = read_xml_raw(text, report, &ResolvedLimits::DEFAULT)?;
450    Doc::from_raw(raw).map_err(Into::into)
451}
452
453/// Parse XML text into a [`Doc`] with schema-guided pretyping of boolean,
454/// integer, and number scalar fields (spec ยง2.2 / issue #114).
455pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError> {
456    read_xml_with_schema_and_limits(text, schema, &Limits::default())
457}
458
459/// [`read_xml_with_schema`] under explicit [`Limits`] (see [`read_xml_with`]
460/// for what each limit governs here).
461pub fn read_xml_with_schema_and_limits(
462    text: &str,
463    schema: &Schema,
464    limits: &Limits,
465) -> Result<Doc, OmnistError> {
466    let resolved = limits.resolve()?;
467    let raw = read_xml_raw(text, None, &resolved)?;
468    let mut guard = crate::limits::IntGuard::new(resolved.max_int_digits);
469    let pretyped = xml_pretype(
470        raw,
471        schema,
472        &FieldType::Ref(schema.root().clone()),
473        &mut guard,
474    );
475    Doc::from_raw_resolved(pretyped, resolved).map_err(Into::into)
476}
477
478/// An element is a node (a container) from its first child element on:
479/// charges that one node against the cap when `first_child` is set.
480fn charge_container(first_child: bool, budget: &mut NodeBudget) -> Result<(), OmnistError> {
481    if first_child {
482        budget.node_count += 1;
483        if budget.node_count > budget.limits.max_nodes {
484            return Err(budget.limits.nodes_error().into());
485        }
486    }
487    Ok(())
488}
489
490/// The limits in force and the nodes (containers) counted so far.
491struct NodeBudget<'a> {
492    limits: &'a ResolvedLimits,
493    node_count: usize,
494}
495
496/// Reads the content of an already-opened element (the matching `Start`
497/// event has already been consumed by the caller) up to and including its
498/// `End` event, returning either an internal node ([`RawNode::Edges`], if
499/// it has child elements) or a leaf ([`RawNode::Leaf`], its untyped text
500/// verbatim as a [`Scalar::Str`]), mirroring Python's `_xml_to_node`.
501fn parse_content(
502    reader: &mut Reader<&[u8]>,
503    source: &str,
504    depth: usize,
505    budget: &mut NodeBudget,
506    path: &str,
507    refusal: &mut Refusal,
508    mut report: Option<&mut WriteReport>,
509) -> Result<RawNode, OmnistError> {
510    if depth > budget.limits.max_depth {
511        return Err(budget.limits.depth_error().into());
512    }
513    let mut text = String::new();
514    let mut children: Vec<(String, RawNode)> = Vec::new();
515    // Same-label occurrence counts, tracked incrementally (O(1) amortized
516    // per child) rather than by rescanning children on every element via
517    // children.iter().filter(...).count(), which is O(n) per element and
518    // made a MAX_NODES-sized sibling run O(n^2).
519    let mut label_counts: std::collections::HashMap<String, usize> =
520        std::collections::HashMap::new();
521    // E-10: a label occurring more than once is indexed on every occurrence,
522    // the first included, but the first occurrence is read before a sibling
523    // shows the label repeats. Its diagnostics are recorded under the bare
524    // path and re-indexed after the loop: label -> (report range of the
525    // first occurrence's subtree, its bare path).
526    let mut firsts: std::collections::HashMap<String, (std::ops::Range<usize>, String)> =
527        std::collections::HashMap::new();
528    let mut buf = Vec::new();
529    loop {
530        buf.clear();
531        let ev = reader
532            .read_event_into(&mut buf)
533            .map_err(|e| xml_parse_error(reader, source, &e))?;
534        match ev {
535            Event::Start(e) => {
536                charge_container(children.is_empty(), budget)?;
537                let tag = local_name(e.name());
538                let index = *label_counts
539                    .entry(tag.clone())
540                    .and_modify(|n| *n += 1)
541                    .or_insert(0);
542                let child_path =
543                    crate::report::child_path(path, &tag, (index > 0).then_some(index));
544                let start = report.as_deref().map_or(0, WriteReport::len);
545                check_attribute_char_refs(reader, source, &e)?;
546                refuse_attribute_entities(&e, refusal);
547                record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
548                let child = parse_content(
549                    reader,
550                    source,
551                    depth + 1,
552                    budget,
553                    &child_path,
554                    refusal,
555                    report.as_deref_mut(),
556                )?;
557                if index == 0 && report.is_some() {
558                    let end = report.as_deref().map_or(0, WriteReport::len);
559                    firsts.insert(tag.clone(), (start..end, child_path));
560                }
561                children.push((tag, child));
562            }
563            Event::Empty(e) => {
564                charge_container(children.is_empty(), budget)?;
565                let tag = local_name(e.name());
566                let index = *label_counts
567                    .entry(tag.clone())
568                    .and_modify(|n| *n += 1)
569                    .or_insert(0);
570                let child_path =
571                    crate::report::child_path(path, &tag, (index > 0).then_some(index));
572                let start = report.as_deref().map_or(0, WriteReport::len);
573                check_attribute_char_refs(reader, source, &e)?;
574                refuse_attribute_entities(&e, refusal);
575                record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
576                if index == 0 && report.is_some() {
577                    let end = report.as_deref().map_or(0, WriteReport::len);
578                    firsts.insert(tag.clone(), (start..end, child_path));
579                }
580                children.push((tag, RawNode::Leaf(Scalar::Str(String::new()))));
581            }
582            Event::End(_) => break,
583            Event::Text(e) => {
584                // `quick_xml` splits entity/character references out into
585                // their own `GeneralRef` events (see the arm below) --
586                // a `Text` event's content never itself contains an
587                // unresolved `&...;` sequence, so only charset decoding
588                // (never entity unescaping) is needed here. `decode()`
589                // cannot fail for a `Reader::from_str`-backed reader (the
590                // crate's own source notes the decoder is fixed to UTF-8
591                // automatically in that case -- there is no declared-
592                // encoding-vs-actual-bytes mismatch possible when the
593                // input was already a Rust `&str`), so this is an
594                // `.expect()`, not a propagated error path.
595                let decoded = e
596                    .decode()
597                    .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
598                text.push_str(&decoded);
599            }
600            Event::GeneralRef(e) => {
601                if let Some(c) = resolve_general_ref(reader, source, &e, refusal)? {
602                    text.push(c);
603                }
604            }
605            // C-9 audit: the reader is `Reader::from_str` over a valid `&str`
606            // and a CDATA body is cut at ASCII delimiters, so the lossy form
607            // below never inserts a U+FFFD (docs/limitations.md,
608            // omnist/tests/c9_vacuous.rs).
609            Event::CData(e) => {
610                text.push_str(&String::from_utf8_lossy(e.as_ref()));
611            }
612            Event::Eof => {
613                return Err(located_error(
614                    reader,
615                    source,
616                    "invalid XML: unexpected end of document",
617                ));
618            }
619            // Comments/PIs inside an element body: skip.
620            _ => {}
621        }
622    }
623    if let Some(rep) = report {
624        for (label, (range, bare)) in firsts {
625            if label_counts[&label] > 0 {
626                rep.reindex_prefix(range, &bare, &format!("{bare}[0]"));
627            }
628        }
629    }
630    if !children.is_empty() {
631        if !text.trim().is_empty() {
632            note_refusal(
633                refusal,
634                "format.mixed-content",
635                "mixed content (text alongside child elements) is outside the data-XML profile",
636            );
637        }
638        Ok(RawNode::Edges(children))
639    } else {
640        Ok(RawNode::Leaf(Scalar::Str(text)))
641    }
642}
643
644/// Builds a [`ParseError`] located at the reader's current byte position
645/// (converted to a 1-based line/column via [`line_col_bytes`]), for the error
646/// conditions this module detects itself rather than receiving from
647/// `quick_xml` (unexpected EOF, mixed content).
648fn located_error(reader: &Reader<&[u8]>, source: &str, message: &str) -> OmnistError {
649    let pos = (reader.buffer_position() as usize).min(source.len());
650    let (line, col) = line_col_bytes(source, pos);
651    ParseError::codec_syntax(line, col, message).into()
652}
653
654/// Records `format.attribute-dropped` and `format.namespace-dropped`
655/// (spec Sec8.3.8, D-3) for one just-opened element (`Start`/`Empty`
656/// event), at `path` -- the path of the element itself, matching the
657/// vectors' convention (the element the attribute/prefix was lost *from*,
658/// not its parent or child). A no-op when `report` is `None`, matching
659/// every other `Option<&mut WriteReport>` consumer in this crate.
660fn record_elem_diagnostics(
661    e: &quick_xml::events::BytesStart<'_>,
662    path: &str,
663    report: Option<&mut WriteReport>,
664) {
665    let Some(rep) = report else { return };
666    if e.attributes().next().is_some() {
667        rep.add(
668            path,
669            "format.attribute-dropped",
670            "an XML attribute was discarded on read",
671            Severity::Warning,
672        );
673    }
674    let name = e.name();
675    let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
676    if raw.contains(':') {
677        rep.add(
678            path,
679            "format.namespace-dropped",
680            "an XML namespace prefix was discarded on read",
681            Severity::Warning,
682        );
683    }
684}
685
686/// The character a strict XML numeric character reference body names (the
687/// text between `&` and `;`, so `#65` or `#x41`), or `None` when the body is
688/// malformed (no digits, `#X`, a sign, a non-digit, a value that does not fit
689/// `u32`) or names a code point outside XML 1.0's `Char` (C-16).
690fn numeric_ref_char(body: &str) -> Option<char> {
691    let digits = body.strip_prefix('#')?;
692    let (radix, digits) = match digits.strip_prefix('x') {
693        Some(hex) => (16, hex),
694        None => (10, digits),
695    };
696    if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
697        return None;
698    }
699    char::from_u32(u32::from_str_radix(digits, radix).ok()?).filter(|&c| !is_xml_illegal_char(c))
700}
701
702/// C-16 for attribute values, which quick_xml does not tokenize into
703/// reference events: a malformed numeric character reference, or one naming a
704/// code point outside `Char`, is `parse.codec-syntax`.
705fn check_attribute_char_refs(
706    reader: &Reader<&[u8]>,
707    source: &str,
708    e: &quick_xml::events::BytesStart<'_>,
709) -> Result<(), OmnistError> {
710    for attr in e.attributes().filter_map(Result::ok) {
711        // An attribute value is a slice of the valid `&str` source cut at
712        // quotes, so it is always UTF-8.
713        let value = std::str::from_utf8(&attr.value).unwrap_or_default();
714        let mut rest: &str = value;
715        while let Some(at) = rest.find("&#") {
716            rest = &rest[at + 1..];
717            let body = rest.find(';').map(|end| &rest[..end]);
718            if body.and_then(numeric_ref_char).is_none() {
719                let shown: String = match body {
720                    Some(b) => format!("&{b};"),
721                    None => format!(
722                        "&{}",
723                        rest.chars()
724                            .take_while(|c| c.is_ascii_alphanumeric() || *c == '#')
725                            .collect::<String>()
726                    ),
727                };
728                return Err(located_error(
729                    reader,
730                    source,
731                    &format!(
732                        "invalid XML: bad character reference '{shown}' in an attribute value"
733                    ),
734                ));
735            }
736        }
737    }
738    Ok(())
739}
740
741/// The data-XML profile refuses an entity reference other than the five
742/// predefined ones "on sight, not on use", wherever it stands -- an
743/// attribute value included, although attributes are otherwise dropped on
744/// read. quick_xml reports entity references as their own events only in
745/// text, so attribute values are scanned here. A numeric character reference
746/// and the five predefined entities stay legal. The refusal is only
747/// RECORDED (raised after well-formedness, like the others); an attribute
748/// quick_xml cannot parse is ignored here because the element's own
749/// well-formedness is checked by the reader.
750fn refuse_attribute_entities(e: &quick_xml::events::BytesStart<'_>, refusal: &mut Refusal) {
751    for attr in e.attributes().filter_map(Result::ok) {
752        // C-9 audit: an attribute value is a slice of the valid `&str`
753        // source cut at quotes, so this never inserts a U+FFFD.
754        let value = String::from_utf8_lossy(&attr.value);
755        let mut rest: &str = &value;
756        while let Some(at) = rest.find('&') {
757            rest = &rest[at + 1..];
758            let Some(end) = rest.find(';') else { break };
759            let name = &rest[..end];
760            if !name.starts_with('#') && !matches!(name, "lt" | "gt" | "amp" | "apos" | "quot") {
761                note_refusal(
762                    refusal,
763                    "format.entity-forbidden",
764                    format!(
765                        "entity reference '&{name};' in an attribute value is outside the \
766                         data-XML profile (only the five predefined XML entities and numeric \
767                         character references are read)"
768                    ),
769                );
770                return;
771            }
772            rest = &rest[end + 1..];
773        }
774    }
775}
776
777/// The local (unprefixed) part of a tag name -- see this module's doc
778/// comment on namespace handling.
779fn local_name(name: quick_xml::name::QName) -> String {
780    let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
781    match raw.rsplit_once(':') {
782        Some((_, local)) => local.to_string(),
783        None => raw.to_string(),
784    }
785}
786
787/// XML mandates line-ending normalization on parse (any of `"\r\n"`,
788/// a lone `"\r"`) to a bare `"\n"` -- live-confirmed against
789/// `defusedxml.ElementTree` (see this module's tests).
790fn normalize_line_endings(s: &str) -> String {
791    s.replace("\r\n", "\n").replace('\r', "\n")
792}
793
794fn xml_parse_error(reader: &Reader<&[u8]>, source: &str, e: &quick_xml::Error) -> OmnistError {
795    let pos = (reader.buffer_position() as usize).min(source.len());
796    let (line, col) = line_col_bytes(source, pos);
797    ParseError::codec_syntax(line, col, format!("invalid XML: {e}")).into()
798}
799
800/// Resolves an `Event::GeneralRef` (a `&...;` entity or character
801/// reference `quick_xml` tokenizes as its own event, separate from
802/// `Text`) to the single `char` it denotes. Handles a numeric character
803/// reference (`&#65;`/`&#x41;`) via the crate's own [`quick_xml::events::BytesRef::resolve_char_ref`],
804/// and the five predefined XML entities by name -- `quick_xml` has no
805/// DTD support, so no other named entity can ever legitimately appear
806/// (see this module's doc comment on why that's a security feature, not
807/// a gap).
808///
809/// An entity reference outside XML's five predefined ones is a data-XML
810/// profile refusal (`format.entity-forbidden`), not malformed input: it is
811/// recorded in `refusal` (raised once the document proves well-formed) and
812/// `Ok(None)` is returned so the scan continues.
813fn resolve_general_ref(
814    reader: &Reader<&[u8]>,
815    source: &str,
816    e: &quick_xml::events::BytesRef<'_>,
817    refusal: &mut Refusal,
818) -> Result<Option<char>, OmnistError> {
819    if let Some(ch) = e
820        .resolve_char_ref()
821        .map_err(|err| xml_parse_error(reader, source, &err))?
822    {
823        if is_xml_illegal_char(ch) {
824            return Err(located_error(
825                reader,
826                source,
827                "invalid XML: character reference names a character outside XML 1.0 Char",
828            ));
829        }
830        return Ok(Some(ch));
831    }
832    let name = e
833        .decode()
834        .expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
835    match name.as_ref() {
836        "lt" => Ok(Some('<')),
837        "gt" => Ok(Some('>')),
838        "amp" => Ok(Some('&')),
839        "apos" => Ok(Some('\'')),
840        "quot" => Ok(Some('"')),
841        other => {
842            note_refusal(
843                refusal,
844                "format.entity-forbidden",
845                format!(
846                    "entity reference '&{other};' is outside the data-XML profile (only the \
847                     five predefined XML entities and numeric character references are read)"
848                ),
849            );
850            Ok(None)
851        }
852    }
853}
854
855// ============================================================== Writer
856
857/// Project a [`Doc`] to XML text. See this module's doc comment for the
858/// single-document-element requirement, sanitization, and depth-guard
859/// decisions.
860pub fn write_xml(
861    doc: &Doc,
862    strict: bool,
863    report: Option<&mut WriteReport>,
864) -> Result<String, WriteError> {
865    let root = doc.root();
866    let Ok(edges) = root.internal_edges() else {
867        return Err(single_root_error());
868    };
869    if edges.len() > 1 {
870        return Err(multiple_roots_error());
871    }
872    if edges.len() != 1 {
873        return Err(single_root_error());
874    }
875    let mut rep = WriteReport::new();
876    scan_xml_cursor(&root, &mut String::from("$"), &mut rep, true)?;
877    let (tag, child_id) = &edges[0];
878    let child_cursor = root.seek(*child_id);
879    let mut out = String::new();
880    write_element(tag, &child_cursor, 0, &mut out);
881    if !matches!(child_cursor.internal_edges(), Ok(e) if !e.is_empty()) && out.ends_with('\n') {
882        out.pop();
883    }
884    crate::report::finish_write(out, rep, strict, report)
885}
886
887/// `format.multiple-roots` (spec section 8.3.8): a Document with several
888/// top-level edges cannot be written as XML. Path `$`, the whole Document.
889fn multiple_roots_error() -> WriteError {
890    WriteError::with_diagnostic(
891        "$",
892        "format.multiple-roots",
893        "XML needs exactly one document element; the root node has several top-level edges (a multi-rooted Document)",
894    )
895}
896
897fn single_root_error() -> WriteError {
898    WriteError::new(
899        "XML needs exactly one document element; the root node must have a single top-level \
900         edge (a single-rooted Document)",
901    )
902}
903
904/// Report what writing XML would adjust, without producing output. Unlike
905/// [`write_xml`], this does not enforce the single-document-element shape
906/// (mirrors Python's `check_xml`, which is just `_scan_xml(node, "$", rep)`
907/// with no root-shape guard of its own).
908pub fn check_xml(doc: &Doc) -> WriteReport {
909    let mut rep = WriteReport::new();
910    // `fail_fast: false` -- `scan_xml_cursor` never returns `Err` on this
911    // path, it only records the same conditions as `write.unsupported-value`
912    // `Severity::Error` adjustments for preview purposes (`check_xml` never
913    // produces output to begin with, so there is nothing to fail).
914    scan_xml_cursor(&doc.root(), &mut String::from("$"), &mut rep, false)
915        .expect("fail_fast: false never returns Err");
916    rep
917}
918
919/// Marker type implementing [`crate::formats::Codec`] for XML -- adapts
920/// [`read_xml`]/[`write_xml`]/[`check_xml`] to the registry's uniform
921/// shape with the documented defaults (`strict: false`, no report). The
922/// single-document-element root-shape error `write_xml` raises fires from
923/// inside `write_xml` itself, outside `finish_write` and before any
924/// scanning, exactly as before -- this impl only calls `write_xml`, it
925/// doesn't reimplement it.
926pub(crate) struct Xml;
927
928impl crate::formats::Codec for Xml {
929    const NAME: &'static str = "xml";
930
931    fn read(text: &str) -> Result<Doc, OmnistError> {
932        read_xml(text)
933    }
934
935    fn write(doc: &Doc) -> Result<String, OmnistError> {
936        write_xml(doc, false, None).map_err(Into::into)
937    }
938
939    fn check(doc: &Doc) -> WriteReport {
940        check_xml(doc)
941    }
942}
943
944/// Scans a subtree for every write-time adjustment/failure XML has, mirrored
945/// against `check_xml`'s preview-only need via `fail_fast`.
946///
947/// Two conditions -- an XML-illegal label and an empty internal node --
948/// fail the write unconditionally (`write.unsupported-value`, spec
949/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) rather than sanitizing/substituting
950/// and reporting a warning; retired the `key.sanitized`/`shape.empty_ambiguous`
951/// codes (see this module's doc comment and `write_xml`'s). With
952/// `fail_fast: true` (the real [`write_xml`] path), this function returns
953/// `Err` the moment either condition is found, before any output is
954/// produced -- so `write_element`'s own `xml_name`-sanitizing branch is now
955/// unreachable in practice and has been removed; every label `write_element`
956/// ever sees has already been confirmed a valid XML name here. With
957/// `fail_fast: false` ([`check_xml`]'s preview-only path), both conditions
958/// are instead recorded as `write.unsupported-value`/`Severity::Error`
959/// adjustments and scanning continues, so `check_xml` can report every
960/// occurrence in one pass rather than just the first.
961fn scan_xml_cursor(
962    cursor: &Cursor,
963    path: &mut String,
964    rep: &mut WriteReport,
965    fail_fast: bool,
966) -> Result<(), WriteError> {
967    match cursor.internal_edges() {
968        Ok(edges) => {
969            if edges.is_empty() {
970                let detail = "empty internal node (no edges) has no XML representation -- it \
971                              would read back as the empty-string leaf '', indistinguishable \
972                              from a genuine empty string";
973                if fail_fast {
974                    return Err(crate::report::unsupported_value_error(path, detail));
975                }
976                rep.add(
977                    path.as_str(),
978                    "write.unsupported-value",
979                    detail,
980                    Severity::Error,
981                );
982                return Ok(());
983            }
984            let mut totals: IndexMap<&str, usize> = IndexMap::new();
985            for (label, _) in edges {
986                *totals.entry(label.as_str()).or_insert(0) += 1;
987            }
988            let mut seen: IndexMap<&str, usize> = IndexMap::new();
989            for (label, child_id) in edges {
990                let i = *seen
991                    .entry(label.as_str())
992                    .and_modify(|n| *n += 1)
993                    .or_insert(0);
994                let base = path.len();
995                crate::report::push_child_path(
996                    path,
997                    label,
998                    crate::report::occurrence_index(i, totals[label.as_str()]),
999                );
1000                if !is_valid_xml_name(label) {
1001                    let detail =
1002                        format!("label {label:?} is not a valid XML name and cannot be written");
1003                    if fail_fast {
1004                        return Err(crate::report::unsupported_value_error(path, detail));
1005                    }
1006                    rep.add(
1007                        path.as_str(),
1008                        "write.unsupported-value",
1009                        detail,
1010                        Severity::Error,
1011                    );
1012                }
1013                let child = cursor.seek(*child_id);
1014                scan_xml_cursor(&child, path, rep, fail_fast)?;
1015                path.truncate(base);
1016            }
1017        }
1018        Err(_) => {
1019            let scalar = cursor.value().unwrap();
1020            // A string holding a character XML 1.0 cannot represent (a C0
1021            // control other than tab/LF/CR, U+FFFE, U+FFFF) fails the write
1022            // unconditionally: there is no substitute spelling (spec
1023            // section 8.3.8, the string-illegal-char condition).
1024            if let Scalar::Str(s) = scalar
1025                && let Some(bad) = s.chars().find(|&c| is_xml_illegal_char(c))
1026            {
1027                let detail = format!(
1028                    "string contains U+{:04X}, which XML 1.0 cannot represent and has no \
1029                     substitute spelling",
1030                    bad as u32
1031                );
1032                if fail_fast {
1033                    return Err(crate::report::unsupported_value_error(path, detail));
1034                }
1035                rep.add(
1036                    path.as_str(),
1037                    "write.unsupported-value",
1038                    detail,
1039                    Severity::Error,
1040                );
1041                return Ok(());
1042            }
1043            // C-10: XML has no null token, and the empty element `<a/>` reads
1044            // back as the empty string, so a written null would be
1045            // indistinguishable from the different, valid Document holding
1046            // "". Fails unconditionally, like TOML's null.
1047            if matches!(scalar, Scalar::Null) {
1048                let detail = "null has no XML representation (XML has no null token, and an \
1049                              empty element reads back as the empty string)";
1050                if fail_fast {
1051                    return Err(crate::report::unsupported_value_error(path, detail));
1052                }
1053                rep.add(
1054                    path.as_str(),
1055                    "write.unsupported-value",
1056                    detail,
1057                    Severity::Error,
1058                );
1059                return Ok(());
1060            }
1061            scan_leaf(scalar, path, rep);
1062        }
1063    }
1064    Ok(())
1065}
1066
1067fn scan_leaf(scalar: &Scalar, path: &str, rep: &mut WriteReport) {
1068    match scalar {
1069        // A null never reaches this function: `scan_xml_cursor` fails the
1070        // write (C-10), or records the failure for `check_xml`, first.
1071        // omnist-rs#86: read_xml no longer infers scalar kind from
1072        // element-text shape, so a non-string scalar written to XML (XML
1073        // has no native typed literals -- everything is text) now reads
1074        // back as a string, not its original type. Previously silent
1075        // (the old shape-based coercion happened to undo this on read);
1076        // now reported like every other type-losing write, matching
1077        // Python's identical fix (`omnist#288`, `value.stringified`).
1078        Scalar::Bool(_) | Scalar::Int(_) | Scalar::Float(_) => rep.add(
1079            path,
1080            "format.value-stringified",
1081            "non-string scalar written as text (reads back as a string)",
1082            Severity::Warning,
1083        ),
1084        // E-5: a leaf takes one of the two codes, the temporal one for the
1085        // three temporal kinds and the value one for the rest -- never both.
1086        Scalar::Date(_) | Scalar::Time(_) | Scalar::Datetime(_) => rep.add(
1087            path,
1088            "format.temporal-stringified",
1089            "date/time/datetime written as its ISO-8601 text (reads back as a string)",
1090            Severity::Warning,
1091        ),
1092        Scalar::Null | Scalar::Str(_) => {}
1093    }
1094    // `string.cr_normalized` retired (spec Sec8.3.8, issue #162): a
1095    // literal CR is no longer written raw and reported lossy -- it is
1096    // escaped as the numeric character reference `&#13;`, which is exempt
1097    // from XML's mandatory line-ending normalization on parse and
1098    // round-trips losslessly. See `xml_escape_text`. Nothing left to
1099    // report here for CR. A string holding a character XML cannot
1100    // represent never reaches this function: `scan_xml_cursor` fails the
1101    // write (or records it, for `check_xml`) before calling it.
1102}
1103
1104/// `tag` is always already a valid XML name by the time this runs -- the
1105/// only two callers are `write_xml` (which fails via `scan_xml_cursor`'s
1106/// `fail_fast: true` pass, before this function is ever reached, on any
1107/// label that isn't) and this function's own recursive call on a child
1108/// label already scanned the same way. No sanitization happens here
1109/// anymore -- see `scan_xml_cursor`'s doc comment on why the write now
1110/// fails unconditionally on an XML-illegal label instead.
1111fn write_element(tag: &str, content: &Cursor, level: usize, out: &mut String) {
1112    let indent = "  ".repeat(level);
1113    out.push_str(&indent);
1114    out.push('<');
1115    out.push_str(tag);
1116    match content.internal_edges() {
1117        Ok(edges) if !edges.is_empty() => {
1118            out.push_str(">\n");
1119            for (label, child_id) in edges {
1120                let child = content.seek(*child_id);
1121                write_element(label, &child, level + 1, out);
1122            }
1123            out.push_str(&indent);
1124            out.push_str("</");
1125            out.push_str(tag);
1126            out.push_str(">\n");
1127        }
1128        // Unreachable via the real `write_xml` path: `scan_xml_cursor`'s
1129        // `fail_fast: true` pass already returned `Err` for any empty
1130        // internal node anywhere in the tree before `write_element` is
1131        // ever called (spec Sec8.3.8/Sec8.3.9, issue #161) -- see that
1132        // function's doc comment. White-box confirmed directly below
1133        // (`write_element_panics_on_empty_internal_node`), same rationale
1134        // as `toml.rs`'s/`yaml.rs`'s identical `unreachable!()` precedents.
1135        Ok(_) => unreachable!(
1136            "write_element is never called on an empty internal node -- scan_xml_cursor \
1137             already failed the write"
1138        ),
1139        Err(_) => {
1140            let scalar = content.value().unwrap();
1141            let text = xml_text(scalar);
1142            if text.is_empty() {
1143                out.push_str(" />\n");
1144            } else {
1145                out.push('>');
1146                out.push_str(&xml_escape_text(&text));
1147                out.push_str("</");
1148                out.push_str(tag);
1149                out.push_str(">\n");
1150            }
1151        }
1152    }
1153}
1154
1155/// A valid XML 1.0 `Name`, simplified to the ASCII-friendly subset Python's
1156/// own `_XML_NAME` regex accepts (`^[A-Za-z_][A-Za-z0-9_.\-]*$`).
1157fn is_valid_xml_name(name: &str) -> bool {
1158    let mut chars = name.chars();
1159    match chars.next() {
1160        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1161        _ => return false,
1162    }
1163    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
1164}
1165
1166fn xml_text(scalar: &Scalar) -> String {
1167    match scalar {
1168        Scalar::Null => String::new(),
1169        Scalar::Bool(b) => {
1170            if *b {
1171                "true".to_string()
1172            } else {
1173                "false".to_string()
1174            }
1175        }
1176        Scalar::Int(i) => i.to_string(),
1177        Scalar::Float(x) => write_float_text(*x),
1178        Scalar::Str(s) | Scalar::Date(s) => s.clone(),
1179        Scalar::Time(s) => super::temporal_text(s, false).into_owned(),
1180        Scalar::Datetime(s) => super::temporal_text(s, true).into_owned(),
1181    }
1182}
1183
1184/// Same float-formatting convention as `toml.rs`'s `write_float`: `nan`/
1185/// `inf`/`-inf` lowercase for special values, an explicit `.0` appended
1186/// whenever the default `Display` rendering doesn't already contain a
1187/// `.`/`e`/`E` marker (matching Python's `str(float)`, which always emits
1188/// one of those markers; Rust's bare `{}` formatter does not, and for
1189/// integral values >= 1e17 it doesn't even include a decimal point -- see
1190/// issue #46).
1191fn write_float_text(x: f64) -> String {
1192    float_fmt::float_to_string(x, "nan", "inf", "-inf")
1193}
1194
1195/// XML 1.0's character-data legality rule (tab/LF/CR plus U+0020-U+D7FF,
1196/// U+E000-U+FFFD, U+10000-U+10FFFF are legal; everything else, including
1197/// the C0 controls other than tab/LF/CR and the BMP noncharacters
1198/// U+FFFE/U+FFFF, is not) -- a Rust `char` can never be a UTF-16 surrogate,
1199/// so that illegal range from Python's version is unreachable here and
1200/// intentionally omitted.
1201fn is_xml_illegal_char(c: char) -> bool {
1202    let cp = c as u32;
1203    (0x00..=0x08).contains(&cp)
1204        || (0x0B..=0x0C).contains(&cp)
1205        || (0x0E..=0x1F).contains(&cp)
1206        || (0xFFFE..=0xFFFF).contains(&cp)
1207}
1208
1209/// Escapes the three characters XML text content requires escaped
1210/// (`&`, `<`, `>`), plus (spec Sec8.3.8, issue #162) a literal carriage
1211/// return as the numeric character reference `&#13;` -- matching
1212/// `ElementTree.tostring`'s text-escaping for the first three (quotes are
1213/// left literal; they only need escaping in attribute values, which this
1214/// module never writes), and going beyond it for CR: XML mandates
1215/// line-ending normalization on parse, so a raw CR byte and a raw LF byte
1216/// read back identical (confirmed live) -- a numeric character reference
1217/// is exempt from that normalization and round-trips intact, so a string
1218/// containing a bare CR writes as `a&#13;b`, and a string containing a
1219/// CRLF sequence writes as `a&#13;\nb`, not the raw bytes.
1220fn xml_escape_text(text: &str) -> String {
1221    let mut out = String::with_capacity(text.len());
1222    for c in text.chars() {
1223        match c {
1224            '&' => out.push_str("&amp;"),
1225            '<' => out.push_str("&lt;"),
1226            '>' => out.push_str("&gt;"),
1227            '\r' => out.push_str("&#13;"),
1228            c => out.push(c),
1229        }
1230    }
1231    out
1232}
1233
1234#[cfg(test)]
1235mod tests;