Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions json/json.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,145 @@ pub fn stringify(
buf.to_string()
}

///|
/// Convert a JsonValue to YAML string format
pub fn to_yaml(self : JsonValue, indent~ : Int = 2) -> String {
let buf = StringBuilder::new(size_hint=0)
fn needs_quotes(s : String) -> Bool {
if s.is_empty() {
return true
}

// Check if string looks like a number, boolean, or null
match s {
"true" | "false" | "null" | "~" => true
_ => {
// Check if it starts with special characters
let first_char = s[0]
if first_char == '-' ||
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be this style is better?

if first_char
    is ('-'
    | ':'
    | '['
    | '{'
    | '!'
    | '&'
    | '*'
    | '|'
    | '>'
    | '\''
    | '"'
    | '#'
    | '@'
    | '`'){
    return true
}

first_char == ':' ||
first_char == '[' ||
first_char == '{' ||
first_char == '!' ||
first_char == '&' ||
first_char == '*' ||
first_char == '|' ||
first_char == '>' ||
first_char == '\'' ||
first_char == '"' ||
first_char == '#' ||
first_char == '@' ||
first_char == '`' {
return true
}

// Check if string contains special characters that need quoting
for c in s {
match c {
':' | '#' | '\n' | '\r' | '\t' => return true
_ => continue
}
}

// Check if string could be parsed as a number
try {
let _ = @strconv.parse_double(s)
true
} catch {
_ => false
}
}
}
}

fn write_string_value(s : String) -> Unit {
if needs_quotes(s) {
// Use JSON-style escaping for quoted strings
buf.write_char('"')
buf.write_string(escape(s, escape_slash=false))
buf.write_char('"')
} else {
buf.write_string(s)
}
}

fn yaml_inner(
value : JsonValue,
level : Int,
is_array_element : Bool,
) -> Unit {
let indent_str = " ".repeat(indent * level)
match value {
Object(members) => {
if members.is_empty() {
buf.write_string("{}")
return
}
let mut first = true
for k, v in members {
if not(is_array_element) || not(first) {
buf.write_string(indent_str)
}
if first {
first = false
}
write_string_value(k)
match v {
Object(_) | Array(_) => {
buf.write_string(":")
buf.write_char('\n')
yaml_inner(v, level + 1, false)
}
_ => {
buf.write_string(": ")
yaml_inner(v, level, false)
buf.write_char('\n')
}
}
}
}
Array(arr) => {
if arr.is_empty() {
buf.write_string("[]")
return
}
for i, v in arr {
if i > 0 || not(is_array_element) {
buf.write_string(indent_str)
}
buf.write_string("- ")
match v {
Object(_) | Array(_) => yaml_inner(v, level + 1, true)
_ => {
yaml_inner(v, level, false)
buf.write_char('\n')
}
}
}
}
String(s) => write_string_value(s)
Number(n, repr~) =>
match repr {
None => buf.write_object(n)
Some(r) => buf.write_string(r)
}
True => buf.write_string("true")
False => buf.write_string("false")
Null => buf.write_string("null")
}
}

yaml_inner(self, 0, false)

// Remove trailing newline if present
let result = buf.to_string()
if result.has_suffix("\n") {
result.substring(end=result.length() - 1)
} else {
result
}
}

///|
fn escape(str : String, escape_slash~ : Bool) -> String {
fn to_hex_digit(i : Int) -> Char {
Expand Down
1 change: 1 addition & 0 deletions json/json.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ fn Json::as_object(Self) -> Map[String, Self]?
fn Json::as_string(Self) -> String?
fn Json::item(Self, Int) -> Self?
fn Json::stringify(Self, escape_slash~ : Bool = .., indent~ : Int = ..) -> String
fn Json::to_yaml(Self, indent~ : Int = ..) -> String
fn Json::value(Self, String) -> Self?
impl Show for Json
impl ToJson for Json
Expand Down
185 changes: 185 additions & 0 deletions json/json_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,188 @@ test "nested json" {
},
})
}

///|
test "to_yaml - null value" {
let json = Json::null()
inspect(json.to_yaml(), content="null")
}

///|
test "to_yaml - boolean values" {
let json_true = Json::boolean(true)
let json_false = Json::boolean(false)
inspect(json_true.to_yaml(), content="true")
inspect(json_false.to_yaml(), content="false")
}

///|
test "to_yaml - number values" {
let json = Json::number(42.5)
inspect(json.to_yaml(), content="42.5")
let json_with_repr = Json::number(1.0 / 0.0, repr="Infinity")
inspect(json_with_repr.to_yaml(), content="Infinity")
}

///|
test "to_yaml - string values" {
// Simple string
let json = Json::string("hello")
inspect(json.to_yaml(), content="hello")

// String that needs quotes (looks like number)
let json_num = Json::string("123")
inspect(json_num.to_yaml(), content="\"123\"")

// String that needs quotes (looks like boolean)
let json_bool = Json::string("true")
inspect(json_bool.to_yaml(), content="\"true\"")

// String with special characters
let json_special = Json::string("hello: world")
inspect(json_special.to_yaml(), content="\"hello: world\"")

// String with newline
let json_newline = Json::string("hello\nworld")
inspect(json_newline.to_yaml(), content="\"hello\\nworld\"")

// Empty string
let json_empty = Json::string("")
inspect(json_empty.to_yaml(), content="\"\"")
}

///|
test "to_yaml - empty array" {
let json = Json::array([])
inspect(json.to_yaml(), content="[]")
}

///|
test "to_yaml - simple array" {
let json = Json::array([
Json::number(1.0),
Json::string("hello"),
Json::boolean(true),
Json::null(),
])
let yaml = json.to_yaml()
inspect(yaml, content="- 1\n- hello\n- true\n- null")
}

///|
test "to_yaml - nested array" {
let json = Json::array([
Json::number(1.0),
Json::array([Json::number(2.0), Json::number(3.0)]),
Json::string("end"),
])
let yaml = json.to_yaml()
inspect(yaml, content="- 1\n- - 2\n - 3\n- end")
}

///|
test "to_yaml - empty object" {
let json = Json::object({})
inspect(json.to_yaml(), content="{}")
}

///|
test "to_yaml - simple object" {
let json = Json::object({
"name": Json::string("John"),
"age": Json::number(30.0),
"active": Json::boolean(true),
})
// Note: Map iteration order might not be guaranteed
let yaml = json.to_yaml()
assert_true(yaml.contains("name: John"))
assert_true(yaml.contains("age: 30"))
assert_true(yaml.contains("active: true"))
}

///|
test "to_yaml - nested object" {
let json = Json::object({
"person": Json::object({
"name": Json::string("John"),
"age": Json::number(30.0),
}),
"items": Json::array([Json::string("item1"), Json::string("item2")]),
})
let yaml = json.to_yaml()
inspect(yaml, content=(
#|person:
#| name: John
#| age: 30
#|items:
#| - item1
#| - item2
))
}

///|
test "to_yaml - array of objects" {
let json = Json::array([
Json::object({ "id": Json::number(1.0), "name": Json::string("First") }),
Json::object({ "id": Json::number(2.0), "name": Json::string("Second") }),
])
let yaml = json.to_yaml()
inspect(yaml, content=(
#|- id: 1
#| name: First
#|- id: 2
#| name: Second
))
}

///|
test "to_yaml - object with array inside" {
let json = Json::object({
"fruits": Json::array([
Json::string("apple"),
Json::string("banana"),
Json::string("orange"),
]),
})
let yaml = json.to_yaml()
inspect(yaml, content=(
#|fruits:
#| - apple
#| - banana
#| - orange
))
}

///|
test "to_yaml - custom indent" {
let json = Json::object({
"level1": Json::object({ "level2": Json::string("value") }),
})
let yaml_2 = json.to_yaml(indent=2)
let yaml_4 = json.to_yaml(indent=4)
inspect(yaml_2, content=(
#|level1:
#| level2: value

))
inspect(yaml_4, content=(
#|level1:
#| level2: value

))
}

///|
test "to_yaml - special string keys" {
let json = Json::object({
"123": Json::string("numeric key"),
"true": Json::string("boolean key"),
"key:with:colons": Json::string("special chars"),
})
let yaml = json.to_yaml()
inspect(yaml, content=(
#|"123": numeric key
#|"true": boolean key
#|"key:with:colons": special chars
))
}
Loading