use crate::value::Value;
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
struct Line {
indent: usize,
content: String,
number: usize,
}
pub fn parse(src: &str) -> Result<Value> {
let lines = significant_lines(src)?;
if lines.is_empty() {
return Ok(Value::Null);
}
let mut pos = 0usize;
let base = lines[0].indent;
let value = parse_block(&lines, &mut pos, base)?;
if pos < lines.len() {
return Err(anyhow!(
"line {}: unexpected content at indentation {} (expected {})",
lines[pos].number,
lines[pos].indent,
base
));
}
Ok(value)
}
fn significant_lines(src: &str) -> Result<Vec<Line>> {
let mut out = Vec::new();
let mut seen_doc_start = false;
for (i, raw) in src.lines().enumerate() {
let number = i + 1;
let indent = raw.len() - raw.trim_start().len();
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "..." {
break; }
if trimmed == "---" {
if seen_doc_start || !out.is_empty() {
return Err(anyhow!(
"line {number}: multiple YAML documents are not supported; \
split them and parse each separately"
));
}
seen_doc_start = true;
continue;
}
reject_unsupported(trimmed, number)?;
out.push(Line {
indent,
content: trimmed.to_string(),
number,
});
}
Ok(out)
}
fn reject_unsupported(line: &str, number: usize) -> Result<()> {
let value_part: String = match split_key(line) {
Some((_, rest)) => rest.trim().to_string(),
None => line.trim_start_matches("- ").trim().to_string(),
};
let value_part = value_part.as_str();
let unsupported = [
("&", "anchors (&name)"),
("*", "aliases (*name)"),
("!", "explicit tags (!!type)"),
("|", "block scalars (|)"),
(">", "folded block scalars (>)"),
];
for (marker, name) in unsupported {
if value_part.starts_with(marker) {
return Err(anyhow!(
"line {number}: {name} are not supported by IronShell's YAML \
subset. Convert the document to JSON, or simplify it."
));
}
}
if line.starts_with("<<") {
return Err(anyhow!(
"line {number}: merge keys (<<) are not supported by IronShell's \
YAML subset."
));
}
Ok(())
}
fn parse_block(lines: &[Line], pos: &mut usize, indent: usize) -> Result<Value> {
if *pos >= lines.len() {
return Ok(Value::Null);
}
let content = lines[*pos].content.as_str();
if content.starts_with("- ") || content == "-" {
parse_sequence(lines, pos, indent)
} else if content.starts_with(['{', '['])
&& serde_json::from_str::<serde_json::Value>(content).is_ok()
{
let number = lines[*pos].number;
*pos += 1;
parse_scalar(content, number)
} else {
parse_mapping(lines, pos, indent)
}
}
fn parse_sequence(lines: &[Line], pos: &mut usize, indent: usize) -> Result<Value> {
let mut items = Vec::new();
while *pos < lines.len() && lines[*pos].indent == indent {
let line = &lines[*pos];
if !(line.content.starts_with("- ") || line.content == "-") {
break;
}
let rest = line.content[1..].trim().to_string();
*pos += 1;
if rest.is_empty() {
items.push(parse_nested(lines, pos, indent)?);
} else if let Some((key, value)) = split_key(&rest) {
let inner_indent = indent + (line.content.len() - rest.len());
let mut map = BTreeMap::new();
insert_entry(&mut map, key, value, lines, pos, inner_indent, line.number)?;
while *pos < lines.len() && lines[*pos].indent == inner_indent {
let l = &lines[*pos];
let Some((k, v)) = split_key(&l.content) else {
break;
};
let n = l.number;
*pos += 1;
insert_entry(&mut map, k, v, lines, pos, inner_indent, n)?;
}
items.push(Value::Record(map));
} else {
items.push(parse_scalar(&rest, line.number)?);
}
}
Ok(Value::Array(items))
}
fn parse_mapping(lines: &[Line], pos: &mut usize, indent: usize) -> Result<Value> {
let mut map = BTreeMap::new();
while *pos < lines.len() && lines[*pos].indent == indent {
let line = lines[*pos].clone();
let Some((key, value)) = split_key(&line.content) else {
return Err(anyhow!(
"line {}: expected `key: value`, found {:?}",
line.number,
line.content
));
};
*pos += 1;
insert_entry(&mut map, key, value, lines, pos, indent, line.number)?;
}
Ok(Value::Record(map))
}
fn insert_entry(
map: &mut BTreeMap<String, Value>,
key: String,
value: String,
lines: &[Line],
pos: &mut usize,
indent: usize,
number: usize,
) -> Result<()> {
let key = key_name(&key, number)?;
let parsed = if value.trim().is_empty() {
parse_nested(lines, pos, indent)?
} else {
parse_scalar(value.trim(), number)?
};
if map.insert(key.clone(), parsed).is_some() {
return Err(anyhow!("line {number}: duplicate key {key:?}"));
}
Ok(())
}
fn parse_nested(lines: &[Line], pos: &mut usize, indent: usize) -> Result<Value> {
if *pos < lines.len() && lines[*pos].indent > indent {
let deeper = lines[*pos].indent;
parse_block(lines, pos, deeper)
} else {
Ok(Value::Null)
}
}
fn split_key(line: &str) -> Option<(String, String)> {
let bytes: Vec<char> = line.chars().collect();
let mut quote: Option<char> = None;
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) => {
if c == '\\' {
i += 1;
} else if c == q {
quote = None;
}
}
None => {
if c == '"' || c == '\'' {
quote = Some(c);
} else if c == ':' {
let next = bytes.get(i + 1);
if next.is_none() || next == Some(&' ') {
let key = line[..byte_index(line, i)].trim();
let value = line[byte_index(line, i + 1)..].trim();
return Some((key.to_string(), value.to_string()));
}
}
}
}
i += 1;
}
None
}
fn byte_index(s: &str, n: usize) -> usize {
s.char_indices().nth(n).map(|(b, _)| b).unwrap_or(s.len())
}
fn key_name(raw: &str, number: usize) -> Result<String> {
let t = raw.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
unescape_double(&t[1..t.len() - 1], number)
} else if t.len() >= 2 && t.starts_with('\'') && t.ends_with('\'') {
Ok(t[1..t.len() - 1].replace("''", "'"))
} else {
Ok(t.to_string())
}
}
fn quoted_before_comment(s: &str) -> Option<&str> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let bytes = s.as_bytes();
let mut i = 1;
let end = loop {
let b = *bytes.get(i)?;
if quote == '"' && b == b'\\' {
i += 2;
continue;
}
if b == quote as u8 {
if quote == '\'' && bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
break i;
}
i += 1;
};
let rest = &s[end + 1..];
let trimmed = rest.trim_start();
if trimmed.is_empty() {
return None;
}
(trimmed.starts_with('#') && rest.len() > trimmed.len()).then(|| &s[..=end])
}
fn parse_scalar(raw: &str, number: usize) -> Result<Value> {
let s = raw.trim();
let s = quoted_before_comment(s).unwrap_or(s);
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
return Ok(Value::Str(unescape_double(&s[1..s.len() - 1], number)?));
}
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
return Ok(Value::Str(s[1..s.len() - 1].replace("''", "'")));
}
if s.starts_with('[') || s.starts_with('{') {
return match serde_json::from_str::<serde_json::Value>(s) {
Ok(j) => match crate::builtins::wide_integer_refusal("from_yaml", s) {
Some(refusal) => Err(refusal),
None => Ok(json_to_value(j)),
},
Err(e) => Err(anyhow!(
"line {number}: flow collection {s:?} is not valid JSON ({e}); \
IronShell's YAML subset supports JSON-compatible flow style only"
)),
};
}
let s = match s.find(" #") {
Some(i) => s[..i].trim(),
None => s,
};
let overflow = || {
crate::safety::value_not_representable(
"from_yaml",
format!("from_yaml: line {number}: integer overflow at {s}: it does not fit in a 64-bit Int"),
&format!("quote it as a string to keep the digits, such as \"{s}\""),
)
};
if let Some((radix, digits)) = radix_integer(s) {
return i64::from_str_radix(digits, radix)
.map(Value::Int)
.map_err(|_| overflow());
}
if crate::builtins::is_wide_integer(s) {
return Err(overflow());
}
if matches!(
s,
".inf"
| ".Inf"
| ".INF"
| "+.inf"
| "+.Inf"
| "+.INF"
| "-.inf"
| "-.Inf"
| "-.INF"
| ".nan"
| ".NaN"
| ".NAN"
) {
return Err(crate::safety::value_not_representable(
"from_yaml",
format!("from_yaml: line {number}: {s} is not a finite number; IronShell numbers are finite"),
&format!("quote it as a string to keep the text, such as \"{s}\""),
));
}
Ok(plain_scalar(s))
}
fn radix_integer(s: &str) -> Option<(u32, &str)> {
if let Some(digits) = s.strip_prefix("0o") {
return (!digits.is_empty() && digits.bytes().all(|b| (b'0'..=b'7').contains(&b)))
.then_some((8, digits));
}
let digits = s.strip_prefix("0x")?;
(!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_hexdigit())).then_some((16, digits))
}
fn plain_scalar(s: &str) -> Value {
match s {
"" | "~" | "null" | "Null" | "NULL" => Value::Null,
"true" | "True" | "TRUE" => Value::Bool(true),
"false" | "False" | "FALSE" => Value::Bool(false),
".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => Value::Float(f64::INFINITY),
"-.inf" | "-.Inf" | "-.INF" => Value::Float(f64::NEG_INFINITY),
".nan" | ".NaN" | ".NAN" => Value::Float(f64::NAN),
_ => {
if let Ok(i) = s.parse::<i64>() {
Value::Int(i)
} else if let Ok(f) = s.parse::<f64>() {
if s.chars().any(|c| c.is_ascii_digit()) {
Value::Float(f)
} else {
Value::Str(s.to_string())
}
} else {
Value::Str(s.to_string())
}
}
}
}
fn unescape_double(s: &str, number: usize) -> Result<String> {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let Some(e) = chars.next() else {
return Err(anyhow!(
"line {number}: a double-quoted string ends in a lone backslash"
));
};
match e {
'0' => out.push('\0'),
'a' => out.push('\u{7}'),
'b' => out.push('\u{8}'),
'e' => out.push('\u{1b}'),
'f' => out.push('\u{c}'),
'v' => out.push('\u{b}'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'"' => out.push('"'),
'/' => out.push('/'),
'\\' => out.push('\\'),
'x' => out.push(hex_escape(&mut chars, 'x', 2, number)?),
'u' => out.push(hex_escape(&mut chars, 'u', 4, number)?),
'U' => out.push(hex_escape(&mut chars, 'U', 8, number)?),
other => {
return Err(anyhow!(
"line {number}: unknown escape \\{other} in a double-quoted string"
))
}
}
}
Ok(out)
}
fn hex_escape(
chars: &mut std::str::Chars<'_>,
kind: char,
digits: usize,
number: usize,
) -> Result<char> {
let hex: String = chars.by_ref().take(digits).collect();
if hex.len() != digits || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow!(
"line {number}: \\{kind} needs {digits} hex digits, found {hex:?}"
));
}
u32::from_str_radix(&hex, 16)
.ok()
.and_then(char::from_u32)
.ok_or_else(|| anyhow!("line {number}: \\{kind}{hex} is not a Unicode code point"))
}
fn json_to_value(j: serde_json::Value) -> Value {
match j {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => n
.as_i64()
.map(Value::Int)
.or_else(|| n.as_f64().map(Value::Float))
.unwrap_or(Value::Null),
serde_json::Value::String(s) => Value::Str(s),
serde_json::Value::Array(a) => Value::Array(a.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(o) => {
Value::Record(o.into_iter().map(|(k, v)| (k, json_to_value(v))).collect())
}
}
}
pub fn emit(value: &Value) -> String {
let mut out = String::new();
emit_into(value, 0, &mut out);
if out.is_empty() {
out.push_str("null\n");
}
out
}
fn emit_into(value: &Value, indent: usize, out: &mut String) {
let pad = " ".repeat(indent);
match value {
Value::Record(map) if map.is_empty() => out.push_str("{}\n"),
Value::Record(map) => {
for (k, v) in map {
out.push_str(&pad);
out.push_str("e_key(k));
match v {
Value::Record(inner) if !inner.is_empty() => {
out.push_str(":\n");
emit_into(v, indent + 2, out);
}
Value::Array(items) if !items.is_empty() => {
out.push_str(":\n");
emit_into(v, indent + 2, out);
}
Value::Record(_) => out.push_str(": {}\n"),
Value::Array(_) => out.push_str(": []\n"),
_ => {
out.push_str(": ");
out.push_str(&scalar_to_yaml(v));
out.push('\n');
}
}
}
}
Value::Array(items) if items.is_empty() => out.push_str("[]\n"),
Value::Array(items) => {
for item in items {
out.push_str(&pad);
match item {
Value::Record(m) if m.is_empty() => out.push_str("- {}\n"),
Value::Array(a) if a.is_empty() => out.push_str("- []\n"),
Value::Record(_) | Value::Array(_) => {
out.push_str("-\n");
emit_into(item, indent + 2, out);
}
_ => {
out.push_str("- ");
out.push_str(&scalar_to_yaml(item));
out.push('\n');
}
}
}
}
other => {
out.push_str(&pad);
out.push_str(&scalar_to_yaml(other));
out.push('\n');
}
}
}
fn quote_key(k: &str) -> String {
if needs_quoting(k) {
format!("\"{}\"", escape_double(k))
} else {
k.to_string()
}
}
fn scalar_to_yaml(v: &Value) -> String {
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => {
if f.is_finite() {
let text = format!("{f:?}");
match text.find('e') {
Some(i) if !text[..i].contains('.') => {
format!("{}.0{}", &text[..i], &text[i..])
}
_ => text,
}
} else if f.is_nan() {
".nan".to_string()
} else if *f > 0.0 {
".inf".to_string()
} else {
"-.inf".to_string()
}
}
Value::Str(s) | Value::Uri(s) => {
if needs_quoting(s) {
format!("\"{}\"", escape_double(s))
} else {
s.clone()
}
}
other => format!("\"{}\"", escape_double(&other.to_display_string())),
}
}
fn needs_quoting(s: &str) -> bool {
if s.is_empty() {
return true;
}
if !matches!(plain_scalar(s), Value::Str(_)) {
return true;
}
if matches!(
s,
"y" | "Y"
| "yes"
| "Yes"
| "YES"
| "n"
| "N"
| "no"
| "No"
| "NO"
| "on"
| "On"
| "ON"
| "off"
| "Off"
| "OFF"
) {
return true;
}
if radix_integer(s).is_some() || s.chars().any(needs_escape) {
return true;
}
if yaml11_non_string(s) {
return true;
}
s.contains(':')
|| s.contains('#')
|| s.contains('\n')
|| s.contains('\t')
|| s.contains('"')
|| s.contains('\'')
|| s.starts_with([
'-', '?', '&', '*', '!', '|', '>', '%', '@', '`', '[', '{', ',',
])
|| s.trim() != s
}
fn yaml11_non_string(s: &str) -> bool {
let b = s.as_bytes();
let digits_with_underscores = s.contains('_')
&& b.first().is_some_and(u8::is_ascii_digit)
&& b.iter().all(|c| c.is_ascii_digit() || *c == b'_');
let date = b.len() >= 10
&& b[..4].iter().all(u8::is_ascii_digit)
&& b[4] == b'-'
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[7] == b'-'
&& b[8..10].iter().all(u8::is_ascii_digit);
let unsigned = s.strip_prefix(['+', '-']).unwrap_or(s);
let binary = unsigned
.strip_prefix("0b")
.is_some_and(|d| !d.is_empty() && d.bytes().all(|c| c == b'0' || c == b'1'));
let dotted = (s.starts_with('+') || s.starts_with('.'))
&& s[1..].starts_with(|c: char| c.is_ascii_digit());
digits_with_underscores || date || binary || dotted
}
fn needs_escape(c: char) -> bool {
c < ' ' || matches!(c, '\u{7f}' | '\u{85}' | '\u{2028}' | '\u{2029}')
}
fn escape_double(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\0' => out.push_str("\\0"),
'\u{7}' => out.push_str("\\a"),
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{b}' => out.push_str("\\v"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
'\u{1b}' => out.push_str("\\e"),
c if c < ' ' || c == '\u{7f}' => out.push_str(&format!("\\x{:02X}", c as u32)),
c if needs_escape(c) => out.push_str(&format!("\\u{:04X}", c as u32)),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(pairs: &[(&str, Value)]) -> Value {
Value::Record(
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect(),
)
}
#[test]
fn parses_nested_mappings() {
let v = parse("server:\n host: localhost\n port: 8080\n").unwrap();
assert_eq!(
v,
rec(&[(
"server",
rec(&[
("host", Value::Str("localhost".into())),
("port", Value::Int(8080)),
])
)])
);
}
#[test]
fn parses_sequences() {
let v = parse("ports:\n - 80\n - 443\n").unwrap();
assert_eq!(
v,
rec(&[("ports", Value::Array(vec![Value::Int(80), Value::Int(443)]))])
);
}
#[test]
fn parses_sequence_of_mappings() {
let v = parse("users:\n - name: ada\n admin: true\n - name: bob\n admin: false\n")
.unwrap();
let Value::Record(r) = &v else { panic!() };
let Some(Value::Array(items)) = r.get("users") else {
panic!("expected array, got {r:?}")
};
assert_eq!(items.len(), 2);
assert_eq!(
items[0],
rec(&[
("name", Value::Str("ada".into())),
("admin", Value::Bool(true))
])
);
}
#[test]
fn strips_comments() {
let v = parse("# leading\nport: 8080 # the port\n").unwrap();
assert_eq!(v, rec(&[("port", Value::Int(8080))]));
}
#[test]
fn keeps_hash_inside_quotes() {
let v = parse(r##"color: "#ff0000""##).unwrap();
assert_eq!(v, rec(&[("color", Value::Str("#ff0000".into()))]));
}
#[test]
fn ignores_document_start_marker() {
let v = parse("---\nname: ae\n").unwrap();
assert_eq!(v, rec(&[("name", Value::Str("ae".into()))]));
}
#[test]
fn colon_inside_a_value_is_not_a_separator() {
let v = parse("time: 12:30\nurl: https://example.com/x\n").unwrap();
assert_eq!(
v,
rec(&[
("time", Value::Str("12:30".into())),
("url", Value::Str("https://example.com/x".into())),
])
);
}
#[test]
fn types_plain_scalars() {
let v = parse("i: 42\nf: 1.5\nt: true\nf2: False\nn: null\ne: ~\ns: hello\n").unwrap();
assert_eq!(
v,
rec(&[
("i", Value::Int(42)),
("f", Value::Float(1.5)),
("t", Value::Bool(true)),
("f2", Value::Bool(false)),
("n", Value::Null),
("e", Value::Null),
("s", Value::Str("hello".into())),
])
);
}
#[test]
fn quoted_numbers_stay_strings() {
let v = parse("version: \"1.0\"\nzip: '01234'\n").unwrap();
assert_eq!(
v,
rec(&[
("version", Value::Str("1.0".into())),
("zip", Value::Str("01234".into())),
])
);
}
#[test]
fn handles_escapes_in_double_quotes() {
let v = parse(r#"msg: "line1\nline2\ttabbed""#).unwrap();
assert_eq!(
v,
rec(&[("msg", Value::Str("line1\nline2\ttabbed".into()))])
);
}
#[test]
fn parses_json_flow_collections() {
let v = parse("list: [1, 2, 3]\nmap: {\"a\": 1}\n").unwrap();
assert_eq!(
v,
rec(&[
(
"list",
Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(3)])
),
("map", rec(&[("a", Value::Int(1))])),
])
);
}
#[test]
fn key_with_no_value_is_null() {
let v = parse("empty:\n").unwrap();
assert_eq!(v, rec(&[("empty", Value::Null)]));
}
#[test]
fn rejects_unsupported_constructs_by_name() {
for (src, expect) in [
("a: &anchor 1\n", "anchors"),
("a: *alias\n", "aliases"),
("a: !!str 1\n", "tags"),
("a: |\n block\n", "block scalars"),
("a: >\n folded\n", "folded"),
("<<: base\n", "merge keys"),
("a: 1\n---\nb: 2\n", "multiple YAML documents"),
] {
let err = parse(src).unwrap_err().to_string();
assert!(
err.contains(expect),
"parsing {src:?} should name {expect:?}, said: {err}"
);
}
}
#[test]
fn rejects_duplicate_keys() {
let err = parse("a: 1\na: 2\n").unwrap_err().to_string();
assert!(err.contains("duplicate key"), "got {err}");
}
#[test]
fn rejects_malformed_flow_collections() {
let err = parse("a: [1, 2\n").unwrap_err().to_string();
assert!(err.contains("not valid JSON"), "got {err}");
}
#[test]
fn rejects_a_line_that_is_not_a_mapping_entry() {
let err = parse("just a bare line\n").unwrap_err().to_string();
assert!(err.contains("expected `key: value`"), "got {err}");
}
#[test]
fn empty_input_is_null() {
assert_eq!(parse("").unwrap(), Value::Null);
assert_eq!(parse("# only a comment\n").unwrap(), Value::Null);
}
#[test]
fn emits_nested_structures() {
let v = rec(&[(
"server",
rec(&[
("host", Value::Str("localhost".into())),
("port", Value::Int(8080)),
]),
)]);
assert_eq!(emit(&v), "server:\n host: localhost\n port: 8080\n");
}
#[test]
fn emits_sequences() {
let v = rec(&[("ports", Value::Array(vec![Value::Int(80), Value::Int(443)]))]);
assert_eq!(emit(&v), "ports:\n - 80\n - 443\n");
}
#[test]
fn quotes_values_that_would_not_read_back() {
let v = rec(&[
("colon", Value::Str("a: b".into())),
("hash", Value::Str("#tag".into())),
("numeric", Value::Str("42".into())),
("empty", Value::Str(String::new())),
]);
let out = emit(&v);
assert!(out.contains(r#"colon: "a: b""#), "got {out}");
assert!(out.contains(r##"hash: "#tag""##), "got {out}");
assert!(out.contains(r#"numeric: "42""#), "got {out}");
assert!(out.contains(r#"empty: """#), "got {out}");
}
#[test]
fn round_trips() {
let original = rec(&[
("name", Value::Str("ae".into())),
("version", Value::Str("1.6.0".into())),
("port", Value::Int(8080)),
("ratio", Value::Float(0.5)),
("debug", Value::Bool(false)),
("nothing", Value::Null),
("tricky", Value::Str("a: b # c".into())),
(
"nested",
rec(&[("deep", Value::Array(vec![Value::Int(1), Value::Int(2)]))]),
),
(
"list",
Value::Array(vec![Value::Str("x".into()), Value::Str("y".into())]),
),
]);
let text = emit(&original);
let reparsed = parse(&text).unwrap_or_else(|e| panic!("reparse failed: {e}\n---\n{text}"));
assert_eq!(reparsed, original, "round trip changed the value:\n{text}");
}
#[test]
fn round_trips_a_sequence_of_records() {
let original = rec(&[(
"users",
Value::Array(vec![
rec(&[("name", Value::Str("ada".into()))]),
rec(&[("name", Value::Str("bob".into()))]),
]),
)]);
let text = emit(&original);
assert_eq!(parse(&text).unwrap(), original, "\n{text}");
}
#[test]
fn emits_empty_collections() {
assert_eq!(emit(&Value::Record(BTreeMap::new())), "{}\n");
assert_eq!(emit(&Value::Array(vec![])), "[]\n");
}
}