// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// How much a nested level is indented.
///
/// Two spaces: what Kubernetes manifests, Compose files and every configuration
/// example in the wild use, and what a reader's eye is trained on.
pub let indent : Int = 2
///|
/// Whether a string can be written without quotes.
///
/// It cannot if it is empty, if it would read back as something other than a
/// string, if it begins with a character YAML gives a meaning to, if it carries
/// a `: ` or a ` #` that would be read as structure or a comment, or if it has
/// space at either end where the reader would trim it.
fn bare(s : String) -> Bool {
if s.length() == 0 {
return false
}
if s == "true" || s == "false" || s == "null" || s == "~" {
return false
}
if number(s) is Some(_) {
return false
}
if is_blank(s[0].to_int()) || is_blank(s[s.length() - 1].to_int()) {
return false
}
// The characters YAML reserves at the start of a scalar.
for c in "-?:,[]{}#&*!|>'\"%@`" {
if s[0].to_int() == c.to_int() {
return false
}
}
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int()
if c < 0x20 || c == 0x7f {
return false
}
if c == ':'.to_int() && (i + 1 == s.length() || is_blank(s[i + 1].to_int())) {
return false
}
if c == '#'.to_int() && i > 0 && is_blank(s[i - 1].to_int()) {
return false
}
}
true
}
///|
/// A string in double quotes, with the escapes YAML defines.
fn quoted(out : StringBuilder, s : String) -> Unit {
out.write_char('"')
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int()
match c {
0x22 => out.write_string("\\\"")
0x5c => out.write_string("\\\\")
0x0a => out.write_string("\\n")
0x0d => out.write_string("\\r")
0x09 => out.write_string("\\t")
_ =>
if c < 0x20 || c == 0x7f {
out.write_string("\\x")
two_hex(out, c)
} else {
out.write_char(s[i].unsafe_to_char())
}
}
}
out.write_char('"')
}
///|
/// A byte as two hex digits.
fn two_hex(out : StringBuilder, c : Int) -> Unit {
let digits = "0123456789abcdef"
out.write_char(digits[(c >> 4) & 0xf].unsafe_to_char())
out.write_char(digits[c & 0xf].unsafe_to_char())
}
///|
/// A number as YAML writes it: a whole value without a fractional part, so a
/// port is `8080` rather than `8080.0`.
fn write_number(out : StringBuilder, n : Double) -> Unit {
if n == n.floor() && n.abs() < 1.0e15 {
out.write_string(n.to_int64().to_string())
} else {
out.write_string(n.to_string())
}
}
///|
/// A scalar, quoted only when plain would read back as something else.
fn write_scalar(out : StringBuilder, value : Json) -> Unit {
match value {
Null => out.write_string("null")
True => out.write_string("true")
False => out.write_string("false")
Number(n, ..) => write_number(out, n)
String(s) => if bare(s) { out.write_string(s) } else { quoted(out, s) }
_ => out.write_string("null")
}
}
///|
/// A string that is better written as a literal block scalar than escaped into
/// one quoted line: the shell script, the PEM key, the message of the day.
///
/// It has to have a line break to be worth it, and it has to carry nothing a
/// literal block cannot hold — a carriage return, a control character, or a line
/// that begins with space, which would need an explicit indentation indicator to
/// survive the round trip.
fn literal_of(value : Json) -> String? {
guard value is String(s) else { return None }
if index_of_unit(s, 0x0a) < 0 {
return None
}
let mut bol = true
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int()
if c == 0x0a {
bol = true
continue
}
if c < 0x20 || c == 0x7f {
return None
}
if bol && is_blank(c) {
return None
}
bol = false
}
Some(s)
}
///|
/// A literal block scalar: the header on the current line, then the content on the
/// lines under it at `column`.
///
/// The chomping indicator carries the trailing breaks exactly — `-` for none, none
/// for one, `+` for more — so what is read back is what was written.
fn write_literal(out : StringBuilder, s : String, column : Int) -> Unit {
let mut end = s.length()
let mut trailing = 0
while end > 0 && s[end - 1].to_int() == 0x0a {
end = end - 1
trailing = trailing + 1
}
out.write_string(
match trailing {
0 => "|-"
1 => "|"
_ => "|+"
},
)
let body = s[0:end].to_owned()
let mut start = 0
for i = 0; i <= body.length(); i = i + 1 {
if i == body.length() || body[i].to_int() == 0x0a {
out.write_char('\n')
if i > start {
pad(out, column)
out.write_string(body[start:i].to_owned())
}
start = i + 1
}
}
for i = 1; i < trailing; i = i + 1 {
out.write_char('\n')
}
}
///|
/// Whether a value is written on the same line as the key or item that owns it.
fn inline(value : Json) -> Bool {
match value {
Array(items) => items.length() == 0
Object(members) => members.length() == 0
_ => true
}
}
///|
/// A value that fits on one line: a scalar, or the flow form of an empty
/// collection — which is the only way block style can express one.
fn write_inline(out : StringBuilder, value : Json) -> Unit {
match value {
Array(_) => out.write_string("[]")
Object(_) => out.write_string("{}")
_ => write_scalar(out, value)
}
}
///|
fn pad(out : StringBuilder, n : Int) -> Unit {
for _ in 0.. Unit {
match value {
Object(members) => {
let names = members.keys().collect()
if sort {
names.sort()
}
let mut first = true
for name in names {
let held = members.get(name).unwrap()
if !first || column > 0 {
if !first {
out.write_char('\n')
}
}
if !first {
pad(out, column)
}
first = false
if bare(name) {
out.write_string(name)
} else {
quoted(out, name)
}
out.write_char(':')
if literal_of(held) is Some(text) {
out.write_char(' ')
write_literal(out, text, column + step)
} else if inline(held) {
out.write_char(' ')
write_inline(out, held)
} else {
out.write_char('\n')
pad(out, column + step)
block(out, held, column + step, step, sort)
}
}
}
Array(items) => {
let mut first = true
for item in items {
if !first {
out.write_char('\n')
pad(out, column)
}
first = false
out.write_string("- ")
if literal_of(item) is Some(text) {
write_literal(out, text, column + 2)
} else if inline(item) {
write_inline(out, item)
} else {
// A nested block under a `-` begins on the same line, indented to
// just past the dash, which is how every hand-written file does it.
block(out, item, column + 2, step, sort)
}
}
}
_ =>
if literal_of(value) is Some(text) {
write_literal(out, text, column + step)
} else {
write_scalar(out, value)
}
}
}
///|
/// Write a `Json` tree as a YAML document.
///
/// Block style, which is what a configuration file is written in and what a
/// person reads. A string is quoted only when plain would read back as
/// something else — a number, a boolean, a null, or not at all — so the output
/// looks like a file someone wrote rather than one a program emitted.
///
/// This is the reason to have a writer at all: a generator that builds YAML by
/// joining strings gets the escaping wrong the first time a value contains a
/// colon, and gets the indentation wrong the first time a value is a list.
pub fn dumps(
value : Json,
indent? : Int = indent,
sort? : Bool = false,
) -> String {
guard indent >= 1 else {
abort("yaml: an indent of \{indent} would not nest anything")
}
let out = StringBuilder()
match value {
Object(members) =>
if members.length() == 0 {
out.write_string("{}")
} else {
block(out, value, 0, indent, sort)
}
Array(items) =>
if items.length() == 0 {
out.write_string("[]")
} else {
block(out, value, 0, indent, sort)
}
_ => write_scalar(out, value)
}
out.write_char('\n')
out.to_string()
}
///|
/// The same, as UTF-8 bytes — the `s` on [`dumps`] is the one Python puts on
/// the form that answers a string.
pub fn dump(
value : Json,
indent? : Int = indent,
sort? : Bool = false,
) -> Bytes {
@utf8.encode(dumps(value, indent~, sort~)[:])
}
///|
/// A stream of documents, each opened by its own `---`.
///
/// The separator goes before each document rather than between them. The two
/// conventions are both in use — PyYAML and Go's `yaml.v3` write it only between,
/// `kubectl` writes it before each — and this takes the one that stays correct
/// under concatenation: two streams joined end to end still read as their
/// documents, and appending one is appending lines. The reader accepts either.
pub fn dumps_all(
values : ArrayView[Json],
indent? : Int = indent,
sort? : Bool = false,
) -> String {
let out = StringBuilder()
for value in values {
out.write_string("---\n")
out.write_string(dumps(value, indent~, sort~))
}
out.to_string()
}
///|
/// The same, as UTF-8 bytes.
pub fn dump_all(
values : ArrayView[Json],
indent? : Int = indent,
sort? : Bool = false,
) -> Bytes {
@utf8.encode(dumps_all(values, indent~, sort~)[:])
}