fig 2.0.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! ZON-specific editing helpers for `Editor(Zon)`, plus its editor tests.
//!
//! ZON's editing needs are small parameter swaps on the generic engine, not the
//! structural logic TOML (multi-region tables)/YAML (reference layer)/Fig
//! (marker-prefix copying) delegate out to their own helper modules:
//!   * every ZON struct/array literal opens `.{` — `editor.isFlow` treats that
//!     as flow, so ZON routes through the same flow-entry splice engine JSON
//!     does (see `editor.zig`'s `kv_sep`/`flowOpenEnd`);
//!   * struct fields separate key and value with ` = `, not `: ` (`kv_sep`);
//!   * a struct-field key logically needs a leading `.` (`.name`), quoted as
//!     `.@"..."` when it isn't a bare identifier — the one piece of real
//!     ZON-specific rendering, factored out here as `appendFieldName` so
//!     `editor.zig`'s `set`-insert path (`formatInsertKey`) can call it without
//!     hosting ZON's identifier/quoting rule itself.
//!
//! `appendFieldName` intentionally duplicates `zon/printer.zig`'s small
//! `isBareIdentifier`/dotted-name-quoting rule rather than importing it: the
//! printer's version writes to a `std.Io.Writer`, while `insertKey`'s callers
//! build `std.ArrayList(u8)` text — not worth a shared abstraction for ~15
//! lines with no room to drift (both must agree with what `std.zig.Ast`
//! accepts as a bare identifier, which is exactly `[A-Za-z_][A-Za-z0-9_]*`
//! minus keywords).

const std = @import("std");

const AST = @import("../../ast/ast.zig");
const editor = @import("../../editor.zig");
const Zon = @import("zon.zig").Language;
const log = std.log.scoped(.editor);

/// Whether `name` is a legal bare Zig field name — mirrors `zon/printer.zig`'s
/// `isBareIdentifier` (`[A-Za-z_][A-Za-z0-9_]*`, and not a keyword).
fn isBareIdentifier(name: []const u8) bool {
    if (name.len == 0) return false;
    for (name, 0..) |c, i| {
        const ok = c == '_' or std.ascii.isAlphabetic(c) or (i > 0 and std.ascii.isDigit(c));
        if (!ok) return false;
    }
    return std.zig.Token.getKeyword(name) == null;
}

/// Render a logical mapping key as ZON struct-field syntax: `.name` when it's a
/// bare identifier, else the quoted form `.@"name with spaces"`. This is the
/// syntax `insertKey`'s `key_text` expects verbatim, so `editor.zig`'s
/// `formatInsertKey` (the `set`-insert path, which only has a *logical* key
/// name) calls this to render one.
pub fn appendFieldName(out: *std.ArrayList(u8), allocator: std.mem.Allocator, name: []const u8) !void {
    try out.append(allocator, '.');
    if (isBareIdentifier(name)) {
        try out.appendSlice(allocator, name);
        return;
    }
    try out.appendSlice(allocator, "@\"");
    for (name) |ch| switch (ch) {
        '"' => try out.appendSlice(allocator, "\\\""),
        '\\' => try out.appendSlice(allocator, "\\\\"),
        else => try out.append(allocator, ch),
    };
    try out.append(allocator, '"');
}

// =======
// TESTS
// =======
//
// Like `json/editor_helper.zig`, most of ZON's coverage is the generic engine
// exercised through `Editor(Zon)` — these tests double as engine smoke tests
// for the `.{`-is-flow / ` = ` / dotted-key behavior above.

fn newZonEditor(input: []const u8) !editor.Editor(Zon) {
    var ed: editor.Editor(Zon) = .{ .allocator = std.testing.allocator };
    try ed.init(input);
    return ed;
}

fn expectZonSource(ed: *const editor.Editor(Zon), expected: []const u8) !void {
    errdefer log.err("actual:   \"{s}\"", .{ed.source.items});
    errdefer log.err("expected: \"{s}\"", .{expected});
    try std.testing.expectEqualStrings(expected, ed.source.items);
}

test "zon replace a scalar value" {
    var ed = try newZonEditor(".{ .name = \"Ada\", .age = 36 }");
    defer ed.deinit();
    try ed.replaceValAtPath(&.{.{ .key = "age" }}, "37");
    try expectZonSource(&ed, ".{ .name = \"Ada\", .age = 37 }");
}

test "zon rename a leaf key (bare identifier span excludes the dot)" {
    var ed = try newZonEditor(".{ .age = 36 }");
    defer ed.deinit();
    try ed.replaceKeyAtPath(&.{.{ .key = "age" }}, "years");
    try expectZonSource(&ed, ".{ .years = 36 }");
}

test "zon insert key into a compact struct" {
    var ed = try newZonEditor(".{ .a = 1 }");
    defer ed.deinit();
    try ed.insertKey(&.{}, ".b", "2");
    try expectZonSource(&ed, ".{ .a = 1, .b = 2 }");
}

test "zon insert key into an empty struct" {
    // Tight, unpadded splice — matching the pre-existing JSON/YAML convention
    // for a freshly-created single member (see `insertFlowEntry`).
    var ed = try newZonEditor(".{}");
    defer ed.deinit();
    try ed.insertKey(&.{}, ".a", "1");
    try expectZonSource(&ed, ".{.a = 1}");
}

test "zon insert key into a pretty-printed struct lands on its own line" {
    var ed = try newZonEditor(
        \\.{
        \\    .a = 1,
        \\    .b = 2,
        \\}
    );
    defer ed.deinit();
    try ed.insertKey(&.{}, ".c", "3");
    try expectZonSource(&ed,
        \\.{
        \\    .a = 1,
        \\    .b = 2,
        \\    .c = 3,
        \\}
    );
}

test "zon insert key into a nested pretty-printed struct matches its indent" {
    var ed = try newZonEditor(
        \\.{
        \\    .server = .{
        \\        .host = "a",
        \\    },
        \\}
    );
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "server" }}, ".port", "8080");
    try expectZonSource(&ed,
        \\.{
        \\    .server = .{
        \\        .host = "a",
        \\        .port = 8080,
        \\    },
        \\}
    );
}

test "zon delete a key" {
    // `deleteKey` splices whole lines (see its doc comment in `editor.zig`), so —
    // as with every other format's delete tests — the fixture is multi-line, one
    // entry per line; a compact single-line struct has no per-entry line to
    // delete without the whole document being "the line."
    var ed = try newZonEditor(
        \\.{
        \\    .a = 1,
        \\    .b = 2,
        \\    .c = 3,
        \\}
    );
    defer ed.deinit();
    try ed.deleteKey(&.{.{ .key = "b" }});
    try expectZonSource(&ed,
        \\.{
        \\    .a = 1,
        \\    .c = 3,
        \\}
    );
}

test "zon delete a key with an owned leading comment" {
    var ed = try newZonEditor(
        \\.{
        \\    .a = 1,
        \\    // note
        \\    .b = 2,
        \\}
    );
    defer ed.deinit();
    try ed.deleteKey(&.{.{ .key = "b" }});
    try expectZonSource(&ed,
        \\.{
        \\    .a = 1,
        \\}
    );
}

test "zon array append/prepend/remove" {
    var ed = try newZonEditor(".{ 1, 2 }");
    defer ed.deinit();
    try ed.appendToSeq(&.{}, "3");
    try expectZonSource(&ed, ".{ 1, 2, 3 }");
    try ed.prependToSeq(&.{}, "0");
    try expectZonSource(&ed, ".{ 0, 1, 2, 3 }");
    try ed.removeSeqItem(&.{}, 2);
    try expectZonSource(&ed, ".{ 0, 1, 3 }");
}

test "zon appendToSeq on an empty .{} is NotASequence (ZON has no way to tell an empty struct from an empty array)" {
    // Unlike JSON's `[]`/`{}` or YAML's `[]`/`{}`, ZON's struct and array
    // literals share one empty spelling (`.{}`); fig's parser resolves that
    // ambiguity by always treating an empty container as a mapping (see
    // `zon/parser.zig`'s "empty .{} is an empty mapping" test). So there is no
    // ZON source an empty *array* append could target — the caller must seed a
    // non-empty array (or a struct) explicitly.
    var ed = try newZonEditor(".{}");
    defer ed.deinit();
    try std.testing.expectError(error.NotASequence, ed.appendToSeq(&.{}, "1"));
}

test "zon append onto a multi-line one-item-per-line array" {
    var ed = try newZonEditor(
        \\.{
        \\    1,
        \\    2,
        \\}
    );
    defer ed.deinit();
    try ed.appendToSeq(&.{}, "3");
    try expectZonSource(&ed,
        \\.{
        \\    1,
        \\    2,
        \\    3,
        \\}
    );
}

test "zon set replaces an existing key" {
    var ed = try newZonEditor(".{ .a = 1 }");
    defer ed.deinit();
    try ed.set(&.{.{ .key = "a" }}, "2");
    try expectZonSource(&ed, ".{ .a = 2 }");
}

test "zon set creates a missing bare-identifier key with a leading dot" {
    var ed = try newZonEditor(".{ .a = 1 }");
    defer ed.deinit();
    try ed.set(&.{.{ .key = "b" }}, "2");
    try expectZonSource(&ed, ".{ .a = 1, .b = 2 }");
}

test "zon set creates a missing key needing @\"...\" quoting" {
    var ed = try newZonEditor(".{ .a = 1 }");
    defer ed.deinit();
    try ed.set(&.{.{ .key = "has space" }}, "2");
    try expectZonSource(&ed, ".{ .a = 1, .@\"has space\" = 2 }");
}

test "zon set auto-vivifies a missing parent as an empty struct" {
    var ed = try newZonEditor(".{}");
    defer ed.deinit();
    try ed.set(&.{ .{ .key = "server" }, .{ .key = "port" } }, "8080");
    try expectZonSource(&ed, ".{.server = .{.port = 8080}}");
}

test "zon insertKey promotes a null value to a struct" {
    var ed = try newZonEditor(".{ .a = null }");
    defer ed.deinit();
    try ed.insertKey(&.{.{ .key = "a" }}, ".b", "1");
    try expectZonSource(&ed, ".{ .a = .{ .b = 1 } }");
}

test "zon failed edit rolls back and keeps editor usable" {
    var ed = try newZonEditor(".{ .a = 1 }");
    defer ed.deinit();
    if (ed.replaceValAtPath(&.{.{ .key = "a" }}, "[oops")) |_| {
        return error.TestExpectedFailedEdit;
    } else |_| {}
    try expectZonSource(&ed, ".{ .a = 1 }");
    try ed.replaceValAtPath(&.{.{ .key = "a" }}, "9");
    try expectZonSource(&ed, ".{ .a = 9 }");
}