fig 2.4.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! NestedText printer: renders a fig AST as canonical, always-block-style
//! NestedText (never the inline `{...}`/`[...]` forms — those are the
//! parser's business to accept, not the printer's to choose; matches how
//! INI's printer always writes `[section]` blocks rather than picking a
//! denser form).
//!
//! Every value is untyped text (NestedText has no typed scalars — same
//! simplification INI makes): a number/boolean/`extended` node stringifies
//! to its canonical text.
//!
//! Same-line (`key: value` / `- value`) vs. nested `>`-block is a
//! ROUND-TRIP-FORCED choice, not a style preference: rest-of-line values are
//! taken 100% literally on reparse (see `parser.zig`'s module doc), so
//! same-line is always safe EXCEPT for two cases forced by the grammar
//! itself: an EMPTY value has no same-line spelling at all (`key:` with
//! nothing after means "value is a nested block", and an absent nested block
//! is a parse error — not empty string; the only way to spell "" is a nested
//! bare `>` line), and a value containing a literal `\n` obviously can't fit
//! one line. A multiline KEY (`: key` form) is similarly forced whenever the
//! key is empty, starts with a byte the tokenizer would misread as a
//! different tag or a comment/indentation character, or contains a literal
//! `\n` or a `: ` substring that would confuse the key/value split scan; see
//! `needsMultilineKey`. A multiline key's value is ALWAYS a nested block
//! (never same-line — the grammar has no same-line form after a multiline
//! key), so a scalar value there is always rendered as a `>`-block even when
//! it would otherwise fit on one line.
//!
//! Comments: `#` full-line only (matches INI's own limitation) — a
//! *trailing* comment has no same-line spelling in this grammar, so it
//! prints as its own `#` line immediately after the item, same convention
//! INI's printer uses.

const Printer = @This();
const std = @import("std");
const AST = @import("../../ast/ast.zig");
const Writer = std.Io.Writer;

pub const Error = Writer.Error || error{ NullUnsupported, NonStringKey, UnresolvedAlias };

const indent_width: usize = 4;

pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    switch (ast.nodes[ast.root].kind) {
        .null_ => {},
        .mapping => try printMapping(writer, ast, ast.root, 0),
        .sequence => try printSequence(writer, ast, ast.root, 0),
        else => try writeStringBlock(writer, 0, try scalarText(ast, ast.root)),
    }
    _ = options;
    try writer.flush();
}

/// Print the subtree at `id` as a standalone fragment, re-rooting a shallow
/// copy of `ast` — same trick INI's `printNode` uses.
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    _ = depth;
    var fragment = ast.*;
    fragment.root = id;
    try print(writer, &fragment, options);
}

// ── Containers ───────────────────────────────────────────────────────────────

fn printMapping(w: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize) Error!void {
    var cur = ast.nodes[id].kind.mapping;
    while (cur) |kv_id| : (cur = ast.nodes[kv_id].next_sibling) {
        const kv = ast.nodes[kv_id].kind.keyvalue;
        try printLeadingComments(w, ast, ast.leadingCommentAnchor(kv_id), depth);
        const key_text = try keyText(ast, kv.key);
        const multiline_key = needsMultilineKey(key_text);
        if (multiline_key) {
            try writeMultilineKeyLines(w, depth, key_text);
        } else {
            try writeIndent(w, depth);
            try w.writeAll(key_text);
            try w.writeByte(':');
        }
        try printItemValue(w, ast, kv.value, depth, multiline_key);
        try printTrailingComment(w, ast, ast.trailingCommentAnchor(kv_id), depth);
    }
    try printDanglingComments(w, ast, id, depth);
}

fn printSequence(w: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize) Error!void {
    var cur = ast.nodes[id].kind.sequence;
    while (cur) |item_id| : (cur = ast.nodes[item_id].next_sibling) {
        try printLeadingComments(w, ast, ast.leadingCommentAnchor(item_id), depth);
        try writeIndent(w, depth);
        try w.writeByte('-');
        try printItemValue(w, ast, item_id, depth, false);
        try printTrailingComment(w, ast, ast.trailingCommentAnchor(item_id), depth);
    }
    try printDanglingComments(w, ast, id, depth);
}

/// Render the value half of a `key:`/`-` item already written up to (but not
/// including) its own line terminator. `force_nested` is set for a multiline
/// key, which has no same-line value form at all.
fn printItemValue(w: *Writer, ast: *const AST, value_id: AST.Node.Id, depth: usize, force_nested: bool) Error!void {
    // `force_nested` (a multiline key) means the key's own last line already
    // ended with its own `\n` — the nested block starts writing indented
    // lines immediately. Otherwise the current line (`key:`/`-`) is still
    // open and needs closing with `\n` before anything nested can start.
    switch (ast.nodes[value_id].kind) {
        .mapping => {
            if (!force_nested) try w.writeByte('\n');
            try printMapping(w, ast, value_id, depth + 1);
        },
        .sequence => {
            if (!force_nested) try w.writeByte('\n');
            try printSequence(w, ast, value_id, depth + 1);
        },
        .alias => return error.UnresolvedAlias,
        .keyvalue => unreachable,
        else => {
            const text = try scalarText(ast, value_id);
            if (!force_nested and text.len != 0 and std.mem.indexOfScalar(u8, text, '\n') == null) {
                try w.writeByte(' ');
                try w.writeAll(text);
                try w.writeByte('\n');
            } else {
                if (!force_nested) try w.writeByte('\n');
                try writeStringBlock(w, depth + 1, text);
            }
        },
    }
}

// ── Keys and scalars ─────────────────────────────────────────────────────────

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,
    };
}

fn scalarText(ast: *const AST, id: AST.Node.Id) Error![]const u8 {
    return switch (ast.nodes[id].kind) {
        .string => |s| s,
        .number => |n| n.raw,
        .boolean => |b| if (b) "true" else "false",
        .extended => |e| e.text,
        .null_ => error.NullUnsupported,
        .alias => error.UnresolvedAlias,
        .sequence, .mapping, .keyvalue => unreachable, // handled by callers before reaching here
    };
}

/// Whether `key` needs the `: key` multiline form instead of plain `key:` —
/// forced whenever plain form would misparse or (for a leading space) be
/// silently absorbed into indentation on reread. See the module doc comment.
fn needsMultilineKey(key: []const u8) bool {
    if (key.len == 0) return true;
    const c0 = key[0];
    if (c0 == '#' or c0 == '{' or c0 == '[' or c0 == ' ' or c0 == '\t') return true;
    if ((c0 == '-' or c0 == ':' or c0 == '>') and (key.len == 1 or key[1] == ' ')) return true;
    if (std.mem.indexOfScalar(u8, key, '\n') != null) return true;
    if (std.mem.indexOf(u8, key, ": ") != null) return true;
    return false;
}

// ── Line writers ─────────────────────────────────────────────────────────────

fn writeIndent(w: *Writer, depth: usize) Writer.Error!void {
    try w.splatByteAll(' ', depth * indent_width);
}

fn writeMultilineKeyLines(w: *Writer, depth: usize, text: []const u8) Writer.Error!void {
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        try writeIndent(w, depth);
        if (line.len == 0) {
            try w.writeAll(":\n");
        } else {
            try w.writeAll(": ");
            try w.writeAll(line);
            try w.writeByte('\n');
        }
    }
}

/// One or more `> line` (or bare `>` for an empty line) lines at `depth`.
/// Splitting `""` on `\n` yields exactly one empty part, so an empty string
/// renders as a single bare `>` — the only way to spell "" in this grammar.
fn writeStringBlock(w: *Writer, depth: usize, text: []const u8) Writer.Error!void {
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        try writeIndent(w, depth);
        if (line.len == 0) {
            try w.writeAll(">\n");
        } else {
            try w.writeAll("> ");
            try w.writeAll(line);
            try w.writeByte('\n');
        }
    }
}

fn writeHashLine(w: *Writer, depth: usize, text: []const u8) Writer.Error!void {
    try writeIndent(w, depth);
    try w.writeByte('#');
    if (text.len != 0) {
        try w.writeByte(' ');
        try w.writeAll(text);
    }
    try w.writeByte('\n');
}

fn printLeadingComments(w: *Writer, ast: *const AST, anchor_id: AST.Node.Id, depth: usize) Error!void {
    for (ast.comments(anchor_id).leading) |c| {
        var it = std.mem.splitScalar(u8, c.text, '\n');
        while (it.next()) |line| try writeHashLine(w, depth, std.mem.trim(u8, line, " \t"));
    }
}

fn printTrailingComment(w: *Writer, ast: *const AST, anchor_id: AST.Node.Id, depth: usize) Error!void {
    const c = ast.comments(anchor_id).trailing orelse return;
    var it = std.mem.splitScalar(u8, c.text, '\n');
    while (it.next()) |line| try writeHashLine(w, depth, std.mem.trim(u8, line, " \t"));
}

fn printDanglingComments(w: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize) Error!void {
    for (ast.comments(id).dangling) |c| {
        var it = std.mem.splitScalar(u8, c.text, '\n');
        while (it.next()) |line| try writeHashLine(w, depth, std.mem.trim(u8, line, " \t"));
    }
}

// ── Tests ────────────────────────────────────────────────────────────────────

const std_testing = std.testing;
const Parser = @import("parser.zig");

fn expectPrint(input: []const u8, expected: []const u8) !void {
    var ast = try Parser.parseAbstract(std_testing.allocator, input, .NESTEDTEXT);
    defer ast.deinit();
    var output: Writer.Allocating = .init(std_testing.allocator);
    defer output.deinit();
    try print(&output.writer, &ast, .{});
    try std_testing.expectEqualStrings(expected, output.written());
}

fn expectRoundTrip(input: []const u8) !void {
    var ast = try Parser.parseAbstract(std_testing.allocator, input, .NESTEDTEXT);
    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(), .NESTEDTEXT);
    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 "simple mapping round-trips through print in place" {
    try expectPrint("name: fig\n", "name: fig\n");
}

test "nested mapping" {
    try expectPrint("server:\n    host: localhost\n    port: 80\n", "server:\n    host: localhost\n    port: 80\n");
}

test "list" {
    try expectPrint("- a\n- b\n", "- a\n- b\n");
}

test "empty string value prints as a nested bare `>` line" {
    try expectPrint(":\n  >\n", ":\n    >\n");
}

test "multiline value prints as a `>` block" {
    var b = AST.Builder.init(std_testing.allocator);
    defer b.deinit();
    const key = try b.addString("k");
    const value = try b.addString("line1\nline2");
    const root = try b.addMapping(&.{.{ .key = key, .value = value }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var output: Writer.Allocating = .init(std_testing.allocator);
    defer output.deinit();
    try print(&output.writer, &ast, .{});
    try std_testing.expectEqualStrings("k:\n    > line1\n    > line2\n", output.written());
}

test "key needing multiline form" {
    var b = AST.Builder.init(std_testing.allocator);
    defer b.deinit();
    const key = try b.addString("- starts like a list tag");
    const value = try b.addString("v");
    const root = try b.addMapping(&.{.{ .key = key, .value = value }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var output: Writer.Allocating = .init(std_testing.allocator);
    defer output.deinit();
    try print(&output.writer, &ast, .{});
    try std_testing.expectEqualStrings(": - starts like a list tag\n    > v\n", output.written());
}

test "round-trips a mixed document with comments" {
    try expectRoundTrip(
        \\# header
        \\name: fig
        \\server:
        \\    host: localhost
        \\    port: 80
        \\items:
        \\    - a
        \\    - b
        \\
    );
}

test "round-trips a leading comment above a list item with a nested value" {
    // Regression for the `takeLeading`/`attachLeading` split in
    // `parser.zig`: the comment must stay above the `-` line, not migrate
    // onto the nested `nested` key's own line.
    try expectRoundTrip("- a\n# middle item note\n-\n    nested: 1\n- c\n");
}

test "empty document prints as nothing" {
    try expectPrint("", "");
}

test "root scalar prints as a `>` block" {
    try expectPrint("> hello\n", "> hello\n");
}

test "null mid-tree is unsupported" {
    var b = AST.Builder.init(std_testing.allocator);
    defer b.deinit();
    const key = try b.addString("k");
    const value = try b.addNull();
    const root = try b.addMapping(&.{.{ .key = key, .value = value }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var output: Writer.Allocating = .init(std_testing.allocator);
    defer output.deinit();
    try std_testing.expectError(error.NullUnsupported, print(&output.writer, &ast, .{}));
}