geam-json 0.2.2

Official Gleam JSON providers for Geam
Documentation
use super::provider::JsonPayload;
use ecow::EcoString;
use geam_core::HostFailure;
use geam_core::provider::HostResult;
use geam_stdlib::provider_support::{StoredStringTree, StringTreePayload};
use num_bigint::BigInt;

pub(super) fn do_to_string(json: &JsonPayload) -> EcoString {
    json.tree().flatten()
}

pub(super) fn to_string_tree(json: &JsonPayload) -> StringTreePayload {
    StringTreePayload::from_stored(json.tree().clone())
}

pub(super) fn do_string(value: EcoString) -> JsonPayload {
    JsonPayload::from_tree(StoredStringTree::text(encode_string(&value)))
}

pub(super) fn do_bool(value: bool) -> JsonPayload {
    let text = if value { "true" } else { "false" };
    JsonPayload::from_tree(StoredStringTree::text(text.into()))
}

pub(super) fn do_int(value: BigInt) -> JsonPayload {
    JsonPayload::from_tree(StoredStringTree::text(value.to_string().into()))
}

pub(super) fn do_float(value: f64) -> HostResult<JsonPayload> {
    if !value.is_finite() {
        return Err(HostFailure::new("JSON cannot encode a non-finite Float").into());
    }
    Ok(JsonPayload::from_tree(StoredStringTree::text(
        encode_float(value).into(),
    )))
}

pub(super) fn do_null() -> JsonPayload {
    JsonPayload::from_tree(StoredStringTree::text("null".into()))
}

pub(super) fn encode_string(value: &str) -> EcoString {
    let mut output = String::with_capacity(value.len() + 2);
    output.push('"');
    for character in value.chars() {
        match character {
            '"' => output.push_str("\\\""),
            '\\' => output.push_str("\\\\"),
            '\u{0008}' => output.push_str("\\b"),
            '\t' => output.push_str("\\t"),
            '\n' => output.push_str("\\n"),
            '\u{000c}' => output.push_str("\\f"),
            '\r' => output.push_str("\\r"),
            character if character <= '\u{001f}' => {
                push_control_escape(&mut output, character);
            }
            character => output.push(character),
        }
    }
    output.push('"');
    output.into()
}

fn push_control_escape(output: &mut String, character: char) {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";
    let value = u32::from(character) as usize;
    output.push_str("\\u00");
    output.push(char::from(HEX[(value >> 4) & 0x0f]));
    output.push(char::from(HEX[value & 0x0f]));
}

fn encode_float(value: f64) -> String {
    let mut buffer = ryu::Buffer::new();
    let encoded = buffer.format_finite(value);
    let Some(exponent_index) = encoded.find('e') else {
        return encoded.to_owned();
    };
    let (mantissa, exponent) = encoded.split_at(exponent_index);
    if mantissa.contains('.') {
        encoded.to_owned()
    } else {
        format!("{mantissa}.0{exponent}")
    }
}

#[cfg(test)]
mod tests {
    use super::{encode_float, encode_string};
    use crate::GleamJsonProfile;
    use crate::test_support::{execution, execution_with_modules, run_state};
    use crate::{ExecutionError, HostError, HostModule, InvariantError, ValueType};

    #[test]
    fn encodes_strings_with_the_otp_json_escape_set() {
        assert_eq!(
            encode_string("\"\\/\u{0008}\t\n\u{000c}\r\u{0000}\u{001f}é"),
            r#""\"\\/\b\t\n\f\r\u0000\u001Fé""#,
        );
    }

    #[test]
    fn normalizes_finite_float_exponents_to_the_otp_shape() {
        assert_eq!(encode_float(1.0), "1.0");
        assert_eq!(encode_float(-0.0), "-0.0");
        assert_eq!(encode_float(1.0e20), "1.0e20");
        assert_eq!(encode_float(1.0e-7), "1.0e-7");
        assert_eq!(encode_float(1.25e20), "1.25e20");
    }

    #[test]
    fn executes_scalar_encoding_through_the_hosted_pipeline() {
        let execution = execution(
            r#"
pub fn main() {
  #(
    do_to_string(do_string("a\"\n\u{0000}é")),
    do_to_string(do_bool(True)),
    do_to_string(do_bool(False)),
    do_to_string(do_int(123456789012345678901234567890)),
    do_to_string(do_float(1.0e20)),
    do_to_string(do_null()),
    do_to_string(do_preprocessed_array([])),
    do_to_string(do_object([])),
  )
}
"#,
        );
        let value = execution
            .run_main(&mut run_state([0; 32]), &mut Vec::new())
            .expect("scalar JSON encoding should run");

        assert_eq!(
            value.inspect().to_string(),
            r#"#("\"a\\\"\\n\\u0000é\"", "true", "false", "123456789012345678901234567890", "1.0e20", "null", "[]", "{}")"#,
        );
    }

    #[test]
    fn constructs_nested_json_and_shares_string_tree_output() {
        let execution = execution(
            r#"
pub fn main() {
  #(
    do_to_string(do_object([
      #("a", do_int(1)),
      #("a", do_int(2)),
      #("b", do_preprocessed_array([do_bool(True), do_null()])),
    ])),
    to_string_tree(do_preprocessed_array([do_int(1), do_string("two")])),
  )
}
"#,
        );
        let value = execution
            .run_main(&mut run_state([0; 32]), &mut Vec::new())
            .expect("nested JSON construction should run");

        assert_eq!(
            value.inspect().to_string(),
            r#"#("{\"a\":1,\"a\":2,\"b\":[true,null]}", string_tree.from_string("[1,\"two\"]"))"#,
        );
    }

    #[test]
    fn rejects_non_finite_float_encoding_as_the_json_host_function() {
        for (name, value) in [("infinity", f64::INFINITY), ("nan", f64::NAN)] {
            let non_finite =
                HostModule::<GleamJsonProfile>::new_for_profile("gleam_json", "host/non_finite")
                    .expect("non-finite host module should be valid")
                    .with_function(name, move || value)
                    .expect("non-finite function should register");
            let source = format!(
                r#"
import host/non_finite

pub fn main() {{
  do_float(non_finite.{name}())
}}
"#,
            );
            let execution = execution_with_modules(&source, [non_finite]);
            let error = execution
                .run_main(&mut run_state([0; 32]), &mut Vec::new())
                .expect_err("non-finite JSON float should fail");
            let error = expect_json_host_error(error);

            assert_eq!(error.package(), "gleam_json");
            assert_eq!(error.module(), "gleam/json");
            assert_eq!(error.function(), "do_float");
            assert_eq!(
                error.failure().message(),
                "JSON cannot encode a non-finite Float",
            );
        }
    }

    #[test]
    #[should_panic(expected = "JSON encoding failure should remain a host failure")]
    fn host_failure_assertion_rejects_other_execution_errors() {
        let _ = expect_json_host_error(ExecutionError::Invariant(
            InvariantError::ListIndexOutOfBounds {
                item_type: ValueType::Int,
                index: 1,
                length: 0,
            },
        ));
    }

    fn expect_json_host_error(error: ExecutionError) -> Box<HostError> {
        let ExecutionError::Host(error) = error else {
            panic!("JSON encoding failure should remain a host failure");
        };
        error
    }
}