//! ZON printer: renders a fig AST as Zig Object Notation.
//!
//! Container literals use `.{ ... }` with trailing commas (idiomatic ZON, as
//! `zig fmt` would produce). Struct field names are emitted bare (`.name`) when
//! they form a legal Zig identifier, otherwise quoted (`.@"name"`).
//!
//! Native ZON scalars carried as `extended` nodes round-trip exactly: an
//! `enum_literal` re-emits as `.name` (or `.@"name"`), a `char_literal` as `'a'`
//! (re-encoded from its stored codepoint). A datetime `extended` node (only
//! produced by TOML) has no ZON form, so it falls back to a quoted string.
//!
//! Best-effort, niche output:
//! * `number.raw` is emitted verbatim. When the AST came from ZON that is
//! always valid; values minted by other formats are assumed decimal.
const Printer = @This();
const std = @import("std");
const AST = @import("../ast/ast.zig");
const Writer = std.Io.Writer;
/// ZON cannot represent a YAML alias (materialize expands them first) nor a
/// non-string struct-field key.
pub const Error = Writer.Error || error{ UnresolvedAlias, NonStringKey };
/// Multi-line ZON uses a fixed four-space indent (what `zig fmt` produces), so
/// `SerializeOptions.indent` is not applied here — only `options.pretty` is
/// honored (compact single-line vs. multi-line).
const block_indent = " ";
writer: *Writer,
ast: *const AST,
options: AST.SerializeOptions,
pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
try p.node(ast.root, 0);
// A container root emitted its own trailing beside its `.{`; a scalar root's
// trailing is emitted here.
if (!p.isContainer(ast.trailingCommentAnchor(ast.root)))
try p.trailingComment(ast.trailingCommentAnchor(ast.root));
try writer.writeByte('\n');
try writer.flush();
}
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
try p.node(id, depth);
}
fn node(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
const n = self.ast.nodes[id];
switch (n.kind) {
.null_ => try self.writer.writeAll("null"),
.boolean => |value| try self.writer.writeAll(if (value) "true" else "false"),
.number => |value| try self.writer.writeAll(value.raw),
.string => |value| try writeString(self.writer, value),
.extended => |ext| try writeExtended(self.writer, ext),
.sequence => |first_child| try self.container(id, first_child, depth),
.mapping => |first_child| try self.container(id, first_child, depth),
.keyvalue => |kv| {
try writeFieldName(self.writer, self.ast, kv.key);
try self.writer.writeAll(" = ");
try self.node(kv.value, depth);
},
.alias => return error.UnresolvedAlias,
}
}
/// Sequences and structs share ZON's `.{ ... }` literal; each child renders the
/// same way (a bare value or a `.field = value`, dispatched by `node`), so one
/// routine serves both. Compact output keeps everything on one line with
/// `.{ a, b }` spacing; multi-line output is the `zig fmt` shape with a trailing
/// comma per element.
fn container(self: *Printer, node_id: AST.Node.Id, first_child: ?AST.Node.Id, depth: usize) Error!void {
// Dangling comments (orphans at the body's end) force the multi-line form so
// they have somewhere to print; only an empty, comment-free, or compact
// container stays inline.
const dangling = if (self.commentsOn()) self.ast.comments(node_id).dangling else &.{};
if (first_child == null and dangling.len == 0) {
try self.writer.writeAll(".{}");
try self.trailingComment(node_id); // empty: `.{} // c`
return;
}
if (!self.options.pretty) {
try self.writer.writeAll(".{ ");
var current = first_child;
while (current) |id| {
try self.node(id, depth + 1);
current = self.ast.nodes[id].next_sibling;
if (current != null) try self.writer.writeAll(", ");
}
try self.writer.writeAll(" }");
return;
}
try self.writer.writeAll(".{");
// The container's own trailing comment rides the line it opened on.
try self.trailingComment(node_id);
try self.writer.writeByte('\n');
var current = first_child;
while (current) |id| {
try self.leadingComments(self.ast.leadingCommentAnchor(id), depth + 1);
try writeIndent(self.writer, depth + 1);
try self.node(id, depth + 1);
try self.writer.writeByte(',');
// A container child emits its own trailing beside its opener; skip here.
const anchor = self.ast.trailingCommentAnchor(id);
if (!self.isContainer(anchor)) try self.trailingComment(anchor);
try self.writer.writeByte('\n');
current = self.ast.nodes[id].next_sibling;
}
// Comments dangling at the end of the body (after the last element, or the
// entire body of an otherwise-empty container).
for (dangling) |c| {
var it = std.mem.splitScalar(u8, c.text, '\n');
while (it.next()) |line| {
try writeIndent(self.writer, depth + 1);
try writeSlashLine(self.writer, std.mem.trim(u8, line, " \t"));
try self.writer.writeByte('\n');
}
}
try writeIndent(self.writer, depth);
try self.writer.writeByte('}');
}
// ── Comments ────────────────────────────────────────────────────────────────
// ZON has only `//` line comments (Zig's syntax), and only in multi-line output
// (a `//` can't survive on a compact single line). A block comment carried from
// another format degrades to a run of `//` lines.
/// True when comments may be emitted: multi-line output only.
fn commentsOn(self: *const Printer) bool {
return self.options.pretty;
}
/// Whether `id` is a container node (whose own trailing comment is emitted beside
/// its `.{`, not by its parent).
fn isContainer(self: *const Printer, id: AST.Node.Id) bool {
return switch (self.ast.nodes[id].kind) {
.sequence, .mapping => true,
else => false,
};
}
/// Emit a node's leading comments above its line, at `depth`.
fn leadingComments(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
if (!self.commentsOn()) return;
for (self.ast.comments(id).leading) |c| {
var it = std.mem.splitScalar(u8, c.text, '\n');
while (it.next()) |line| {
try writeIndent(self.writer, depth);
try writeSlashLine(self.writer, std.mem.trim(u8, line, " \t"));
try self.writer.writeByte('\n');
}
}
}
/// Emit a node's trailing comment after its line (` // …`), newlines flattened.
fn trailingComment(self: *Printer, id: AST.Node.Id) Error!void {
if (!self.commentsOn()) return;
const c = self.ast.comments(id).trailing orelse return;
try self.writer.writeAll(" //");
if (c.text.len != 0) {
try self.writer.writeByte(' ');
for (c.text) |ch| try self.writer.writeByte(if (ch == '\n') ' ' else ch);
}
}
/// Write `// text` (or a bare `//` for an empty comment).
fn writeSlashLine(w: *Writer, text: []const u8) Writer.Error!void {
try w.writeAll("//");
if (text.len != 0) {
try w.writeByte(' ');
try w.writeAll(text);
}
}
/// Emit a struct field name: `.name` if it's a bare identifier, else `.@"..."`.
fn writeFieldName(writer: *Writer, ast: *const AST, key_id: AST.Node.Id) Error!void {
const name = switch (ast.nodes[key_id].kind) {
.string => |s| s,
else => return error.NonStringKey, // ZON field names must derive from a string key
};
try writeDotName(writer, name);
}
/// Render an `extended` scalar back to ZON. Enum and char literals reproduce
/// their source form; a datetime (TOML-only) has no ZON type, so it degrades to
/// a quoted string.
fn writeExtended(writer: *Writer, ext: AST.Node.Kind.Extended) Error!void {
switch (ext.kind) {
.enum_literal => try writeDotName(writer, ext.text),
.char_literal => try writeCharLiteral(writer, ext.text),
else => try writeString(writer, ext.text),
}
}
/// Emit a dotted name (`.name` or `.@"..."`) — shared by struct field names and
/// enum literals, which have identical lexical rules.
fn writeDotName(writer: *Writer, name: []const u8) Error!void {
try writer.writeByte('.');
if (isBareIdentifier(name)) {
try writer.writeAll(name);
} else {
try writer.writeAll("@\"");
try writeStringInner(writer, name);
try writer.writeByte('"');
}
}
/// Re-encode a char literal from its stored decimal codepoint. Common escapes
/// and printable ASCII emit directly; anything else uses `'\u{...}'`. A codepoint
/// that fails to parse (only if minted oddly by another format) falls back to its
/// numeric text, which is still valid ZON.
fn writeCharLiteral(writer: *Writer, text: []const u8) Error!void {
const cp = std.fmt.parseInt(u21, text, 10) catch return writer.writeAll(text);
try writer.writeByte('\'');
switch (cp) {
'\'' => try writer.writeAll("\\'"),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
0x20...0x26, 0x28...0x5b, 0x5d...0x7e => try writer.writeByte(@intCast(cp)),
else => try writer.print("\\u{{{x}}}", .{cp}),
}
try writer.writeByte('\'');
}
fn writeString(writer: *Writer, value: []const u8) Writer.Error!void {
try writer.writeByte('"');
try writeStringInner(writer, value);
try writer.writeByte('"');
}
fn writeStringInner(writer: *Writer, value: []const u8) Writer.Error!void {
for (value) |char| {
switch (char) {
'"' => try writer.writeAll("\\\""),
'\\' => try writer.writeAll("\\\\"),
'\n' => try writer.writeAll("\\n"),
'\r' => try writer.writeAll("\\r"),
'\t' => try writer.writeAll("\\t"),
// Other control bytes: Zig uses `\xNN` (no `\uNNNN` form for these).
0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writeHexEscape(writer, char),
else => try writer.writeByte(char),
}
}
}
fn writeHexEscape(writer: *Writer, char: u8) Writer.Error!void {
const hex = "0123456789abcdef";
try writer.writeAll("\\x");
try writer.writeByte(hex[char >> 4]);
try writer.writeByte(hex[char & 0x0f]);
}
fn writeIndent(writer: *Writer, depth: usize) Writer.Error!void {
for (0..depth) |_| try writer.writeAll(block_indent);
}
/// A name is a bare ZON identifier when it matches `[A-Za-z_][A-Za-z0-9_]*` and
/// is not a Zig keyword (`error`, `true`, ...), which would have to be quoted.
fn isBareIdentifier(name: []const u8) bool {
if (name.len == 0) return false;
for (name, 0..) |c, i| {
const ok = c == '_' or std.ascii.isAlphabetic(c) or (i > 0 and std.ascii.isDigit(c));
if (!ok) return false;
}
return std.zig.Token.getKeyword(name) == null;
}
// =========
// TESTS
// =========
const Parser = @import("parser.zig");
fn expectPrint(input: []const u8, expected: []const u8) !void {
try expectPrintOpts(input, expected, .{});
}
fn expectPrintOpts(input: []const u8, expected: []const u8, options: AST.SerializeOptions) !void {
var ast = try Parser.parseAbstract(std.testing.allocator, input, .ZON);
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());
}
test "container comment: opening line is head, closing line is bottom" {
// `.{ // head` rides the open line; `} // tail` normalizes to a bottom comment.
try expectPrint(
\\.{
\\ .a = .{ // head
\\ 1,
\\ },
\\ .b = .{
\\ 2,
\\ // tail
\\ },
\\}
,
\\.{
\\ .a = .{ // head
\\ 1,
\\ },
\\ .b = .{
\\ 2,
\\ // tail
\\ },
\\}
\\
);
}
test "captures and re-emits comments (leading, trailing, nested)" {
try expectPrint(
\\.{
\\ // the name
\\ .name = "Ada", // a person
\\ .nums = .{
\\ 1, // first
\\ 2,
\\ },
\\}
,
\\.{
\\ // the name
\\ .name = "Ada", // a person
\\ .nums = .{
\\ 1, // first
\\ 2,
\\ },
\\}
\\
);
}
test "prints a struct literal" {
try expectPrint(".{ .name = \"Ada\", .age = 36 }",
\\.{
\\ .name = "Ada",
\\ .age = 36,
\\}
\\
);
}
test "prints nested arrays and structs" {
try expectPrint(".{ .xs = .{ 1, 2 }, .ok = true }",
\\.{
\\ .xs = .{
\\ 1,
\\ 2,
\\ },
\\ .ok = true,
\\}
\\
);
}
test "prints empty container" {
try expectPrint(".{}", ".{}\n");
}
test "quotes non-identifier field names" {
try expectPrint(".{ .@\"has space\" = 1 }",
\\.{
\\ .@"has space" = 1,
\\}
\\
);
}
test "keyword field names are quoted" {
try expectPrint(".{ .@\"error\" = 1 }",
\\.{
\\ .@"error" = 1,
\\}
\\
);
}
test "escapes strings" {
try expectPrint(".{ .s = \"a\\tb\\nc\" }",
\\.{
\\ .s = "a\tb\nc",
\\}
\\
);
}
test "enum literals round-trip" {
try expectPrint(".{ .mode = .fast, .@\"weird key\" = .@\"weird val\" }",
\\.{
\\ .mode = .fast,
\\ .@"weird key" = .@"weird val",
\\}
\\
);
}
test "char literals round-trip" {
try expectPrint(".{ .a = 'A', .nl = '\\n', .q = '\\'', .emoji = '😀' }",
\\.{
\\ .a = 'A',
\\ .nl = '\n',
\\ .q = '\'',
\\ .emoji = '\u{1f600}',
\\}
\\
);
}
test "compact struct stays on one line" {
try expectPrintOpts(
".{ .name = \"Ada\", .age = 36 }",
".{ .name = \"Ada\", .age = 36 }\n",
.{ .pretty = false },
);
}
test "compact nests structs and arrays inline" {
try expectPrintOpts(
".{ .xs = .{ 1, 2 }, .ok = true }",
".{ .xs = .{ 1, 2 }, .ok = true }\n",
.{ .pretty = false },
);
}
test "compact empty container" {
try expectPrintOpts(".{}", ".{}\n", .{ .pretty = false });
}