use std::sync::LazyLock;
use omgbase_properties::{Value as YamlValue, js_number_string, resolve_plain};
use regex::Regex;
use serde_json::{Map, Value};
static NOT_PLAIN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"^[\n\t ,\[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$"#,
)
.expect("regex")
});
static TRAILING_BLANK_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n[\t ]+$").expect("regex"));
static DOCUMENT_MARKER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^(%|---|\.\.\.)").expect("regex"));
fn plain_ok(s: &str, implicit_key: bool) -> bool {
if s.is_empty() || NOT_PLAIN.is_match(s) || DOCUMENT_MARKER.is_match(s) {
return false;
}
if implicit_key && s.contains('\n') {
return false;
}
matches!(resolve_plain(s), YamlValue::String(_))
}
fn quoted(s: &str) -> String {
let has_double = s.contains('"');
let has_single = s.contains('\'');
if has_double && !has_single {
format!("'{}'", s.replace('\'', "''"))
} else {
serde_json::to_string(s).expect("strings serialize")
}
}
fn block_scalar(s: &str, indent: &str) -> String {
let trimmed = s.trim_end_matches('\n');
let trailing = s.len() - trimmed.len();
let chomp = match trailing {
0 => "-",
1 => "",
_ => "+",
};
let header = if s.starts_with(' ') || s.starts_with('\n') {
format!("|2{chomp}")
} else {
format!("|{chomp}")
};
let body: Vec<String> = trimmed
.split('\n')
.map(|ln| {
if ln.is_empty() {
String::new()
} else {
format!("{indent}{ln}")
}
})
.collect();
let mut out = format!("{header}\n{}", body.join("\n"));
for _ in 1..trailing {
out.push('\n');
}
out
}
fn scalar(v: &Value, indent: &str) -> String {
match v {
Value::Null => "null".to_owned(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => js_number_string(n.as_f64().unwrap_or(f64::NAN)),
Value::String(s) => {
if s.contains('\n') {
if s.trim().is_empty() || TRAILING_BLANK_LINE.is_match(s) {
quoted(s)
} else {
block_scalar(s, indent)
}
} else if plain_ok(s, false) {
s.clone()
} else {
quoted(s)
}
}
Value::Array(_) | Value::Object(_) => unreachable!("collections are emitted structurally"),
}
}
fn key(k: &str) -> String {
if plain_ok(k, true) {
k.to_owned()
} else {
quoted(k)
}
}
fn value_after_prefix(v: &Value, indent: usize) -> String {
match v {
Value::Object(m) if m.is_empty() => " {}".to_owned(),
Value::Array(a) if a.is_empty() => " []".to_owned(),
Value::Object(m) => format!("\n{}", mapping(m, indent)),
Value::Array(a) => format!("\n{}", sequence(a, indent)),
other => format!(" {}", scalar(other, &" ".repeat(indent))),
}
}
fn mapping(m: &Map<String, Value>, indent: usize) -> String {
let pad = " ".repeat(indent);
m.iter()
.map(|(k, v)| format!("{pad}{}:{}", key(k), value_after_prefix(v, indent + 2)))
.collect::<Vec<_>>()
.join("\n")
}
fn sequence(items: &[Value], indent: usize) -> String {
let pad = " ".repeat(indent);
items
.iter()
.map(|item| match item {
Value::Object(m) if !m.is_empty() => {
let inner = mapping(m, indent + 2);
format!("{pad}- {}", inner.trim_start())
}
other => format!("{pad}-{}", value_after_prefix(other, indent + 2)),
})
.collect::<Vec<_>>()
.join("\n")
}
#[must_use]
pub fn stringify(m: &Map<String, Value>) -> String {
if m.is_empty() {
return "{}\n".to_owned();
}
format!("{}\n", mapping(m, 0))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn emit(v: Value) -> String {
stringify(v.as_object().unwrap())
}
#[test]
fn flat_mappings_match_the_yaml_package() {
assert_eq!(
emit(json!({ "title": "Hi", "count": 2, "draft": true, "tags": ["a", "b"] })),
"title: Hi\ncount: 2\ndraft: true\ntags:\n - a\n - b\n"
);
assert_eq!(
emit(json!({ "n": null, "f": 1.5, "e": [], "o": {} })),
"n: null\nf: 1.5\ne: []\no: {}\n"
);
assert_eq!(emit(json!({})), "{}\n");
}
#[test]
fn strings_quote_when_they_would_read_back_differently() {
assert_eq!(emit(json!({ "a": "123" })), "a: \"123\"\n");
assert_eq!(emit(json!({ "a": "true" })), "a: \"true\"\n");
assert_eq!(emit(json!({ "a": "null" })), "a: \"null\"\n");
assert_eq!(emit(json!({ "a": "" })), "a: \"\"\n");
assert_eq!(emit(json!({ "a": "x: y" })), "a: \"x: y\"\n");
assert_eq!(emit(json!({ "a": "# c" })), "a: \"# c\"\n");
assert_eq!(emit(json!({ "a": "x #c" })), "a: \"x #c\"\n");
assert_eq!(emit(json!({ "a": "- x" })), "a: \"- x\"\n");
assert_eq!(emit(json!({ "a": "trailing " })), "a: \"trailing \"\n");
assert_eq!(emit(json!({ "a": "say \"hi\"" })), "a: say \"hi\"\n");
assert_eq!(emit(json!({ "a": "\"x\": y" })), "a: '\"x\": y'\n");
assert_eq!(emit(json!({ "a": "it's" })), "a: it's\n");
assert_eq!(emit(json!({ "a": "x:y" })), "a: x:y\n");
assert_eq!(emit(json!({ "a": "1.0.0" })), "a: 1.0.0\n");
assert_eq!(emit(json!({ "a": "2026-09-26" })), "a: 2026-09-26\n");
assert_eq!(emit(json!({ "a": "/people/x.md" })), "a: /people/x.md\n");
assert_eq!(emit(json!({ "a": "[[n]]" })), "a: \"[[n]]\"\n");
}
#[test]
fn nested_collections_and_block_scalars() {
assert_eq!(
emit(json!({ "o": { "a": 1, "b": ["x"] }, "s": [{ "k": "v", "w": 2 }, "z"] })),
"o:\n a: 1\n b:\n - x\ns:\n - k: v\n w: 2\n - z\n"
);
assert_eq!(emit(json!({ "t": "l1\nl2" })), "t: |-\n l1\n l2\n");
assert_eq!(emit(json!({ "t": "l1\nl2\n" })), "t: |\n l1\n l2\n");
assert_eq!(
emit(json!({ "a b": 1, "1": 2, "x\ny": 3 })),
"a b: 1\n\"1\": 2\n\"x\\ny\": 3\n"
);
}
}