//! fig authoring dialect printer — renders an AST in the `fig fmt` house
//! style (DESIGN.md "What `fig fmt` normalizes"):
//!
//! * Spaced marker runs (`> > key`) and NO leading indentation by default —
//! the markers themselves form the visual ruler (each level shifts the
//! line two columns, the same geometry indentation would add, without a
//! redundant second signal to maintain). `options.fig_indent` is an
//! opt-in (`fig fmt --indent`) cosmetic overlay that prefixes each marker
//! run with `2 × depth` literal spaces on top — purely visual (see
//! `writeCosmeticIndent`); canonical (default) output is unaffected.
//! * Fits-or-breaks: a container value renders as inline flow iff every
//! descendant is flow-representable, no comment would be dropped or
//! re-anchored, no object directly contains another object, and the whole
//! line fits `options.width`. All-or-nothing per node — no partial
//! hoisting of the widest member — so output is stable under small edits.
//! * Dotted-key collapse: chains of single-child maps collapse to `a.b.c`.
//! * Sections at the document root: scalar/inline children print as dotted
//! assignments; a map prints under a dotted section header while its block
//! body stays within depth 2 and hoists each child to its own section when
//! it would nest deeper; a sequence of maps prints as a `path[]` append
//! header with `+` continuation blocks for the rest of its elements.
//! * Comments are conservative blockers: any spelling a re-parse would not
//! re-anchor identically falls back to the nested form that does.
//!
//! Every emitted document parses back to the same AST (modulo comments, which
//! DO round-trip).
const Printer = @This();
const std = @import("std");
const AST = @import("../../ast/ast.zig");
const tok = @import("tokenizer.zig");
const Writer = std.Io.Writer;
pub const Error = Writer.Error || error{ UnresolvedAlias, NonStringKey, UnexpectedNodeKind, FigUnrepresentableRoot };
/// Deepest `>` count a section header's block body may reach before the map is
/// hoisted into per-child sections instead.
const max_body_depth = 2;
/// A flow-representable sequence with more than this many elements breaks onto
/// multiple lines (one element per line) even when it would fit the width
/// budget. Short lists (`[a, b, c]`) stay inline; longer ones read and diff
/// better stacked. Six keeps typical frontmatter lists (a handful of tags,
/// authors, audiences) inline while stacking the long index-style lists that
/// motivated multi-line flow. Independent of the width test below — either
/// trigger (too wide, or too many items) breaks the list.
const max_inline_seq_items = 6;
/// Recursion guard for the section path buffer (collapse/hoist recursion is
/// bounded by tree depth; 128 dotted segments is far past any real config).
const path_cap = 128;
/// Columns of `" = "`, the operator between a key and its inline value.
const assign_op_width = 3;
/// Columns one marker level occupies — a `> ` run cell, or the `* ` bullet.
const marker_cell = 2;
/// Memoized `inlineWidth` result for a node, keyed by node id (`width_memo`).
/// `inlineWidth` is asked for the same subtree repeatedly (the block-depth
/// pre-pass, the fits-inline test, the multiline-flow test, then real
/// emission), and each call otherwise walks the whole subtree — so caching the
/// comment-policy-independent core collapses that from superlinear to one pass.
const WidthMemo = struct { done: bool = false, width: ?usize = null };
writer: *Writer,
ast: *const AST,
options: AST.SerializeOptions,
/// Per-node-id `inlineWidth` cache, sized to `ast.nodes.len` by the entry
/// points. Empty (`&.{}`) if the one-time allocation failed — the width
/// helpers fall back to recomputing, so the cache is a pure speedup, never a
/// correctness dependency.
width_memo: []WidthMemo = &.{},
/// Dotted path (key node ids) of the section currently being emitted.
path: [path_cap]AST.Node.Id = undefined,
path_len: usize = 0,
/// Section separation state: a blank line goes between two sections when
/// either side is multi-line (or carries leading comments).
started: bool = false,
prev_multiline: bool = false,
pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
const memo = p.initWidthMemo();
defer if (memo) |m| ast.allocator.free(m);
try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
try p.root(ast.root, .document);
try writer.flush();
}
/// Render `ast.root` as a value *fragment* — the caller-built-`Value` path
/// behind `fig_value_serialize_opts`, which backs the editors' `replace`/`set`
/// (`Editor`/`Embed` splice a replacement value's text into existing source,
/// e.g. after `key = `). Unlike `print`, a fragment is never asked to stand
/// alone as a document, so a scalar/null root — which `root`'s `.document` mode
/// rejects as `FigUnrepresentableRoot` — is fine here: `root`'s `.fragment` mode
/// renders it with the same bare/quoted spelling `value` uses for any other
/// scalar, and the caller only ever reads it back in the context it's spliced
/// into, not as a standalone reparse.
pub fn printFragment(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
const memo = p.initWidthMemo();
defer if (memo) |m| ast.allocator.free(m);
try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
try p.root(ast.root, .fragment);
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 };
const memo = p.initWidthMemo();
defer if (memo) |m| ast.allocator.free(m);
switch (ast.nodes[id].kind) {
.mapping => if (depth == 0) {
try p.emitSections(id);
for (p.danglingOf(id)) |c| try p.commentLines(c, 0);
} else try p.mapBody(id, depth),
.sequence => try p.seqBody(id, depth),
// A scalar reaching here at depth 0 is being asked to stand as an
// entire fig document/subdocument — see `root`'s doc comment below,
// which this mirrors exactly (a non-zero depth never reaches this
// switch arm: nested scalars go through `value`, not `printNode`).
else => if (depth == 0) return error.FigUnrepresentableRoot else {
try p.value(id, false);
try p.writer.writeByte('\n');
},
}
}
/// `.document`: `print`'s mode — a scalar/null/extended/alias root is rejected,
/// since it has no authoring spelling as a *whole fig document* (see the
/// `.document` case below). `.fragment`: `printFragment`'s mode — such a root
/// is instead rendered with its ordinary scalar spelling, because a fragment
/// is never reparsed alone.
const RootMode = enum { document, fragment };
/// The root is a map or a sequence in `.document` mode (never a bare scalar —
/// see DESIGN.md and docs/spec.md § 2); `.fragment` mode additionally accepts a
/// bare scalar/null root (see `printFragment`). A root map is emitted as
/// sections; a root sequence as zero-marker `*` elements.
fn root(self: *Printer, id: AST.Node.Id, mode: RootMode) Error!void {
// A fragment destined for an inline splice (`key = <fragment>`, requested
// via `options.flow`) has no valid block spelling: `* ` element lines and
// section headers only parse as standalone lines, so a block container
// spliced inline re-reads as a bare string (docs/spec.md § 6.3's bracket
// commitment is the only inline container syntax). Flow is the one
// spelling that survives the round-trip; scalar fragments fall through
// unchanged.
if (mode == .fragment and self.options.flow) {
switch (self.ast.nodes[id].kind) {
.mapping, .sequence => {
try self.flowValue(id);
try self.writer.writeByte('\n');
return;
},
else => {},
}
}
switch (self.ast.nodes[id].kind) {
.mapping => {
try self.emitSections(id);
for (self.danglingOf(id)) |c| try self.commentLines(c, 0);
},
.sequence => try self.seqBody(id, 0), // prints its own dangling run
else => switch (mode) {
.fragment => {
try self.value(id, false);
try self.writer.writeByte('\n');
},
.document => {
// A scalar/null/extended/alias root has no authoring spelling
// in the fig dialect: emitting it as a single bare value line
// (the old behavior) does NOT re-parse — a bare token at the
// document root reads as a container header with no children
// (`FigEmptyContainer`), not a scalar. Hard-error instead of
// silently emitting non-conforming output; the caller should
// use canonical form or another output format instead (see
// docs/spec.md § 2).
return error.FigUnrepresentableRoot;
},
},
}
}
// ── Section emission (document root) ────────────────────────────────────────
fn emitSections(self: *Printer, map_id: AST.Node.Id) Error!void {
var cur = self.ast.nodes[map_id].kind.mapping;
while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
try self.emitSection(kv_id);
}
}
/// Emit one map entry at section level: the current dotted `path` plus this
/// entry's key names the target, and the value's shape picks the spelling.
fn emitSection(self: *Printer, kv_id: AST.Node.Id) Error!void {
const kv = self.ast.nodes[kv_id].kind.keyvalue;
self.path[self.path_len] = kv.key;
self.path_len += 1;
defer self.path_len -= 1;
const can_extend = self.path_len < path_cap;
// Dotted collapse: step through single-child maps (`a.b.c …`).
if (can_extend) if (self.collapseChild(kv_id)) |child| return self.emitSection(child);
const v = kv.value;
switch (self.ast.nodes[v].kind) {
.mapping => |first_opt| {
if (self.fitsInline(v, self.pathWidth() + assign_op_width, .allow_trailing))
return self.sectionAssign(kv.key, v);
// Hoist: a body that would pass the depth budget reads better as
// sections. Only when every comment re-anchors: the map itself
// gets no dedicated line, so it must carry none of its own.
//
// Consecutive FLAT children (scalar / inline-fitting) group under
// ONE header re-entry — naming the path once instead of repeating
// it per line — while each deep child gets its own section.
// Headers are re-enterable, so interleaved flat runs stay legal
// and key order is preserved exactly. A lone flat child stays a
// dotted assignment (one line beats a two-line header group).
if (can_extend and first_opt != null and
self.blockDepthMap(v, 1) > max_body_depth and
!self.keyHasLeading(kv.key) and !self.hasTrailingOrDangling(v))
{
var cur = first_opt;
while (cur != null) {
// Measure the run of consecutive flat children at `cur`.
var run_len: usize = 0;
var scan = cur;
while (scan) |child| : (scan = self.ast.nodes[child].next_sibling) {
if (!self.isFlatChild(child)) break;
run_len += 1;
}
if (run_len == 1) {
try self.emitSection(cur.?);
cur = self.ast.nodes[cur.?].next_sibling;
} else if (run_len >= 2) {
try self.beginSection(true, null);
try self.writePath();
try self.writer.writeByte('\n');
for (0..run_len) |_| {
try self.mapEntryLine(cur.?, 1);
cur = self.ast.nodes[cur.?].next_sibling;
}
}
// `cur` now sits on a deep child (or the end).
if (cur) |deep| {
try self.emitSection(deep);
cur = self.ast.nodes[deep].next_sibling;
}
}
return;
}
// Dotted section header + block body.
try self.beginSection(true, kv.key);
try self.writePath();
try self.trailingComment(v);
try self.writer.writeByte('\n');
try self.mapBody(v, 1);
},
.sequence => |first_opt| {
if (self.seqFitsInline(v, self.pathWidth() + assign_op_width, .allow_trailing))
return self.sectionAssign(kv.key, v);
// A list of maps is the append header's home turf: `path[]` for
// the first element, `+` for the rest, fields one `>` deep.
if (first_opt != null and self.allElementsMappings(v) and
!self.keyHasLeading(kv.key) and !self.hasTrailingOrDangling(v))
return self.emitAppendGroup(v);
// A flow-representable scalar/nested-list list that overflowed the
// inline budget (or the item-count threshold) stacks as multi-line
// flow — `path = [` / one element per line / `]` — the terse
// surface for long frontmatter lists (markdown links, tags, …).
if (self.multilineFlowEligible(v, .allow_trailing))
return self.sectionMultilineFlowSeq(kv.key, v);
// Otherwise a header + `* v` element lines (elements carrying
// comments, multi-line strings, or enum/float atoms land here).
try self.beginSection(true, kv.key);
try self.writePath();
try self.trailingComment(v);
try self.writer.writeByte('\n');
try self.seqBody(v, 1);
},
else => return self.sectionAssign(kv.key, v),
}
}
/// A single-line section: `path = value` (scalar or inline flow).
fn sectionAssign(self: *Printer, anchor_key: AST.Node.Id, v: AST.Node.Id) Error!void {
try self.beginSection(false, anchor_key);
try self.writePath();
switch (self.ast.nodes[v].kind) {
.mapping, .sequence => {
try self.writer.writeAll(" = ");
try self.flowValue(v);
},
else => {
_ = try self.writeTypeAnnotation(v);
try self.writer.writeAll(" = ");
try self.value(v, false);
},
}
try self.trailingComment(v);
try self.writer.writeByte('\n');
}
/// A section whose value is a multi-line flow list: `path = [` then one element
/// per line then `]`. Used for long flow-representable scalar/nested-list values
/// at the document root. Deliberately NOT blank-line separated (`multiline =
/// false`): a stacked list is still one assignment, so it packs tight with its
/// scalar neighbors — the frontmatter look — rather than reading as a section.
fn sectionMultilineFlowSeq(self: *Printer, anchor_key: AST.Node.Id, v: AST.Node.Id) Error!void {
try self.beginSection(false, anchor_key);
try self.writePath();
try self.writer.writeAll(" = ");
try self.multilineFlowSeq(v, 0, v);
try self.writer.writeByte('\n');
}
/// `path[]` for the first element, `+` for each further one. Emitted as ONE
/// section (blank lines around the group, none inside it).
fn emitAppendGroup(self: *Printer, seq_id: AST.Node.Id) Error!void {
var cur = self.ast.nodes[seq_id].kind.sequence;
var i: usize = 0;
while (cur) |el| : ({
cur = self.ast.nodes[el].next_sibling;
i += 1;
}) {
if (i == 0) try self.beginSection(true, null);
try self.leadingComments(el, 0);
if (i == 0) {
try self.writePath();
try self.writer.writeAll("[]");
} else {
try self.writer.writeByte('+');
}
try self.trailingComment(el);
try self.writer.writeByte('\n');
try self.mapBody(el, 1);
}
}
/// Blank-line separation, then the section's leading comments (anchored to
/// `anchor` — the key a re-parse of the section's first line binds them to).
fn beginSection(self: *Printer, multiline: bool, anchor: ?AST.Node.Id) Error!void {
const has_lead = if (anchor) |a| self.commentsOn() and self.ast.comments(a).leading.len > 0 else false;
const ml = multiline or has_lead;
if (self.started and (ml or self.prev_multiline)) try self.writer.writeByte('\n');
self.started = true;
self.prev_multiline = ml;
if (anchor) |a| try self.leadingComments(a, 0);
}
fn writePath(self: *Printer) Error!void {
for (self.path[0..self.path_len], 0..) |key_id, i| {
if (i > 0) try self.writer.writeByte('.');
try self.writeKey(key_id);
}
}
fn pathWidth(self: *const Printer) usize {
var w: usize = if (self.path_len > 0) self.path_len - 1 else 0; // dots
for (self.path[0..self.path_len]) |key_id| w += self.keyWidth(key_id);
return w;
}
/// Would this entry render as a single body line under a header at depth 1
/// (scalar, or a container fitting inline)? Deep children — anything needing
/// its own nested body — get hoisted to their own sections instead.
fn isFlatChild(self: *const Printer, kv_id: AST.Node.Id) bool {
const chain = self.resolveChain(kv_id);
const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
return switch (self.ast.nodes[end.value].kind) {
.mapping => self.fitsInline(end.value, mapEntryPrefixWidth(1, chain.key_width), .allow_trailing),
// Past the item-count threshold a sequence stacks (multiple lines), so
// it is not flat — `seqFitsInline` is the shared rule mapEntryLine uses.
.sequence => self.seqFitsInline(end.value, mapEntryPrefixWidth(1, chain.key_width), .allow_trailing),
else => true,
};
}
fn allElementsMappings(self: *const Printer, seq_id: AST.Node.Id) bool {
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
if (self.ast.nodes[el].kind != .mapping) return false;
}
return true;
}
fn seqLen(self: *const Printer, seq_id: AST.Node.Id) usize {
var n: usize = 0;
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) n += 1;
return n;
}
/// A sequence renders as inline flow (`[a, b, c]`) iff it is under the
/// item-count threshold AND its inline spelling fits the width budget at
/// `prefix_width`. The single home for this rule — `mapEntryLine`, `seqBody`,
/// `isFlatChild`, and `emitSection` all consult it, so it can't drift.
fn seqFitsInline(self: *const Printer, seq_id: AST.Node.Id, prefix_width: usize, policy: CommentPolicy) bool {
return self.seqLen(seq_id) <= max_inline_seq_items and self.fitsInline(seq_id, prefix_width, policy);
}
/// Columns before a map entry's value: the marker run (`> ` per level), the
/// dotted key, then `" = "`.
fn mapEntryPrefixWidth(depth: usize, key_width: usize) usize {
return marker_cell * depth + key_width + assign_op_width;
}
fn hasMappingElement(self: *const Printer, seq_id: AST.Node.Id) bool {
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
if (self.ast.nodes[el].kind == .mapping) return true;
}
return false;
}
/// Is this sequence a candidate for MULTI-LINE flow (`[` / one element per line
/// / `]`) rather than `> *` block lines? Requires a non-empty list, no direct
/// mapping element (lists of maps stay on the append-header / block path —
/// DESIGN.md: flow of maps is brace-heavy), no element that flow would quote
/// but block leaves bare (the quote-avoidance rule below), and full
/// flow-representability with no comment dropped (`inlineWidth` returns null on
/// a dangling comment, a leading/trailing comment barred by `policy`, an
/// enum/float/char atom, an alias, or an object-in-object). The caller reaches
/// here only after the inline form was rejected for width or the item-count
/// threshold.
fn multilineFlowEligible(self: *const Printer, seq_id: AST.Node.Id, policy: CommentPolicy) bool {
if (self.ast.nodes[seq_id].kind.sequence == null) return false; // empty → `= []`
if (self.hasMappingElement(seq_id)) return false;
if (self.flowQuotesWhatBlockLeavesBare(seq_id)) return false;
return self.inlineWidth(seq_id, policy) != null;
}
/// The quote-avoidance rule (DESIGN.md "Multi-line flow"): would the flow
/// spelling force quotes onto an element that block position renders bare?
/// Prose with a top-level comma is the common case — `,` terminates a bare
/// flow value but is ordinary text in a block bare string — so a list of
/// sentences reads better as `> *` lines than as a stack of quoted flow
/// elements. Elements needing quotes in BOTH positions (`true`, a leading
/// space, genuine nested flow) don't count against flow: quoting is the same
/// price either way. Recurses into nested sequences (their interiors are
/// flow-quoted too when the outer value stays flow); map elements never reach
/// here (excluded by `multilineFlowEligible`).
fn flowQuotesWhatBlockLeavesBare(self: *const Printer, id: AST.Node.Id) bool {
switch (self.ast.nodes[id].kind) {
.string => |s| return !isBareSafe(s, false, true) and isBareSafe(s, false, false),
.sequence => |first_opt| {
var cur = first_opt;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
if (self.flowQuotesWhatBlockLeavesBare(el)) return true;
}
return false;
},
else => return false,
}
}
/// Render a sequence as multi-line flow: `[`, then each element on its own line
/// (two-space cosmetic indent, trailing comma), then `]` back at the opening
/// line's `indent`. The interior is a suspended flow region — the block layer's
/// markers do not apply, so the indentation is purely visual and the re-parse
/// ignores it. `comment_anchor`'s trailing comment (if any) prints after the
/// opening `[` — the multiline-string opener rule's flow twin — so the comment
/// stays next to the key instead of trailing the far-away `]`. Each element
/// re-decides fits-or-breaks independently: a nested list rejected for the
/// inline form (width budget or item-count threshold, same triggers as the
/// caller) stacks recursively; everything else prints with its inline
/// (`in_flow`) spelling.
fn multilineFlowSeq(self: *Printer, seq_id: AST.Node.Id, indent: usize, comment_anchor: ?AST.Node.Id) Error!void {
try self.writer.writeByte('[');
if (comment_anchor) |a| try self.trailingComment(a);
try self.writer.writeByte('\n');
const inner = indent + 2;
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
for (0..inner) |_| try self.writer.writeByte(' ');
const el_node = self.ast.nodes[el];
if (el_node.kind == .sequence and el_node.kind.sequence != null and
!self.seqFitsInline(el, inner + 1, .strict)) // +1: the trailing comma
{
try self.multilineFlowSeq(el, inner, null);
} else {
try self.value(el, true);
}
try self.writer.writeAll(",\n");
}
for (0..indent) |_| try self.writer.writeByte(' ');
try self.writer.writeByte(']');
}
// ── Block bodies ─────────────────────────────────────────────────────────────
fn body(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
switch (self.ast.nodes[id].kind) {
.mapping => try self.mapBody(id, depth),
.sequence => try self.seqBody(id, depth),
// `body` is only reached for containers today, but it is on the
// data-driven path from the public `print`/`printNode` — a scalar
// arriving from a hand-built or cross-format AST is recoverable, not UB.
else => return error.UnexpectedNodeKind,
}
}
/// Emit every entry of map `id` as block lines at `depth` (then the map's own
/// dangling comment run).
fn mapBody(self: *Printer, map_id: AST.Node.Id, depth: usize) Error!void {
var cur = self.ast.nodes[map_id].kind.mapping;
while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
try self.mapEntryLine(kv_id, depth);
}
for (self.danglingOf(map_id)) |c| try self.commentLines(c, depth);
}
/// One map entry as block line(s) at `depth`: leading comments, marker run,
/// (collapsed) dotted key, then value — inline, nested body, or scalar.
fn mapEntryLine(self: *Printer, kv_id: AST.Node.Id, depth: usize) Error!void {
const chain = self.resolveChain(kv_id);
const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
try self.leadingComments(end.key, depth);
try self.writeMarkers(depth);
try self.writeChainKeys(kv_id, chain.end_kv);
const v = end.value;
const prefix_width = mapEntryPrefixWidth(depth, chain.key_width);
switch (self.ast.nodes[v].kind) {
.mapping => {
if (self.fitsInline(v, prefix_width, .allow_trailing)) {
try self.writer.writeAll(" = ");
try self.flowValue(v);
try self.trailingComment(v);
try self.writer.writeByte('\n');
} else {
// Nested container header (`> key`, children one deeper).
try self.trailingComment(v);
try self.writer.writeByte('\n');
try self.body(v, depth + 1);
}
},
.sequence => {
if (self.seqFitsInline(v, prefix_width, .allow_trailing)) {
try self.writer.writeAll(" = ");
try self.flowValue(v);
try self.trailingComment(v);
try self.writer.writeByte('\n');
} else if (self.multilineFlowEligible(v, .allow_trailing)) {
try self.writer.writeAll(" = ");
try self.multilineFlowSeq(v, 2 * depth, v);
try self.writer.writeByte('\n');
} else {
// Nested sequence header (`> key`, `>> *` elements deeper).
try self.trailingComment(v);
try self.writer.writeByte('\n');
try self.body(v, depth + 1);
}
},
else => {
_ = try self.writeTypeAnnotation(v);
try self.writer.writeAll(" = ");
try self.value(v, false);
try self.trailingComment(v);
try self.writer.writeByte('\n');
},
}
}
/// Emit every element of sequence `id` as `> *` lines at `depth` (bare `*` at
/// the root), then the sequence's own dangling comment run.
fn seqBody(self: *Printer, seq_id: AST.Node.Id, depth: usize) Error!void {
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
try self.leadingComments(el, depth);
try self.writeElementMarkers(depth);
switch (self.ast.nodes[el].kind) {
.mapping => {
// Element leading comments are already printed, so they don't
// block the inline form.
if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) {
try self.writer.writeByte(' ');
try self.flowValue(el);
try self.trailingComment(el);
try self.writer.writeByte('\n');
} else {
try self.trailingComment(el);
try self.writer.writeByte('\n');
try self.body(el, depth + 1);
}
},
.sequence => {
if (self.seqFitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) {
try self.writer.writeByte(' ');
try self.flowValue(el);
try self.trailingComment(el);
try self.writer.writeByte('\n');
} else if (self.multilineFlowEligible(el, .allow_leading_trailing)) {
try self.writer.writeByte(' ');
try self.multilineFlowSeq(el, elementPrefixWidth(depth), el);
try self.writer.writeByte('\n');
} else {
try self.trailingComment(el);
try self.writer.writeByte('\n');
try self.body(el, depth + 1);
}
},
else => {
const annotated = try self.writeTypeAnnotation(el);
try self.writer.writeAll(if (annotated) " = " else " ");
try self.value(el, false);
try self.trailingComment(el);
try self.writer.writeByte('\n');
},
}
}
for (self.danglingOf(seq_id)) |c| try self.commentLines(c, depth);
}
// ── Layout decisions ─────────────────────────────────────────────────────────
const CommentPolicy = enum {
/// Any comment disqualifies (interior flow position — comments would drop).
strict,
/// The node's own trailing comment prints after the line (value position).
allow_trailing,
/// Leading comments print before the line too (element position).
allow_leading_trailing,
};
fn budget(self: *const Printer) usize {
return self.options.width;
}
/// Allocate the per-node `width_memo` from the AST's allocator and install it
/// on `self`. Returns the owned slice for the caller to free (or null if the
/// allocation failed — the width helpers degrade to recomputing).
fn initWidthMemo(self: *Printer) ?[]WidthMemo {
const memo = self.ast.allocator.alloc(WidthMemo, self.ast.nodes.len) catch return null;
@memset(memo, .{});
self.width_memo = memo;
return memo;
}
fn fitsInline(self: *const Printer, id: AST.Node.Id, prefix_width: usize, policy: CommentPolicy) bool {
const w = self.inlineWidth(id, policy) orelse return false;
return prefix_width + w <= self.budget();
}
/// Width of `id` rendered as inline flow, or null when it is not
/// flow-eligible: a comment would be dropped or re-anchored (dangling always;
/// leading/trailing per `policy`), the node has no flow spelling (enum
/// literal, non-finite float, char literal, alias), or an object directly
/// contains another object (that much structure reads better as block lines).
///
/// `policy` gates ONLY `id`'s own comments; everything else — the tag check and
/// the recursive structural width — is a pure function of `id` (children always
/// recurse with `.strict`), factored into the memoized `structuralInlineWidth`.
fn inlineWidth(self: *const Printer, id: AST.Node.Id, policy: CommentPolicy) ?usize {
if (self.commentsOn()) {
const c = self.ast.comments(id);
if (c.dangling.len > 0) return null;
switch (policy) {
.strict => if (c.leading.len > 0 or c.trailing != null) return null,
.allow_trailing => if (c.leading.len > 0) return null,
.allow_leading_trailing => {},
}
}
return self.structuralInlineWidth(id);
}
/// The comment-policy-independent core of `inlineWidth`, memoized by node id in
/// `width_memo`. Falls back to recomputing when the cache is absent (id past
/// its end, e.g. the allocation failed).
fn structuralInlineWidth(self: *const Printer, id: AST.Node.Id) ?usize {
if (id < self.width_memo.len and self.width_memo[id].done)
return self.width_memo[id].width;
const w = self.computeInlineWidth(id);
if (id < self.width_memo.len) self.width_memo[id] = .{ .done = true, .width = w };
return w;
}
fn computeInlineWidth(self: *const Printer, id: AST.Node.Id) ?usize {
// A tag-annotated scalar needs `: type =`, which only exists in block
// position — so it forces its container out of inline flow (same reason as
// enum_literal/number_special below). A tag the printer DROPS (a `.string`
// tag on a value with no bare total-sink spelling — see `emittableKindTag`)
// doesn't count: the emitted document is untagged, so treating the node as
// tagged here would break fmt idempotence.
if (self.emittableKindTag(id) != null) return null;
switch (self.ast.nodes[id].kind) {
.null_ => return 4,
.boolean => |b| return @as(usize, if (b) 4 else 5),
.number => |n| return n.raw.len,
// A multi-line string deserves a real `'''`/`"""` block, which only
// exists in block position — so it forces its container out of flow.
.string => |s| return if (std.mem.indexOfScalar(u8, s, '\n') != null) null else scalarStringWidth(s, false, true),
.extended => |e| return switch (e.kind) {
.offset_datetime, .local_datetime, .local_date, .local_time => e.text.len,
// enum/char/non-finite need a `: type =` annotation, which exists
// only in block position — so none is flow-eligible.
else => null,
},
.alias, .keyvalue => return null,
.sequence => |first_opt| {
var w: usize = 2; // "[" + "]"
var cur = first_opt;
var i: usize = 0;
while (cur) |el| : ({
cur = self.ast.nodes[el].next_sibling;
i += 1;
}) {
const ew = self.inlineWidth(el, .strict) orelse return null;
w += ew + (if (i > 0) @as(usize, 2) else 0); // ", "
}
return w;
},
.mapping => |first_opt| {
if (first_opt == null) return 2; // "{}"
var w: usize = 4; // "{ " + " }"
var cur = first_opt;
var i: usize = 0;
while (cur) |kv_id| : ({
cur = self.ast.nodes[kv_id].next_sibling;
i += 1;
}) {
const kv = self.ast.nodes[kv_id].kind.keyvalue;
if (self.commentsOn() and self.ast.comments(kv.key).leading.len > 0) return null;
if (self.ast.nodes[kv.value].kind == .mapping) return null; // object-in-object
const vw = self.inlineWidth(kv.value, .strict) orelse return null;
w += self.keyWidth(kv.key) + 3 + vw + (if (i > 0) @as(usize, 2) else 0);
}
return w;
},
}
}
/// Deepest `>` count `mapBody(map_id, depth)` would emit — used to decide
/// header-vs-hoist at section level. Mirrors mapBody's decisions exactly.
fn blockDepthMap(self: *const Printer, map_id: AST.Node.Id, depth: usize) usize {
var maxd = depth;
var cur = self.ast.nodes[map_id].kind.mapping;
while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
const chain = self.resolveChain(kv_id);
const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
switch (self.ast.nodes[end.value].kind) {
.mapping => {
if (self.fitsInline(end.value, mapEntryPrefixWidth(depth, chain.key_width), .allow_trailing)) continue;
maxd = @max(maxd, self.blockDepthMap(end.value, depth + 1));
},
.sequence => {
if (self.fitsInline(end.value, mapEntryPrefixWidth(depth, chain.key_width), .allow_trailing)) continue;
if (self.multilineFlowEligible(end.value, .allow_trailing)) continue; // flow value, no block depth
maxd = @max(maxd, self.blockDepthSeq(end.value, depth + 1));
},
else => {},
}
}
return maxd;
}
fn blockDepthSeq(self: *const Printer, seq_id: AST.Node.Id, depth: usize) usize {
var maxd = depth;
var cur = self.ast.nodes[seq_id].kind.sequence;
while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
switch (self.ast.nodes[el].kind) {
.mapping => {
if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) continue;
maxd = @max(maxd, self.blockDepthMap(el, depth + 1));
},
.sequence => {
if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) continue;
if (self.multilineFlowEligible(el, .allow_leading_trailing)) continue; // flow value, no block depth
maxd = @max(maxd, self.blockDepthSeq(el, depth + 1));
},
else => {},
}
}
return maxd;
}
/// Columns before an element's value: `* ` at root, `> > * ` etc. below — the
/// `> ` marker run plus the `* ` bullet cell (one more `marker_cell`).
fn elementPrefixWidth(depth: usize) usize {
return marker_cell * (depth + 1);
}
const Chain = struct { end_kv: AST.Node.Id, key_width: usize };
/// Walk the dotted-key collapse chain starting at `kv_id`: follow single-child
/// maps as far as comment anchors allow, accumulating the dotted key width.
fn resolveChain(self: *const Printer, kv_id: AST.Node.Id) Chain {
var cur = kv_id;
var w = self.keyWidth(self.ast.nodes[cur].kind.keyvalue.key);
while (self.collapseChild(cur)) |child| {
cur = child;
w += 1 + self.keyWidth(self.ast.nodes[cur].kind.keyvalue.key);
}
return .{ .end_kv = cur, .key_width = w };
}
/// If `kv_id`'s value is a single-entry map that a dotted-key collapse may
/// step through, return that inner keyvalue. Blocked by any comment whose
/// anchor a re-parse of the collapsed spelling would move: the outer key's
/// leading run (it would re-anchor onto the chain's final key) and the
/// intermediate map's own comments (its line disappears entirely).
fn collapseChild(self: *const Printer, kv_id: AST.Node.Id) ?AST.Node.Id {
const kv = self.ast.nodes[kv_id].kind.keyvalue;
switch (self.ast.nodes[kv.value].kind) {
.mapping => |first_opt| {
const first = first_opt orelse return null;
if (self.ast.nodes[first].next_sibling != null) return null;
if (self.commentsOn()) {
if (self.ast.comments(kv.key).leading.len > 0) return null;
const vc = self.ast.comments(kv.value);
if (vc.leading.len > 0 or vc.trailing != null or vc.dangling.len > 0) return null;
}
return first;
},
else => return null,
}
}
/// Write the dotted keys of a collapse chain: `kv_id`'s key through
/// `end_kv`'s, joined by `.`.
fn writeChainKeys(self: *Printer, kv_id: AST.Node.Id, end_kv: AST.Node.Id) Error!void {
var cur = kv_id;
try self.writeKey(self.ast.nodes[cur].kind.keyvalue.key);
while (cur != end_kv) {
cur = self.collapseChild(cur).?;
try self.writer.writeByte('.');
try self.writeKey(self.ast.nodes[cur].kind.keyvalue.key);
}
}
// ── Comment queries ──────────────────────────────────────────────────────────
fn commentsOn(self: *const Printer) bool {
return !self.options.strip_comments;
}
fn danglingOf(self: *const Printer, id: AST.Node.Id) []const AST.Comment {
if (!self.commentsOn()) return &.{};
return self.ast.comments(id).dangling;
}
fn keyHasLeading(self: *const Printer, key_id: AST.Node.Id) bool {
return self.commentsOn() and self.ast.comments(key_id).leading.len > 0;
}
fn hasTrailingOrDangling(self: *const Printer, id: AST.Node.Id) bool {
if (!self.commentsOn()) return false;
const c = self.ast.comments(id);
return c.trailing != null or c.dangling.len > 0;
}
// ── Line primitives ──────────────────────────────────────────────────────────
/// Opt-in (`options.fig_indent`) cosmetic indentation ahead of a marker run:
/// `2 × depth` literal spaces, agreeing with the `> ` markers that alone carry
/// the real parse depth (docs/spec.md § 3.3) — purely visual, never emitted by
/// default.
fn writeCosmeticIndent(self: *Printer, depth: usize) Error!void {
if (self.options.fig_indent) try self.writer.splatByteAll(' ', 2 * depth);
}
/// The spaced marker run for a key/comment line: `"> "` per level, so the last
/// space doubles as the marker↔key separator (`> > key`).
fn writeMarkers(self: *Printer, depth: usize) Error!void {
try self.writeCosmeticIndent(depth);
for (0..depth) |_| try self.writer.writeAll("> ");
}
/// The marker run for an element line: the depth run, then the `*` bullet in
/// its own cell (`> > *`); a root element is a bare `*`.
fn writeElementMarkers(self: *Printer, depth: usize) Error!void {
try self.writeCosmeticIndent(depth);
for (0..depth) |_| try self.writer.writeAll("> ");
try self.writer.writeByte('*');
}
fn writeKey(self: *Printer, key_id: AST.Node.Id) Error!void {
const name = switch (self.ast.nodes[key_id].kind) {
.string => |s| s,
else => return error.NonStringKey,
};
try self.writeBareOrQuoted(name, true, false);
}
fn keyWidth(self: *const Printer, key_id: AST.Node.Id) usize {
return switch (self.ast.nodes[key_id].kind) {
.string => |s| scalarStringWidth(s, true, false),
else => 0, // fails with NonStringKey at write time regardless
};
}
/// The `.kind` type tag on `id` (`ast.node_tags`), or null when the node is
/// untagged or carries a verbatim `.text` tag (a YAML custom tag fig can't
/// spell). This is how a fig `: type =` annotation round-trips.
fn kindTagOf(self: *const Printer, id: AST.Node.Id) ?AST.Tag.KindTag {
if (id < self.ast.node_tags.len) if (self.ast.node_tags[id]) |t| switch (t) {
.kind => |k| return k,
.text => {},
};
return null;
}
fn isStringTagged(self: *const Printer, id: AST.Node.Id) bool {
return if (self.emittableKindTag(id)) |k| k == .string else false;
}
/// The kind tag `writeTypeAnnotation` will actually re-emit for `id`:
/// `kindTagOf` minus any `.string` tag whose value the all-raw `: string` form
/// cannot carry. The annotated RHS is read VERBATIM to end of line (the total
/// sink), so quoting under the annotation would turn the quotes into content —
/// a string value with no bare spelling (`isTotalSinkSafe` false: multiline,
/// leading/trailing space, comment hazard) must DROP its tag and take the
/// ordinary untagged quoted/multiline form instead of corrupting the value.
fn emittableKindTag(self: *const Printer, id: AST.Node.Id) ?AST.Tag.KindTag {
const k = self.kindTagOf(id) orelse return null;
if (k == .string) switch (self.ast.nodes[id].kind) {
.string => |s| if (!isTotalSinkSafe(s)) return null,
else => {},
};
return k;
}
/// Write an explicit `: type` annotation for `id`, if it needs one. Two sources:
/// a stored `.kind` type tag (a fig `: int/float/string/bool =` that round-trips
/// via `ast.node_tags`), or an `extended` scalar with no bare spelling
/// (`enum_literal`/`number_special`, DESIGN.md "Enum: explicit-only"). Returns
/// whether an annotation was written (an annotated line needs ` = ` where a
/// plain one has a space).
fn writeTypeAnnotation(self: *Printer, id: AST.Node.Id) Error!bool {
if (self.emittableKindTag(id)) |k| switch (k) {
.integer => {
try self.writer.writeAll(": int");
return true;
},
.float => {
try self.writer.writeAll(": float");
return true;
},
.string => {
try self.writer.writeAll(": string");
return true;
},
.boolean => {
try self.writer.writeAll(": bool");
return true;
},
// null/seq/map carry no fig `: type =` spelling — emit no annotation.
.null_, .sequence, .mapping => {},
};
switch (self.ast.nodes[id].kind) {
.extended => |e| switch (e.kind) {
.enum_literal => {
try self.writer.writeAll(": enum");
return true;
},
.char_literal => {
try self.writer.writeAll(": char");
return true;
},
.number_special => {
try self.writer.writeAll(": float");
return true;
},
else => return false,
},
else => return false,
}
}
/// A scalar (or, in flow position, any) value. `in_flow` tightens the
/// bare-string rules to what survives inside `[…]`/`{…}`.
fn value(self: *Printer, id: AST.Node.Id, in_flow: bool) Error!void {
switch (self.ast.nodes[id].kind) {
.null_ => try self.writer.writeAll("null"),
.boolean => |b| try self.writer.writeAll(if (b) "true" else "false"),
.number => |n| try self.writer.writeAll(n.raw),
.string => |s| if (!in_flow and std.mem.indexOfScalar(u8, s, '\n') != null) {
try self.writeMultilineString(s);
} else if (!in_flow and self.isStringTagged(id)) {
// A `: string =`-tagged value re-emits BARE: the annotated RHS is
// read verbatim (the total sink), so no quotes are needed even for
// a value that would otherwise commit to flow (`[ 1 + 2 ]`) or open
// a quote form (`"hello"` with the quotes as content).
// `isStringTagged` is already gated on `isTotalSinkSafe`, so the
// bare bytes re-parse to exactly this value.
try self.writer.writeAll(s);
} else {
try self.writeBareOrQuoted(s, false, in_flow);
},
.extended => |e| try self.writeExtended(e),
.mapping, .sequence => try self.flowValue(id),
.keyvalue => unreachable,
.alias => return error.UnresolvedAlias,
}
}
/// A container in flow spelling: `[a, b]` / `{ x = 1 }` (fig-inline pairs).
fn flowValue(self: *Printer, id: AST.Node.Id) Error!void {
switch (self.ast.nodes[id].kind) {
.sequence => |first_opt| {
try self.writer.writeByte('[');
var cur = first_opt;
var i: usize = 0;
while (cur) |el| : ({
cur = self.ast.nodes[el].next_sibling;
i += 1;
}) {
if (i > 0) try self.writer.writeAll(", ");
try self.value(el, true);
}
try self.writer.writeByte(']');
},
.mapping => |first_opt| {
if (first_opt == null) {
try self.writer.writeAll("{}");
return;
}
try self.writer.writeAll("{ ");
var cur = first_opt;
var i: usize = 0;
while (cur) |kv_id| : ({
cur = self.ast.nodes[kv_id].next_sibling;
i += 1;
}) {
if (i > 0) try self.writer.writeAll(", ");
const kv = self.ast.nodes[kv_id].kind.keyvalue;
try self.writeKey(kv.key);
try self.writer.writeAll(" = ");
try self.value(kv.value, true);
}
try self.writer.writeAll(" }");
},
// Only containers have a flow spelling; a non-container reaching here
// from a foreign AST is recoverable rather than undefined behavior.
else => return error.UnexpectedNodeKind,
}
}
fn writeExtended(self: *Printer, e: AST.Node.Kind.Extended) Error!void {
switch (e.kind) {
.offset_datetime, .local_datetime, .local_date, .local_time => try self.writer.writeAll(e.text),
.enum_literal, .number_special => try self.writer.writeAll(e.text),
// A char literal renders as the ZON-style `'A'` atom, paired with the
// `: char =` annotation `writeTypeAnnotation` emits (char is never
// flow-eligible, so it always reaches here in annotated block position).
.char_literal => try writeCharLiteral(self.writer, e.text),
// plist date/data text is always flow-safe bare content (no `,[]{}`,
// never line-initial here since a `key = `/`* ` marker always
// precedes it) — same untagged raw-passthrough treatment as a
// datetime above.
.plist_date, .plist_data => try self.writer.writeAll(e.text),
}
}
/// Re-encode a `char_literal` from its stored decimal codepoint as `'A'`. Mirrors
/// the ZON printer: 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.
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('\'');
}
/// A string containing newlines, in block value position: `'''` raw when the
/// content round-trips verbatim, else `"""` with `\`, `"`, and `\r` escaped
/// (escaping every `"` also rules out an accidental `"""` terminator).
/// Content and closer are emitted flush-left: a raw block is verbatim by
/// definition (indent would become content), and a column-0 closer pins the
/// escaped flavor's smart-dedent at zero so leading whitespace in the value
/// survives. The tokenizer drops the newline before the closer's line, so a
/// value's own trailing newline (or lack of one) round-trips exactly.
fn writeMultilineString(self: *Printer, s: []const u8) Error!void {
const raw_ok = std.mem.indexOf(u8, s, "'''") == null and
std.mem.indexOfScalar(u8, s, '\r') == null;
if (raw_ok) {
try self.writer.writeAll("'''\n");
try self.writer.writeAll(s);
try self.writer.writeAll("\n'''");
return;
}
try self.writer.writeAll("\"\"\"\n");
for (s) |c| switch (c) {
'\\' => try self.writer.writeAll("\\\\"),
'"' => try self.writer.writeAll("\\\""),
'\r' => try self.writer.writeAll("\\r"),
else => try self.writer.writeByte(c),
};
try self.writer.writeAll("\n\"\"\"");
}
/// Bare when the literal-else-string sniff would round-trip it unchanged
/// (and, for a key, when it contains no structural character); otherwise the
/// minimal quoted form that round-trips it — raw `'…'` (§ 4.5) when the
/// content holds neither `'` nor a newline (no escaping needed at all), else
/// escaped `"…"`. Mirrors `languages/yaml/printer.zig`'s `printScalar`
/// (bare → single-quote → double-quote as a last resort).
fn writeBareOrQuoted(self: *Printer, s: []const u8, is_key: bool, in_flow: bool) Error!void {
if (isBareSafe(s, is_key, in_flow)) {
try self.writer.writeAll(s);
return;
}
if (canRawQuote(s)) {
try self.writer.writeByte('\'');
try self.writer.writeAll(s);
try self.writer.writeByte('\'');
return;
}
try self.writer.writeByte('"');
for (s) |c| switch (c) {
'"' => try self.writer.writeAll("\\\""),
'\\' => try self.writer.writeAll("\\\\"),
'\n' => try self.writer.writeAll("\\n"),
'\r' => try self.writer.writeAll("\\r"),
'\t' => try self.writer.writeAll("\\t"),
else => try self.writer.writeByte(c),
};
try self.writer.writeByte('"');
}
/// Whether `s` survives verbatim inside a raw `'…'` (§ 4.5: MUST NOT contain
/// `'` or a newline — there is no escape to spell either one raw).
fn canRawQuote(s: []const u8) bool {
return std.mem.indexOfAny(u8, s, "'\n") == null;
}
fn quotedWidth(s: []const u8) usize {
if (canRawQuote(s)) return s.len + 2; // '…' — no escaping, just the two quotes
var w: usize = 2;
for (s) |c| w += @as(usize, switch (c) {
'"', '\\', '\n', '\r', '\t' => 2,
else => 1,
});
return w;
}
fn scalarStringWidth(s: []const u8, is_key: bool, in_flow: bool) usize {
return if (isBareSafe(s, is_key, in_flow)) s.len else quotedWidth(s);
}
/// Whether `s` survives verbatim as the RHS of a `: string =` assignment — the
/// total-sink form, which `scanBareRestOfLine` reads to end-of-line with
/// sniffing and flow-commitment BOTH off. Unlike `isBareSafe` there is no sniff
/// or bracket-commit check (that is the annotation's whole point — `[ 1 + 2 ]`
/// stays bare); only the mechanical scan hazards matter: a leading/trailing
/// space is trimmed, a control char breaks the line, and a `#` at line-start or
/// after whitespace opens a comment. When false, the printer quotes instead.
fn isTotalSinkSafe(s: []const u8) bool {
if (s.len == 0) return false;
if (s[0] == ' ' or s[s.len - 1] == ' ') return false;
for (s) |c| if (c == '\n' or c == '\r' or c == '\t') return false;
var prev_ws = true; // line-start counts as "after whitespace"
for (s) |c| {
if (c == '#' and prev_ws) return false;
prev_ws = (c == ' ');
}
return true;
}
fn isBareSafe(s: []const u8, is_key: bool, in_flow: bool) bool {
if (s.len == 0) return false;
if (s[0] == ' ' or s[s.len - 1] == ' ') return false;
for (s) |c| {
if (c == '\n' or c == '\r' or c == '\t') return false;
if (is_key and !tok.isBareKeyChar(c)) return false;
}
if (is_key and (s[0] == '-' or s[0] == '>')) return false;
if (!is_key) {
// A bare non-key value must round-trip through the literal-else-string
// sniff as a plain string, and must not open a committed form.
switch (s[0]) {
'\'', '"' => return false, // opens a committed string form
'[', '{' => {
// A leading bracket is bare-safe only when it re-parses as a
// bare string — a balanced close with trailing content
// (markdown link, glob, regex). A terminal close would commit
// to flow and a non-closing bracket would error (DESIGN.md
// "Committed values"). In flow position the same rule holds via
// the bracket-aware flow scanner, so it short-circuits here
// (skipping the blanket `,`/`]`/`}` rejection below, which those
// interior — balanced — brackets would otherwise trip).
if (in_flow) return flowLeadingBracketBare(s);
if (tok.classifyBracketCommit(s, 0) != .bare_trailing) return false;
},
else => {},
}
if (in_flow) for (s) |c| switch (c) {
// A bare flow value runs to the next `,`/`]`/`}` — any of these
// inside the string would truncate it.
',', ']', '}' => return false,
else => {},
};
// A `#` at the start or after whitespace would re-parse as a comment and
// truncate the bare value — quote to preserve it (the `#`-after-
// whitespace rule, mirrored).
var prev_ws = true;
for (s) |c| {
if (c == '#' and prev_ws) return false;
prev_ws = (c == ' ' or c == '\t');
}
switch (tok.sniffBare(s)) {
.string => {},
else => return false,
}
}
return true;
}
/// A flow value whose first char is `[`/`{` is bare-safe only as a
/// balanced-then-trailing string (markdown link, glob, regex) that the
/// bracket-aware flow scanner (`scanFlowBareBracket`) reproduces verbatim. That
/// needs a tail after the first balanced group (else it is genuine nested flow,
/// which must be quoted to stay a string) and no depth-0 `,`/`]`/`}` or
/// `#`-comment that would truncate the value. `\n`/`\r`/`\t` and edge spaces are
/// already rejected by the caller.
fn flowLeadingBracketBare(s: []const u8) bool {
if (tok.classifyFlowBracket(s, 0) != .bare_trailing) return false;
var depth: usize = 0;
var prev_space = false;
for (s) |c| {
switch (c) {
'[', '{' => depth += 1,
']', '}' => {
if (depth == 0) return false;
depth -= 1;
},
',' => if (depth == 0) return false,
'#' => if (prev_space and depth == 0) return false,
else => {},
}
prev_space = (c == ' ' or c == '\t');
}
return depth == 0;
}
fn leadingComments(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
if (!self.commentsOn()) return;
for (self.ast.comments(id).leading) |c| try self.commentLines(c, depth);
}
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);
}
}
fn commentLines(self: *Printer, c: AST.Comment, depth: usize) Error!void {
var it = std.mem.splitScalar(u8, c.text, '\n');
while (it.next()) |line| {
try self.writeMarkers(depth);
try self.writer.writeByte('#');
const trimmed = std.mem.trim(u8, line, " \t");
if (trimmed.len != 0) {
try self.writer.writeByte(' ');
try self.writer.writeAll(trimmed);
}
try self.writer.writeByte('\n');
}
}
// =========
// TESTS
// =========
const Parser = @import("parser.zig");
fn expectPrint(input: []const u8, expected: []const u8) !void {
var ast = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
defer ast.deinit();
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, &ast, .{});
try std.testing.expectEqualStrings(expected, out.written());
}
fn expectRoundTrip(input: []const u8) !void {
var ast1 = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
defer ast1.deinit();
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, &ast1, .{});
var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
defer ast2.deinit();
try std.testing.expect(ast1.eql(ast2));
// Idempotence: a second fmt pass must be byte-identical.
var out2: Writer.Allocating = .init(std.testing.allocator);
defer out2.deinit();
try print(&out2.writer, &ast2, .{});
try std.testing.expectEqualStrings(out.written(), out2.written());
}
test "a scalar-root AST errors rather than emitting non-conforming output" {
// Not reachable by parsing fig source (the dialect's grammar never
// produces a bare-scalar root — that's the whole point), but reachable
// from a foreign AST (e.g. `fig get scalar.json -o fig`, where
// `scalar.json` is just `42`). The old fallback printed a bare `42\n`
// line, which does NOT re-parse (a bare token at the document root reads
// as an empty container header, `FigEmptyContainer`) — so this must hard
// error instead.
var b = AST.Builder.init(std.testing.allocator);
defer b.deinit();
const root_id = try b.addNumberRaw("42", false);
var ast = try b.finish(root_id);
defer ast.deinit();
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try std.testing.expectError(error.FigUnrepresentableRoot, print(&out.writer, &ast, .{}));
try std.testing.expectEqual(@as(usize, 0), out.written().len);
// A root map/sequence is unaffected.
var b2 = AST.Builder.init(std.testing.allocator);
defer b2.deinit();
const k = try b2.addString("a");
const v = try b2.addNumberRaw("1", false);
const map_root = try b2.addMapping(&.{.{ .key = k, .value = v }});
var ast2 = try b2.finish(map_root);
defer ast2.deinit();
var out2: Writer.Allocating = .init(std.testing.allocator);
defer out2.deinit();
try print(&out2.writer, &ast2, .{});
try std.testing.expectEqualStrings("a = 1\n", out2.written());
}
test "prints a flat map" {
try expectPrint("x = 1\ny = hello\n",
\\x = 1
\\y = hello
\\
);
}
test "small nested maps inline; single-child chains collapse to dotted keys" {
try expectPrint(
\\database
\\> host = localhost
\\> pool
\\>> size = 10
,
\\database
\\> host = localhost
\\> pool.size = 10
\\
);
}
test "a leaf map of scalars fits inline at section level" {
try expectPrint(
\\point
\\> x = 1
\\> y = 2
,
\\point = { x = 1, y = 2 }
\\
);
}
test "spaced markers, no indentation" {
// Long values defeat the inline budget, forcing genuine nesting.
try expectPrint(
\\server
\\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
\\> limits
\\>> connections = this line is also deliberately padded far past the eighty column budget so it stays put
\\>> burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
,
\\server
\\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
\\> limits
\\> > connections = this line is also deliberately padded far past the eighty column budget so it stays put
\\> > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
\\
);
}
test "fig_indent: opt-in cosmetic indentation is 2 x depth, canonical output unaffected, both re-parse equal" {
const input =
\\server
\\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
\\> limits
\\>> connections = this line is also deliberately padded far past the eighty column budget so it stays put
\\>> burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
;
var ast = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
defer ast.deinit();
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, &ast, .{ .fig_indent = true });
try std.testing.expectEqualStrings(
\\server
\\ > description = this line is deliberately padded far past the eighty column budget so it cannot inline
\\ > limits
\\ > > connections = this line is also deliberately padded far past the eighty column budget so it stays put
\\ > > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
\\
, out.written());
// Canonical (default) output is completely unchanged by the field's
// existence — `.{}` still means zero-indent, markers-only output.
var out_plain: Writer.Allocating = .init(std.testing.allocator);
defer out_plain.deinit();
try print(&out_plain.writer, &ast, .{});
try std.testing.expectEqualStrings(
\\server
\\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
\\> limits
\\> > connections = this line is also deliberately padded far past the eighty column budget so it stays put
\\> > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
\\
, out_plain.written());
// Both re-parse to the same AST: indentation is cosmetic, the markers
// alone carry parse depth (docs/spec.md § 3.3's "clean" convention).
var ast_indented = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
defer ast_indented.deinit();
var ast_plain = try Parser.parseAbstract(std.testing.allocator, out_plain.written(), .Fig);
defer ast_plain.deinit();
try std.testing.expect(ast_indented.eql(ast_plain));
}
test "a deep map hoists into dotted sections (and nested lists of maps into [] headers)" {
try expectPrint(
\\workspace
\\> resolver = "2"
\\> metadata
\\>> release
\\>>> shared-version-key = a deliberately long value to keep the release table from fitting inline here
\\>>> tag-message-etc = another deliberately long value to keep the release table from fitting inline
\\>> replacements
\\>>> *
\\>>>> file = README.md
\\>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
,
\\workspace.resolver = '2'
\\
\\workspace.metadata.release
\\> shared-version-key = a deliberately long value to keep the release table from fitting inline here
\\> tag-message-etc = another deliberately long value to keep the release table from fitting inline
\\
\\workspace.metadata.replacements[]
\\> file = README.md
\\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\
);
}
test "a sequence of maps prints as a [] append header with + continuations" {
try expectPrint(
\\replacements[]
\\> file = README.md
\\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\replacements[]
\\> file = CHANGELOG.md
\\> search = another deliberately long pattern string so the sequence cannot fit inline either way
,
\\replacements[]
\\> file = README.md
\\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\+
\\> file = CHANGELOG.md
\\> search = another deliberately long pattern string so the sequence cannot fit inline either way
\\
);
}
test "scalar lists inline when short, multi-line flow when long" {
// A short list stays inline; one that overflows the width budget stacks as
// multi-line flow (one element per line) rather than `> *` block lines.
// A stacked list is still one assignment, so it packs tight with its
// neighbors — no blank-line separation.
try expectPrint(
\\ports
\\> * 1
\\> * 2
\\members
\\>* crates/some_long_crate_name_one
\\>* crates/some_long_crate_name_two
\\>* crates/some_long_crate_name_three
\\>* crates/some_long_crate_name_four
,
\\ports = [1, 2]
\\members = [
\\ crates/some_long_crate_name_one,
\\ crates/some_long_crate_name_two,
\\ crates/some_long_crate_name_three,
\\ crates/some_long_crate_name_four,
\\]
\\
);
}
test "a list of more than six short items stacks even when it would fit inline" {
try expectPrint(
\\letters = [a, b, c, d, e, f, g]
,
\\letters = [
\\ a,
\\ b,
\\ c,
\\ d,
\\ e,
\\ f,
\\ g,
\\]
\\
);
// Exactly six still fits on one line.
try expectPrint(
\\letters = [a, b, c, d, e, f]
,
\\letters = [a, b, c, d, e, f]
\\
);
}
test "a scalar list with a comment on an element keeps the > * block form" {
// A dropped comment is never worth a prettier shape, so a list whose
// elements carry comments falls back to `> *` lines (multi-line flow
// discards interior comments).
try expectPrint(
\\members
\\> # the first crate
\\> * crates/some_long_crate_name_one
\\> * crates/some_long_crate_name_two
\\> * crates/some_long_crate_name_three
\\> * crates/some_long_crate_name_four
,
\\members
\\> # the first crate
\\> * crates/some_long_crate_name_one
\\> * crates/some_long_crate_name_two
\\> * crates/some_long_crate_name_three
\\> * crates/some_long_crate_name_four
\\
);
}
test "markdown links stay bare inside multi-line flow (the frontmatter case)" {
// The motivating example: a `contents` list of markdown links. Each link is
// a balanced-then-trailing bare string — no `> *`, no quotes.
try expectPrint(
\\contents
\\> * [Archived Documents](</Archive/Archived documents.md>)
\\> * [Blog](/Blog/Blog.md)
\\> * [Creative Writing](</Creative Writing/Creative Writing.md>)
\\> * [Daily Index](/Daily/daily_index.md)
\\> * [Resume](/Resume.md)
\\> * [Utility](/Utility/utility.md)
\\> * [Writing For Today](</Writing for today.md>)
,
\\contents = [
\\ [Archived Documents](</Archive/Archived documents.md>),
\\ [Blog](/Blog/Blog.md),
\\ [Creative Writing](</Creative Writing/Creative Writing.md>),
\\ [Daily Index](/Daily/daily_index.md),
\\ [Resume](/Resume.md),
\\ [Utility](/Utility/utility.md),
\\ [Writing For Today](</Writing for today.md>),
\\]
\\
);
}
test "a short markdown-link list stays inline and bare" {
try expectPrint(
\\links = ["[Blog](/Blog/Blog.md)", "[Resume](/Resume.md)"]
,
\\links = [[Blog](/Blog/Blog.md), [Resume](/Resume.md)]
\\
);
}
test "quote-avoidance: prose with commas prefers > * block lines over stacked flow" {
// A top-level `,` terminates a bare flow value, so flow would force quotes
// onto the second element; block position keeps both bare. Bare beats
// quoted, so the list takes the `> *` form.
try expectPrint(
\\random = [test of very long things in a list to see if it works, "hello there, this is also a long test; hopefully it works"]
,
\\random
\\> * test of very long things in a list to see if it works
\\> * hello there, this is also a long test; hopefully it works
\\
);
// A short comma-carrying list that FITS stays inline — quote-avoidance
// only arbitrates between the two broken forms, never un-inlines.
try expectPrint(
\\pair = [a, "b, c"]
,
\\pair = [a, 'b, c']
\\
);
// An element quoted in BOTH positions (`true` sniffs to bool bare) doesn't
// count against flow: quoting is the same price either way.
try expectPrint(
\\flags = [crates/some_long_crate_name_one, crates/some_long_crate_name_two, crates/some_long_crate_name_three, "true"]
,
\\flags = [
\\ crates/some_long_crate_name_one,
\\ crates/some_long_crate_name_two,
\\ crates/some_long_crate_name_three,
\\ 'true',
\\]
\\
);
// The rule recurses: a comma-prose string trapped inside a NESTED list
// would be flow-quoted too, so the whole value goes block.
try expectPrint(
\\notes = [[first long note about the thing, "second note, with a comma inside it somewhere"], [third]]
,
\\notes
\\> *
\\> > * first long note about the thing
\\> > * second note, with a comma inside it somewhere
\\> * [third]
\\
);
}
test "a multi-line flow list's trailing comment sits on the opener line" {
// The `'''` opener rule's flow twin: `key = [ # c` parses the comment as
// block-layer trailing on the sequence, and fmt emits it there — next to
// the key, not after the far-away `]`. A comment written on the `]` line
// migrates to the opener on the next fmt.
const stacked =
\\contents = [ # this is a contents comment
\\ crates/some_long_crate_name_one,
\\ crates/some_long_crate_name_two,
\\ crates/some_long_crate_name_three,
\\ crates/some_long_crate_name_four,
\\]
\\
;
try expectPrint(stacked, stacked);
try expectPrint(
\\contents = [
\\ crates/some_long_crate_name_one,
\\ crates/some_long_crate_name_two,
\\ crates/some_long_crate_name_three,
\\ crates/some_long_crate_name_four,
\\] # this is a contents comment
, stacked);
try expectRoundTrip(stacked);
}
test "nested lists inside multi-line flow re-decide fits-or-breaks" {
// A nested list that overflows the width budget stacks recursively (two
// more columns per level, closer back at the opener's indent); a short
// nested sibling stays inline.
try expectPrint(
\\pairs = [[crates/some_long_crate_name_one, crates/some_long_crate_name_two, crates/some_long_crate_name_three], [a, b]]
,
\\pairs = [
\\ [
\\ crates/some_long_crate_name_one,
\\ crates/some_long_crate_name_two,
\\ crates/some_long_crate_name_three,
\\ ],
\\ [a, b],
\\]
\\
);
}
test "multi-line flow packs tight between scalar assignments (frontmatter)" {
try expectPrint(
\\title = Adam's Archive
\\contents = [one_long_crate_name_here, two_long_crate_name_here, three_long_crate_name_here]
\\author = Adam Harris
,
\\title = Adam's Archive
\\contents = [
\\ one_long_crate_name_here,
\\ two_long_crate_name_here,
\\ three_long_crate_name_here,
\\]
\\author = Adam Harris
\\
);
}
test "multi-line flow lists round-trip (bare links, nested lists, quoted edges)" {
try expectRoundTrip(
\\contents = [
\\ [Archived Documents](</Archive/Archived documents.md>),
\\ [Blog](/Blog/Blog.md),
\\ [Creative Writing](</Creative Writing/Creative Writing.md>),
\\ "a, b needs quotes",
\\ "[a-flow-looking-string]",
\\ plain,
\\]
);
// A list of nested lists stacks the outer level, keeps the inner ones inline.
try expectRoundTrip(
\\grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [13, 14, 15], [16, 17, 18], [19, 20, 21]]
);
}
test "empty containers round-trip via = {} / = []" {
try expectPrint("a = {}\nb = []\n", "a = {}\nb = []\n");
}
test "empty document round-trips as empty output" {
// An empty file parses to an empty root map, which emits zero sections — so
// it prints back to nothing, keeping `fig fmt new.figl` a no-op.
try expectPrint("", "");
// Comments-only stays comments-only (the dangling run re-anchors to root).
try expectPrint("# just a note\n", "# just a note\n");
}
test "minimal-quote selection: raw single-quote when possible, double-quote only for ' or newline" {
// "2" needs quoting (bare would sniff as a number); no `'`/newline inside,
// so the raw form is picked.
try expectPrint("resolver = \"2\"\n", "resolver = '2'\n");
// A value starting with `'` can't itself be bare (opens a committed string
// form) and can't use the raw form either (§ 4.5: raw MUST NOT contain
// `'`) — falls back to escaped double-quotes, same as before this change.
try expectPrint("s = \"'tis the season\"\n", "s = \"'tis the season\"\n");
// A key containing a literal newline (unreachable from fig source — keys
// can't be authored with one — but reachable from a foreign/built AST)
// can't use the raw form either (single-line raw MUST NOT contain a
// newline): falls back to escaped double-quotes with `\n` escaped.
var b = AST.Builder.init(std.testing.allocator);
defer b.deinit();
const k = try b.addString("a\nb");
const v = try b.addNumberRaw("1", false);
const root_id = try b.addMapping(&.{.{ .key = k, .value = v }});
var ast = try b.finish(root_id);
defer ast.deinit();
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, &ast, .{});
try std.testing.expectEqualStrings("\"a\\nb\" = 1\n", out.written());
}
test "flow strings that would not survive bare are quoted" {
// A comma inside a flow element would split it; a leading bracket would
// nest; a sniffable number-string would change type.
try expectPrint(
\\xs = ["a, b", "[glob]", "99", plain]
,
\\xs = ['a, b', '[glob]', '99', plain]
\\
);
}
test "comments keep their anchors through fmt" {
try expectPrint(
\\# on the section
\\deps
\\> # on serde's key
\\> serde = 1 # trailing
\\> other = this value is long enough to keep the whole deps table from ever fitting the inline budget
,
\\# on the section
\\deps
\\> # on serde's key
\\> serde = 1 # trailing
\\> other = this value is long enough to keep the whole deps table from ever fitting the inline budget
\\
);
}
test "round-trips through a second parse (and fmt is idempotent)" {
try expectRoundTrip(
\\database
\\> host = localhost
\\> port = 5432
\\servers
\\> *
\\>> host = a.com
\\>> port = 1
);
try expectRoundTrip(
\\workspace
\\> resolver = "2"
\\> members
\\>>* crates/one_rather_long_member_path_aaaaaaaaaaaa
\\>>* crates/two_rather_long_member_path_bbbbbbbbbbbb
\\> package
\\>> version = 1.6.1
\\>> license-file = LICENSE.md
\\> dependencies
\\>> serde = { version = "1.0", features = [derive] }
\\>> fig
\\>>> version = 1.0.0
\\>>> features-list = [serde, yaml, derive, indexmap, and, extra, entries, to, defeat, inlining, of, fig]
\\> metadata
\\>> replacements
\\>>> *
\\>>>> file = README.md
\\>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\>>> *
\\>>>> file = CHANGELOG.md
\\>>>> search = another deliberately long pattern string keeping the sequence out of the inline budget
\\profile
\\> release = { lto = fat, codegen-units = 1 }
);
try expectRoundTrip(
\\values
\\> answer = 42
\\> flag = "true"
\\> zip = 007
\\> movie = 12 monkeys
\\> when = 2026-07-01T12:00:00Z
\\> class: enum = minecraft
\\> huge: float = inf
\\> long-tail = a sufficiently long value that the values table cannot be rendered through inline flow
);
}
test "char literals print as `: char =` atoms and round-trip" {
// Each char re-emits as its ZON-style `'…'` literal beside a `: char`
// annotation — the explicit-typing form, never a flow value.
try expectPrint(
\\letter: char = 'A'
\\tab: char = '\t'
\\quote: char = '\''
\\emoji: char = '\u{1F600}'
,
\\letter: char = 'A'
\\tab: char = '\t'
\\quote: char = '\''
\\emoji: char = '\u{1f600}'
\\
);
try expectRoundTrip(
\\letter: char = 'A'
\\newline: char = '\n'
\\emoji: char = '\u{1f600}'
);
}
test "hoisting groups flat-sibling runs under one header re-entry" {
// A map over the depth budget with a run of scalars AND a deep child:
// the scalars share one `workspace.metadata.release` header instead of
// each repeating the full dotted path; the deep child gets its own
// `[]` section after.
try expectPrint(
\\workspace
\\> metadata
\\>> release
\\>>> shared-version = true
\\>>> consolidate-commits = true
\\>>> push = false
\\>>> publish = false
\\>>> tag = false
\\>>> tag-name = v{{version}}
\\>>> tag-message = Release v{{version}}
\\>>> pre-release-commit-message = chore: release v{{version}}
\\>>> pre-release-replacements
\\>>>> *
\\>>>>> file = README.md
\\>>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\>>>> *
\\>>>>> file = CHANGELOG.md
\\>>>>> search = another deliberately long pattern string keeping the sequence out of the inline budget
,
\\workspace.metadata.release
\\> shared-version = true
\\> consolidate-commits = true
\\> push = false
\\> publish = false
\\> tag = false
\\> tag-name = v{{version}}
\\> tag-message = Release v{{version}}
\\> pre-release-commit-message = chore: release v{{version}}
\\
\\workspace.metadata.release.pre-release-replacements[]
\\> file = README.md
\\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
\\+
\\> file = CHANGELOG.md
\\> search = another deliberately long pattern string keeping the sequence out of the inline budget
\\
);
}
test "a lone flat child between deep siblings stays a dotted assignment" {
try expectPrint(
\\workspace
\\> resolver = "2"
\\> dependencies
\\>> fig
\\>>> version = 1.0.0
\\>>> features-list = [serde, yaml, derive, indexmap, extras, and-more]
\\>> chrono
\\>>> version = "0.4"
\\>>> features-list = [serde, std, clock, alloc, extra, padding]
,
\\workspace.resolver = '2'
\\
\\workspace.dependencies
\\> fig
\\> > version = 1.0.0
\\> > features-list = [serde, yaml, derive, indexmap, extras, and-more]
\\> chrono
\\> > version = '0.4'
\\> > features-list = [serde, std, clock, alloc, extra, padding]
\\
);
}
test "multi-line strings print as ''' raw blocks (escaped \"\"\" when needed)" {
// A trailing newline in the value becomes one empty line before the
// flush-left closer (the tokenizer drops the newline before the closer's
// line). Interior indentation is content and survives verbatim.
try expectPrint("script = \"set -e\\nif x; then\\n echo hi\\nfi\\n\"\n",
\\script = '''
\\set -e
\\if x; then
\\ echo hi
\\fi
\\
\\'''
\\
);
// Content containing ''' cannot be raw — escaped flavor, quotes escaped.
try expectPrint("q = \"has '''\\nquotes\"\n",
\\q = """
\\has '''
\\quotes
\\"""
\\
);
}
test "multi-line strings round-trip (raw, escaped, trailing-newline shapes)" {
try expectRoundTrip("a = \"x\\ny\"\nb = \"x\\ny\\n\"\nc = \"tail '''\\nline2\"\nd = \"\\n\\nleading blanks\"\n");
}
test "a container holding a multi-line string breaks out of flow" {
try expectPrint("wrap\n> cmd = \"a\\nb\"\n",
\\wrap.cmd = '''
\\a
\\b
\\'''
\\
);
}
test "typed sequence elements keep their annotations" {
try expectPrint(
\\weights
\\>*: int = 1
\\>*: enum = heavy
\\>* and a long plain string element so the whole weights sequence stays out of the inline budget
,
\\weights
\\> *: int = 1
\\> *: enum = heavy
\\> * and a long plain string element so the whole weights sequence stays out of the inline budget
\\
);
}
test "explicit type annotations round-trip (the : type surface + verbatim lexeme)" {
// A REDUNDANT annotation (the value already sniffs to the type) is preserved.
try expectPrint("num: int = 3\n", "num: int = 3\n");
// A trailing-dot float keeps BOTH its lexeme (`1.`, not `1.0`) and its `: float`.
try expectPrint("sig: float = 1.\n", "sig: float = 1.\n");
// A leading-zero int keeps its padding (`09`, not `9`).
try expectPrint("lead: int = 09\n", "lead: int = 09\n");
// A `: string` total sink re-emits BARE — no re-quoting the bracketed value.
try expectPrint("strs: string = [ 1 + 2 ]\n", "strs: string = [ 1 + 2 ]\n");
// Idempotence + parse→print→parse stability across all four together.
try expectRoundTrip("num: int = 3\nsig: float = 1.\nlead: int = 09\nstrs: string = [ 1 + 2 ]\n");
}
test "a : string value containing quote characters round-trips bare" {
// Under the all-raw rule the RHS of `s: string = "hello"` is the
// seven-character string `"hello"` — and because the annotation re-reads
// the RHS verbatim, the printer re-emits exactly those bytes bare.
try expectPrint("s: string = \"hello\"\n", "s: string = \"hello\"\n");
try expectRoundTrip("s: string = \"hello\"\n");
// Same for a value that merely STARTS with a quote character.
try expectPrint("s: string = \" x\"\n", "s: string = \" x\"\n");
try expectRoundTrip("s: string = \" x\"\n");
// And a lone triple-quote opener: literal content, never a multiline form.
try expectPrint("s: string = '''\n", "s: string = '''\n");
try expectRoundTrip("s: string = '''\n");
}
/// Swap in a tags table with a `: string` kind tag on `id` — simulates a
/// string-type assertion arriving from a surface fig itself can no longer
/// produce for this value (a YAML `!!str`, a builder API), so the printer's
/// drop-the-tag guard can be pinned.
fn injectStringTag(ast: *AST, id: AST.Node.Id) !void {
const tags = try std.testing.allocator.alloc(?AST.Tag, ast.nodes.len);
@memset(tags, null);
tags[id] = .{ .kind = .string };
std.testing.allocator.free(ast.node_tags);
ast.node_tags = tags;
}
/// Print `ast`, assert the exact bytes, then re-parse and assert the value at
/// key `s` is byte-identical to `want_value` and that a second print is
/// byte-identical to the first (idempotence). `eql`/`tagsEql` can't be the
/// harness here: dropping the tag is the CORRECT lossy step, so only the value
/// and the emitted bytes are asserted.
fn expectTagDropped(ast: *AST, expected: []const u8, want_value: []const u8) !void {
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, ast, .{});
try std.testing.expectEqualStrings(expected, out.written());
var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
defer ast2.deinit();
const v = try ast2.getValByPath(&.{.{ .key = "s" }});
try std.testing.expectEqualStrings(want_value, v.kind.string);
if (v.id < ast2.node_tags.len) try std.testing.expect(ast2.node_tags[v.id] == null);
var out2: Writer.Allocating = .init(std.testing.allocator);
defer out2.deinit();
try print(&out2.writer, &ast2, .{});
try std.testing.expectEqualStrings(out.written(), out2.written());
}
test "a string-tagged MULTILINE value drops the tag and uses the ordinary block form" {
// Raw `: string` text is single-line, so `s: string = '''…` would re-parse
// as the literal characters `'''`. The printer must never emit the
// annotation with a multiline value: tag dropped, ordinary `'''` block.
var ast = try Parser.parseAbstract(std.testing.allocator, "s = '''\na\nb\n'''\n", .Fig);
defer ast.deinit();
const v = try ast.getValByPath(&.{.{ .key = "s" }});
try std.testing.expectEqualStrings("a\nb", v.kind.string);
try injectStringTag(&ast, v.id);
try expectTagDropped(&ast, "s = '''\na\nb\n'''\n", "a\nb");
}
test "a string-tagged value with no bare spelling drops the tag and quotes normally" {
// Leading whitespace would be skipped by the raw scan, so bare emission
// under the annotation can't represent it — tag dropped, quoted untagged.
var ast = try Parser.parseAbstract(std.testing.allocator, "s = \" x\"\n", .Fig);
defer ast.deinit();
const v = try ast.getValByPath(&.{.{ .key = "s" }});
try std.testing.expectEqualStrings(" x", v.kind.string);
try injectStringTag(&ast, v.id);
try expectTagDropped(&ast, "s = ' x'\n", " x");
}
test "a string-tagged value that IS bare-representable keeps the tag when injected" {
// The same injection on a total-sink-safe value keeps the annotation and
// emits bare — including a value that starts with a quote character.
var ast = try Parser.parseAbstract(std.testing.allocator, "s = \"\\\"q\\\" and more\"\n", .Fig);
defer ast.deinit();
const v = try ast.getValByPath(&.{.{ .key = "s" }});
try std.testing.expectEqualStrings("\"q\" and more", v.kind.string);
try injectStringTag(&ast, v.id);
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try print(&out.writer, &ast, .{});
try std.testing.expectEqualStrings("s: string = \"q\" and more\n", out.written());
var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
defer ast2.deinit();
try std.testing.expect(ast.eql(ast2));
try std.testing.expect(ast.tagsEql(ast2));
}