///|
/// Options for controlling NestedText output formatting.
///
/// # Fields
/// * `indent` — Number of spaces per indentation level (default: 4).
/// * `sort_keys` — When `true`, dictionary keys are emitted in ascending
/// string order (default: `false`).
pub(all) struct DumpOptions {
indent : Int
sort_keys : Bool
} derive(Debug)
///|
/// Default formatting options: 4-space indent, no key sorting.
pub fn DumpOptions::default() -> DumpOptions {
{ indent: 4, sort_keys: false }
}
///|
/// Serialize a `Value` to a NestedText string.
///
/// # Parameters
/// * `value` — The parsed NestedText value to emit.
/// * `options` — Formatting options (indent size, key sorting).
///
/// # Returns
/// A NestedText document string, including a trailing newline.
pub fn dumps(value : Value, options : DumpOptions) -> String {
let lines : Array[String] = []
render_value(value, 0, options, lines)
if lines.is_empty() {
""
} else {
lines.join("\n") + "\n"
}
}
///|
fn render_value(
value : Value,
depth : Int,
options : DumpOptions,
lines : Array[String],
) -> Unit {
match value {
String(s) => render_string(s, depth, lines)
List(items) => render_list(items, depth, options, lines)
Dict(pairs) => render_dict(pairs, depth, options, lines)
}
}
///|
fn render_string(s : String, depth : Int, lines : Array[String]) -> Unit {
let indent = " ".repeat(depth)
if s.is_empty() {
lines.push(indent + ">")
} else {
for view in s.split("\n") {
lines.push(indent + "> " + view.to_owned())
}
}
}
///|
fn render_list(
items : Array[Value],
depth : Int,
options : DumpOptions,
lines : Array[String],
) -> Unit {
let indent = " ".repeat(depth)
if items.is_empty() {
lines.push(indent + "[]")
return
}
for item in items {
match item {
String(s) if s.is_empty() => lines.push(indent + "-")
String(s) if !value_needs_multiline(s) => lines.push(indent + "- " + s)
_ => {
lines.push(indent + "-")
render_value(item, depth + options.indent, options, lines)
}
}
}
}
///|
fn render_dict(
pairs : Array[(String, Value)],
depth : Int,
options : DumpOptions,
lines : Array[String],
) -> Unit {
let indent = " ".repeat(depth)
if pairs.is_empty() {
lines.push(indent + "{}")
return
}
let sorted = if options.sort_keys {
let copy = pairs.copy()
copy.sort_by(fn(a, b) { key_cmp(a.0, b.0) })
copy
} else {
pairs
}
for pair in sorted {
let (key, value) = pair
if key_requires_multiline(key) {
for view in key.split("\n") {
let key_line = view.to_owned()
if key_line.is_empty() {
lines.push(indent + ":")
} else {
lines.push(indent + ": " + key_line)
}
}
render_value(value, depth + options.indent, options, lines)
} else {
match value {
String(s) if s.is_empty() => lines.push(indent + key + ":")
String(s) if !value_needs_multiline(s) =>
lines.push(indent + key + ": " + s)
_ => {
lines.push(indent + key + ":")
render_value(value, depth + options.indent, options, lines)
}
}
}
}
}
///|
fn key_requires_multiline(key : String) -> Bool {
if key.is_empty() || key.contains("\n") {
return true
}
let trimmed = key.trim().to_owned()
if trimmed != key {
return true
}
if key.contains(": ") || key.has_suffix(":") {
return true
}
if key.has_prefix("- ") ||
key == "-" ||
key.has_prefix("> ") ||
key == ">" ||
key.has_prefix(": ") ||
key == ":" ||
key.has_prefix("#") ||
key.has_prefix("[") ||
key.has_prefix("{") {
return true
}
false
}
///|
fn value_needs_multiline(s : String) -> Bool {
if s.contains("\n") {
return true
}
if !s.is_empty() {
let trimmed = s.trim().to_owned()
if trimmed != s {
return true
}
}
false
}
///|
fn key_cmp(a : String, b : String) -> Int {
if a > b {
1
} else if a < b {
-1
} else {
0
}
}