fig-sys 3.2.0

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! INI-specific editing helpers for `Editor(Ini)`.
//!
//! The generic span-splice engine lives in `../../editor.zig`; this module holds
//! the INI-only logic it delegates to, mirroring TOML/fig's own
//! `editor_helper.zig` split (structural per-language decisions live here;
//! `editor.zig` stays a one-line dispatch to them). INI is nearly flat like
//! dotenv/.properties — one level of `[section]` nesting, no arrays/inline
//! tables/dotted keys — so it needs far less than TOML: no multi-region
//! gather, because a reopened/scattered section already threads correctly
//! through the generic `lastChild`-anchored block insert (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). What
//! IS needed:
//!
//!   - `iniInsertKey`: INI has no flow syntax at all, so this skips the
//!     generic `isFlow` sniff outright rather than risk a false positive — a
//!     file opening directly with `[section]` would otherwise make `isFlow`
//!     see the `[` and misdetect the root as a bracket-delimited flow
//!     container (the same hazard TOML's tables have, which is why TOML
//!     declares an `insertKey` hook of its own too).
//!   - `sectionDeleteGuard` (over `isSectionHeaderLine`): the `deleteKey`
//!     guard that refuses to line-delete a `[section]` entry — its span is
//!     anchored at the FIRST occurrence's header only (see this module's
//!     sibling `parser.zig`), so a reopened section's later entries would be
//!     orphaned into misparsed content if the "table" were deleted this way.
//!     TOML's `CannotDeleteTable` twin (`CannotDeleteSection` here).
//!
//! Unlike TOML/fig, INI does NOT get its own `set` auto-vivify path — it has
//! no literal spelling for "an empty nested mapping" (`{}` is just a
//! two-character STRING value in INI, not a container), so there is nothing
//! for a seed to splice and `set` refuses rather than write a nonsense
//! `section = {}` root key. That is an ABSENCE of syntax, not logic to
//! delegate, so it is declared as `syntax().empty_map_literal = null` in
//! `ini.zig` — see `manifest.Syntax.empty_map_literal`.

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 these ops drive — the INI arm of the generic engine.
const IniEditor = editor.Editor(Ini);

const lineStartBefore = editor.lineStartBefore;
const lineEndAfter = editor.lineEndAfter;
const firstNonSpace = editor.firstNonSpace;

/// The multi-region machinery INI shares with TOML and fig — everything
/// downstream of the gather below. See `../shared/sections.zig`.
const sections = @import("../shared/sections.zig");
const Region = sections.Region;

/// Insert `key_text = value_text` into the mapping at `node` (root or a
/// section) — the same block-mapping primitive JSON/YAML/dotenv/.properties
/// use (`Editor.insertBlockKey`), just reached without the generic `isFlow`
/// check INI doesn't need (see the module doc). `node.kind` must already be
/// `.mapping`; anything else is a real type error, not a container to insert
/// into (e.g. a path landing on a plain scalar key).
///
/// Takes the full `insertKey` hook signature (see `editor.Editor.insertKey`);
/// `path` and `span` are the generic engine's, unused here.
pub fn iniInsertKey(self: *IniEditor, parsed: Document, path: []const AST.PathSegment, node: AST.Node, span: Span, key_text: []const u8, value_text: []const u8) !void {
    _ = path;
    _ = span;
    return switch (node.kind) {
        .mapping => self.insertBlockKey(parsed, node, key_text, value_text),
        else => error.NotAMapping,
    };
}

/// Refuse a line-based delete of a `[section]` entry — INI's twin of TOML's
/// `CannotDeleteTable`.
///
/// The `deleteKeyGuard` hook (see `editor.Editor.deleteKey`). A section's span
/// is anchored at its FIRST occurrence's header only, so deleting that line
/// would orphan a reopened section's later entries into misparsed content.
pub fn sectionDeleteGuard(self: *IniEditor, parsed: Document, node: AST.Node, span: Span) !void {
    _ = parsed;
    _ = node;
    if (isSectionHeaderLine(self.source.items, span)) return error.CannotDeleteSection;
}

/// Refuse a span-splice replacement of a whole `[section]` — INI's twin of
/// TOML's `CannotReplaceTable`.
///
/// The `replaceValGuard` hook (see `editor.Editor.replaceValAtPath`). Same span
/// fact the delete guard rests on, with a worse outcome: a section mapping's
/// span is just its NAME token inside the header, so the generic splice writes
/// the replacement over that name and reports success — `[server]` becomes
/// `[REPLACED]`, renaming the section while its entries stay put. INI has no
/// flow syntax, so a non-root mapping is always a section; every scalar value
/// splices normally, and the root (empty path) spans the whole document, which
/// is what replacing the root means.
pub fn sectionReplaceGuard(self: *IniEditor, parsed: Document, path: []const AST.PathSegment, node: AST.Node, span: Span) !void {
    _ = parsed;
    _ = self;
    _ = span;
    if (path.len == 0) return;
    if (node.kind == .mapping) return error.CannotReplaceSection;
}

/// Refuse a block-move of, or onto, a `[section]` header — INI's twin of
/// TOML's `CannotMoveTable`.
///
/// The `moveKeyGuard` hook (see `editor.Editor.moveKey`). A section entry's
/// block is its header LINE, so moving it relocates the name and leaves the
/// entries for whichever section now precedes them; and moving anything to sit
/// *before* a header drops it at the tail of the preceding section's body,
/// turning a root key into that section's key. `moveContainer` moves a section
/// whole.
pub fn sectionMoveGuard(self: *IniEditor, parsed: Document, src: AST.Node, src_span: Span, dest: AST.Node, dest_span: Span) !void {
    _ = parsed;
    _ = src;
    _ = dest;
    const source = self.source.items;
    if (isSectionHeaderLine(source, src_span) or isSectionHeaderLine(source, dest_span))
        return error.CannotMoveSection;
}

/// Refuse a reorder that changes a `[section]`'s position among its siblings —
/// INI's twin of `CannotReorderTables`.
///
/// The `reorderKeysGuard` hook (see `editor.Editor.reorderKeys`), which passes
/// only the entries whose position changes. Entry blocks tile up to the next
/// sibling's line, so a section carries its body — except the last one, whose
/// block stops at its own header and leaves its entries outside the spliced
/// range for the section that lands before them. `reorderContainers` is the op
/// for sections; reordering root keys that are all plain entries is untouched.
pub fn sectionReorderGuard(self: *IniEditor, parsed: Document, moved: []const AST.Node) !void {
    const source = self.source.items;
    for (moved) |node| {
        if (isSectionHeaderLine(source, parsed.span(node))) return error.CannotReorderSections;
    }
}

/// Whether the entry at `span` is a `[section]` header line — i.e. whether
/// deleting it via the generic line-based `deleteKey` would only remove that
/// one header line and orphan a reopened section's later entries elsewhere
/// in the file. `span.start` may land anywhere on the header line (an INI
/// section-mapping's span is anchored at just its name token, not the
/// header's own extent — see `parser.zig`'s `parseSectionHeader`), so this
/// scans back to the line start first rather than checking `span.start`
/// itself.
pub fn isSectionHeaderLine(source: []const u8, span: Span) bool {
    const fns = firstNonSpace(source, lineStartBefore(source, span.start));
    return fns < source.len and source[fns] == '[';
}

// ============================================================================
// WHOLE-SECTION STRUCTURAL EDITING (multi-region)
// ============================================================================
//
// What `sectionDeleteGuard` above refuses, these do properly. A `[section]` may
// be REOPENED (`[a]` … `[b]` … `[a]`), which the parser merges into one mapping
// whose span anchors only the FIRST header — so a section's bytes are scattered
// exactly the way a TOML table's or a fig container's are, and the same answer
// applies: gather the disjoint line-regions, rebuild the source once. The
// reopened headers come from `Document.reentry_headers`, which `parser.zig`
// records at its merge branch for this.
//
// INI's gather is the simplest of the three: one level of nesting, no arrays,
// no dotted keys, no flow syntax — a section is its header lines plus its
// entries' lines, with no recursion. Everything after that is shared
// (`../shared/sections.zig`).
//
// There is no `insertContainer`/`renameContainer` twin: a new `[section]` is
// `set`'s business (INI cannot auto-vivify — see the module doc), and a rename
// is one tight span the generic `replaceKeyAtPath` already rewrites, since an
// INI header has no dotted descendants to follow.

/// The physical line of a `[section]` header — the owned comment block above it
/// through the header line's own newline. `content_start` is any position on
/// that line at or after its indent: a section mapping's own span (anchored at
/// the name token inside the brackets) or a recorded re-entry's `content_start`.
fn headerLineRegion(source: []const u8, content_start: usize) Region {
    const ls = lineStartBefore(source, content_start);
    return .{ .start = editor.commentBlockStart(source, ls, .semicolon), .end = lineEndAfter(source, ls) };
}

/// Every region belonging to the section at `path`: its header line, every
/// reopened header line, and each of its entries' own lines.
///
/// `error.NotAContainer` when `path` doesn't name a `[section]` — a root-level
/// scalar key (use `deleteKey`), or a path that resolves to a value rather than
/// a mapping. INI has one level of nesting, so a section is always at the root.
fn gatherSection(parsed: Document, source: []const u8, allocator: std.mem.Allocator, path: []const AST.PathSegment) !struct { node: AST.Node, regions: std.ArrayList(Region) } {
    if (path.len != 1) return error.NotAContainer;
    const node = try parsed.ast.getValByPath(path);
    if (node.kind != .mapping) return error.NotAContainer;

    var regions: std.ArrayList(Region) = .empty;
    errdefer regions.deinit(allocator);
    try regions.append(allocator, headerLineRegion(source, parsed.span(node).start));
    for (parsed.reentry_headers) |rh| {
        if (rh.node_id == node.id) try regions.append(allocator, headerLineRegion(source, rh.content_start));
    }
    var cur = node.kind.mapping;
    while (cur) |id| : (cur = parsed.ast.nodes[id].next_sibling) {
        try regions.append(allocator, sections.entryLineRegion(source, parsed.span(parsed.ast.nodes[id]), .semicolon));
    }
    return .{ .node = node, .regions = regions };
}

/// Delete the whole `[section]` named by `path` — every occurrence of its
/// header plus all of its entries — leaving interleaved foreign sections in
/// place. The op `sectionDeleteGuard` points a `deleteKey` caller at.
pub fn deleteContainer(self: *IniEditor, path: []const AST.PathSegment) !void {
    const parsed = try self.getParsed();
    const source = self.source.items;
    var g = try gatherSection(parsed, source, self.allocator, path);
    defer g.regions.deinit(self.allocator);
    const n = sections.normalize(g.regions.items, true);
    try sections.spliceOut(self, g.regions.items[0..n]);
}

/// Move the whole `[section]` at `src_path` so it begins immediately before the
/// section at `dest_path`, or at end-of-file when `dest_path` is null. A
/// reopened section's fragments are collapsed together at the destination;
/// foreign sections stay put.
pub fn moveContainer(self: *IniEditor, src_path: []const AST.PathSegment, dest_path: ?[]const AST.PathSegment) !void {
    const parsed = try self.getParsed();
    const source = self.source.items;
    var g = try gatherSection(parsed, source, self.allocator, src_path);
    defer g.regions.deinit(self.allocator);
    const n = sections.normalize(g.regions.items, true);

    const dest_at = blk: {
        if (dest_path) |dp| {
            if (dp.len != 1) return error.NotAContainer;
            const dn = try parsed.ast.getValByPath(dp);
            if (dn.kind != .mapping) return error.NotAContainer;
            break :blk headerLineRegion(source, parsed.span(dn).start).start;
        }
        break :blk source.len;
    };
    try sections.relocate(self, g.regions.items[0..n], dest_at);
}

/// Reorder the `[section]`s named by `order` among themselves, each re-emitted
/// contiguously at the position the earliest currently occupies. Sections not
/// named — and any root-level keys above the first section — are untouched.
pub fn reorderContainers(self: *IniEditor, order: []const []const u8) !void {
    if (order.len == 0) return;
    const parsed = try self.getParsed();
    const source = self.source.items;

    var all: std.ArrayList(Region) = .empty;
    defer all.deinit(self.allocator);
    var bundles: std.ArrayList([]u8) = .empty;
    defer {
        for (bundles.items) |b| self.allocator.free(b);
        bundles.deinit(self.allocator);
    }

    for (order) |name| {
        const path: [1]AST.PathSegment = .{.{ .key = name }};
        var g = try gatherSection(parsed, source, self.allocator, &path);
        defer g.regions.deinit(self.allocator);
        const n = sections.normalize(g.regions.items, true);
        const owned = try sections.captureBundle(self.allocator, source, g.regions.items[0..n], &all);
        errdefer self.allocator.free(owned);
        try bundles.append(self.allocator, owned);
    }
    const total = sections.normalize(all.items, true);
    try sections.reorderBundles(self, all.items[0..total], bundles.items);
}

// ── 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 `sectionDeleteGuard` refuses a line-delete for: `[a]` is
    // scattered, and its second header is in no node's span. Without
    // `Document.reentry_headers` 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 re-entry — 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);
}