// Copyright 2026 moonbit-toml contributors
//
// SPDX-License-Identifier: MIT
///|
/// toml2json: parse a TOML file and print it as toml-test protocol JSON
/// (values tagged as `{"type": ..., "value": ...}`).
///
/// Usage: `toml2json `
///
/// Exit status: 0 on success, non-zero when the input is not valid TOML
/// (the error message goes to stderr).
fn main raise {
let args = @env.args()
// The file to convert is expected as the last command line argument.
let path = if args.length() >= 2 {
args[args.length() - 1]
} else {
abort("usage: toml2json ")
}
let bytes = @fs.read_file_to_bytes(path)
let text = decode_utf8_strict(bytes)
let doc = @toml.parse(text) catch { err => abort(err.message()) }
let sb = StringBuilder::StringBuilder()
emit_tagged(sb, @toml.Value::Table(doc))
println(sb.to_string())
}
///|
/// Decodes UTF-8 bytes into a String, rejecting invalid sequences
/// (bad continuation bytes, surrogates, overlong encodings, > U+10FFFF).
/// Invalid input aborts with a message on stderr.
fn decode_utf8_strict(bytes : Bytes) -> String {
let sb = StringBuilder::StringBuilder()
let len = bytes.length()
let mut i = 0
while i < len {
let b0 = bytes[i].to_int()
if b0 < 0x80 {
sb.write_char(b0.to_char().unwrap_or('\u{FFFD}'))
i += 1
} else if b0 >= 0xC2 && b0 <= 0xDF {
let b1 = cont(bytes, i + 1, len)
sb.write_char(
(((b0 & 0x1F) << 6) | (b1 & 0x3F)).to_char().unwrap_or('\u{FFFD}'),
)
i += 2
} else if b0 >= 0xE0 && b0 <= 0xEF {
let b1 = cont(bytes, i + 1, len)
// Reject overlong forms and surrogates.
if (b0 == 0xE0 && b1 < 0xA0) || (b0 == 0xED && b1 > 0x9F) {
abort("invalid UTF-8 byte \{i}")
}
let b2 = cont(bytes, i + 2, len)
let cp = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F)
sb.write_char(cp.to_char().unwrap_or('\u{FFFD}'))
i += 3
} else if b0 >= 0xF0 && b0 <= 0xF4 {
let b1 = cont(bytes, i + 1, len)
// Reject overlong forms and code points beyond U+10FFFF.
if (b0 == 0xF0 && b1 < 0x90) || (b0 == 0xF4 && b1 > 0x8F) {
abort("invalid UTF-8 byte \{i}")
}
let b2 = cont(bytes, i + 2, len)
let b3 = cont(bytes, i + 3, len)
let cp = ((b0 & 0x07) << 18) |
((b1 & 0x3F) << 12) |
((b2 & 0x3F) << 6) |
(b3 & 0x3F)
sb.write_char(cp.to_char().unwrap_or('\u{FFFD}'))
i += 4
} else {
// Stray continuation byte, 0xC0/0xC1, or 0xF5..0xFF.
abort("invalid UTF-8 byte \{i}")
}
}
sb.to_string()
}
///|
/// Reads one continuation byte (0x80..0xBF) at `i`, aborting when missing
/// or malformed.
fn cont(bytes : Bytes, i : Int, len : Int) -> Int {
if i >= len {
abort("truncated UTF-8 sequence at byte \{i}")
}
let b = bytes[i].to_int()
if b < 0x80 || b > 0xBF {
abort("invalid UTF-8 continuation byte \{i}")
}
b
}
///|
/// Emits a TOML value in the toml-test tagged JSON protocol.
fn emit_tagged(sb : StringBuilder, v : @toml.Value) -> Unit {
match v {
@toml.Str(s) => {
sb.write_string("{\"type\": \"string\", \"value\": ")
emit_json_string(sb, s)
sb.write_char('}')
}
@toml.Int(i) => {
sb.write_string("{\"type\": \"integer\", \"value\": ")
emit_json_string(sb, i.to_string())
sb.write_char('}')
}
@toml.Float(f) => {
sb.write_string("{\"type\": \"float\", \"value\": ")
emit_json_string(sb, format_float_json(f))
sb.write_char('}')
}
@toml.Bool(b) => {
sb.write_string("{\"type\": \"bool\", \"value\": ")
emit_json_string(sb, if b { "true" } else { "false" })
sb.write_char('}')
}
@toml.Datetime(dt) => {
sb.write_string("{\"type\": \"")
sb.write_string(dt.kind_name())
sb.write_string("\", \"value\": ")
emit_json_string(sb, @toml.encode_datetime(dt))
sb.write_char('}')
}
@toml.Array(items) => {
sb.write_char('[')
for i, item in items {
if i > 0 {
sb.write_char(',')
}
emit_tagged(sb, item)
}
sb.write_char(']')
}
@toml.Table(t) => {
sb.write_char('{')
let mut i = 0
for k in t.keys() {
if i > 0 {
sb.write_char(',')
}
emit_json_string(sb, k)
sb.write_char(':')
emit_tagged(sb, t.get(k).unwrap_or(@toml.Value::Bool(false)))
i += 1
}
sb.write_char('}')
}
}
}
///|
/// Canonical float rendering for the tagged protocol: shortest decimal
/// that round-trips, always with a fraction or exponent, `inf`/`nan`
/// specials.
fn format_float_json(f : Double) -> String {
if f.is_nan() {
return "nan"
}
if f.is_inf() {
return if f > 0.0 { "inf" } else { "-inf" }
}
let s = f.to_string()
let mut needs_frac = true
for c in s {
if c == '.' || c == 'e' || c == 'E' {
needs_frac = false
break
}
}
if needs_frac {
s + ".0"
} else {
s
}
}
///|
/// Emits a JSON string literal with proper escaping (control characters
/// as \uXXXX, everything else raw UTF-8).
fn emit_json_string(sb : StringBuilder, s : String) -> Unit {
sb.write_char('"')
for c in s {
let code = c.to_int()
match c {
'"' => sb.write_string("\\\"")
'\\' => sb.write_string("\\\\")
'\u{0008}' => sb.write_string("\\b")
'\u{000C}' => sb.write_string("\\f")
'\n' => sb.write_string("\\n")
'\r' => sb.write_string("\\r")
'\t' => sb.write_string("\\t")
_ =>
if code < 0x20 {
sb.write_string(unicode_escape_json(code))
} else {
sb.write_char(c)
}
}
}
sb.write_char('"')
}
///|
fn unicode_escape_json(code : Int) -> String {
let sb = StringBuilder::StringBuilder()
sb.write_string("\\u")
for shift in [12, 8, 4, 0] {
let d = (code >> shift) & 0xF
sb.write_char(
if d < 10 {
(0x30 + d).to_char().unwrap_or('0')
} else {
(0x41 + d - 10).to_char().unwrap_or('A')
},
)
}
sb.to_string()
}