///|
/// Represents a JSON5 value tree.
///
/// This enum is the primary data model for parsed JSON5 content in this
/// package. It mirrors regular JSON shapes while preserving JSON5-friendly
/// numeric forms during parsing (for example `NaN` and `Infinity`).
///
/// Variants:
/// - `Null`
/// - `True`
/// - `False`
/// - `Number(Double)`
/// - `String(String)`
/// - `Array(Array[Json5Value])`
/// - `Object(Map[String, Json5Value])`
///
/// Example:
///
/// ```mbt check
/// test {
/// let value = @json5.parse("{foo: [1, true, 'x']}")
/// guard value is Object(obj)
/// assert_true(obj.get("foo") is Some(Array(_)))
/// }
/// ```
pub enum Json5Value {
Null
True
False
Number(Double)
String(String)
Array(Array[Json5Value])
Object(Map[String, Json5Value])
} derive(Eq, Debug)
///|
/// Renders `Json5Value` as compact JSON5 text for display.
pub impl Show for Json5Value with output(self, logger) {
logger.write_string(stringify(self))
}
///|
/// Converts `Json5Value` to core `Json`.
///
/// This is useful when you want to parse with JSON5 syntax and then operate
/// with MoonBit's standard `@json` ecosystem.
///
/// Notes:
/// - `NaN` and infinities are delegated to `Double::to_json` behavior.
/// - Object key order follows the underlying map iteration order.
pub impl ToJson for Json5Value with to_json(self : Json5Value) -> Json {
match self {
Null => Json::null()
True => Json::boolean(true)
False => Json::boolean(false)
Number(n) => n.to_json()
String(s) => Json::string(s)
Array(arr) => Json::array(arr.map(v => v.to_json()))
Object(obj) => {
let out = Map::new()
for k, v in obj {
out.set(k, v.to_json())
}
Json::object(out)
}
}
}
///|
/// Converts core `Json` to `Json5Value`.
///
/// This implementation is recursive and propagates JSON path information to
/// nested conversions, making decode errors easier to locate.
///
/// Typical usage:
///
/// ```mbt check
/// test {
/// let json : Json = { "name": "moonbit", "ok": true }
/// let value : @json5.Json5Value = @json.from_json(json)
/// guard value is Object(obj)
/// assert_true(obj.get("name") is Some(_))
/// }
/// ```
pub impl @json.FromJson for Json5Value with from_json(json, path) {
match json {
Null => Null
True => True
False => False
Number(n, ..) => Number(n)
String(s) => String(s)
Array(arr) =>
Array(arr.mapi((i, v) => @json.from_json(v, path=path.add_index(i))))
Object(obj) => {
let out = Map::new()
for k, v in obj {
out.set(k, @json.from_json(v, path=path.add_key(k)))
}
Object(out)
}
}
}
///|
/// Internal token type produced by the lexer.
///
/// Kept private because token-level API is not intended for external use.
priv enum Token {
LBrace
RBrace
LBracket
RBracket
Comma
Colon
String(String)
Number(Double)
True
False
Null
EOF
} derive(Eq)
///|
impl Show for Token with output(self, logger) {
match self {
LBrace => logger.write_string("{")
RBrace => logger.write_string("}")
LBracket => logger.write_string("[")
RBracket => logger.write_string("]")
Comma => logger.write_string(",")
Colon => logger.write_string(":")
String(s) => {
logger.write_char('"')
logger.write_string(s)
logger.write_char('"')
}
Number(n) => logger.write_string(n.to_string())
True => logger.write_string("true")
False => logger.write_string("false")
Null => logger.write_string("null")
EOF => logger.write_string("")
}
}
///|
/// Internal lexer state for JSON5 tokenization.
///
/// The lexer supports JSON5-specific lexical features such as comments,
/// unquoted identifiers, single-quoted strings and hexadecimal numbers.
priv struct Lexer {
input : String
mut pos : Int
}
///|
fn Lexer::new(input : String) -> Lexer {
{ input, pos: 0 }
}
///|
fn Lexer::is_eof(self : Lexer) -> Bool {
self.pos >= self.input.length()
}
///|
fn Lexer::peek(self : Lexer) -> Char {
if self.is_eof() {
'\u{0000}'
} else {
match self.input.code_unit_at(self.pos).to_char() {
Some(c) => c
None => '\u{0000}'
}
}
}
///|
fn Lexer::next(self : Lexer) -> Unit {
self.pos = self.pos + 1
}
///|
/// Skips spaces and JSON5 comments.
///
/// Supported comments:
/// - Line comments: `// ...`
/// - Block comments: `/* ... */`
fn Lexer::skip_whitespace(self : Lexer) -> Unit {
while !self.is_eof() {
let c = self.peek()
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
self.next()
continue
}
if c == '/' {
self.next()
if self.is_eof() {
return
}
let next = self.peek()
if next == '/' {
self.next()
while !self.is_eof() {
let p = self.peek()
if p == '\n' || p == '\r' {
break
}
self.next()
}
continue
}
if next == '*' {
self.next()
while !self.is_eof() {
let p = self.peek()
if p == '*' {
self.next()
if !self.is_eof() && self.peek() == '/' {
self.next()
break
}
} else {
self.next()
}
}
continue
}
self.pos = self.pos - 1
break
}
break
}
}
///|
/// Reads the next token from input.
///
/// Raises an error for unsupported or malformed characters.
fn Lexer::next_token(self : Lexer) -> Token raise Error {
self.skip_whitespace()
if self.is_eof() {
return EOF
}
let c = self.peek()
match c {
'{' => {
self.next()
LBrace
}
'}' => {
self.next()
RBrace
}
'[' => {
self.next()
LBracket
}
']' => {
self.next()
RBracket
}
',' => {
self.next()
Comma
}
':' => {
self.next()
Colon
}
'\"' | '\'' => self.read_string()
_ =>
if is_digit(c) || c == '-' || c == '+' || c == '.' {
self.read_number()
} else if is_alpha(c) {
self.read_identifier()
} else {
fail("unexpected character: \{c}")
}
}
}
///|
fn is_digit(c : Char) -> Bool {
c >= '0' && c <= '9'
}
///|
fn is_alpha(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == '$'
}
///|
fn is_identifier_continue(c : Char) -> Bool {
is_alpha(c) || is_digit(c)
}
///|
fn hex_value(c : Char) -> Int raise Error {
if c >= '0' && c <= '9' {
c.to_int() - '0'.to_int()
} else if c >= 'a' && c <= 'f' {
10 + c.to_int() - 'a'.to_int()
} else if c >= 'A' && c <= 'F' {
10 + c.to_int() - 'A'.to_int()
} else {
fail("invalid hex digit: \{c}")
}
}
///|
fn parse_hex_number(s : String) -> Double raise Error {
if s.length() <= 2 {
fail("invalid hex number: \{s}")
}
let mut value = 0
for i in 2.. c
None => fail("invalid UTF-16 code unit")
}
value = value * 16 + hex_value(c)
}
value.to_double()
}
///|
/// Reads a quoted JSON5 string token.
///
/// Supported escapes include `\n`, `\r`, `\t`, `\\`, `\"`, and `\'`.
/// Unicode escape `\uXXXX` is currently not implemented and will raise.
fn Lexer::read_string(self : Lexer) -> Token raise Error {
let quote = self.peek()
self.next()
let mut s = ""
while !self.is_eof() {
let c = self.peek()
if c == quote {
self.next()
return String(s)
}
if c == '\\' {
self.next()
if self.is_eof() {
fail("unexpected EOF in string")
}
let esc = self.peek()
match esc {
'\"' => s = s + "\""
'\'' => s = s + "'"
'\\' => s = s + "\\"
'/' => s = s + "/"
'b' => s = s + "\b"
'f' => s = s + "\u000C"
'n' => s = s + "\n"
'r' => s = s + "\r"
't' => s = s + "\t"
'u' => fail("unicode escape is not supported yet")
_ => s = s + esc.to_string()
}
self.next()
} else {
if c == '\n' || c == '\r' {
fail("unexpected newline in string")
}
s = s + c.to_string()
self.next()
}
}
fail("unexpected EOF in string")
}
///|
/// Reads a JSON5 number token.
///
/// Supported forms include:
/// - Decimal numbers
/// - Signed numbers
/// - Exponent notation
/// - Hex notation: `0x...`
/// - `Infinity`, `-Infinity`, `NaN`
fn Lexer::read_number(self : Lexer) -> Token raise Error {
let start = self.pos
while !self.is_eof() {
let c = self.peek()
if is_digit(c) ||
c == '.' ||
c == '-' ||
c == '+' ||
c == 'e' ||
c == 'E' ||
c == 'x' ||
c == 'X' ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F') {
self.next()
} else {
break
}
}
let s = self.input[start:self.pos].to_owned()
if s == "Infinity" || s == "+Infinity" {
return Number(1.0 / 0.0)
}
if s == "-Infinity" {
return Number(-1.0 / 0.0)
}
if s == "NaN" {
return Number(0.0 / 0.0)
}
if s.has_prefix("0x") || s.has_prefix("0X") {
return Number(parse_hex_number(s))
}
Number(@string.parse_double(s))
}
///|
/// Reads JSON5 identifiers and reserved words.
///
/// Reserved words are converted to dedicated tokens (`true`, `false`, `null`,
/// `Infinity`, `NaN`). Other identifiers are emitted as string tokens, which
/// are used by the parser for unquoted object keys.
fn Lexer::read_identifier(self : Lexer) -> Token {
let start = self.pos
while !self.is_eof() && is_identifier_continue(self.peek()) {
self.next()
}
let s = self.input[start:self.pos].to_owned()
match s {
"true" => True
"false" => False
"null" => Null
"Infinity" => Number(1.0 / 0.0)
"NaN" => Number(0.0 / 0.0)
_ => String(s) // For unquoted keys
}
}
///|
/// Internal recursive-descent parser for JSON5 values.
priv struct Parser {
lexer : Lexer
mut current_token : Token
}
///|
fn Parser::new(input : String) -> Parser raise Error {
let lexer = Lexer::new(input)
let current_token = lexer.next_token()
{ lexer, current_token }
}
///|
fn Parser::advance(self : Parser) -> Unit raise Error {
self.current_token = self.lexer.next_token()
}
///|
/// Parses any JSON5 value from the current token stream position.
fn Parser::parse_value(self : Parser) -> Json5Value raise Error {
match self.current_token {
Null => {
self.advance()
Null
}
True => {
self.advance()
True
}
False => {
self.advance()
False
}
Number(n) => {
self.advance()
Number(n)
}
String(s) => {
self.advance()
String(s)
}
LBrace => self.parse_object()
LBracket => self.parse_array()
_ => fail("unexpected token: \{self.current_token}")
}
}
///|
/// Parses a JSON5 object.
///
/// Supports:
/// - quoted and unquoted keys
/// - trailing comma
fn Parser::parse_object(self : Parser) -> Json5Value raise Error {
self.advance() // skip {
let obj = Map::new()
if self.current_token == RBrace {
self.advance()
return Object(obj)
}
while self.current_token != RBrace {
let key = match self.current_token {
String(s) => s
_ => fail("expected object key, got \{self.current_token}")
}
self.advance() // skip key
if self.current_token != Colon {
fail("expected ':', got \{self.current_token}")
}
self.advance() // skip :
let val = self.parse_value()
obj.set(key, val)
if self.current_token == Comma {
self.advance()
if self.current_token == RBrace {
break
}
continue
} else if self.current_token == RBrace {
break
} else {
fail("expected ',' or '}', got \{self.current_token}")
}
}
self.advance() // skip }
Object(obj)
}
///|
/// Parses a JSON5 array.
///
/// Supports trailing comma.
fn Parser::parse_array(self : Parser) -> Json5Value raise Error {
self.advance() // skip [
let arr = []
if self.current_token == RBracket {
self.advance()
return Array(arr)
}
while self.current_token != RBracket {
let val = self.parse_value()
arr.push(val)
if self.current_token == Comma {
self.advance()
if self.current_token == RBracket {
break
}
continue
} else if self.current_token == RBracket {
break
} else {
fail("expected ',' or ']', got \{self.current_token}")
}
}
self.advance() // skip ]
Array(arr)
}
///|
/// Parses a JSON5 text into `Json5Value`.
///
/// This is the primary entry point for JSON5 parsing in this package.
///
/// Raises `Error` for malformed input.
///
/// Example:
///
/// ```mbt check
/// test {
/// let value = @json5.parse("{foo: 'bar', nums: [1, 2,],}")
/// guard value is Object(obj)
/// assert_true(obj.get("foo") is Some(_))
/// }
/// ```
pub fn parse(input : String) -> Json5Value raise Error {
let p = Parser::new(input)
let val = p.parse_value()
if p.current_token != EOF {
fail("unexpected token at EOF: \{p.current_token}")
}
val
}
///|
/// Parses JSON5 text directly into core `Json`.
///
/// This helper is convenient when your downstream code already uses `@json`
/// APIs and you do not need to manually inspect `Json5Value`.
///
/// Equivalent to:
///
/// `parse(input).to_json()`
pub fn parse_json(input : String) -> Json raise Error {
parse(input).to_json()
}
///|
/// Escapes a string for JSON/JSON5 output.
///
/// Handles common control characters and quote/backslash escaping.
fn escape_string(s : String) -> String {
let mut out = ""
for c in s {
match c {
'\\' => out = out + "\\\\"
'\"' => out = out + "\\\""
'\n' => out = out + "\\n"
'\r' => out = out + "\\r"
'\t' => out = out + "\\t"
_ => out = out + c.to_string()
}
}
out
}
///|
/// Returns true if the string is a valid JSON5 unquoted identifier key.
///
/// A valid identifier starts with a letter, `$`, or `_`, followed by
/// letters, digits, `$`, or `_`.
fn is_json5_identifier(s : String) -> Bool {
if s.is_empty() {
return false
}
let mut i = 0
for c in s {
let ok = if i == 0 {
c == '_' || c == '$' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
} else {
c == '_' ||
c == '$' ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
}
if !ok {
return false
}
i = i + 1
}
true
}
///|
/// Serializes `Json5Value` to compact text.
///
/// Object keys that are valid JSON5 identifiers are written unquoted;
/// all other keys are quoted.
///
/// Number behavior:
/// - finite numbers: normal decimal formatting
/// - `NaN`: `"NaN"`
/// - positive infinity: `"Infinity"`
/// - negative infinity: `"-Infinity"`
///
/// Example:
///
/// ```mbt check
/// test {
/// let value = @json5.parse("{foo: 'bar'}")
/// let text = @json5.stringify(value)
/// assert_true(text.contains("foo:\"bar\""))
/// }
/// ```
pub fn stringify(val : Json5Value) -> String {
match val {
Null => "null"
True => "true"
False => "false"
Number(n) =>
if n.is_nan() {
"NaN"
} else if n == 1.0 / 0.0 {
"Infinity"
} else if n == -1.0 / 0.0 {
"-Infinity"
} else {
n.to_string()
}
String(s) => "\"" + escape_string(s) + "\""
Array(arr) => {
let mut s = "["
for i, v in arr {
if i > 0 {
s = s + ","
}
s = s + stringify(v)
}
s = s + "]"
s
}
Object(obj) => {
let mut s = "{"
let mut first = true
for k, v in obj {
if !first {
s = s + ","
}
let key = if is_json5_identifier(k) {
k
} else {
"\"" + escape_string(k) + "\""
}
s = s + key + ":" + stringify(v)
first = false
}
s = s + "}"
s
}
}
}
///|
/// Writes a `Json5Value` to a `StringBuilder` with pretty indentation.
///
/// Object keys that are valid JSON5 identifiers are written unquoted.
fn stringify_pretty_write(
buf : StringBuilder,
val : Json5Value,
indent : Int,
indent_size : Int,
) -> Unit {
match val {
Null => buf.write_string("null")
True => buf.write_string("true")
False => buf.write_string("false")
Number(n) =>
if n.is_nan() {
buf.write_string("NaN")
} else if n == 1.0 / 0.0 {
buf.write_string("Infinity")
} else if n == -1.0 / 0.0 {
buf.write_string("-Infinity")
} else {
buf.write_string(n.to_string())
}
String(s) => {
buf.write_char('"')
buf.write_string(escape_string(s))
buf.write_char('"')
}
Array(arr) =>
if arr.is_empty() {
buf.write_string("[]")
} else {
buf.write_string("[\n")
let child = indent + indent_size
for i, item in arr {
for _ in 0..
if obj.is_empty() {
buf.write_string("{}")
} else {
buf.write_string("{\n")
let child = indent + indent_size
let entries = obj.iter().collect()
for i, pair in entries {
let (k, v) = pair
for _ in 0.. String {
let buf = StringBuilder::new()
stringify_pretty_write(buf, val, 0, indent_size)
buf.write_char('\n')
buf.to_string()
}
///|
/// Directly converts core `Json` to `Json5Value` without going through the
/// `@json.FromJson` trait (which carries an unused error type in its signature).
fn json_to_json5(json : Json) -> Json5Value {
match json {
Null => Null
True => True
False => False
Number(n, ..) => Number(n)
String(s) => String(s)
Array(arr) => Array(arr.map(json_to_json5))
Object(obj) => {
let out = Map::new()
for k, v in obj {
out.set(k, json_to_json5(v))
}
Object(out)
}
}
}
///|
/// Serializes core `Json` to a pretty-printed JSON5 string.
///
/// Convenience wrapper around `stringify_pretty` that converts `Json` first.
/// The `indent_size` labeled argument defaults to `2`.
pub fn stringify_json_pretty(json : Json, indent_size? : Int = 2) -> String {
stringify_pretty(json_to_json5(json), indent_size~)
}
///|
/// Serializes core `Json` to compact JSON5 text.
///
/// Object keys that are valid JSON5 identifiers are written unquoted.
pub fn stringify_json(json : Json) -> String {
stringify(json_to_json5(json))
}