acorn-schema 0.4.0

Portable ACORN schema, validation, and codecs
use super::ZonErrorCode::{E101, E103};
use super::{decode::resolves_to_compound, validate_encoded, validate_value, Limits, ZonError};
use crate::validation::rules;
use alloc::{format, string::String, string::ToString, vec::Vec};
use serde_json::{Map, Number, Value};
fn canonical_number(value: &Number) -> String {
    let source = value.to_string();
    let lower = source.to_ascii_lowercase();
    match lower.split_once('e') {
        | _ if source.starts_with('-') && source.parse::<f64>().is_ok_and(|value| value == 0.0) => "0".to_string(),
        | None => source,
        | Some((mantissa, exponent)) => expand_exponent(mantissa, exponent.parse::<i32>().unwrap_or_default()),
    }
}
fn encode_compound_value(value: &Value, depth: usize) -> Result<String, ZonError> {
    match value {
        | Value::Array(values) => values
            .iter()
            .map(|value| encode_compound_value(value, depth + 1))
            .collect::<Result<Vec<_>, _>>()
            .map(|values| format!("[{}]", values.join(","))),
        | Value::Object(fields) => encode_inline_object(fields, depth),
        | _ => encode_inline_value(value, depth),
    }
}
fn encode_csv_string(value: &str) -> Result<String, ZonError> {
    rules::zon(value)
        .map_err(|_| ZonError::from(E101))
        .map(|()| match requires_quotes(value) {
            | false => value.to_string(),
            | true => format!("\"{}\"", escape_csv_string(value)),
        })
}
fn encode_inline_object(fields: &Map<String, Value>, depth: usize) -> Result<String, ZonError> {
    sorted_fields(fields)
        .into_iter()
        .map(|(key, value)| {
            encode_key(key).and_then(|key| match value {
                | Value::Object(nested) => encode_inline_object(nested, depth + 1).map(|value| format!("{key}{value}")),
                | Value::Array(values) => values
                    .iter()
                    .map(|value| encode_compound_value(value, depth + 1))
                    .collect::<Result<Vec<_>, _>>()
                    .map(|values| format!("{key}:[{}]", values.join(","))),
                | _ => encode_inline_value(value, depth + 1).map(|value| format!("{key}:{value}")),
            })
        })
        .collect::<Result<Vec<_>, _>>()
        .map(|values| format!("{{{}}}", values.join(",")))
}
fn encode_inline_value(value: &Value, depth: usize) -> Result<String, ZonError> {
    match value {
        | Value::Array(values) => values
            .iter()
            .map(|value| encode_compound_value(value, depth + 1))
            .collect::<Result<Vec<_>, _>>()
            .map(|values| format!("\"{}\"", escape_outer_compound(&format!("[{}]", values.join(","))))),
        | Value::Bool(value) => Ok(if *value { "T" } else { "F" }.to_string()),
        | Value::Null => Ok("null".to_string()),
        | Value::Number(value) => Ok(canonical_number(value)),
        | Value::Object(fields) if fields.is_empty() => Ok("\"{}\"".to_string()),
        | Value::Object(fields) => encode_inline_object(fields, depth),
        | Value::String(value) => encode_string(value),
    }
}
fn encode_key(value: &str) -> Result<String, ZonError> {
    rules::zon(value)
        .map_err(|_| ZonError::from(E101))
        .map(|()| match requires_quotes(value) {
            | false => value.to_string(),
            | true => format!("\"{}\"", escape_string(value)),
        })
}
fn encode_root(value: &Value) -> Result<String, ZonError> {
    match value {
        | Value::Object(fields) if fields.is_empty() => Ok(String::new()),
        | Value::Object(fields) => sorted_fields(fields)
            .into_iter()
            .map(|(key, value)| encode_root_field(key, value))
            .collect::<Result<Vec<_>, _>>()
            .map(|lines| lines.join("\n")),
        | Value::Array(values) if table_shape(values).is_some() => encode_table(None, values),
        | _ => encode_inline_value(value, 0),
    }
}
fn encode_root_field(key: &str, value: &Value) -> Result<String, ZonError> {
    encode_key(key).and_then(|key| match value {
        | Value::Array(values) if table_shape(values).is_some() => encode_table(Some(&key), values),
        | Value::Object(fields) if !fields.is_empty() => encode_inline_object(fields, 1).map(|object| format!("{key}{object}")),
        | _ => encode_inline_value(value, 1).map(|value| format!("{key}:{value}")),
    })
}
fn encode_string(value: &str) -> Result<String, ZonError> {
    rules::zon(value)
        .map_err(|_| ZonError::from(E101))
        .map(|()| match requires_quotes(value) {
            | false => value.to_string(),
            | true if resolves_to_compound(value) => {
                let nested = format!("\"{}\"", escape_string(value));
                format!("\"{}\"", escape_string(&nested))
            }
            | true => format!("\"{}\"", escape_string(value)),
        })
}
fn encode_table(key: Option<&str>, values: &[Value]) -> Result<String, ZonError> {
    match table_shape(values) {
        | None => Err(ZonError::from(E103)),
        | Some(columns) => columns
            .iter()
            .map(|column| encode_table_key(column))
            .collect::<Result<Vec<_>, _>>()
            .map(|columns| columns.join(","))
            .and_then(|header_columns| {
                let header = format!(
                    "{}@({}):{header_columns}",
                    key.map(|key| format!("{key}:")).unwrap_or_default(),
                    values.len()
                );
                values
                    .iter()
                    .map(|value| match value {
                        | Value::Object(fields) => columns
                            .iter()
                            .map(|column| fields.get(column).ok_or_else(|| ZonError::from(E103)))
                            .map(|value| value.and_then(encode_table_cell))
                            .collect::<Result<Vec<_>, _>>()
                            .map(|cells| cells.join(",")),
                        | _ => Err(ZonError::from(E103)),
                    })
                    .collect::<Result<Vec<_>, _>>()
                    .map(|rows| core::iter::once(header).chain(rows).collect::<Vec<_>>().join("\n"))
            }),
    }
}
fn encode_table_cell(value: &Value) -> Result<String, ZonError> {
    match value {
        | Value::String(value) => encode_csv_string(value),
        | _ => encode_inline_value(value, 0),
    }
}
fn encode_table_key(value: &str) -> Result<String, ZonError> {
    match requires_quotes(value) {
        | true => encode_csv_string(value),
        | false => Ok(value.to_string()),
    }
}
pub(super) fn encode_value_with_limits(value: &Value, limits: Limits) -> Result<String, ZonError> {
    validate_value(value, 0, limits)
        .and_then(|()| encode_root(value))
        .and_then(|encoded| validate_encoded(&encoded, limits).map(|()| encoded))
}
fn escape_csv_string(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t")
        .replace('"', "\"\"")
}
fn escape_outer_compound(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn escape_string(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t")
}
fn expand_exponent(mantissa: &str, exponent: i32) -> String {
    let negative = mantissa.starts_with('-');
    let unsigned = mantissa.trim_start_matches('-');
    let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
    let digits = format!("{whole}{fraction}");
    let decimal = whole.len() as i32 + exponent;
    let expanded = if decimal <= 0 {
        format!("0.{}{}", "0".repeat((-decimal) as usize), digits)
    } else if decimal as usize >= digits.len() {
        format!("{}{}", digits, "0".repeat(decimal as usize - digits.len()))
    } else {
        let split = decimal as usize;
        format!("{}.{}", &digits[..split], &digits[split..])
    };
    let normalized = match expanded.contains('.') {
        | true => expanded.trim_end_matches('0').trim_end_matches('.').to_string(),
        | false => expanded,
    };
    let normalized = if normalized.is_empty() { "0".to_string() } else { normalized };
    match negative && normalized != "0" {
        | true => format!("-{normalized}"),
        | false => normalized,
    }
}
fn requires_quotes(value: &str) -> bool {
    let lower = value.to_ascii_lowercase();
    value.is_empty()
        || value.trim() != value
        || value
            .chars()
            .any(|character| matches!(character, ',' | ':' | '[' | ']' | '{' | '}' | '"' | '\'' | '\\' | '\n' | '\r' | '\t'))
        || matches!(lower.as_str(), "f" | "false" | "nil" | "none" | "null" | "t" | "true")
        || value
            .strip_prefix('@')
            .and_then(|rest| rest.chars().next())
            .is_some_and(|character| character == '(' || character.is_ascii_digit())
        || rules::numeric(value).is_ok()
}
fn table_shape(values: &[Value]) -> Option<Vec<String>> {
    values.first().and_then(Value::as_object).and_then(|first| {
        let columns = first.keys().cloned().collect::<Vec<_>>();
        (!columns.is_empty()
            && values.iter().all(|value| {
                value.as_object().is_some_and(|fields| {
                    fields.len() == columns.len()
                        && columns
                            .iter()
                            .all(|column| fields.get(column).is_some_and(|value| !value.is_array() && !value.is_object()))
                })
            }))
        .then_some(columns)
    })
}
fn sorted_fields(fields: &Map<String, Value>) -> Vec<(&String, &Value)> {
    let mut fields = fields.iter().collect::<Vec<_>>();
    fields.sort_by(|left, right| left.0.cmp(right.0));
    fields
}