use crate::host::buildtime::sandbox::SandboxValue;
#[derive(Debug, Clone, thiserror::Error)]
pub enum SerializeError {
#[error("value {0} has no valid TypeScript literal representation")]
NotRepresentable(&'static str),
#[error("number {0} cannot be serialized as a TypeScript numeric literal")]
NumberNotRepresentable(f64),
}
pub fn value_to_ts_source(value: &SandboxValue) -> Result<String, SerializeError> {
let mut out = String::new();
write_value(value, &mut out)?;
Ok(out)
}
fn write_value(value: &SandboxValue, out: &mut String) -> Result<(), SerializeError> {
match value {
SandboxValue::Null => out.push_str("null"),
SandboxValue::Undefined => out.push_str("undefined"),
SandboxValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
SandboxValue::Number(n) => write_number(*n, out)?,
SandboxValue::BigInt(i) => {
out.push_str(&i.to_string());
out.push('n');
}
SandboxValue::String(s) => write_string_literal(s, out),
SandboxValue::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_value(item, out)?;
}
out.push(']');
}
SandboxValue::Object(map) => {
out.push('{');
for (i, (key, val)) in map.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_object_key(key, out);
out.push_str(": ");
write_value(val, out)?;
}
out.push('}');
}
SandboxValue::SourceCode(text) => out.push_str(text),
}
Ok(())
}
fn write_number(n: f64, out: &mut String) -> Result<(), SerializeError> {
if n.is_nan() || n.is_infinite() {
return Err(SerializeError::NumberNotRepresentable(n));
}
if n == n.trunc() && n.abs() < 1e16 {
if n == 0.0 && n.is_sign_negative() {
out.push_str("-0");
} else {
out.push_str(&format!("{}", n as i64));
}
} else {
out.push_str(&format!("{n}"));
}
Ok(())
}
fn write_string_literal(s: &str, out: &mut String) {
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"),
'\x08' => out.push_str("\\b"),
'\x0c' => 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('"');
}
fn write_object_key(key: &str, out: &mut String) {
if is_valid_ts_identifier(key) {
out.push_str(key);
} else {
write_string_literal(key, out);
}
}
fn is_valid_ts_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_alphabetic() || first == '_' || first == '$') {
return false;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') {
return false;
}
!is_reserved_word(s)
}
fn is_reserved_word(s: &str) -> bool {
matches!(
s,
"break"
| "case"
| "catch"
| "class"
| "const"
| "continue"
| "debugger"
| "default"
| "delete"
| "do"
| "else"
| "enum"
| "export"
| "extends"
| "false"
| "finally"
| "for"
| "function"
| "if"
| "import"
| "in"
| "instanceof"
| "new"
| "null"
| "return"
| "super"
| "switch"
| "this"
| "throw"
| "true"
| "try"
| "typeof"
| "var"
| "void"
| "while"
| "with"
)
}
#[cfg(test)]
mod serialize_unit {
use super::*;
use std::collections::BTreeMap;
#[test]
fn null_and_undefined() {
assert_eq!(value_to_ts_source(&SandboxValue::Null).unwrap(), "null");
assert_eq!(
value_to_ts_source(&SandboxValue::Undefined).unwrap(),
"undefined"
);
}
#[test]
fn booleans() {
assert_eq!(
value_to_ts_source(&SandboxValue::Bool(true)).unwrap(),
"true"
);
assert_eq!(
value_to_ts_source(&SandboxValue::Bool(false)).unwrap(),
"false"
);
}
#[test]
fn integer_numbers_go_out_as_integers() {
assert_eq!(
value_to_ts_source(&SandboxValue::Number(42.0)).unwrap(),
"42"
);
assert_eq!(
value_to_ts_source(&SandboxValue::Number(-17.0)).unwrap(),
"-17"
);
assert_eq!(value_to_ts_source(&SandboxValue::Number(0.0)).unwrap(), "0");
}
#[test]
fn negative_zero_is_preserved() {
assert_eq!(
value_to_ts_source(&SandboxValue::Number(-0.0)).unwrap(),
"-0"
);
}
#[test]
fn fractional_numbers_round_trip() {
assert_eq!(
value_to_ts_source(&SandboxValue::Number(1.23)).unwrap(),
"1.23"
);
}
#[test]
fn nan_is_rejected() {
let err = value_to_ts_source(&SandboxValue::Number(f64::NAN)).unwrap_err();
assert!(matches!(err, SerializeError::NumberNotRepresentable(_)));
}
#[test]
fn infinity_is_rejected() {
assert!(value_to_ts_source(&SandboxValue::Number(f64::INFINITY)).is_err());
assert!(value_to_ts_source(&SandboxValue::Number(f64::NEG_INFINITY)).is_err());
}
#[test]
fn bigint_gets_n_suffix() {
assert_eq!(
value_to_ts_source(&SandboxValue::BigInt(42)).unwrap(),
"42n"
);
assert_eq!(
value_to_ts_source(&SandboxValue::BigInt(-9_999_999_999_999_999)).unwrap(),
"-9999999999999999n"
);
}
#[test]
fn strings_are_json_escaped() {
assert_eq!(
value_to_ts_source(&SandboxValue::String("hi".to_string())).unwrap(),
r#""hi""#
);
assert_eq!(
value_to_ts_source(&SandboxValue::String("a\"b".to_string())).unwrap(),
r#""a\"b""#
);
assert_eq!(
value_to_ts_source(&SandboxValue::String("line\nnext".to_string())).unwrap(),
r#""line\nnext""#
);
assert_eq!(
value_to_ts_source(&SandboxValue::String("\x01".to_string())).unwrap(),
r#""\u0001""#
);
}
#[test]
fn arrays() {
let value = SandboxValue::Array(vec![
SandboxValue::Number(1.0),
SandboxValue::Number(2.0),
SandboxValue::Number(3.0),
]);
assert_eq!(value_to_ts_source(&value).unwrap(), "[1, 2, 3]");
}
#[test]
fn nested_arrays() {
let value = SandboxValue::Array(vec![
SandboxValue::Array(vec![SandboxValue::Number(1.0), SandboxValue::Number(2.0)]),
SandboxValue::Array(vec![SandboxValue::Number(3.0)]),
]);
assert_eq!(value_to_ts_source(&value).unwrap(), "[[1, 2], [3]]");
}
#[test]
fn objects_with_valid_identifier_keys() {
let mut map = BTreeMap::new();
map.insert("a".to_string(), SandboxValue::Number(1.0));
map.insert("b".to_string(), SandboxValue::Number(2.0));
let value = SandboxValue::Object(map);
assert_eq!(value_to_ts_source(&value).unwrap(), "{a: 1, b: 2}");
}
#[test]
fn objects_with_non_identifier_keys_are_quoted() {
let mut map = BTreeMap::new();
map.insert("has-dash".to_string(), SandboxValue::Number(1.0));
map.insert("2leading".to_string(), SandboxValue::Number(2.0));
let value = SandboxValue::Object(map);
let out = value_to_ts_source(&value).unwrap();
assert!(out.contains(r#""has-dash""#));
assert!(out.contains(r#""2leading""#));
}
#[test]
fn reserved_words_are_quoted() {
let mut map = BTreeMap::new();
map.insert("class".to_string(), SandboxValue::Number(1.0));
let value = SandboxValue::Object(map);
assert_eq!(value_to_ts_source(&value).unwrap(), r#"{"class": 1}"#);
}
#[test]
fn source_code_splices_verbatim() {
let value = SandboxValue::SourceCode("export const X = 42;".to_string());
assert_eq!(value_to_ts_source(&value).unwrap(), "export const X = 42;");
}
#[test]
fn dollar_sign_is_valid_identifier_start() {
let mut map = BTreeMap::new();
map.insert("$var".to_string(), SandboxValue::Number(1.0));
let value = SandboxValue::Object(map);
assert_eq!(value_to_ts_source(&value).unwrap(), "{$var: 1}");
}
}