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 `< > & ' "` 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 (`A`/`A`) 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 ` `, 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 ` ` -- 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 b`, and a string containing a
928/// CRLF sequence writes as `a \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("&"),
934 '<' => out.push_str("<"),
935 '>' => out.push_str(">"),
936 '\r' => out.push_str(" "),
937 c => out.push(c),
938 }
939 }
940 out
941}
942
943#[cfg(test)]
944mod tests;