// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A field's value, typed.
///
/// Fields are carried as values rather than as text so a layout decides how
/// they read: `7` and `"7"` are the same three characters on a line and
/// different things in a JSON document.
pub(all) enum Value {
Null
Bool(Bool)
Int(Int64)
Float(Double)
Str(String)
List(Array[Value])
Map(Array[(String, Value)])
Error(String, Array[String])
Other(String)
} derive(Debug)
///|
pub extend Value with Debug::{to_repr}
///|
/// `Null`.
pub fn Value::of_null() -> Value {
Null
}
///|
/// A `Bool`.
pub fn Value::of_bool(b : Bool) -> Value {
Bool(b)
}
///|
/// An `Int`, widened to 64 bits.
pub fn Value::of_int(n : Int32) -> Value {
Int(n.to_int64())
}
///|
/// An `Int` from a 64-bit integer.
pub fn Value::of_long(n : Int64) -> Value {
Int(n)
}
///|
/// A `Float`.
pub fn Value::of_float(x : Double) -> Value {
Float(x)
}
///|
/// A `Str`.
pub fn Value::of_str(s : StringView) -> Value {
Str(s.to_owned())
}
///|
/// A `List` of values.
pub fn Value::of_list(items : Array[Value]) -> Value {
List(items)
}
///|
/// A `Map`, its pairs kept in the order given.
pub fn Value::of_map(pairs : Array[(String, Value)]) -> Value {
Map(pairs)
}
///|
/// An error with its chain of causes, the way `error` travels as a field.
pub fn Value::of_error(
message~ : String,
causes~ : Array[String] = [],
) -> Value {
Error(message, causes)
}
///|
/// Anything else, shown as text.
pub fn Value::of_other(text : String) -> Value {
Other(text)
}
///|
/// The text form: bare strings, `[a, b]` for a list, `{k=v}` for a map, and an
/// error with its causes after colons.
pub fn Value::to_text(self : Value) -> String {
match self {
Null => "null"
Bool(b) => b.to_string()
Int(n) => n.to_string()
Float(x) => x.to_string()
Str(s) => s
Other(s) => s
Error(message, causes) => {
let out = StringBuilder()
out.write_string(message)
for cause in causes {
out.write_string(": ")
out.write_string(cause)
}
out.to_string()
}
List(items) => {
let out = StringBuilder()
out.write_char('[')
for i, item in items {
if i > 0 {
out.write_string(", ")
}
out.write_string(item.to_text())
}
out.write_char(']')
out.to_string()
}
Map(pairs) => {
let out = StringBuilder()
out.write_char('{')
for i, (k, v) in pairs {
if i > 0 {
out.write_string(", ")
}
out.write_string(k)
out.write_char('=')
out.write_string(v.to_text())
}
out.write_char('}')
out.to_string()
}
}
}
///|
/// The value as JSON.
pub fn Value::encode(self : Value) -> String {
let out = StringBuilder()
self.write_json(out)
out.to_string()
}
///|
/// Writes the value as JSON into `out`.
pub fn Value::write_json(self : Value, out : StringBuilder) -> Unit {
match self {
Null => out.write_string("null")
Bool(b) =>
if b {
out.write_string("true")
} else {
out.write_string("false")
}
Int(n) => out.write_string(n.to_string())
Float(x) => {
// JSON has no infinity and no NaN; the text form is what a pipeline can
// still read, and it stays a string rather than a number that lies.
let text = x.to_string()
if x.is_inf() || x.is_nan() {
write_json_string(out, text)
} else {
out.write_string(text)
}
}
Str(s) => write_json_string(out, s)
Other(s) => write_json_string(out, s)
Error(message, causes) => {
out.write_string("{\"message\":")
write_json_string(out, message)
if causes.length() > 0 {
out.write_string(",\"causes\":[")
for i, cause in causes {
if i > 0 {
out.write_char(',')
}
write_json_string(out, cause)
}
out.write_char(']')
}
out.write_char('}')
}
List(items) => {
out.write_char('[')
for i, item in items {
if i > 0 {
out.write_char(',')
}
item.write_json(out)
}
out.write_char(']')
}
Map(pairs) => {
out.write_char('{')
for i, (k, v) in pairs {
if i > 0 {
out.write_char(',')
}
write_json_string(out, k)
out.write_char(':')
v.write_json(out)
}
out.write_char('}')
}
}
}
///|
/// Writes `s` as a JSON string, quotes and control characters escaped.
fn write_json_string(out : StringBuilder, s : StringView) -> Unit {
out.write_char('"')
for c in s {
match c {
'"' => out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
_ =>
if c.to_int() < 32 {
let code = c.to_int()
out.write_string("\\u00")
out.write_char(hex_digit(code / 16))
out.write_char(hex_digit(code % 16))
} else {
out.write_char(c)
}
}
}
out.write_char('"')
}
///|
fn hex_digit(n : Int32) -> Char {
if n < 10 {
(48 + n).unsafe_to_char()
} else {
(87 + n).unsafe_to_char()
}
}
///|
/// `s` bare when it cannot be mistaken for two fields, quoted the logfmt way
/// otherwise: empty, or holding a space, a quote or an `=`.
fn write_logfmt(out : StringBuilder, s : StringView) -> Unit {
let mut bare = s.length() > 0
if bare {
for c in s {
if c == '"' || c == '=' || c.is_whitespace() {
bare = false
break
}
}
}
if bare {
out.write_string(s.to_owned())
} else {
write_json_string(out, s)
}
}