use crate::WriteError;
use crate::document::{Cursor, Doc, MAX_DEPTH, MAX_NODES, RawNode, Scalar};
use crate::error::{DocumentError, OmnistError, ParseError};
use crate::formats::float_fmt;
use crate::formats::textpos::line_col_bytes;
use crate::report::{Severity, WriteReport};
use crate::schema::{FieldType, Resolved, ScalarKind, Schema};
use indexmap::IndexMap;
use quick_xml::Reader;
use quick_xml::events::Event;
static XML_INT_RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"^-?(0|[1-9]\d*)$").unwrap());
static XML_NUM_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$").unwrap()
});
fn xml_pretype_scalar(node: RawNode, s: &crate::schema::Scalar) -> RawNode {
let RawNode::Leaf(Scalar::Str(ref val)) = node else {
return node;
};
match s.kind() {
ScalarKind::Boolean => {
if val == "true" {
RawNode::Leaf(Scalar::Bool(true))
} else if val == "false" {
RawNode::Leaf(Scalar::Bool(false))
} else {
node
}
}
ScalarKind::Integer => {
if XML_INT_RE.is_match(val) {
let digits = if let Some(stripped) = val.strip_prefix('-') {
stripped
} else {
val.as_str()
};
if digits.len() <= crate::formats::int_cap::MAX_INT_DIGITS {
let i: num_bigint::BigInt = val
.parse()
.expect("XML_INT_RE guarantees valid integer literal");
return RawNode::Leaf(Scalar::Int(i));
}
}
node
}
ScalarKind::Number => {
if XML_NUM_RE.is_match(val) {
let digits = if let Some(stripped) = val.strip_prefix('-') {
stripped
} else {
val.as_str()
};
let int_digits = digits.split(['.', 'e', 'E']).next().unwrap_or(digits);
if int_digits.len() <= crate::formats::int_cap::MAX_INT_DIGITS {
let f: f64 = val
.parse()
.expect("XML_NUM_RE guarantees valid float literal");
return RawNode::Leaf(Scalar::Float(f));
}
}
node
}
_ => node,
}
}
fn xml_pretype(node: RawNode, schema: &Schema, ty: &FieldType) -> RawNode {
let d = schema.resolve(ty);
match d {
Resolved::Any => node,
Resolved::Scalar(s) => xml_pretype_scalar(node, &s),
Resolved::Record(rec) => {
let RawNode::Edges(edges) = node else {
return node;
};
let mut out = Vec::with_capacity(edges.len());
for (label, child) in edges {
let pretyped_child = if let Some(field) = rec.field(&label) {
xml_pretype(child, schema, &field.ty)
} else {
child
};
out.push((label, pretyped_child));
}
RawNode::Edges(out)
}
}
}
fn read_xml_raw(text: &str, mut report: Option<&mut WriteReport>) -> Result<RawNode, OmnistError> {
let normalized = normalize_line_endings(text);
let mut reader = Reader::from_str(&normalized);
reader.config_mut().trim_text(false);
let mut buf = Vec::new();
let root_node: RawNode = loop {
buf.clear();
let ev = reader
.read_event_into(&mut buf)
.map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
match ev {
Event::Start(e) => {
let mut node_count = 1;
let tag = local_name(e.name());
let path = crate::report::child_path("$", &tag, 0);
record_elem_diagnostics(&e, &path, report.as_deref_mut());
let content =
parse_content(&mut reader, &normalized, 1, &mut node_count, &path, report)?;
break RawNode::Edges(vec![(tag, content)]);
}
Event::Empty(e) => {
let tag = local_name(e.name());
let path = crate::report::child_path("$", &tag, 0);
record_elem_diagnostics(&e, &path, report.as_deref_mut());
break RawNode::Edges(vec![(tag, RawNode::Leaf(Scalar::Str(String::new())))]);
}
Event::Eof => {
return Err(located_error(
&reader,
&normalized,
"invalid XML: no root element found",
));
}
Event::Text(t) => {
if !t.iter().all(|b| b.is_ascii_whitespace()) {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text outside root element",
));
}
}
Event::CData(t) => {
if !t.iter().all(|b| b.is_ascii_whitespace()) {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text outside root element",
));
}
}
Event::GeneralRef(_) => {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text outside root element",
));
}
Event::Decl(_)
| Event::Comment(_)
| Event::PI(_)
| Event::DocType(_)
| Event::End(_) => {
}
}
};
loop {
buf.clear();
let ev = reader
.read_event_into(&mut buf)
.map_err(|e| xml_parse_error(&reader, &normalized, &e))?;
match ev {
Event::Eof => break,
Event::Text(t) => {
if !t.iter().all(|b| b.is_ascii_whitespace()) {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text after root element",
));
}
}
Event::CData(t) => {
if !t.iter().all(|b| b.is_ascii_whitespace()) {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text after root element",
));
}
}
Event::GeneralRef(_) => {
return Err(located_error(
&reader,
&normalized,
"invalid XML: unexpected text after root element",
));
}
Event::Comment(_)
| Event::PI(_)
| Event::DocType(_)
| Event::Decl(_)
| Event::End(_) => {
}
Event::Start(_) | Event::Empty(_) => {
return Err(located_error(
&reader,
&normalized,
"invalid XML: multiple root elements found",
));
}
}
}
Ok(root_node)
}
pub fn read_xml(text: &str) -> Result<Doc, OmnistError> {
let raw = read_xml_raw(text, None)?;
let doc = Doc::from_raw(raw)?;
Ok(doc)
}
pub fn read_xml_report(text: &str, report: Option<&mut WriteReport>) -> Result<Doc, OmnistError> {
let raw = read_xml_raw(text, report)?;
let doc = Doc::from_raw(raw)?;
Ok(doc)
}
pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError> {
let raw = read_xml_raw(text, None)?;
let pretyped = xml_pretype(raw, schema, &FieldType::Ref(schema.root().clone()));
let doc = Doc::from_raw(pretyped)?;
Ok(doc)
}
fn parse_content(
reader: &mut Reader<&[u8]>,
source: &str,
depth: usize,
node_count: &mut usize,
path: &str,
mut report: Option<&mut WriteReport>,
) -> Result<RawNode, OmnistError> {
if depth > MAX_DEPTH {
return Err(DocumentError::new(
"$",
format!("nesting exceeds the maximum depth ({MAX_DEPTH})"),
)
.into());
}
let mut text = String::new();
let mut children: Vec<(String, RawNode)> = Vec::new();
let mut label_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut buf = Vec::new();
loop {
buf.clear();
let ev = reader
.read_event_into(&mut buf)
.map_err(|e| xml_parse_error(reader, source, &e))?;
match ev {
Event::Start(e) => {
*node_count += 1;
if *node_count > MAX_NODES {
return Err(DocumentError::new(
"$",
format!("document exceeds the maximum node count ({MAX_NODES})"),
)
.into());
}
let tag = local_name(e.name());
let index = *label_counts
.entry(tag.clone())
.and_modify(|n| *n += 1)
.or_insert(0);
let child_path = crate::report::child_path(path, &tag, index);
record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
let child = parse_content(
reader,
source,
depth + 1,
node_count,
&child_path,
report.as_deref_mut(),
)?;
children.push((tag, child));
}
Event::Empty(e) => {
*node_count += 1;
if *node_count > MAX_NODES {
return Err(DocumentError::new(
"$",
format!("document exceeds the maximum node count ({MAX_NODES})"),
)
.into());
}
let tag = local_name(e.name());
let index = *label_counts
.entry(tag.clone())
.and_modify(|n| *n += 1)
.or_insert(0);
let child_path = crate::report::child_path(path, &tag, index);
record_elem_diagnostics(&e, &child_path, report.as_deref_mut());
children.push((tag, RawNode::Leaf(Scalar::Str(String::new()))));
}
Event::End(_) => break,
Event::Text(e) => {
let decoded = e
.decode()
.expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
text.push_str(&decoded);
}
Event::GeneralRef(e) => {
text.push(resolve_general_ref(reader, source, &e)?);
}
Event::CData(e) => {
text.push_str(&String::from_utf8_lossy(e.as_ref()));
}
Event::Eof => {
return Err(located_error(
reader,
source,
"invalid XML: unexpected end of document",
));
}
_ => {}
}
}
if !children.is_empty() {
if !text.trim().is_empty() {
return Err(located_error(
reader,
source,
"invalid XML: mixed content (text alongside child elements) is outside the \
data-XML profile",
));
}
Ok(RawNode::Edges(children))
} else {
Ok(RawNode::Leaf(Scalar::Str(text)))
}
}
fn located_error(reader: &Reader<&[u8]>, source: &str, message: &str) -> OmnistError {
let pos = (reader.buffer_position() as usize).min(source.len());
let (line, col) = line_col_bytes(source, pos);
ParseError::new(line, col, message).into()
}
fn record_elem_diagnostics(
e: &quick_xml::events::BytesStart<'_>,
path: &str,
report: Option<&mut WriteReport>,
) {
let Some(rep) = report else { return };
if e.attributes().next().is_some() {
rep.add(
path,
"format.attribute-dropped",
"an XML attribute was discarded on read",
Severity::Warning,
);
}
let name = e.name();
let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
if raw.contains(':') {
rep.add(
path,
"format.namespace-dropped",
"an XML namespace prefix was discarded on read",
Severity::Warning,
);
}
}
fn local_name(name: quick_xml::name::QName) -> String {
let raw = std::str::from_utf8(name.as_ref()).unwrap_or_default();
match raw.rsplit_once(':') {
Some((_, local)) => local.to_string(),
None => raw.to_string(),
}
}
fn normalize_line_endings(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "\n")
}
fn xml_parse_error(reader: &Reader<&[u8]>, source: &str, e: &quick_xml::Error) -> OmnistError {
let pos = (reader.buffer_position() as usize).min(source.len());
let (line, col) = line_col_bytes(source, pos);
ParseError::new(line, col, format!("invalid XML: {e}")).into()
}
fn resolve_general_ref(
reader: &Reader<&[u8]>,
source: &str,
e: &quick_xml::events::BytesRef<'_>,
) -> Result<char, OmnistError> {
if let Some(ch) = e
.resolve_char_ref()
.map_err(|err| xml_parse_error(reader, source, &err))?
{
return Ok(ch);
}
let name = e
.decode()
.expect("Reader::from_str fixes the decoder to UTF-8; decode() cannot fail");
match name.as_ref() {
"lt" => Ok('<'),
"gt" => Ok('>'),
"amp" => Ok('&'),
"apos" => Ok('\''),
"quot" => Ok('"'),
other => {
let pos = (reader.buffer_position() as usize).min(source.len());
let (line, col) = line_col_bytes(source, pos);
Err(ParseError::new(
line,
col,
format!(
"invalid XML: unrecognized entity reference '&{other};' (only the five \
predefined XML entities are supported; quick_xml has no DTD support)"
),
)
.into())
}
}
}
pub fn write_xml(
doc: &Doc,
strict: bool,
report: Option<&mut WriteReport>,
) -> Result<String, WriteError> {
let root = doc.root();
let Ok(edges) = root.internal_edges() else {
return Err(single_root_error());
};
if edges.len() != 1 {
return Err(single_root_error());
}
let mut rep = WriteReport::new();
scan_xml_cursor(&root, "$", &mut rep, true)?;
let (tag, child_id) = &edges[0];
let child_cursor = root.seek(*child_id);
let mut out = String::new();
write_element(tag, &child_cursor, 0, &mut out);
if !matches!(child_cursor.internal_edges(), Ok(e) if !e.is_empty()) && out.ends_with('\n') {
out.pop();
}
crate::report::finish_write(out, rep, strict, report)
}
fn single_root_error() -> WriteError {
WriteError::new(
"XML needs exactly one document element; the root node must have a single top-level \
edge (a single-rooted Document)",
)
}
pub fn check_xml(doc: &Doc) -> WriteReport {
let mut rep = WriteReport::new();
scan_xml_cursor(&doc.root(), "$", &mut rep, false).expect("fail_fast: false never returns Err");
rep
}
pub(crate) struct Xml;
impl crate::formats::Codec for Xml {
const NAME: &'static str = "xml";
fn read(text: &str) -> Result<Doc, OmnistError> {
read_xml(text)
}
fn write(doc: &Doc) -> Result<String, OmnistError> {
write_xml(doc, false, None).map_err(Into::into)
}
fn check(doc: &Doc) -> WriteReport {
check_xml(doc)
}
}
fn scan_xml_cursor(
cursor: &Cursor,
path: &str,
rep: &mut WriteReport,
fail_fast: bool,
) -> Result<(), WriteError> {
match cursor.internal_edges() {
Ok(edges) => {
if edges.is_empty() {
let detail = "empty internal node (no edges) has no XML representation -- it \
would read back as the empty-string leaf '', indistinguishable \
from a genuine empty string";
if fail_fast {
return Err(crate::report::unsupported_value_error(path, detail));
}
rep.add(path, "write.unsupported-value", detail, Severity::Error);
return Ok(());
}
let mut counts: IndexMap<&str, usize> = IndexMap::new();
for (label, child_id) in edges {
let entry = counts.entry(label.as_str()).or_insert(0);
let i = *entry;
*entry += 1;
let p = crate::report::child_path(path, label, i);
if !is_valid_xml_name(label) {
let detail =
format!("label {label:?} is not a valid XML name and cannot be written");
if fail_fast {
return Err(crate::report::unsupported_value_error(&p, detail));
}
rep.add(
p.clone(),
"write.unsupported-value",
detail,
Severity::Error,
);
}
let child = cursor.seek(*child_id);
scan_xml_cursor(&child, &p, rep, fail_fast)?;
}
}
Err(_) => {
let scalar = cursor.value().unwrap();
scan_leaf(scalar, path, rep);
}
}
Ok(())
}
fn scan_leaf(scalar: &Scalar, path: &str, rep: &mut WriteReport) {
match scalar {
Scalar::Null => rep.add(
path,
"null.omitted",
"null written as an empty element",
Severity::Warning,
),
Scalar::Bool(_)
| Scalar::Int(_)
| Scalar::Float(_)
| Scalar::Date(_)
| Scalar::Time(_)
| Scalar::Datetime(_) => rep.add(
path,
"value.stringified",
"non-string scalar written as text (reads back as a string)",
Severity::Warning,
),
Scalar::Str(_) => {}
}
if let Scalar::Str(v) = scalar
&& v.chars().any(is_xml_illegal_char)
{
rep.add(
path,
"string.illegal_xml_char",
"string contains a character XML 1.0 cannot represent (e.g. a C0 control other \
than tab/LF/CR); it is replaced with U+FFFD on write so the output stays \
well-formed",
Severity::Error,
);
}
}
fn write_element(tag: &str, content: &Cursor, level: usize, out: &mut String) {
let indent = " ".repeat(level);
out.push_str(&indent);
out.push('<');
out.push_str(tag);
match content.internal_edges() {
Ok(edges) if !edges.is_empty() => {
out.push_str(">\n");
for (label, child_id) in edges {
let child = content.seek(*child_id);
write_element(label, &child, level + 1, out);
}
out.push_str(&indent);
out.push_str("</");
out.push_str(tag);
out.push_str(">\n");
}
Ok(_) => unreachable!(
"write_element is never called on an empty internal node -- scan_xml_cursor \
already failed the write"
),
Err(_) => {
let scalar = content.value().unwrap();
let text = xml_sanitize(&xml_text(scalar));
if text.is_empty() {
out.push_str(" />\n");
} else {
out.push('>');
out.push_str(&xml_escape_text(&text));
out.push_str("</");
out.push_str(tag);
out.push_str(">\n");
}
}
}
}
fn is_valid_xml_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}
fn xml_text(scalar: &Scalar) -> String {
match scalar {
Scalar::Null => String::new(),
Scalar::Bool(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
Scalar::Int(i) => i.to_string(),
Scalar::Float(x) => write_float_text(*x),
Scalar::Str(s) | Scalar::Date(s) | Scalar::Time(s) | Scalar::Datetime(s) => s.clone(),
}
}
fn write_float_text(x: f64) -> String {
float_fmt::float_to_string(x, "nan", "inf", "-inf")
}
fn xml_sanitize(text: &str) -> String {
text.chars()
.map(|c| {
if is_xml_illegal_char(c) {
'\u{FFFD}'
} else {
c
}
})
.collect()
}
fn is_xml_illegal_char(c: char) -> bool {
let cp = c as u32;
(0x00..=0x08).contains(&cp)
|| (0x0B..=0x0C).contains(&cp)
|| (0x0E..=0x1F).contains(&cp)
|| (0xFFFE..=0xFFFF).contains(&cp)
}
fn xml_escape_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'\r' => out.push_str(" "),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests;