// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Write a `Json` tree as a TOML document.
///
/// Tables go under their own headers and scalars stay at the level they are on, so
/// the output reads like a configuration file someone wrote rather than one a
/// program emitted. An array of tables is written as `[[…]]` when every one of its
/// elements is a table, and inline otherwise, which is the only honest choice: TOML
/// has no way to spell an array mixing tables with scalars across headers.
///
/// `sort` orders keys by name; off, they keep the order the tree has. A document
/// read by this module and written again carries the same values, but not
/// necessarily the same layout — TOML can spell the same tree several ways.
pub fn dumps(value : Json, sort? : Bool = false) -> String {
let out = StringBuilder()
match value {
Object(fields) => table(out, fields, [], sort)
_ =>
// A document is a table; anything else is written as the one key a caller
// can read back.
table(out, { "value": value }, [], sort)
}
out.to_string()
}
///|
/// The same, as UTF-8 bytes.
pub fn dump(value : Json, sort? : Bool = false) -> Bytes {
@utf8.encode(dumps(value, sort~)[:])
}
///|
/// One table's scalars, then its sub-tables under their own headers.
fn table(
out : StringBuilder,
fields : Map[String, Json],
path : Array[String],
sort : Bool,
) -> Unit {
let names = fields.keys().collect()
if sort {
names.sort()
}
// Scalars first: a key written after a header would belong to that header.
for name in names {
let held = fields.get(name).unwrap()
if !is_table(held) && !is_table_array(held) {
write_key(out, name)
out.write_string(" = ")
write_inline(out, held)
out.write_char('\n')
}
}
for name in names {
let held = fields.get(name).unwrap()
let here = path + [name]
match held {
Object(inner) => {
if out.to_string().length() > 0 {
out.write_char('\n')
}
out.write_char('[')
write_path(out, here)
out.write_string("]\n")
table(out, inner, here, sort)
}
Array(items) =>
if is_table_array(held) {
for item in items {
match item {
Object(inner) => {
if out.to_string().length() > 0 {
out.write_char('\n')
}
out.write_string("[[")
write_path(out, here)
out.write_string("]]\n")
table(out, inner, here, sort)
}
_ => ()
}
}
}
_ => ()
}
}
}
///|
/// Whether a value is written under a header of its own.
fn is_table(v : Json) -> Bool {
v is Object(_)
}
///|
/// Whether a value is an array every element of which is a table, which is the
/// only shape `[[…]]` can spell.
fn is_table_array(v : Json) -> Bool {
match v {
Array(items) => {
if items.length() == 0 {
return false
}
for item in items {
if !(item is Object(_)) {
return false
}
}
true
}
_ => false
}
}
///|
/// A dotted path, each segment quoted only if it has to be.
fn write_path(out : StringBuilder, path : Array[String]) -> Unit {
for i = 0; i < path.length(); i = i + 1 {
if i > 0 {
out.write_char('.')
}
write_key(out, path[i])
}
}
///|
/// A key bare when it can be, quoted when it cannot.
fn write_key(out : StringBuilder, name : String) -> Unit {
if bare(name) {
out.write_string(name)
} else {
quoted(out, name)
}
}
///|
/// Whether a key may be written without quotes: letters, digits, underscore and
/// dash, and not empty (§Keys).
fn bare(name : String) -> Bool {
if name.length() == 0 {
return false
}
for i = 0; i < name.length(); i = i + 1 {
let c = name[i].to_int()
let ok = (c >= 0x41 && c <= 0x5a) ||
(c >= 0x61 && c <= 0x7a) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x5f ||
c == 0x2d
if !ok {
return false
}
}
true
}
///|
/// A value on one line: a scalar, an array, or an inline table.
fn write_inline(out : StringBuilder, value : Json) -> Unit {
match value {
Null =>
// TOML has no null. A caller that needs one spells it, which is what every
// encoder in reach does, and an empty string is the least surprising.
out.write_string("\"\"")
True => out.write_string("true")
False => out.write_string("false")
Number(n, repr~) =>
match repr {
// The text a number was read from is kept, so a value past what a Double
// holds exactly writes back as the digits it came in as.
Some(text) => out.write_string(text)
None => write_number(out, n)
}
String(s) => write_string(out, s)
Array(items) => {
out.write_char('[')
for i = 0; i < items.length(); i = i + 1 {
if i > 0 {
out.write_string(", ")
}
write_inline(out, items[i])
}
out.write_char(']')
}
Object(fields) => {
out.write_char('{')
let names = fields.keys().collect()
for i = 0; i < names.length(); i = i + 1 {
if i > 0 {
out.write_string(", ")
} else {
out.write_char(' ')
}
write_key(out, names[i])
out.write_string(" = ")
write_inline(out, fields.get(names[i]).unwrap())
}
out.write_string(" }")
}
}
}
///|
/// A number as TOML writes it: a whole value with no fractional part, so a port is
/// `12000` rather than `12000.0`.
fn write_number(out : StringBuilder, n : Double) -> Unit {
if n.is_nan() {
out.write_string("nan")
} else if n.is_inf() {
out.write_string(if n > 0.0 { "inf" } else { "-inf" })
} else if n == n.floor() && n.abs() < 1.0e15 {
out.write_string(n.to_int64().to_string())
} else {
out.write_string(n.to_string())
}
}
///|
/// A string in the form that reads back as itself.
///
/// A date-time that came in as one is written bare, because that is what it is:
/// this module reads TOML's four date-time types into their normalised text, so
/// quoting it on the way out would turn a timestamp into a string.
fn write_string(out : StringBuilder, s : String) -> Unit {
if is_moment(s) {
out.write_string(s)
return
}
// A string with a line break in it goes in a multi-line literal when it can,
// which is how a key or a script stays readable.
if index_of(s, 0x0a) >= 0 &&
index_of(s, 0x27) < 0 &&
index_of(s, 0x5c) < 0 &&
!has_control(s) {
out.write_string("'''\n")
out.write_string(s)
out.write_string("'''")
return
}
quoted(out, s)
}
///|
/// Whether a string is one of the date-times this module reads, and so should be
/// written back as one.
fn is_moment(s : String) -> Bool {
if !dateish(s) {
return false
}
try {
let _ = @moondate.parse(s[:])
true
} catch {
_ => false
}
}
///|
fn has_control(s : String) -> Bool {
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int()
if (c < 0x20 && c != 0x0a && c != 0x09) || c == 0x7f {
return true
}
}
false
}
///|
/// A basic string with the escapes TOML 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")
0x08 => out.write_string("\\b")
0x0c => out.write_string("\\f")
_ =>
if c < 0x20 || c == 0x7f {
out.write_string("\\u")
for shift in [12, 8, 4, 0] {
out.write_char(
"0123456789abcdef"[(c >> shift) & 0xf].unsafe_to_char(),
)
}
} else {
out.write_char(s[i].unsafe_to_char())
}
}
}
out.write_char('"')
}