use crate::error::Diagnostic;
use crate::eval::value::Value;
use crate::span::Span;
fn err(code: &'static str, msg: String) -> Diagnostic {
Diagnostic::error(code, msg, Span::new(0, 0, 0), "during serialization")
}
pub fn to_json(v: &Value<'_>) -> Result<serde_json::Value, Diagnostic> {
let mut path: Vec<String> = Vec::new();
go(v, &mut path)
}
fn go(v: &Value<'_>, path: &mut Vec<String>) -> Result<serde_json::Value, Diagnostic> {
use serde_json::Value as J;
Ok(match v {
Value::Null => J::Null,
Value::Bool(b) => J::Bool(*b),
Value::Int(n) => J::Number((*n).into()),
Value::Float(n) => J::Number(
serde_json::Number::from_f64(*n)
.ok_or_else(|| err("E0602", format!("non-finite float at '{}'", path.join("."))))?,
),
Value::Str(s) => J::String(s.to_string()),
Value::List(xs) => {
let mut out = Vec::with_capacity(xs.len());
for (i, item) in xs.iter().enumerate() {
path.push(format!("[{i}]"));
out.push(go(item, path)?);
path.pop();
}
J::Array(out)
}
Value::Object(m) => {
let mut out = serde_json::Map::with_capacity(m.len());
for (k, item) in m.iter() {
if path.is_empty()
&& matches!(item, Value::Schema(_) | Value::Function(_) | Value::Enum(_))
{
continue;
}
path.push(k.clone());
out.insert(k.clone(), go(item, path)?);
path.pop();
}
J::Object(out)
}
Value::Schema(_) | Value::Function(_) | Value::Enum(_) => {
return Err(err(
"E0601",
format!(
"{} is not serializable (at '{}')",
v.type_name(),
path.join(".")
),
))
}
})
}
pub fn to_yaml_string(v: &Value<'_>) -> Result<String, Diagnostic> {
let json = to_json(v)?;
let mut out = String::new();
emit_yaml(&json, 0, &mut out);
Ok(out)
}
pub fn json_to_yaml_string(json: &serde_json::Value) -> String {
let mut out = String::new();
emit_yaml(json, 0, &mut out);
out
}
pub fn json_to_toml_string(json: &serde_json::Value) -> Result<String, Diagnostic> {
if !json.is_object() {
return Err(err(
"E0603",
"TOML requires an object at the top level".to_string(),
));
}
toml::to_string_pretty(json).map_err(|e| err("E0603", format!("cannot emit TOML: {e}")))
}
fn emit_yaml(v: &serde_json::Value, indent: usize, out: &mut String) {
use serde_json::Value as J;
let pad = " ".repeat(indent);
match v {
J::Object(map) if map.is_empty() => out.push_str("{}\n"),
J::Array(xs) if xs.is_empty() => out.push_str("[]\n"),
J::Object(map) => {
for (i, (k, val)) in map.iter().enumerate() {
if i > 0 {
out.push_str(&pad);
}
out.push_str(&yaml_scalar(k));
out.push(':');
emit_child(val, indent, out);
}
}
J::Array(xs) => {
for (i, item) in xs.iter().enumerate() {
if i > 0 {
out.push_str(&pad);
}
out.push_str("- ");
match item {
J::Object(m) if !m.is_empty() => emit_yaml(item, indent + 2, out),
J::Array(a) if !a.is_empty() => emit_yaml(item, indent + 2, out),
_ => {
out.push_str(&yaml_atom(item));
out.push('\n');
}
}
}
}
_ => {
out.push_str(&yaml_atom(v));
out.push('\n');
}
}
}
fn emit_child(v: &serde_json::Value, indent: usize, out: &mut String) {
use serde_json::Value as J;
match v {
J::Object(m) if !m.is_empty() => {
out.push('\n');
out.push_str(&" ".repeat(indent + 2));
emit_yaml(v, indent + 2, out);
}
J::Array(a) if !a.is_empty() => {
out.push('\n');
out.push_str(&" ".repeat(indent));
emit_yaml(v, indent, out);
}
_ => {
out.push(' ');
out.push_str(&yaml_atom(v));
out.push('\n');
}
}
}
fn yaml_atom(v: &serde_json::Value) -> String {
use serde_json::Value as J;
match v {
J::Null => "null".to_string(),
J::Bool(b) => b.to_string(),
J::Number(n) => n.to_string(),
J::String(s) => yaml_scalar(s),
J::Object(_) => "{}".to_string(),
J::Array(_) => "[]".to_string(),
}
}
fn yaml_scalar(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
if s.contains(|c: char| c.is_control()) {
return format!("{:?}", s);
}
let first = s.chars().next().unwrap();
let looks_structural = "-?:,[]{}#&*!|>'\"%@`".contains(first)
|| s.contains(": ")
|| s.contains(" #")
|| s.starts_with(' ')
|| s.ends_with(' ')
|| s.contains('\t');
if looks_structural || reads_back_as_non_string(s) {
return format!("'{}'", s.replace('\'', "''"));
}
s.to_string()
}
fn reads_back_as_non_string(s: &str) -> bool {
matches!(
s,
"null" | "Null" | "NULL" | "~" | "true" | "True" | "TRUE" | "false" | "False" | "FALSE"
) || s.parse::<i64>().is_ok()
|| s.parse::<f64>().is_ok()
}
pub fn to_toml_string(v: &Value<'_>) -> Result<String, Diagnostic> {
let json = to_json(v)?;
if !json.is_object() {
return Err(err(
"E0603",
"TOML requires an object at the top level".to_string(),
));
}
toml::to_string_pretty(&json).map_err(|e| {
err(
"E0603",
format!("cannot emit TOML (note: TOML has no null): {e}"),
)
})
}
pub fn to_json_flat(v: &Value<'_>) -> Result<serde_json::Value, Diagnostic> {
let json = to_json(v)?;
let serde_json::Value::Object(map) = json else {
return Ok(json);
};
let mut out = serde_json::Map::new();
flatten("", &serde_json::Value::Object(map), &mut out)?;
Ok(serde_json::Value::Object(out))
}
fn flatten(
prefix: &str,
v: &serde_json::Value,
out: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<(), Diagnostic> {
match v {
serde_json::Value::Object(m) if m.is_empty() => insert(prefix, v, out),
serde_json::Value::Object(m) => {
for (k, inner) in m {
let key = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
flatten(&key, inner, out)?;
}
Ok(())
}
leaf => insert(prefix, leaf, out),
}
}
fn insert(
key: &str,
value: &serde_json::Value,
out: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<(), Diagnostic> {
if out.contains_key(key) {
let mut d = err(
"E0604",
format!("flattened key '{key}' is ambiguous: two values map to it"),
);
d.help = Some(
"a key containing a dot is indistinguishable from a nested path once \
flattened; use --format json, or rename the key"
.to_string(),
);
return Err(d);
}
out.insert(key.to_string(), value.clone());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn yaml_of(json: serde_json::Value) -> String {
let mut out = String::new();
emit_yaml(&json, 0, &mut out);
out
}
#[test]
fn strings_that_look_like_other_types_are_quoted() {
for s in [
"3", "-1", "0.5", "1e3", "true", "false", "True", "FALSE", "null", "Null", "~",
] {
let out = yaml_of(serde_json::json!({ "k": s }));
assert_eq!(out, format!("k: '{s}'\n"), "{s} must be quoted");
}
for s in ["2.4.1", "gateway", "apps/v1", "company/img:2.4.1", "a-b_c"] {
let out = yaml_of(serde_json::json!({ "k": s }));
assert_eq!(out, format!("k: {s}\n"), "{s} must stay plain");
}
}
#[test]
fn structural_characters_force_quoting() {
for s in [
"- item", "? q", ": v", "a: b", "x #c", "#c", "[a]", "{a}", "&anchor", "*alias",
"!tag", "|block", ">fold", "'q", "\"q", "%d", "@a", " lead", "trail ", "",
] {
let out = yaml_of(serde_json::json!({ "k": s }));
assert!(
out.starts_with("k: '") || out.starts_with("k: \""),
"{s:?} must be quoted, got {out:?}"
);
}
}
#[test]
fn quotes_matter_only_where_yaml_gives_them_meaning() {
assert_eq!(yaml_of(serde_json::json!({ "k": "it's 3" })), "k: it's 3\n");
assert_eq!(
yaml_of(serde_json::json!({ "k": "'quoted'" })),
"k: '''quoted'''\n"
);
}
#[test]
fn nesting_and_sequences_match_the_conventional_layout() {
let out = yaml_of(serde_json::json!({
"spec": { "containers": [ { "name": "gw", "port": 8080 } ], "replicas": 3 }
}));
assert_eq!(
out, "spec:\n containers:\n - name: gw\n port: 8080\n replicas: 3\n",
"{out}"
);
}
#[test]
fn empty_collections_and_scalars() {
assert_eq!(yaml_of(serde_json::json!({ "a": {} })), "a: {}\n");
assert_eq!(yaml_of(serde_json::json!({ "a": [] })), "a: []\n");
assert_eq!(yaml_of(serde_json::json!({ "a": null })), "a: null\n");
assert_eq!(yaml_of(serde_json::json!({ "a": 1.5 })), "a: 1.5\n");
}
#[test]
fn emitted_yaml_reads_back_identically() {
let cases = vec![
serde_json::json!({"n": 3, "s": "3", "b": true, "sb": "true", "z": null, "sz": "null"}),
serde_json::json!({"list": ["a", "1", 1, true, "true", ""]}),
serde_json::json!({"deep": {"a": {"b": {"c": "v: w"}}}}),
serde_json::json!({"odd": ["- x", "#c", " pad ", "it's"]}),
serde_json::json!({"empty_map": {}, "empty_list": [], "f": 0.25}),
];
for case in cases {
let text = yaml_of(case.clone());
let docs = yaml_rust2::YamlLoader::load_from_str(&text)
.unwrap_or_else(|e| panic!("emitted invalid YAML for {case}: {e}\n{text}"));
assert_eq!(docs.len(), 1, "{text}");
let back = yaml_to_json(&docs[0]);
assert_eq!(back, case, "round-trip changed the value:\n{text}");
}
}
#[test]
fn flattening_keeps_every_key_and_treats_lists_as_leaves() {
let flat = flat_of(serde_json::json!({
"api": { "port": 8080, "tls": { "on": true } },
"tags": ["a", "b"],
"objs": [{ "k": 1 }],
"n": 1
}))
.expect("flattens");
assert_eq!(
flat,
serde_json::json!({
"api.port": 8080,
"api.tls.on": true,
"tags": ["a", "b"],
"objs": [{ "k": 1 }],
"n": 1
})
);
}
#[test]
fn an_empty_object_survives_flattening() {
let flat = flat_of(serde_json::json!({
"empty": {},
"nested": { "also_empty": {} },
"empty_list": [],
"kept": 1
}))
.expect("flattens");
assert_eq!(
flat,
serde_json::json!({
"empty": {},
"nested.also_empty": {},
"empty_list": [],
"kept": 1
})
);
}
#[test]
fn a_dotted_key_colliding_with_a_nested_path_is_e0604() {
let d = flat_of(serde_json::json!({
"a": { "b": "from-nesting" },
"a.b": "from-a-literal-dotted-key"
}))
.expect_err("a collision must not silently drop a value");
assert_eq!(d.code, "E0604");
assert!(d.message.contains("a.b"), "{}", d.message);
assert!(d.help.is_some(), "the error must say what to do instead");
let ok = flat_of(serde_json::json!({ "a.b": 1, "c": { "d": 2 } })).expect("no collision");
assert_eq!(ok, serde_json::json!({ "a.b": 1, "c.d": 2 }));
}
fn flat_of(json: serde_json::Value) -> Result<serde_json::Value, Diagnostic> {
let mut out = serde_json::Map::new();
flatten("", &json, &mut out)?;
Ok(serde_json::Value::Object(out))
}
fn yaml_to_json(y: &yaml_rust2::Yaml) -> serde_json::Value {
use serde_json::Value as J;
use yaml_rust2::Yaml as Y;
match y {
Y::Null | Y::BadValue | Y::Alias(_) => J::Null,
Y::Boolean(b) => J::Bool(*b),
Y::Integer(n) => J::Number((*n).into()),
Y::Real(r) => r
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map_or_else(|| J::String(r.clone()), J::Number),
Y::String(s) => J::String(s.clone()),
Y::Array(xs) => J::Array(xs.iter().map(yaml_to_json).collect()),
Y::Hash(h) => J::Object(
h.iter()
.map(|(k, v)| {
let key = match k {
Y::String(s) => s.clone(),
other => format!("{other:?}"),
};
(key, yaml_to_json(v))
})
.collect(),
),
}
}
}