serde_toon_format 0.1.2

Serde-compatible TOON v3.0 encoder/decoder
Documentation
use memchr::{memchr, memchr2, memchr3};
use smallvec::SmallVec;

pub trait ByteSink {
    fn push_byte(&mut self, byte: u8);
    fn extend_bytes(&mut self, bytes: &[u8]);
    fn len(&self) -> usize;
    fn is_empty(&self) -> bool {
        self.len() == 0
    }
    fn truncate(&mut self, len: usize);
    fn last_byte(&self) -> Option<u8>;
    fn pop_byte(&mut self) -> Option<u8>;
    fn as_slice(&self) -> &[u8];
}

impl ByteSink for Vec<u8> {
    fn push_byte(&mut self, byte: u8) {
        self.push(byte);
    }

    fn extend_bytes(&mut self, bytes: &[u8]) {
        self.extend_from_slice(bytes);
    }

    fn len(&self) -> usize {
        Vec::len(self)
    }

    fn truncate(&mut self, len: usize) {
        Vec::truncate(self, len);
    }

    fn last_byte(&self) -> Option<u8> {
        self.last().copied()
    }

    fn pop_byte(&mut self) -> Option<u8> {
        self.pop()
    }

    fn as_slice(&self) -> &[u8] {
        self.as_slice()
    }
}

macro_rules! impl_bytesink_for_smallvec {
    ($len:expr) => {
        impl ByteSink for SmallVec<[u8; $len]> {
            fn push_byte(&mut self, byte: u8) {
                self.push(byte);
            }

            fn extend_bytes(&mut self, bytes: &[u8]) {
                self.extend_from_slice(bytes);
            }

            fn len(&self) -> usize {
                SmallVec::len(self)
            }

            fn truncate(&mut self, len: usize) {
                SmallVec::truncate(self, len);
            }

            fn last_byte(&self) -> Option<u8> {
                self.last().copied()
            }

            fn pop_byte(&mut self) -> Option<u8> {
                self.pop()
            }

            fn as_slice(&self) -> &[u8] {
                self.as_slice()
            }
        }
    };
}

impl_bytesink_for_smallvec!(32);
impl_bytesink_for_smallvec!(256);

pub fn analyze_string(value: &str, delimiter: char) -> (bool, bool) {
    if value.is_empty() {
        return (true, false);
    }
    if is_literal_like(value) {
        return (true, false);
    }
    if !value.is_ascii() {
        return analyze_string_unicode(value, delimiter);
    }

    let bytes = value.as_bytes();
    let first = bytes[0];
    let mut needs_quote = false;
    let mut needs_escape = false;

    if first.is_ascii_whitespace() || first == b'-' {
        needs_quote = true;
    }
    if is_structural_byte(first, delimiter)
        || first == b'\\'
        || first == b'"'
        || first == delimiter as u8
    {
        needs_quote = true;
    }
    if matches!(first, b'\\' | b'"' | b'\n' | b'\r' | b'\t') {
        needs_escape = true;
    }
    if first == b'0' && bytes.len() > 1 && bytes[1].is_ascii_digit() {
        needs_quote = true;
    }
    if bytes.len() == 1 {
        return (needs_quote, needs_escape);
    }

    if let Some(&last) = bytes.last() {
        if last.is_ascii_whitespace() {
            needs_quote = true;
        }
    }

    let tail = &bytes[1..];
    let has_escape =
        memchr3(b'\\', b'"', b'\n', tail).is_some() || memchr2(b'\r', b'\t', tail).is_some();
    if has_escape {
        needs_quote = true;
        needs_escape = true;
    }

    let has_structural = memchr(b'[', tail).is_some()
        || memchr(b']', tail).is_some()
        || memchr(b'{', tail).is_some()
        || memchr(b'}', tail).is_some()
        || memchr(b':', tail).is_some()
        || (delimiter == ',' && memchr(b',', tail).is_some())
        || (delimiter != ',' && memchr(delimiter as u8, tail).is_some());
    if has_structural {
        needs_quote = true;
    }

    (needs_quote, needs_escape)
}

pub fn escape_string_into(out: &mut String, value: &str) {
    let bytes = value.as_bytes();
    let mut start = 0;
    for (idx, byte) in bytes.iter().enumerate() {
        let escaped = match byte {
            b'\n' => "\\n",
            b'\r' => "\\r",
            b'\t' => "\\t",
            b'"' => "\\\"",
            b'\\' => "\\\\",
            _ => continue,
        };
        if start < idx {
            out.push_str(&value[start..idx]);
        }
        out.push_str(escaped);
        start = idx + 1;
    }
    if start < value.len() {
        out.push_str(&value[start..]);
    }
}

pub fn escape_string_into_bytes<B: ByteSink>(out: &mut B, value: &str) {
    let bytes = value.as_bytes();
    let mut start = 0;
    while start < bytes.len() {
        let slice = &bytes[start..];
        let idx_a = memchr3(b'\\', b'"', b'\n', slice);
        let idx_b = memchr2(b'\r', b'\t', slice);
        let idx = match (idx_a, idx_b) {
            (Some(a), Some(b)) => Some(a.min(b)),
            (Some(a), None) => Some(a),
            (None, Some(b)) => Some(b),
            (None, None) => None,
        };

        let Some(rel_idx) = idx else {
            out.extend_bytes(slice);
            break;
        };
        let idx = start + rel_idx;
        if start < idx {
            out.extend_bytes(&bytes[start..idx]);
        }
        let escaped: &[u8] = match bytes[idx] {
            b'\n' => b"\\n",
            b'\r' => b"\\r",
            b'\t' => b"\\t",
            b'"' => b"\\\"",
            b'\\' => b"\\\\",
            _ => {
                start = idx + 1;
                continue;
            }
        };
        out.extend_bytes(escaped);
        start = idx + 1;
    }
}

pub fn is_canonical_unquoted_key(key: &str) -> bool {
    let bytes = key.as_bytes();
    if bytes.is_empty() {
        return false;
    }
    let first = bytes[0];
    if !first.is_ascii_alphabetic() && first != b'_' {
        return false;
    }
    bytes[1..]
        .iter()
        .all(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.')
}

pub fn is_identifier_segment(key: &str) -> bool {
    let bytes = key.as_bytes();
    if bytes.is_empty() {
        return false;
    }
    let first = bytes[0];
    if !first.is_ascii_alphabetic() && first != b'_' {
        return false;
    }
    bytes[1..]
        .iter()
        .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
}

fn is_literal_like(value: &str) -> bool {
    is_keyword(value) || is_numeric_like(value)
}

fn is_keyword(value: &str) -> bool {
    matches!(value, "true" | "false" | "null")
}

fn is_numeric_like(value: &str) -> bool {
    let bytes = value.as_bytes();
    if bytes.is_empty() {
        return false;
    }
    let mut i = 0;
    if bytes[0] == b'-' {
        i = 1;
    }
    if i >= bytes.len() {
        return false;
    }
    let first = bytes[i];
    if !first.is_ascii_digit() {
        return false;
    }
    bytes[i..].iter().all(|b| {
        b.is_ascii_digit() || *b == b'.' || *b == b'e' || *b == b'E' || *b == b'+' || *b == b'-'
    })
}

fn is_structural_char(ch: char, delimiter: char) -> bool {
    matches!(ch, '[' | ']' | '{' | '}' | ':') || (delimiter == ',' && ch == ',')
}

fn is_structural_byte(byte: u8, delimiter: char) -> bool {
    matches!(byte, b'[' | b']' | b'{' | b'}' | b':') || (delimiter == ',' && byte == b',')
}

fn analyze_string_unicode(value: &str, delimiter: char) -> (bool, bool) {
    let mut chars = value.chars();
    let first = match chars.next() {
        Some(ch) => ch,
        None => return (true, false),
    };

    let mut needs_quote = false;
    let mut needs_escape = false;

    if first.is_whitespace() || first == '-' {
        needs_quote = true;
    }

    if is_structural_char(first, delimiter) || first == '\\' || first == '"' || first == delimiter {
        needs_quote = true;
    }

    if matches!(first, '\\' | '"' | '\n' | '\r' | '\t') {
        needs_escape = true;
    }

    if first == '0' && chars.clone().next().is_some_and(|ch| ch.is_ascii_digit()) {
        needs_quote = true;
    }

    let mut last = first;
    for ch in chars {
        if is_structural_char(ch, delimiter)
            || ch == '\\'
            || ch == '"'
            || ch == delimiter
            || ch == '\n'
            || ch == '\r'
            || ch == '\t'
        {
            needs_quote = true;
        }
        if matches!(ch, '\\' | '"' | '\n' | '\r' | '\t') {
            needs_escape = true;
        }
        last = ch;
    }

    if last.is_whitespace() {
        needs_quote = true;
    }

    (needs_quote, needs_escape)
}