Skip to content

XML ↔ JSON Converter — Rust source

Convert XML to JSON and back, preserving attributes. Validates input and reports errors clearly, runs entirely in your browser, with a shareable link to your exact input.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! xml-to-json — bidirectional XML <-> JSON converter.
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the XML-to-JSON tool, ported
//!           from cli/xml-to-json/xml-to-json.go (the canonical Go CLI twin).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//!   - Pure + deterministic; never panics (the public API returns `Option`,
//!     `None` for malformed input).
//!   - Functionally equivalent to the Go twin: same inputs -> same output.
//!   - Self-contained: std only (no crates.io dependencies — Rust's std has no
//!     XML or JSON support, so both are hand-rolled here, mirroring the Go
//!     twin's `encoding/xml` + `encoding/json` semantics).
//!
//! Contract (matches the Go twin):
//!   - `xml_to_json` returns `None` on malformed XML (any parse error, no root,
//!     multiple roots, mismatched tags).
//!   - Attributes become `@_<name>` string keys.
//!   - A text-only element with no attributes becomes its bare string value.
//!   - An element with attributes and/or children becomes an object; its
//!     direct text becomes the `#text` key.
//!   - Repeated child tags become a JSON array.
//!   - Object keys are emitted sorted (BTreeMap), matching Go's `json.Marshal`
//!     which sorts `map[string]interface{}` keys. JSON output is pretty-printed
//!     with 2-space indentation, matching `json.MarshalIndent`.
//!
//! XML parsing note: the Go twin leans on `encoding/xml`'s strict, well-formed
//! tokenizer. Rust's std has no equivalent, so we hand-roll a minimal
//! recursive-descent parser over the byte slice: it handles the subset the tool
//! is designed for (elements, attributes, text, CDATA, comments, processing
//! instructions, the five named entities plus numeric character references).
//! All slicing happens at ASCII delimiter boundaries (`<`, `>`, `/`, `"`, `'`,
//! `=`, whitespace), which are single bytes that never occur inside a UTF-8
//! multibyte sequence, so the text slices are always valid UTF-8.

use std::collections::BTreeMap;
use std::str::from_utf8;

/// A minimal JSON value tree. Element text and attributes are always `Str`
/// (the Go twin does no number coercion); `Num`/`Bool`/`Null` only arise when
/// parsing the input JSON for `json_to_xml`. `Obj` keys are sorted via
/// `BTreeMap`, matching Go's `json.Marshal` key ordering.
#[derive(Debug, Clone, PartialEq)]
enum Json {
    Str(String),
    Num(f64),
    Bool(bool),
    Null,
    Arr(Vec<Json>),
    Obj(BTreeMap<String, Json>),
}

/// Parse a well-formed XML string to pretty-printed JSON, or `None` if
/// malformed.
pub fn xml_to_json(input: &str) -> Option<String> {
    let mut p = Parser::new(input);

    // Skip the prologue (leading CharData, comments, PIs, DOCTYPE). If the
    // stream ends before an element starts, the document has no root.
    loop {
        p.skip_ws();
        if p.eof() {
            return None;
        }
        if p.peek() != b'<' {
            // Leading text before the root — allowed by the Go twin (CharData).
            while !p.eof() && p.peek() != b'<' {
                p.pos += 1;
            }
            continue;
        }
        if p.starts_with(b"<?") {
            p.skip_pi()?;
            continue;
        }
        if p.starts_with(b"<!--") {
            p.skip_comment()?;
            continue;
        }
        if p.starts_with(b"<!") {
            p.skip_doctype()?;
            continue;
        }
        break; // element start
    }

    let (root_tag, value) = p.parse_element()?;

    // After the root closes, consume the remainder: trailing CharData,
    // comments, and PIs are fine; a second element or a decoder error is not.
    loop {
        p.skip_ws();
        if p.eof() {
            break;
        }
        if p.starts_with(b"<!--") {
            p.skip_comment()?;
            continue;
        }
        if p.starts_with(b"<?") {
            p.skip_pi()?;
            continue;
        }
        if p.peek() == b'<' {
            // A second root element or a stray close tag — not well-formed.
            return None;
        }
        // Trailing CharData — allowed by the Go twin.
        while !p.eof() && p.peek() != b'<' {
            p.pos += 1;
        }
    }

    let mut root = BTreeMap::new();
    root.insert(root_tag, value);
    Some(pretty_json(&Json::Obj(root)))
}

/// Build an indented XML string from a JSON string whose root is an object, or
/// `None` if the JSON does not parse or the root is not an object (numbers,
/// strings, bools, null, arrays are rejected).
pub fn json_to_xml(input: &str) -> Option<String> {
    let value = parse_json(input)?;
    let map = match value {
        Json::Obj(m) => m,
        _ => return None, // reject non-object roots
    };
    let mut out = String::new();
    // BTreeMap iterates in sorted key order, matching the Go twin.
    for (tag, val) in map.iter() {
        write_element(&mut out, tag, val, 0);
    }
    Some(out)
}

// ----------------------------------------------------------------------------
// XML parser
// ----------------------------------------------------------------------------

/// A byte-index cursor over the XML input. Positions always land on ASCII
/// delimiter bytes, so `from_utf8` on any sliced range is valid.
struct Parser<'a> {
    b: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(s: &'a str) -> Self {
        Parser { b: s.as_bytes(), pos: 0 }
    }

    fn eof(&self) -> bool {
        self.pos >= self.b.len()
    }

    fn peek(&self) -> u8 {
        self.b[self.pos]
    }

    fn starts_with(&self, needle: &[u8]) -> bool {
        self.b[self.pos..].starts_with(needle)
    }

    /// Slice the input bytes from `from` to the current position as a &str.
    /// Safe: both bounds sit at ASCII delimiter bytes.
    fn slice_from(&self, from: usize) -> &'a str {
        from_utf8(&self.b[from..self.pos]).unwrap_or("")
    }

    fn skip_ws(&mut self) {
        while !self.eof() && (self.peek()).is_ascii_whitespace() {
            self.pos += 1;
        }
    }

    /// At `<?`, advance past the processing instruction to its closing `?>`.
    fn skip_pi(&mut self) -> Option<()> {
        self.pos += 2; // "<?"
        while self.pos + 2 <= self.b.len() && !(self.b[self.pos] == b'?' && self.b[self.pos + 1] == b'>') {
            self.pos += 1;
        }
        if self.pos + 2 > self.b.len() {
            return None;
        }
        self.pos += 2;
        Some(())
    }

    /// At `<!--`, advance past the comment to its closing `-->`.
    fn skip_comment(&mut self) -> Option<()> {
        self.pos += 4; // "<!--"
        while self.pos + 3 <= self.b.len() && !self.starts_with(b"-->") {
            self.pos += 1;
        }
        if self.pos + 3 > self.b.len() {
            return None;
        }
        self.pos += 3;
        Some(())
    }

    /// At `<!` (not a comment), advance past the DOCTYPE to its closing `>`.
    /// Limitation: a DOCTYPE with an internal subset (`[ ... ]`) containing a
    /// nested `>` is not tracked — the showcase input set doesn't use one.
    fn skip_doctype(&mut self) -> Option<()> {
        self.pos += 2; // "<!"
        while !self.eof() && self.peek() != b'>' {
            self.pos += 1;
        }
        if self.eof() {
            return None;
        }
        self.pos += 1;
        Some(())
    }

    /// Read an XML name: `[A-Za-z_:][A-Za-z0-9_:.\-]*`. The caller guarantees
    /// the current byte is a valid name-start.
    fn parse_name(&mut self) -> Option<String> {
        if self.eof() || !is_name_start(self.peek()) {
            return None;
        }
        let start = self.pos;
        self.pos += 1;
        while !self.eof() && is_name_char(self.peek()) {
            self.pos += 1;
        }
        Some(self.slice_from(start).to_string())
    }

    /// Parse `<tag ...>...</tag>`. The cursor is at the leading `<`. Returns
    /// the tag name and the composed value.
    fn parse_element(&mut self) -> Option<(String, Json)> {
        self.pos += 1; // "<"
        let tag = self.parse_name()?;

        let mut attrs: Vec<(String, String)> = Vec::new();
        let mut children: BTreeMap<String, Json> = BTreeMap::new();
        let mut has_child = false;
        let mut text = String::new();

        // Parse attributes (or detect self-closing) until the opening tag ends.
        loop {
            self.skip_ws();
            if self.eof() {
                return None;
            }
            match self.peek() {
                b'/' => {
                    // Self-closing element: <tag ... />
                    if !self.starts_with(b"/>") {
                        return None;
                    }
                    self.pos += 2;
                    return Some((tag, compose_value(attrs, children, text, has_child)));
                }
                b'>' => {
                    self.pos += 1;
                    break;
                }
                _ => {
                    // attribute name = "value"
                    let aname = self.parse_name()?;
                    self.skip_ws();
                    if self.eof() || self.peek() != b'=' {
                        return None;
                    }
                    self.pos += 1; // "="
                    self.skip_ws();
                    if self.eof() || (self.peek() != b'"' && self.peek() != b'\'') {
                        return None;
                    }
                    let quote = self.peek();
                    self.pos += 1;
                    let start = self.pos;
                    while !self.eof() && self.peek() != quote {
                        self.pos += 1;
                    }
                    if self.eof() {
                        return None;
                    }
                    let raw = self.slice_from(start);
                    self.pos += 1; // closing quote
                    attrs.push((aname, xml_unescape(raw)));
                }
            }
        }

        // Parse content until the matching close tag.
        loop {
            if self.eof() {
                return None; // unterminated element
            }
            if self.starts_with(b"</") {
                self.pos += 2; // "</"
                let cname = self.parse_name()?;
                self.skip_ws();
                if self.eof() || self.peek() != b'>' {
                    return None;
                }
                self.pos += 1; // ">"
                if cname != tag {
                    return None; // mismatched close tag
                }
                break;
            }
            if self.starts_with(b"<!--") {
                self.skip_comment()?;
                continue;
            }
            if self.starts_with(b"<![CDATA[") {
                self.pos += b"<![CDATA[".len();
                let start = self.pos;
                while !self.eof() && !self.starts_with(b"]]>") {
                    self.pos += 1;
                }
                if self.eof() {
                    return None;
                }
                text.push_str(self.slice_from(start));
                self.pos += 3; // "]]>"
                continue;
            }
            if self.starts_with(b"<?") {
                self.skip_pi()?;
                continue;
            }
            if self.peek() == b'<' {
                // Child element.
                has_child = true;
                let (ctag, cval) = self.parse_element()?;
                if let Some(existing) = children.remove(&ctag) {
                    let merged = match existing {
                        Json::Arr(arr) => {
                            let mut a = arr;
                            a.push(cval);
                            Json::Arr(a)
                        }
                        other => Json::Arr(vec![other, cval]),
                    };
                    children.insert(ctag, merged);
                } else {
                    children.insert(ctag, cval);
                }
                continue;
            }
            // A run of character data until the next markup byte.
            let start = self.pos;
            while !self.eof() && self.peek() != b'<' {
                self.pos += 1;
            }
            text.push_str(&xml_unescape(self.slice_from(start)));
        }

        Some((tag, compose_value(attrs, children, text, has_child)))
    }
}

/// Assemble an element's value from its parsed parts, mirroring the Go twin's
/// `composeValue`.
fn compose_value(
    attrs: Vec<(String, String)>,
    children: BTreeMap<String, Json>,
    text: String,
    has_child: bool,
) -> Json {
    let trimmed = text.trim().to_string();
    if !has_child && attrs.is_empty() {
        // Pure text element (or empty element) — value is the bare string.
        return Json::Str(trimmed);
    }
    let mut m = attrs
        .into_iter()
        .map(|(k, v)| (format!("@_{}", k), Json::Str(v)))
        .collect::<BTreeMap<String, Json>>();
    for (k, v) in children {
        m.insert(k, v);
    }
    if !trimmed.is_empty() {
        m.insert("#text".to_string(), Json::Str(trimmed));
    }
    Json::Obj(m)
}

fn is_name_start(b: u8) -> bool {
    b.is_ascii_alphabetic() || b == b'_' || b == b':'
}

fn is_name_char(b: u8) -> bool {
    b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'.' | b'-')
}

/// Decode the five predefined XML named entities plus numeric character
/// references (`&#NN;` / `&#xHH;`). Unknown/invalid entities are left intact.
fn xml_unescape(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'&' {
            if let Some(rel) = s[i + 1..].find(';') {
                let ent = &s[i + 1..i + 1 + rel];
                if let Some(rep) = decode_entity(ent) {
                    out.push_str(&rep);
                    i += 1 + rel + 1; // & + entity + ;
                    continue;
                }
            }
        }
        // Push one char (UTF-8 safe advance).
        let ch = s[i..].chars().next().unwrap();
        out.push(ch);
        i += ch.len_utf8();
    }
    out
}

fn decode_entity(ent: &str) -> Option<String> {
    match ent {
        "lt" => Some("<".into()),
        "gt" => Some(">".into()),
        "amp" => Some("&".into()),
        "quot" => Some("\"".into()),
        "apos" => Some("'".into()),
        _ => {
            let rest = ent.strip_prefix('#')?;
            let code = if let Some(h) = rest.strip_prefix('x').or_else(|| rest.strip_prefix('X')) {
                u32::from_str_radix(h, 16).ok()?
            } else {
                rest.parse::<u32>().ok()?
            };
            char::from_u32(code).map(|c| c.to_string())
        }
    }
}

// ----------------------------------------------------------------------------
// XML serialization (json -> xml)
// ----------------------------------------------------------------------------

/// Render `<tag ...>...</tag>` for a value at an indent depth, mirroring the
/// Go twin's `writeElement`.
fn write_element(out: &mut String, tag: &str, val: &Json, depth: usize) {
    let indent = "  ".repeat(depth);
    let inner = "  ".repeat(depth + 1);

    let mut attr_parts: Vec<String> = Vec::new();
    let mut children: Vec<(String, Json)> = Vec::new();
    let mut text_part = String::new();
    let mut has_text = false;

    match val {
        Json::Obj(m) => {
            for (k, v) in m.iter() {
                if let Some(name) = k.strip_prefix("@_") {
                    attr_parts.push(format!("{}=\"{}\"", name, escape_xml(&stringify(v))));
                } else if k == "#text" {
                    text_part = stringify(v);
                    has_text = true;
                } else {
                    children.push((k.clone(), v.clone()));
                }
            }
        }
        _ => {
            text_part = stringify(val);
            has_text = true;
        }
    }

    out.push_str(&indent);
    out.push('<');
    out.push_str(tag);
    for a in &attr_parts {
        out.push(' ');
        out.push_str(a);
    }

    let no_children = children.is_empty();
    if no_children && !has_text {
        out.push_str("></");
        out.push_str(tag);
        out.push_str(">\n");
        return;
    }
    if no_children {
        out.push('>');
        out.push_str(&escape_xml(&text_part));
        out.push_str("</");
        out.push_str(tag);
        out.push_str(">\n");
        return;
    }

    out.push_str(">\n");
    if has_text && !text_part.trim().is_empty() {
        out.push_str(&inner);
        out.push_str(&escape_xml(&text_part));
        out.push('\n');
    }
    for (ctag, cval) in &children {
        match cval {
            Json::Arr(arr) => {
                for item in arr {
                    write_element(out, ctag, item, depth + 1);
                }
            }
            other => write_element(out, ctag, other, depth + 1),
        }
    }
    out.push_str(&indent);
    out.push_str("</");
    out.push_str(tag);
    out.push_str(">\n");
}

/// Escape the XML special characters (& < > " ') for text/attribute content,
/// matching the Go twin's `xml.EscapeText`.
fn escape_xml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            c => out.push(c),
        }
    }
    out
}

/// Render a JSON value as a string for XML text/attribute content (mirrors the
/// Go twin's `stringify`).
fn stringify(v: &Json) -> String {
    match v {
        Json::Str(s) => s.clone(),
        Json::Num(n) => format_num(*n),
        Json::Bool(b) => if *b { "true".into() } else { "false".into() },
        Json::Null => String::new(),
        other => compact_json(other),
    }
}

// ----------------------------------------------------------------------------
// JSON serialization (the pretty 2-space emitter for xml_to_json output)
// ----------------------------------------------------------------------------

fn pretty_json(v: &Json) -> String {
    let mut out = String::new();
    write_json(v, 0, &mut out);
    out
}

fn push_indent(out: &mut String, depth: usize) {
    for _ in 0..depth {
        out.push_str("  ");
    }
}

fn write_json(v: &Json, depth: usize, out: &mut String) {
    match v {
        Json::Str(s) => out.push_str(&json_string(s)),
        Json::Num(n) => out.push_str(&format_num(*n)),
        Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Json::Null => out.push_str("null"),
        Json::Arr(a) => {
            if a.is_empty() {
                out.push_str("[]");
                return;
            }
            out.push('[');
            for (i, item) in a.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push('\n');
                push_indent(out, depth + 1);
                write_json(item, depth + 1, out);
            }
            out.push('\n');
            push_indent(out, depth);
            out.push(']');
        }
        Json::Obj(m) => {
            if m.is_empty() {
                out.push_str("{}");
                return;
            }
            out.push('{');
            for (i, (k, val)) in m.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push('\n');
                push_indent(out, depth + 1);
                out.push_str(&json_string(k));
                out.push_str(": ");
                write_json(val, depth + 1, out);
            }
            out.push('\n');
            push_indent(out, depth);
            out.push('}');
        }
    }
}

/// Compact JSON emitter for the `stringify` fallback (object/array values).
fn compact_json(v: &Json) -> String {
    let mut out = String::new();
    write_compact(v, &mut out);
    out
}

fn write_compact(v: &Json, out: &mut String) {
    match v {
        Json::Str(s) => out.push_str(&json_string(s)),
        Json::Num(n) => out.push_str(&format_num(*n)),
        Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Json::Null => out.push_str("null"),
        Json::Arr(a) => {
            out.push('[');
            for (i, item) in a.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_compact(item, out);
            }
            out.push(']');
        }
        Json::Obj(m) => {
            out.push('{');
            for (i, (k, val)) in m.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&json_string(k));
                out.push(':');
                write_compact(val, out);
            }
            out.push('}');
        }
    }
}

/// Render a `&str` as a complete quoted JSON string literal — the surrounding
/// `"` plus escaped inner characters. Both the pretty and compact emitters
/// route string values and object keys through here.
fn json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn format_num(n: f64) -> String {
    if n == n.trunc() && n.abs() < 1e15 {
        format!("{}", n as i64)
    } else {
        format!("{}", n)
    }
}

// ----------------------------------------------------------------------------
// JSON parser (for json_to_xml input)
// ----------------------------------------------------------------------------

struct JsonParser<'a> {
    b: &'a [u8],
    pos: usize,
}

fn parse_json(s: &str) -> Option<Json> {
    let mut p = JsonParser { b: s.as_bytes(), pos: 0 };
    p.skip_ws();
    let v = p.parse_value()?;
    p.skip_ws();
    if p.pos != p.b.len() {
        return None; // trailing junk
    }
    Some(v)
}

impl<'a> JsonParser<'a> {
    fn eof(&self) -> bool {
        self.pos >= self.b.len()
    }

    fn peek(&self) -> u8 {
        self.b[self.pos]
    }

    fn skip_ws(&mut self) {
        while !self.eof() && (self.peek()).is_ascii_whitespace() {
            self.pos += 1;
        }
    }

    fn parse_value(&mut self) -> Option<Json> {
        self.skip_ws();
        if self.eof() {
            return None;
        }
        match self.peek() {
            b'{' => self.parse_object(),
            b'[' => self.parse_array(),
            b'"' => self.parse_string().map(Json::Str),
            b't' | b'f' => self.parse_bool(),
            b'n' => self.parse_null(),
            b'-' | b'0'..=b'9' => self.parse_number(),
            _ => None,
        }
    }

    fn parse_object(&mut self) -> Option<Json> {
        self.pos += 1; // "{"
        let mut m = BTreeMap::new();
        self.skip_ws();
        if !self.eof() && self.peek() == b'}' {
            self.pos += 1;
            return Some(Json::Obj(m));
        }
        loop {
            self.skip_ws();
            let key = self.parse_string()?;
            self.skip_ws();
            if self.eof() || self.peek() != b':' {
                return None;
            }
            self.pos += 1; // ":"
            let val = self.parse_value()?;
            m.insert(key, val);
            self.skip_ws();
            if self.eof() {
                return None;
            }
            match self.peek() {
                b',' => {
                    self.pos += 1;
                }
                b'}' => {
                    self.pos += 1;
                    return Some(Json::Obj(m));
                }
                _ => return None,
            }
        }
    }

    fn parse_array(&mut self) -> Option<Json> {
        self.pos += 1; // "["
        let mut a = Vec::new();
        self.skip_ws();
        if !self.eof() && self.peek() == b']' {
            self.pos += 1;
            return Some(Json::Arr(a));
        }
        loop {
            let val = self.parse_value()?;
            a.push(val);
            self.skip_ws();
            if self.eof() {
                return None;
            }
            match self.peek() {
                b',' => {
                    self.pos += 1;
                }
                b']' => {
                    self.pos += 1;
                    return Some(Json::Arr(a));
                }
                _ => return None,
            }
        }
    }

    fn parse_string(&mut self) -> Option<String> {
        if self.eof() || self.peek() != b'"' {
            return None;
        }
        self.pos += 1;
        let mut out = String::new();
        while !self.eof() {
            let c = self.peek();
            self.pos += 1;
            match c {
                b'"' => return Some(out),
                b'\\' => {
                    if self.eof() {
                        return None;
                    }
                    let e = self.peek();
                    self.pos += 1;
                    match e {
                        b'"' => out.push('"'),
                        b'\\' => out.push('\\'),
                        b'/' => out.push('/'),
                        b'n' => out.push('\n'),
                        b't' => out.push('\t'),
                        b'r' => out.push('\r'),
                        b'b' => out.push('\u{08}'),
                        b'f' => out.push('\u{0c}'),
                        b'u' => {
                            let hex = self.slice_n(4)?;
                            let code = u32::from_str_radix(hex, 16).ok()?;
                            if let Some(ch) = char::from_u32(code) {
                                out.push(ch);
                            }
                        }
                        _ => return None,
                    }
                }
                _ => {
                    // Push the full UTF-8 char starting at this byte.
                    let rest = from_utf8(&self.b[self.pos - 1..]).ok()?;
                    let ch = rest.chars().next()?;
                    out.push(ch);
                    self.pos = self.pos - 1 + ch.len_utf8();
                }
            }
        }
        None // unterminated string
    }

    /// Read exactly `n` hex/ascii bytes as a &str slice starting at the cursor.
    fn slice_n(&mut self, n: usize) -> Option<&'a str> {
        if self.pos + n > self.b.len() {
            return None;
        }
        let s = from_utf8(&self.b[self.pos..self.pos + n]).ok()?;
        self.pos += n;
        Some(s)
    }

    fn parse_bool(&mut self) -> Option<Json> {
        if self.b[self.pos..].starts_with(b"true") {
            self.pos += 4;
            Some(Json::Bool(true))
        } else if self.b[self.pos..].starts_with(b"false") {
            self.pos += 5;
            Some(Json::Bool(false))
        } else {
            None
        }
    }

    fn parse_null(&mut self) -> Option<Json> {
        if self.b[self.pos..].starts_with(b"null") {
            self.pos += 4;
            Some(Json::Null)
        } else {
            None
        }
    }

    fn parse_number(&mut self) -> Option<Json> {
        let start = self.pos;
        if self.peek() == b'-' {
            self.pos += 1;
        }
        while !self.eof() && (self.peek()).is_ascii_digit() {
            self.pos += 1;
        }
        if !self.eof() && self.peek() == b'.' {
            self.pos += 1;
            while !self.eof() && (self.peek()).is_ascii_digit() {
                self.pos += 1;
            }
        }
        if !self.eof() && (self.peek() == b'e' || self.peek() == b'E') {
            self.pos += 1;
            if !self.eof() && (self.peek() == b'+' || self.peek() == b'-') {
                self.pos += 1;
            }
            while !self.eof() && (self.peek()).is_ascii_digit() {
                self.pos += 1;
            }
        }
        let text = from_utf8(&self.b[start..self.pos]).ok()?;
        text.parse::<f64>().ok().map(Json::Num)
    }
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_simple_xml_to_json() {
        // Exact-match: object keys sorted, 2-space indent — same bytes as the
        // Go twin's json.MarshalIndent.
        assert_eq!(
            xml_to_json("<root><name>Alice</name></root>"),
            Some("{\n  \"root\": {\n    \"name\": \"Alice\"\n  }\n}".to_string())
        );
    }

    #[test]
    fn preserves_attributes_as_prefixed_strings() {
        assert_eq!(
            xml_to_json(r#"<user id="7"><name>Alice</name></user>"#),
            Some(
                "{\n  \"user\": {\n    \"@_id\": \"7\",\n    \"name\": \"Alice\"\n  }\n}".to_string()
            )
        );
    }

    #[test]
    fn repeated_tags_become_an_array() {
        assert_eq!(
            xml_to_json("<list><item>a</item><item>b</item></list>"),
            Some(
                "{\n  \"list\": {\n    \"item\": [\n      \"a\",\n      \"b\"\n    ]\n  }\n}"
                    .to_string()
            )
        );
    }

    #[test]
    fn malformed_xml_returns_none() {
        assert_eq!(xml_to_json("<a><b></a>"), None); // mismatched close
        assert_eq!(xml_to_json("not xml at all"), None); // no root element
        assert_eq!(xml_to_json(""), None); // empty
    }

    #[test]
    fn builds_xml_from_json() {
        let xml = json_to_xml(r#"{"root":{"name":"Alice"}}"#).unwrap();
        assert!(xml.contains("<root>"));
        assert!(xml.contains("<name>Alice</name>"));
        assert!(xml.contains("</root>"));
    }

    #[test]
    fn builds_attributes_and_rejects_invalid_roots() {
        let xml = json_to_xml(r#"{"user":{"@_id":"7","name":"Alice"}}"#).unwrap();
        assert!(xml.contains("id=\"7\""));

        assert_eq!(json_to_xml("{not valid json"), None);
        assert_eq!(json_to_xml("42"), None); // non-object root
        assert_eq!(json_to_xml("null"), None);
        assert_eq!(json_to_xml("[1,2,3]"), None); // array root
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →