//! TOML printer: renders a fig AST as canonical TOML.
//!
//! This is a *canonical* serializer, not a format-preserving round-tripper — the
//! editor (`editor.zig`) handles in-place, comment-preserving edits via source
//! spans. The printer takes a bare `*const AST` (no spans) and emits clean TOML.
//!
//! Structure mapping (the inverse of the parser):
//! * the root mapping's scalar/array entries print as bare `key = value` lines;
//! * a mapping value prints as a `[header.path]` section (so an inline-table
//! value `p = {x=1}` canonicalizes to a `[p]` section — same AST);
//! * a non-empty all-mapping sequence prints as `[[header.path]]` array-of-
//! tables elements;
//! * every other value (scalars, scalar/mixed arrays, empty arrays) prints
//! inline, with nested mappings becoming inline tables `{ ... }`.
//!
//! A table's own header is suppressed when it has no direct inline entries but
//! does have sub-tables/AoTs (their headers imply it); an otherwise-empty table
//! still emits its `[header]` so its existence round-trips.
//!
//! **Key order is preserved.** TOML forces section headers to trail a table's
//! `key = value` lines, so a table child that precedes later inline siblings
//! cannot become a `[section]` without semantically reordering the map. Such
//! mid-order children *demote* to order-preserving spellings — dotted keys for
//! a table, an inline array for an array-of-tables — when they are
//! comment-free; a commented one keeps the trailing-section form (comments
//! outrank order). See `Ctx.body`.
//!
//! TOML has no null, so a `null` value is `error.NullUnsupported` (e.g. a JSON
//! `null` cannot convert to TOML). `enum_literal`/`char_literal` extended scalars
//! (only minted by ZON) degrade to a string / integer; datetimes print verbatim.
const Printer = @This();
const std = @import("std");
const AST = @import("../../ast/ast.zig");
const width = @import("../../util/util.zig").width;
const Writer = std.Io.Writer;
/// TOML cannot represent a YAML alias (materialize expands them first), a null,
/// or a non-string table key.
pub const Error = Writer.Error || error{ NullUnsupported, NonStringKey, UnresolvedAlias };
/// A header path built on the call stack (one link per nesting level), rendered
/// parent-first so no allocation is needed to accumulate `a.b.c`.
const Path = struct {
key: []const u8,
parent: ?*const Path,
};
/// How a value renders inside its parent table body.
const Class = enum {
/// `key = value` on one line (scalars, arrays, inline tables).
inline_,
/// `[header]` section (any mapping value).
section,
/// `[[header]]` array-of-tables (non-empty, all-mapping sequence).
aot,
};
/// Document-emit state: `wrote` gates the blank line printed before each
/// section/AoT header (suppressed at the very start of the output); `opts`
/// carries the width budget / indent / pretty knobs that drive the
/// inline-vs-expanded layout decisions below.
const Ctx = struct {
w: *Writer,
opts: AST.SerializeOptions,
wrote: bool = false,
/// Emit a table's body: its `key = value` lines in document order, then —
/// as TOML requires — the trailing sub-table / array-of-tables sections.
/// `path` is this table's header path (null at root).
///
/// TOML cannot re-open a table header, so section children may only TRAIL
/// the body lines. A table child that sits BEFORE a later inline sibling
/// would be silently reordered by a naive inline-then-sections split — a
/// semantic key-order change. Instead, such a child is **demoted** to an
/// order-preserving spelling: dotted keys (`fig.version = "1.0.0"`) for a
/// table, an inline array for an array-of-tables. Demotion requires a
/// comment-free entry (the demoted spellings would drop or re-anchor
/// comments); a commented mid-position child keeps the trailing-section
/// form — comments outrank order.
fn body(ctx: *Ctx, ast: *const AST, node_id: AST.Node.Id, first_child: ?AST.Node.Id, path: ?*const Path) Error!void {
// The last inline-classified child bounds the demotion zone: only
// section/AoT children before it are out of order.
var last_inline: ?AST.Node.Id = null;
{
var cur = first_child;
while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
if (classify(ctx, ast, id) == .inline_) last_inline = id;
}
}
// Pass 1: body lines, in document order.
var cur = first_child;
var in_body_zone = (last_inline != null);
while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
if (!in_body_zone) break;
const kv = ast.nodes[id].kind.keyvalue;
switch (classify(ctx, ast, id)) {
.inline_ => try ctx.kvLine(ast, kv.key, kv.value),
.section => if (demotable(ast, id)) {
const seg = Path{ .key = try keyText(ast, kv.key), .parent = null };
try ctx.dottedBody(ast, &seg, ast.nodes[kv.value].kind.mapping);
},
.aot => if (demotable(ast, id)) try ctx.kvLine(ast, kv.key, kv.value),
}
if (id == last_inline.?) in_body_zone = false;
}
// Comments dangling at the end of this table's own lines (after its
// inline entries, before any sub-tables).
for (ast.comments(node_id).dangling) |c| {
var it = std.mem.splitScalar(u8, c.text, '\n');
while (it.next()) |line| {
try writeHashLine(ctx.w, std.mem.trim(u8, line, " \t"));
try ctx.w.writeByte('\n');
}
ctx.wrote = true;
}
// Pass 2: trailing sections — children after the last inline line,
// plus any commented mid-position child that could not demote.
cur = first_child;
var in_demote_zone = (last_inline != null);
while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
const kv = ast.nodes[id].kind.keyvalue;
const demoted = in_demote_zone and demotable(ast, id);
switch (classify(ctx, ast, id)) {
.inline_ => {},
.section => if (!demoted) try ctx.section(ast, kv.key, kv.value, path),
.aot => if (!demoted) try ctx.aot(ast, kv.key, kv.value, path),
}
if (in_demote_zone and id == last_inline.?) in_demote_zone = false;
}
}
/// Emit a demoted table as dotted-key lines (`fig.version = "1.0.0"`),
/// preserving document order where a `[section]` would have to move past
/// later inline siblings. Only reached for comment-free subtrees. A
/// non-empty sub-table recurses into deeper dots; everything else —
/// scalars, arrays (including arrays of tables, rendered inline), empty
/// tables — is one `prefix.key = value` line.
fn dottedBody(ctx: *Ctx, ast: *const AST, prefix: *const Path, first_child: ?AST.Node.Id) Error!void {
var cur = first_child;
while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
const kv = ast.nodes[id].kind.keyvalue;
const seg = Path{ .key = try keyText(ast, kv.key), .parent = prefix };
switch (ast.nodes[kv.value].kind) {
.mapping => |first| if (first != null) {
try ctx.dottedBody(ast, &seg, first);
continue;
},
else => {},
}
try writePath(ctx.w, &seg);
try ctx.w.writeAll(" = ");
const col = (pathByteLen(&seg) orelse 0) + 3;
try ctx.writeValue(ast, kv.value, col);
try ctx.w.writeByte('\n');
ctx.wrote = true;
}
}
fn kvLine(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id) Error!void {
try ctx.leading(ast, key_id);
try writeKey(ctx.w, ast, key_id);
try ctx.w.writeAll(" = ");
const col = (keyByteLen(ast, key_id) orelse 0) + 3; // `key` + ` = `
try ctx.writeValue(ast, value_id, col);
try ctx.trailing(ast, value_id);
try ctx.w.writeByte('\n');
ctx.wrote = true;
}
/// Render a `key = value` right-hand side. A scalar/mixed array that would
/// overflow `opts.width` (and `pretty` is set) wraps one element per line;
/// everything else renders inline. (Mappings and array-of-tables that reach
/// here already fit by construction — see `classify`.)
fn writeValue(ctx: *Ctx, ast: *const AST, value_id: AST.Node.Id, col: usize) Error!void {
switch (ast.nodes[value_id].kind) {
.sequence => |first| {
if (ctx.opts.pretty and first != null) {
// A null/alias inside makes the array un-measurable; fall
// through so `writeInline` surfaces the proper error.
if (inlineByteLen(ast, value_id)) |len| {
if (col + len > ctx.opts.width) return ctx.writeArrayMultiline(ast, first);
}
}
try writeInline(ctx.w, ast, value_id);
},
else => try writeInline(ctx.w, ast, value_id),
}
}
/// Wrap an array across lines: `[`, then each element indented one level with
/// a trailing comma, then `]` at column 0. Elements themselves render inline.
fn writeArrayMultiline(ctx: *Ctx, ast: *const AST, first: ?AST.Node.Id) Error!void {
const w = ctx.w;
try w.writeAll("[\n");
var cur = first;
while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
try w.splatByteAll(' ', ctx.opts.indent);
try writeInline(w, ast, id);
try w.writeAll(",\n");
}
try w.writeByte(']');
}
fn section(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id, parent: ?*const Path) Error!void {
const seg = Path{ .key = try keyText(ast, key_id), .parent = parent };
const first = ast.nodes[value_id].kind.mapping;
if (needsHeader(ctx, ast, first)) {
if (ctx.wrote) try ctx.w.writeByte('\n');
try ctx.leading(ast, key_id); // comments above the `[header]` line
try ctx.w.writeByte('[');
try writePath(ctx.w, &seg);
try ctx.w.writeAll("]\n");
ctx.wrote = true;
}
try ctx.body(ast, value_id, first, &seg);
}
// ── Comments ──────────────────────────────────────────────────────────
// TOML has only `#` line comments. A block comment carried from another
// format degrades to a run of `#` lines (one per content line).
/// Emit a key's leading comments above its line, at column 0.
fn leading(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id) Error!void {
for (ast.comments(key_id).leading) |c| {
var it = std.mem.splitScalar(u8, c.text, '\n');
while (it.next()) |line| {
try writeHashLine(ctx.w, std.mem.trim(u8, line, " \t"));
try ctx.w.writeByte('\n');
}
ctx.wrote = true;
}
}
/// Emit a value's trailing comment after its line (` # …`). A multi-line
/// block flattens to one line (newlines → spaces).
fn trailing(ctx: *Ctx, ast: *const AST, value_id: AST.Node.Id) Error!void {
const c = ast.comments(value_id).trailing orelse return;
try ctx.w.writeAll(" #");
if (c.text.len != 0) {
try ctx.w.writeByte(' ');
for (c.text) |ch| try ctx.w.writeByte(if (ch == '\n') ' ' else ch);
}
}
fn aot(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id, parent: ?*const Path) Error!void {
const seg = Path{ .key = try keyText(ast, key_id), .parent = parent };
var elem = ast.nodes[value_id].kind.sequence;
while (elem) |eid| : (elem = ast.nodes[eid].next_sibling) {
if (ctx.wrote) try ctx.w.writeByte('\n');
try ctx.w.writeAll("[[");
try writePath(ctx.w, &seg);
try ctx.w.writeAll("]]\n");
ctx.wrote = true;
try ctx.body(ast, eid, ast.nodes[eid].kind.mapping, &seg);
}
}
};
/// Whether a section emits its own `[header]`: yes when empty (records its
/// existence) or when it has at least one direct inline entry; no when it has
/// only sub-tables/AoTs (whose own headers imply this table). Note an inline
/// table counts as a direct inline entry, so a table whose sub-mappings all
/// collapse to inline tables does regain its header.
fn needsHeader(ctx: *Ctx, ast: *const AST, first_child: ?AST.Node.Id) bool {
var cur = first_child orelse return true; // empty table
while (true) {
if (classify(ctx, ast, cur) == .inline_) return true;
cur = ast.nodes[cur].next_sibling orelse return false;
}
}
/// Decide how a key/value entry renders. A mapping collapses to an inline table
/// (`{ ... }`) when `fitsInline` allows it, otherwise it expands to a
/// `[section]`. A non-empty all-mapping sequence is always an `[[array.of.tables]]`
/// (the block form reads better and is the conventional shape — we don't collapse
/// it even when it would fit). Scalars and scalar/mixed arrays are always
/// `inline_` (the array may still *wrap*, but it stays in place).
fn classify(ctx: *Ctx, ast: *const AST, kv_id: AST.Node.Id) Class {
const kv = ast.nodes[kv_id].kind.keyvalue;
return switch (ast.nodes[kv.value].kind) {
.mapping => if (fitsInline(ctx, ast, kv_id)) .inline_ else .section,
.sequence => |first| if (allMappings(ast, first)) .aot else .inline_,
else => .inline_,
};
}
/// Whether a mapping value should render inline (`k = { ... }`) rather than as a
/// `[section]`. Inlining is allowed only when the whole `key = value` line fits in
/// `opts.width`, the subtree carries no comments (the inline form can't emit them
/// — they'd be silently dropped), and the value isn't an empty mapping (kept as
/// `[header]` so the table's existence round-trips).
fn fitsInline(ctx: *Ctx, ast: *const AST, kv_id: AST.Node.Id) bool {
const kv = ast.nodes[kv_id].kind.keyvalue;
const value_id = kv.value;
switch (ast.nodes[value_id].kind) {
.mapping => |first| if (first == null) return false,
else => {},
}
if (nodeHasComments(ast, kv_id)) return false;
if (subtreeHasComments(ast, kv.key)) return false;
if (subtreeHasComments(ast, value_id)) return false;
const klen = keyByteLen(ast, kv.key) orelse return false;
const vlen = inlineByteLen(ast, value_id) orelse return false;
return klen + 3 + vlen <= ctx.opts.width; // `key` + ` = ` + value
}
// ── Width / comment measurement ─────────────────────────────────────────────
// The inline-layout decision measures a value's rendered width by printing it to
// a discarding writer with the very functions that emit the real output (see
// `util.width`), so the estimate can never drift from what's actually written.
/// Rendered byte width of a value's inline form, or null if it can't be inlined
/// (a `null`/alias inside makes `writeInline` error).
fn inlineByteLen(ast: *const AST, id: AST.Node.Id) ?usize {
return width.rendered(writeInline, .{ ast, id });
}
/// Rendered byte width of a key (bare or quoted), or null for a non-string key.
fn keyByteLen(ast: *const AST, key_id: AST.Node.Id) ?usize {
return width.rendered(writeKey, .{ ast, key_id });
}
/// A mid-order table/AoT child may demote to dotted keys / an inline array
/// only when the whole entry (kv node, key, and value subtree) is
/// comment-free: the demoted spellings would drop interior comments or
/// re-anchor a key's leading run. Comments outrank order, so a commented
/// child keeps the trailing-section form instead.
fn demotable(ast: *const AST, kv_id: AST.Node.Id) bool {
const kv = ast.nodes[kv_id].kind.keyvalue;
if (nodeHasComments(ast, kv_id)) return false;
if (subtreeHasComments(ast, kv.key)) return false;
if (subtreeHasComments(ast, kv.value)) return false;
return true;
}
/// Rendered byte width of a dotted path, or null for a non-string segment.
fn pathByteLen(path: *const Path) ?usize {
return width.rendered(writePath, .{path});
}
fn nodeHasComments(ast: *const AST, id: AST.Node.Id) bool {
const c = ast.comments(id);
return c.leading.len != 0 or c.trailing != null or c.dangling.len != 0;
}
/// Whether any node in the subtree rooted at `id` carries a comment. Used to keep
/// a commented mapping/array as an expanded section, where the printer can still
/// emit the comments (the inline `{ ... }` / `[ ... ]` forms cannot).
fn subtreeHasComments(ast: *const AST, id: AST.Node.Id) bool {
if (nodeHasComments(ast, id)) return true;
switch (ast.nodes[id].kind) {
.sequence => |first| {
var cur = first;
while (cur) |e| : (cur = ast.nodes[e].next_sibling)
if (subtreeHasComments(ast, e)) return true;
},
.mapping => |first| {
var cur = first;
while (cur) |kvid| : (cur = ast.nodes[kvid].next_sibling) {
if (nodeHasComments(ast, kvid)) return true;
const kv = ast.nodes[kvid].kind.keyvalue;
if (subtreeHasComments(ast, kv.key)) return true;
if (subtreeHasComments(ast, kv.value)) return true;
}
},
else => {},
}
return false;
}
/// True for a non-empty sequence whose every element is a mapping — the shape
/// TOML writes as `[[array.of.tables]]`.
fn allMappings(ast: *const AST, first: ?AST.Node.Id) bool {
var cur = first orelse return false;
while (true) {
if (ast.nodes[cur].kind != .mapping) return false;
cur = ast.nodes[cur].next_sibling orelse return true;
}
}
// ── Inline value rendering ──────────────────────────────────────────────────
fn writeInline(w: *Writer, ast: *const AST, id: AST.Node.Id) Error!void {
switch (ast.nodes[id].kind) {
.null_ => return error.NullUnsupported,
.boolean => |b| try w.writeAll(if (b) "true" else "false"),
.number => |n| try w.writeAll(n.raw),
.string => |s| try writeBasicString(w, s),
.extended => |ext| try writeExtended(w, ext),
.sequence => |first| try writeInlineArray(w, ast, first),
.mapping => |first| try writeInlineTable(w, ast, first),
.keyvalue => unreachable, // a keyvalue is never a value position
.alias => return error.UnresolvedAlias,
}
}
fn writeInlineArray(w: *Writer, ast: *const AST, first: ?AST.Node.Id) Error!void {
if (first == null) {
try w.writeAll("[]");
return;
}
try w.writeByte('[');
var cur = first;
while (cur) |id| {
try writeInline(w, ast, id);
cur = ast.nodes[id].next_sibling;
if (cur != null) try w.writeAll(", ");
}
try w.writeByte(']');
}
fn writeInlineTable(w: *Writer, ast: *const AST, first: ?AST.Node.Id) Error!void {
if (first == null) {
try w.writeAll("{}");
return;
}
try w.writeAll("{ ");
var cur = first;
while (cur) |id| {
const kv = ast.nodes[id].kind.keyvalue;
try writeKey(w, ast, kv.key);
try w.writeAll(" = ");
try writeInline(w, ast, kv.value);
cur = ast.nodes[id].next_sibling;
if (cur != null) try w.writeAll(", ");
}
try w.writeAll(" }");
}
/// Render an `extended` scalar. Datetimes print verbatim (their text is already
/// valid TOML). An `enum_literal` (ZON-only) has no TOML form, so it degrades to
/// a string; a `char_literal`'s text is a decimal codepoint, valid as an integer.
fn writeExtended(w: *Writer, ext: AST.Node.Kind.Extended) Error!void {
switch (ext.kind) {
.offset_datetime, .local_datetime, .local_date, .local_time => try w.writeAll(ext.text),
.enum_literal => try writeBasicString(w, ext.text),
.char_literal => try w.writeAll(ext.text),
// JSON5 `Infinity`/`NaN` map onto TOML's native lowercase float forms.
.number_special => try w.writeAll(tomlSpecial(ext.text)),
}
}
/// Map a JSON5 non-finite lexeme (`Infinity`, `-Infinity`, `+Infinity`, `NaN`,
/// `-NaN`, `+NaN`) to its TOML float spelling (`inf`/`-inf`/`+inf`/`nan`).
fn tomlSpecial(text: []const u8) []const u8 {
if (std.mem.endsWith(u8, text, "NaN")) {
return if (text.len > 0 and text[0] == '-') "-nan" else if (text.len > 0 and text[0] == '+') "+nan" else "nan";
}
if (std.mem.startsWith(u8, text, "-")) return "-inf";
if (std.mem.startsWith(u8, text, "+")) return "+inf";
return "inf";
}
// ── Keys and strings ────────────────────────────────────────────────────────
fn keyText(ast: *const AST, key_id: AST.Node.Id) Error![]const u8 {
return switch (ast.nodes[key_id].kind) {
.string => |s| s,
else => error.NonStringKey,
};
}
/// A key prints bare when non-empty and all of `[A-Za-z0-9_-]`, else quoted.
fn writeKey(w: *Writer, ast: *const AST, key_id: AST.Node.Id) Error!void {
const name = try keyText(ast, key_id);
if (isBareKey(name)) {
try w.writeAll(name);
} else {
try writeBasicString(w, name);
}
}
/// Write `# text` (or a bare `#` for an empty comment).
fn writeHashLine(w: *Writer, text: []const u8) Writer.Error!void {
try w.writeByte('#');
if (text.len != 0) {
try w.writeByte(' ');
try w.writeAll(text);
}
}
fn isBareKey(name: []const u8) bool {
if (name.len == 0) return false;
for (name) |c| {
const ok = (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or
(c >= '0' and c <= '9') or c == '_' or c == '-';
if (!ok) return false;
}
return true;
}
/// Render a header path (`a.b.c`), parent segment first.
fn writePath(w: *Writer, path: *const Path) Error!void {
if (path.parent) |par| {
try writePath(w, par);
try w.writeByte('.');
}
if (isBareKey(path.key)) {
try w.writeAll(path.key);
} else {
try writeBasicString(w, path.key);
}
}
/// Emit a TOML basic string: double-quoted, with `"`, `\`, and control bytes
/// escaped (TOML keeps other Unicode literal).
fn writeBasicString(w: *Writer, value: []const u8) Writer.Error!void {
try w.writeByte('"');
for (value) |char| {
switch (char) {
'"' => try w.writeAll("\\\""),
'\\' => try w.writeAll("\\\\"),
0x08 => try w.writeAll("\\b"),
'\t' => try w.writeAll("\\t"),
'\n' => try w.writeAll("\\n"),
0x0c => try w.writeAll("\\f"),
'\r' => try w.writeAll("\\r"),
0x00...0x07, 0x0b, 0x0e...0x1f, 0x7f => try writeUnicodeEscape(w, char),
else => try w.writeByte(char),
}
}
try w.writeByte('"');
}
fn writeUnicodeEscape(w: *Writer, char: u8) Writer.Error!void {
const hex = "0123456789ABCDEF";
try w.writeAll("\\u00");
try w.writeByte(hex[char >> 4]);
try w.writeByte(hex[char & 0x0f]);
}
// ── Public entry points ─────────────────────────────────────────────────────
pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
var ctx = Ctx{ .w = writer, .opts = options };
switch (ast.nodes[ast.root].kind) {
.mapping => |first| try ctx.body(ast, ast.root, first, null),
// A non-table root (e.g. converting a JSON array) has no valid TOML
// document form; emit the inline value as a best-effort fragment.
else => {
try writeInline(writer, ast, ast.root);
try writer.writeByte('\n');
},
}
try writer.flush();
}
/// Print the subtree at `id` as a standalone fragment (used by `get <path>`): a
/// mapping prints as a document body rooted there; any other node prints inline.
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
_ = depth;
var ctx = Ctx{ .w = writer, .opts = options };
switch (ast.nodes[id].kind) {
.mapping => |first| try ctx.body(ast, id, first, null),
else => {
try writeInline(writer, ast, id);
try writer.writeByte('\n');
},
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
const Parser = @import("parser.zig");
fn expectPrint(input: []const u8, expected: []const u8) !void {
try expectPrintWith(input, expected, .{});
}
fn expectPrintWith(input: []const u8, expected: []const u8, options: AST.SerializeOptions) !void {
var ast = try Parser.parseAbstract(std.testing.allocator, input, .TOML_1_1);
defer ast.deinit();
var output: Writer.Allocating = .init(std.testing.allocator);
defer output.deinit();
try print(&output.writer, &ast, options);
try std.testing.expectEqualStrings(expected, output.written());
}
/// Round-trip: the printed TOML must reparse without error, and the canonical
/// form must be a fixed point — re-printing the reparse yields identical bytes.
/// (We can't compare ASTs directly: `AST.eql` is structural-identity on node ids,
/// which canonicalization to inline tables intentionally changes even though the
/// data is preserved. Idempotency is the right invariant for a canonical printer.)
fn expectRoundTrip(input: []const u8) !void {
var ast = try Parser.parseAbstract(std.testing.allocator, input, .TOML_1_1);
defer ast.deinit();
var out1: Writer.Allocating = .init(std.testing.allocator);
defer out1.deinit();
try print(&out1.writer, &ast, .{});
var reparsed = try Parser.parseAbstract(std.testing.allocator, out1.written(), .TOML_1_1);
defer reparsed.deinit();
var out2: Writer.Allocating = .init(std.testing.allocator);
defer out2.deinit();
errdefer std.log.err("printed:\n{s}", .{out1.written()});
try print(&out2.writer, &reparsed, .{});
try std.testing.expectEqualStrings(out1.written(), out2.written());
}
test "captures and re-emits comments (leading, trailing, table header)" {
try expectPrint(
\\# document header
\\name = "Tom" # the name
\\count = 42
\\
\\# the server section
\\[server]
\\host = "a" # primary host
\\
,
\\# document header
\\name = "Tom" # the name
\\count = 42
\\
\\# the server section
\\[server]
\\host = "a" # primary host
\\
);
}
test "block comment carried in degrades to a # run" {
const a = std.testing.allocator;
var b = AST.Builder.init(a);
defer b.deinit();
const v = try b.addInt(1);
const k = try b.addString("x");
try b.setComments(k, .{ .leading = &.{.{ .text = "line one\nline two", .style = .block }} });
const root = try b.addMapping(&.{.{ .key = k, .value = v }});
var ast = try b.finish(root);
defer ast.deinit();
var out: Writer.Allocating = .init(a);
defer out.deinit();
try print(&out.writer, &ast, .{});
try std.testing.expectEqualStrings("# line one\n# line two\nx = 1\n", out.written());
}
test "prints root scalars" {
try expectPrint(
\\name = "Tom"
\\count = 42
\\pi = 3.14
\\flag = true
\\
,
\\name = "Tom"
\\count = 42
\\pi = 3.14
\\flag = true
\\
);
}
test "canonicalizes numbers" {
try expectPrint("hex = 0xDEAD_beef\noct = 0o17\nneg = -0\n", "hex = 3735928559\noct = 15\nneg = 0\n");
}
test "datetimes print verbatim" {
try expectPrint("when = 1979-05-27T07:32:00Z\nd = 1979-05-27\nt = 07:32:00\n", "when = 1979-05-27T07:32:00Z\nd = 1979-05-27\nt = 07:32:00\n");
}
test "a small table collapses to an inline table" {
try expectPrint(
\\[server]
\\host = "a"
\\port = 80
\\
,
\\server = { host = "a", port = 80 }
\\
);
}
test "a table too wide for the budget stays a [section]" {
// The same table forced to expand by a tight width budget.
try expectPrintWith(
\\[server]
\\host = "a"
\\port = 80
\\
,
\\[server]
\\host = "a"
\\port = 80
\\
, .{ .width = 20 });
}
test "a table carrying a comment stays a [section]" {
// Inlining would drop the comment, so the table keeps its header form.
try expectPrint(
\\[server]
\\host = "a" # primary
\\port = 80
\\
,
\\[server]
\\host = "a" # primary
\\port = 80
\\
);
}
test "scalars precede sub-tables and blank-line separates (when expanded)" {
// A tight budget keeps `[a]` and `[a.b]` from collapsing inline, exercising
// the scalars-before-subtables ordering and the blank-line separator.
try expectPrintWith(
\\[a]
\\x = 1
\\[a.b]
\\y = 2
\\
,
\\[a]
\\x = 1
\\
\\[a.b]
\\y = 2
\\
, .{ .width = 8 });
}
test "a mid-order sub-table demotes to dotted keys (order preserved)" {
// `b` (a table) precedes the scalar `c`; a `[a.b]` section would have to
// move past it — a semantic key-order change — so it demotes to dotted
// keys in place. The output is its own fixed point.
try expectPrintWith(
\\[a]
\\b.x = 1
\\b.y = 2
\\c = 3
\\
,
\\[a]
\\b.x = 1
\\b.y = 2
\\c = 3
\\
, .{ .width = 8 });
}
test "a commented mid-order sub-table keeps its section (comments outrank order)" {
try expectPrintWith(
\\[a]
\\b.x = 1 # note
\\b.y = 2
\\c = 3
\\
,
\\[a]
\\c = 3
\\
\\[a.b]
\\x = 1 # note
\\y = 2
\\
, .{ .width = 8 });
}
test "a mid-order array-of-tables demotes to an inline array (order preserved)" {
try expectPrintWith(
\\[t]
\\a = [{ x = 1 }, { x = 2 }]
\\b = "a long enough string value to keep the whole table t from collapsing inline"
\\
,
\\[t]
\\a = [{ x = 1 }, { x = 2 }]
\\b = "a long enough string value to keep the whole table t from collapsing inline"
\\
, .{});
}
test "order-preserving demotions round-trip" {
try expectRoundTrip("[a]\nb.x = 1\nb.y = 2\nc = 3\n");
try expectRoundTrip("[t]\na = [{ x = 1 }, { x = 2 }]\nb = 3\n");
try expectRoundTrip("fig.version = \"1.0.0\"\nfig.default-features = false\nserde = \"1.0\"\n");
}
test "supertable header is suppressed when it has only sub-tables" {
// A tight budget keeps `b` expanded, so `a` has only the sub-table `b` and
// its own header is implied (suppressed) — printing `[a.b]` directly.
try expectPrintWith(
\\[a.b]
\\y = 2
\\
,
\\[a.b]
\\y = 2
\\
, .{ .width = 8 });
}
test "empty table still emits its header" {
try expectPrint("[a]\n", "[a]\n");
}
test "a small inline table stays inline (within the budget)" {
try expectPrint("point = { x = 1, y = 2 }\n", "point = { x = 1, y = 2 }\n");
}
test "a wide inline table expands to a section" {
try expectPrintWith(
"point = { x = 1, y = 2 }\n",
"[point]\nx = 1\ny = 2\n",
.{ .width = 10 },
);
}
test "arrays of scalars print inline" {
try expectPrint("nums = [1, 2, 3]\nnested = [[1, 2], [\"a\", \"b\"]]\n", "nums = [1, 2, 3]\nnested = [[1, 2], [\"a\", \"b\"]]\n");
}
test "a wide array wraps one element per line with a trailing comma" {
try expectPrintWith(
"members = [\"alpha\", \"beta\", \"gamma\"]\n",
\\members = [
\\ "alpha",
\\ "beta",
\\ "gamma",
\\]
\\
,
.{ .width = 20 },
);
}
test "pretty=false keeps a wide array on one line" {
try expectPrintWith(
"members = [\"alpha\", \"beta\", \"gamma\"]\n",
"members = [\"alpha\", \"beta\", \"gamma\"]\n",
.{ .width = 20, .pretty = false },
);
}
test "wrapped-array indent honors the indent option" {
try expectPrintWith(
"xs = [1, 2]\n",
"xs = [\n 1,\n 2,\n]\n",
.{ .width = 4, .indent = 2 },
);
}
test "array of tables prints as double-bracket sections" {
try expectPrint(
\\[[fruit]]
\\name = "apple"
\\
\\[[fruit]]
\\name = "pear"
\\
,
\\[[fruit]]
\\name = "apple"
\\
\\[[fruit]]
\\name = "pear"
\\
);
}
test "quotes non-bare keys" {
try expectPrint("\"a.b\" = 1\n\"\" = 2\n", "\"a.b\" = 1\n\"\" = 2\n");
}
test "escapes control characters in strings" {
try expectPrint("s = \"a\\tb\\nc\"\n", "s = \"a\\tb\\nc\"\n");
}
test "null value is unsupported" {
// Build an AST with a null by hand (TOML never parses one).
var nodes = [_]AST.Node{
.{ .id = 0, .kind = .{ .mapping = 1 } },
.{ .id = 1, .kind = .{ .keyvalue = .{ .key = 2, .value = 3 } } },
.{ .id = 2, .kind = .{ .string = "k" } },
.{ .id = 3, .kind = .null_ },
};
const ast = AST{ .allocator = std.testing.allocator, .root = 0, .nodes = &nodes };
var output: Writer.Allocating = .init(std.testing.allocator);
defer output.deinit();
try std.testing.expectError(error.NullUnsupported, print(&output.writer, &ast, .{}));
}
// Round-trips across the structural features.
test "round-trips a mixed document" {
try expectRoundTrip(
\\title = "demo"
\\ports = [8000, 8001]
\\
\\[owner]
\\name = "Tom"
\\dob = 1979-05-27T07:32:00Z
\\
\\[servers.alpha]
\\ip = "10.0.0.1"
\\
\\[servers.beta]
\\ip = "10.0.0.2"
\\
\\[[products]]
\\name = "Hammer"
\\sku = 738594937
\\
\\[[products]]
\\name = "Nail"
\\color = "gray"
\\
);
}
test "round-trips nested arrays of tables" {
try expectRoundTrip(
\\[[a]]
\\x = 1
\\
\\[[a.b]]
\\y = 2
\\
\\[[a.b]]
\\y = 3
\\
);
}
test "round-trips deeply nested empty and dotted tables" {
try expectRoundTrip(
\\a.b.c = 1
\\
\\[x]
\\
\\[x.y.z]
\\w = "deep"
\\
);
}