fig 2.4.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! dotenv printer: renders a fig AST as canonical `.env`.
//!
//! Flat only: every root entry prints as `KEY=value`; a nested
//! mapping/sequence value has no dotenv form at all (unlike INI, which can
//! at least hold one level of `[section]` nesting) — `error.UnsupportedValue`.
//! A key must already be a valid bash identifier (`[A-Za-z_][A-Za-z0-9_]*`,
//! matching the parser's grammar) — anything else, from a cross-format
//! conversion, is `error.InvalidKey`.
//!
//! A value prints bare when that's unambiguous, else double-quoted with
//! escapes (`\n \t \r \\ \"`) — this printer never emits a literal embedded
//! newline even though the parser can read one back (a canonical single-line-
//! per-entry form is friendlier output; see `needsQuoting`/`writeQuoted`).
//! Comments: leading comments print as `# ...` lines above a key; UNLIKE INI,
//! dotenv's grammar has a real trailing-comment form (a `#` after a value,
//! gated by preceding whitespace — see the tokenizer), so a trailing comment
//! prints inline as `KEY=value # ...`, not pushed to its own line.

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, InvalidKey, UnsupportedValue, UnresolvedAlias };

const Ctx = struct {
    w: *Writer,
    opts: AST.SerializeOptions,

    fn commentBlock(ctx: *Ctx, c: AST.Comment) Error!void {
        var it = std.mem.splitScalar(u8, c.text, '\n');
        while (it.next()) |line| {
            try ctx.w.writeByte('#');
            const trimmed = std.mem.trim(u8, line, " \t");
            if (trimmed.len != 0) {
                try ctx.w.writeByte(' ');
                try ctx.w.writeAll(trimmed);
            }
            try ctx.w.writeByte('\n');
        }
    }

    fn leading(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id) Error!void {
        for (ast.comments(key_id).leading) |c| try ctx.commentBlock(c);
    }

    fn dangling(ctx: *Ctx, ast: *const AST, id: AST.Node.Id) Error!void {
        for (ast.comments(id).dangling) |c| try ctx.commentBlock(c);
    }

    fn kvLine(ctx: *Ctx, ast: *const AST, key_id: AST.Node.Id, value_id: AST.Node.Id) Error!void {
        try ctx.leading(ast, key_id);
        try writeKey(ctx.w, ast, key_id);
        try ctx.w.writeByte('=');
        try writeValue(ctx.w, ast, value_id);
        if (ast.comments(value_id).trailing) |c| {
            try ctx.w.writeAll(" #");
            if (c.text.len != 0) {
                try ctx.w.writeByte(' ');
                for (c.text) |ch| try ctx.w.writeByte(if (ch == '\n') ' ' else ch);
            }
        }
        try ctx.w.writeByte('\n');
    }
};

pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var ctx = Ctx{ .w = writer, .opts = options };
    switch (ast.nodes[ast.root].kind) {
        .mapping => |first| {
            var cur = first;
            while (cur) |id| : (cur = ast.nodes[id].next_sibling) {
                const kv = ast.nodes[id].kind.keyvalue;
                try ctx.kvLine(ast, kv.key, kv.value);
            }
            try ctx.dangling(ast, ast.root);
        },
        else => {
            try writeValue(writer, ast, ast.root);
            try writer.writeByte('\n');
        },
    }
    try writer.flush();
}

/// Print the subtree at `id` as a standalone fragment: a mapping prints as a
/// full document rooted there, anything else prints as a bare value line.
pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    _ = depth;
    switch (ast.nodes[id].kind) {
        .mapping => {
            var fragment = ast.*;
            fragment.root = id;
            try print(writer, &fragment, options);
        },
        else => {
            try writeValue(writer, ast, id);
            try writer.writeByte('\n');
        },
    }
}

// ── Keys and values ─────────────────────────────────────────────────────────

fn isIdentStart(c: u8) bool {
    return c == '_' or std.ascii.isAlphabetic(c);
}
fn isIdentChar(c: u8) bool {
    return c == '_' or std.ascii.isAlphanumeric(c);
}
fn isIdentifier(name: []const u8) bool {
    if (name.len == 0 or !isIdentStart(name[0])) return false;
    for (name[1..]) |c| if (!isIdentChar(c)) return false;
    return true;
}

fn writeKey(w: *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,
    };
    if (!isIdentifier(name)) return error.InvalidKey;
    try w.writeAll(name);
}

/// Render a value. dotenv has no typed scalars, so a non-string node (from a
/// cross-format conversion) stringifies to its canonical text; a
/// sequence/mapping has no dotenv value form at all (not even one level, per
/// the module doc).
fn writeValue(w: *Writer, ast: *const AST, id: AST.Node.Id) Error!void {
    switch (ast.nodes[id].kind) {
        .string => |s| try writeText(w, s),
        .number => |n| try w.writeAll(n.raw),
        .boolean => |b| try w.writeAll(if (b) "true" else "false"),
        .extended => |ext| try writeText(w, ext.text),
        .null_ => return error.NullUnsupported,
        .sequence, .mapping => return error.UnsupportedValue,
        .keyvalue => unreachable, // never a value position
        .alias => return error.UnresolvedAlias,
    }
}

fn writeText(w: *Writer, value: []const u8) Writer.Error!void {
    if (!needsQuoting(value)) return w.writeAll(value);
    try w.writeByte('"');
    for (value) |c| switch (c) {
        '\n' => try w.writeAll("\\n"),
        '\r' => try w.writeAll("\\r"),
        '\t' => try w.writeAll("\\t"),
        '"' => try w.writeAll("\\\""),
        '\\' => try w.writeAll("\\\\"),
        else => try w.writeByte(c),
    };
    try w.writeByte('"');
}

/// An empty value is fine bare (`KEY=`); anything else needs quoting if it has
/// leading/trailing whitespace (would be trimmed back on reread) or contains a
/// byte this printer would otherwise have to worry about disambiguating
/// (newline, `"`, `\`, or `#` — conservative: quoting is always SAFE, so any
/// `#` triggers it even though some positions would technically round-trip
/// unquoted too).
fn needsQuoting(value: []const u8) bool {
    if (value.len == 0) return false;
    if (value[0] == ' ' or value[0] == '\t' or value[value.len - 1] == ' ' or value[value.len - 1] == '\t') return true;
    for (value) |c| if (c == '\n' or c == '\r' or c == '"' or c == '\\' or c == '#') return true;
    return false;
}

// ── 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, .DOTENV);
    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, .DOTENV);
    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(), .DOTENV);
    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 "plain values print bare" {
    try expectPrint("NAME=fig\n", "NAME=fig\n");
}

test "a value needing quoting round-trips through double quotes" {
    try expectRoundTrip("MSG=\"line1\\nline2\"\n");
    try expectRoundTrip("A=' leading space'\n");
}

test "trailing comment prints inline" {
    try expectPrint("A=1 # note\n", "A=1 # note\n");
}

test "leading comment prints as a `#` line" {
    try expectPrint("# header\nA=1\n", "# header\nA=1\n");
}

test "round-trips a mixed document" {
    try expectRoundTrip(
        \\# config
        \\NAME=fig
        \\PATH='C:\Users\bob'
        \\MSG="hi \"there\""
        \\EMPTY=
        \\
    );
}

test "a non-identifier key is unrepresentable" {
    const a = std_testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();
    const v = try b.addString("x");
    const k = try b.addString("not-an-ident");
    const root = try b.addMapping(&.{.{ .key = k, .value = v }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var output: Writer.Allocating = .init(a);
    defer output.deinit();
    try std_testing.expectError(error.InvalidKey, print(&output.writer, &ast, .{}));
}

test "a nested mapping value is unsupported (dotenv has no sections)" {
    const a = std_testing.allocator;
    var b = AST.Builder.init(a);
    defer b.deinit();
    const inner = try b.addMapping(&.{});
    const k = try b.addString("SERVER");
    const root = try b.addMapping(&.{.{ .key = k, .value = inner }});
    var ast = try b.finish(root);
    defer ast.deinit();
    var output: Writer.Allocating = .init(a);
    defer output.deinit();
    try std_testing.expectError(error.UnsupportedValue, print(&output.writer, &ast, .{}));
}