fig-sys 4.1.0

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
Documentation
//! INI editor tests for `Editor(Ini)`.
//!
//! No INI-specific editing logic is left: the generic span-splice engine in
//! `../../editor.zig` does every edit, driven by `ini.zig`'s `syntax`. This
//! module used to hold one hook, `iniInsertKey`, whose whole job was to skip
//! the engine's flow sniff on a root whose first byte is a `[section]`'s
//! bracket; `Syntax.flow_containers = false` and the engine's own rule that a
//! section format's root is block (`editor.Editor.isFlowNode`) say the same
//! thing as data. A reopened/scattered section threads correctly through the
//! generic `lastChild`-anchored block insert, because parsing always appends
//! a reopened section's new entries to the tail of its child list, in file
//! order — see `parser.zig`'s `parseSectionHeader` merge branch.
//!
//! Everything about a `[section]` as a WHOLE is the engine's. A section may be
//! REOPENED (`[a]` … `[b]` … `[a]`), which the parser merges into one mapping
//! whose span anchors only the FIRST header's name token — so its bytes are
//! scattered exactly the way a TOML table's or a fig container's are.
//! `parser.zig` records every header line of every section in
//! `Document.node_regions`, and from that the engine derives the section's
//! region set (`editor/regions.zig`): `deleteContainer`, `moveContainer` and
//! `reorderContainers` gather it and rebuild the source once, and the four
//! line-splice ops (`deleteKey`, `replaceValAtPath`, `moveKey`, `reorderKeys`)
//! refuse a section node — `CannotDeleteSection`, `CannotReplaceSection`,
//! `CannotMoveSection`, `CannotReorderSections`, per `Syntax.section_noun` —
//! and point at those ops. The tests below pin every one of those behaviours
//! for INI; the logic they exercise lives in `editor.zig`.
//!
//! There is no `insertContainer`/`renameContainer` twin: a new `[section]` is
//! `set`'s business (INI cannot auto-vivify — it has no literal spelling for
//! "an empty nested mapping", since `{}` is just a two-character STRING value
//! in INI, declared as `syntax().empty_map_literal = null` in `ini.zig`), and
//! a rename is one tight span the generic `replaceKeyAtPath` already rewrites,
//! since an INI header has no dotted descendants to follow.

const std = @import("std");
const testing = std.testing;

const AST = @import("../../ast/ast.zig");
const Document = @import("../../document.zig");
const Span = @import("../../util/span.zig");
const editor = @import("../../editor.zig");
const Ini = @import("ini.zig").Language;

/// The concrete editor the tests drive — the INI arm of the generic engine.
const IniEditor = editor.Editor(Ini);

// ── Tests ────────────────────────────────────────────────────────────────────
//
// Structural/section-nesting behavior lives here, next to the logic it
// exercises (mirroring TOML/fig's own editor-test placement); the bare
// root-level sanity checks stay in `editor.zig` alongside dotenv/.properties.

test "ini insertKey adds a key into an EXISTING section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[server]\nhost = localhost\n");
    defer ed.deinit();
    try ed.set(&.{ .{ .key = "server" }, .{ .key = "port" } }, "80");
    try testing.expectEqualStrings("[server]\nhost = localhost\nport = 80\n", ed.source.items);
}

test "ini insertKey adds the first key into an EMPTY existing section" {
    // `[server]\n` with nothing under it yet — an empty section is a
    // childless block mapping, the same shape a from-scratch dotenv/
    // .properties file starts as, but with a narrow (name-token-anchored)
    // span rather than root's whole-file span — exercises the root-vs-
    // section split in `Editor.insertBlockKey`.
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[server]\n");
    defer ed.deinit();
    try ed.set(&.{ .{ .key = "server" }, .{ .key = "host" } }, "localhost");
    try testing.expectEqualStrings("[server]\nhost = localhost\n", ed.source.items);
}

test "ini set does NOT auto-vivify a missing section; surfaces NotFound" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("name = fig\n");
    defer ed.deinit();
    try testing.expectError(error.NotFound, ed.set(&.{ .{ .key = "server" }, .{ .key = "host" } }, "localhost"));
    // Refused cleanly — no stray `server = {}` (or any other) line spliced in.
    try testing.expectEqualStrings("name = fig\n", ed.source.items);
}

test "ini deleteKey refuses to delete a whole [section] header" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[server]\nhost = localhost\n");
    defer ed.deinit();
    try testing.expectError(error.CannotDeleteSection, ed.deleteKey(&.{.{ .key = "server" }}));
    // File is untouched by the refused delete.
    try testing.expectEqualStrings("[server]\nhost = localhost\n", ed.source.items);
    // A key WITHIN the section still deletes normally, leaving the (now
    // empty) section header intact.
    try ed.deleteKey(&.{ .{ .key = "server" }, .{ .key = "host" } });
    try testing.expectEqualStrings("[server]\n", ed.source.items);
}

test "ini replaceValAtPath refuses a whole [section] (would rename the header)" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[server]\nhost = localhost\n");
    defer ed.deinit();
    // The section mapping's span is the NAME token inside the header, so the
    // generic splice used to write `[REPLACED]` and report success — renaming
    // the section while its entries stayed under it.
    try testing.expectError(error.CannotReplaceSection, ed.replaceValAtPath(&.{.{ .key = "server" }}, "REPLACED"));
    try testing.expectEqualStrings("[server]\nhost = localhost\n", ed.source.items);
    // A value WITHIN the section still replaces normally.
    try ed.replaceValAtPath(&.{ .{ .key = "server" }, .{ .key = "host" } }, "example.com");
    try testing.expectEqualStrings("[server]\nhost = example.com\n", ed.source.items);
}

test "ini replaceValAtPath at the root rewrites the whole document" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[server]\nhost = localhost\n");
    defer ed.deinit();
    // The root's span is the whole file — the one container the guard exempts,
    // and the reason it tests the PATH rather than sniffing for a `[` (which the
    // first line here would trip).
    try ed.replaceValAtPath(&.{}, "[db]\nname = fig\n");
    try testing.expectEqualStrings("[db]\nname = fig\n", ed.source.items);
}

test "ini deleteContainer removes a whole section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteContainer(&.{.{ .key = "a" }});
    try testing.expectEqualStrings("[b]\ny = 2\n", ed.source.items);
}

test "ini deleteContainer removes EVERY occurrence of a reopened section" {
    // The case the engine's section rule refuses a line-delete for: `[a]` is
    // scattered, and its second header is in no node's span. Without
    // `Document.node_regions` the trailing `[a]` would survive and adopt
    // whatever followed it.
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n[a]\nz = 3\n");
    defer ed.deinit();
    try ed.deleteContainer(&.{.{ .key = "a" }});
    try testing.expectEqualStrings("[b]\ny = 2\n", ed.source.items);
}

test "ini deleteContainer removes an EMPTY reopened header too" {
    // A reopen with no entries under it has nothing to find it by except the
    // recorded header line — a gather that scanned upward from each child
    // would leave this one behind.
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n[a]\n");
    defer ed.deinit();
    try ed.deleteContainer(&.{.{ .key = "a" }});
    try testing.expectEqualStrings("[b]\ny = 2\n", ed.source.items);
}

test "ini deleteContainer takes owned comments with the section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("; about a\n[a]\n; about x\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.deleteContainer(&.{.{ .key = "a" }});
    try testing.expectEqualStrings("[b]\ny = 2\n", ed.source.items);
}

test "ini deleteContainer refuses a root-level scalar key" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("name = fig\n[a]\nx = 1\n");
    defer ed.deinit();
    try testing.expectError(error.NotAContainer, ed.deleteContainer(&.{.{ .key = "name" }}));
    try testing.expectEqualStrings("name = fig\n[a]\nx = 1\n", ed.source.items);
}

test "ini moveContainer relocates a section before another, collapsing its fragments" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n[a]\nz = 3\n");
    defer ed.deinit();
    // `a`'s two fragments are removed and re-emitted as one section at `b`.
    // No blank line before it: `b` was already the file's second section, so
    // the relocated block lands at the very start with nothing preceding it to
    // separate from (see `sections.appendWithBlankBefore`).
    try ed.moveContainer(&.{.{ .key = "a" }}, &.{.{ .key = "b" }});
    try testing.expectEqualStrings("[a]\nx = 1\n[a]\nz = 3\n[b]\ny = 2\n", ed.source.items);
}

test "ini moveContainer with a null destination moves to EOF" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    try ed.moveContainer(&.{.{ .key = "a" }}, null);
    try testing.expectEqualStrings("[b]\ny = 2\n\n[a]\nx = 1\n", ed.source.items);
}

test "ini reorderContainers reorders named sections, leaving others in place" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\ny = 2\n[c]\nz = 3\n");
    defer ed.deinit();
    try ed.reorderContainers(&.{ "c", "a" });
    // `b` is untouched; `c` and `a` swap into the slot `a` held.
    try testing.expectEqualStrings("[c]\nz = 3\n[a]\nx = 1\n[b]\ny = 2\n", ed.source.items);
}

test "ini reopened/scattered section: insertKey appends after the LAST physical entry" {
    // Merged sections thread new entries onto the tail of the (single,
    // logical) child list in file order, so the generic `lastChild`-anchored
    // `insertBlockKey` already lands the new key right after the section's
    // most recent physical occurrence — no multi-region gather needed,
    // unlike TOML's scattered tables.
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\n[b]\nz = 1\n[a]\ny = 2\n");
    defer ed.deinit();
    try ed.set(&.{ .{ .key = "a" }, .{ .key = "w" } }, "3");
    try testing.expectEqualStrings("[a]\nx = 1\n[b]\nz = 1\n[a]\ny = 2\nw = 3\n", ed.source.items);
}

// --- the move/reorder guards (a section's block is its header LINE) ---
//
// The same span fact the delete and replace guards rest on, in the two ops
// that relocate an entry's block. `moveContainer`/`reorderContainers` above are
// what these refusals point at; both generic ops used to report success while
// handing one section's entries to another.

test "ini moveKey refuses to move a [section], or to move an entry before one" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("z = 0\n[a]\nx = 1\n[b]\ny = 2\n");
    defer ed.deinit();
    // Moving the section relocates its header alone, leaving `y = 2` for
    // whichever section ends up above it.
    try testing.expectError(error.CannotMoveSection, ed.moveKey(&.{.{ .key = "b" }}, &.{.{ .key = "z" }}));
    // And "before `[b]`" is the tail of `[a]`'s body, so the root key `z` would
    // have become `a.z`.
    try testing.expectError(error.CannotMoveSection, ed.moveKey(&.{.{ .key = "z" }}, &.{.{ .key = "b" }}));
    try testing.expectEqualStrings("z = 0\n[a]\nx = 1\n[b]\ny = 2\n", ed.source.items);
}

test "ini moveKey still moves plain entries inside a section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\ny = 2\nz = 3\n");
    defer ed.deinit();
    try ed.moveKey(&.{ .{ .key = "a" }, .{ .key = "z" } }, &.{ .{ .key = "a" }, .{ .key = "y" } });
    try testing.expectEqualStrings("[a]\nx = 1\nz = 3\ny = 2\n", ed.source.items);
}

test "ini reorderKeys refuses a reorder that shifts a section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("z = 0\n[b]\ny = 2\n[a]\nx = 1\n");
    defer ed.deinit();
    // Used to produce `z = 0\n[a]\n[b]\ny = 2\nx = 1\n` — `[a]` emptied and
    // `x = 1` rehomed into `b`.
    try testing.expectError(error.CannotReorderSections, ed.reorderKeys(&.{}, &.{ "z", "a", "b" }));
    try testing.expectEqualStrings("z = 0\n[b]\ny = 2\n[a]\nx = 1\n", ed.source.items);
}

test "ini reorderKeys still reorders entries within a section" {
    var ed: IniEditor = .{ .allocator = testing.allocator, .format = .INI };
    try ed.init("[a]\nx = 1\ny = 2\nz = 3\n");
    defer ed.deinit();
    try ed.reorderKeys(&.{.{ .key = "a" }}, &.{ "z", "x" });
    try testing.expectEqualStrings("[a]\nz = 3\nx = 1\ny = 2\n", ed.source.items);
}