fig 2.3.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
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
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
//! The body of each CLI action — one function per `CliAction`, called from
//! `main.zig`'s thin dispatch switch. Each function takes the arena
//! allocator, the process `Io`, the stdout/stderr terminals, and its own
//! action's option struct (from `types.zig`); together they're exactly the
//! free variables the original monolithic `main()` switch closed over.
const std = @import("std");
const fig = @import("fig");
const build_options = @import("build_options");

const gron = @import("gron.zig");
const diff = @import("diff.zig");
const types = @import("types.zig");
const help = @import("help.zig");
const args_mod = @import("args.zig");
const fileio = @import("fileio.zig");
const diag_report = @import("diag_report.zig");
const parse_dispatch = @import("parse_dispatch.zig");
const edit_ops = @import("edit_ops.zig");
const reformat = @import("reformat.zig");

const Help = help.Help;
const Format = types.Format;
const EditOp = types.EditOp;
const append_index = types.append_index;
const Io = std.Io;

pub fn runHelp(stderr_term: *Io.Terminal, binary_name: []const u8) !void {
    try stderr_term.writer.print(help.title_string, .{});
    try Help.general(stderr_term, binary_name);
}

/// Print the CLI's own version alongside the core library version it embeds
/// and its marketing epoch — two independent SemVer tracks (see
/// docs/VERSIONING.md; a CLI-only breaking change bumps `cli_version`
/// without requiring a `core_version`/ABI release, and vice versa) plus one
/// purely cosmetic label (`epoch` has no compatibility meaning — it's the
/// core's marketing name, not a version number).
pub fn runVersion(stdout_term: *Io.Terminal, cli_version: []const u8, core_version: []const u8, epoch: []const u8) !void {
    try stdout_term.writer.print("fig {s} (core {s} \"{s}\")\n", .{ cli_version, core_version, epoch });
    try stdout_term.writer.flush();
}

pub fn runEdit(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, binary_name: []const u8, opts: types.EditOptions) !void {
    if (opts.requested_help) {
        try Help.edit(stdout_term, binary_name);
        return;
    }
    const input = try fileio.getInput(io, opts.file, .read_write);
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    const op: EditOp = if (opts.key) .replace_key else .replace_value;
    if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| {
        try edit_ops.applyToEmbed(a, io, input, embed_type, opts.path, opts.replacement, op);
    } else switch (if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format) {
        .json, .jsonc => |f| if (comptime build_options.lang_json) {
            const replacement = try std.fmt.allocPrint(a, "\"{s}\"", .{opts.replacement});
            try edit_ops.applyToFile(fig.Language.JSON, a, io, input, opts.path, replacement, op, edit_ops.jsonDialect(f));
        } else return error.FormatDisabled,
        .yaml, .yml => if (comptime build_options.lang_yaml) {
            try edit_ops.applyToFile(fig.Language.YAML, a, io, input, opts.path, opts.replacement, op, fig.Language.YAML.default_type);
        } else return error.FormatDisabled,
        // TOML value/key replacement: a value or key node has a tight,
        // contiguous span (the parser's node_spans point at the original
        // source bytes), so the generic span-splice editor handles it
        // even when the owning table is assembled from scattered headers.
        // The replacement is taken verbatim as a TOML literal, like YAML
        // and ZON. (Structural inserts/deletes that must place text
        // relative to a scattered table are still unsupported.)
        .toml => if (comptime build_options.lang_toml)
            try edit_ops.applyToFile(fig.Language.TOML, a, io, input, opts.path, opts.replacement, op, fig.Language.TOML.default_type)
        else
            return error.FormatDisabled,
        // ZON edits take the replacement verbatim (a literal ZON value),
        // like YAML — the editor splices and reparses it.
        .zon => if (comptime build_options.lang_zon)
            try edit_ops.applyToFile(fig.Language.ZON, a, io, input, opts.path, opts.replacement, op, fig.Language.ZON.default_type)
        else
            return error.FormatDisabled,
        // XML is reader-only: no in-place editor yet.
        .xml => return error.UnsupportedXmlEdit,
        // JSON5 is read/serialize only so far; comment-preserving
        // in-place editing of it is not wired yet.
        .json5 => return error.UnsupportedJson5Edit,
        // The canonical form is a parse/print pair with no span-splicing
        // editor; convert via `get` instead of editing in place.
        .canonical => return error.UnsupportedCanonicalEdit,
        // fig value/key replacement: `Editor(Fig)` splices the exact
        // node span (`Fig.Parser` now tracks real spans — see
        // `fig/parser.zig`'s "AST assembly" section), same as TOML.
        // The replacement is taken verbatim as a fig literal.
        .fig => if (comptime build_options.lang_fig)
            try edit_ops.applyToFile(fig.Language.FIG, a, io, input, opts.path, opts.replacement, op, fig.Language.FIG.default_type)
        else
            return error.FormatDisabled,
        // gron is a CLI-only get/echo format with no in-place editor.
        .gron => return error.UnsupportedGronEdit,
    }
}

pub fn runSet(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.SetOptions) !void {
    if (opts.requested_help) {
        try Help.set(stdout_term, binary_name);
        return;
    }
    if (opts.path.len == 0) {
        try stderr_term.writer.print("error: set needs a path to the key (or, with --seq, the sequence) to upsert.\n", .{});
        try stderr_term.writer.flush();
        std.process.exit(2);
    }
    // `set` upserts, so it may target a file that doesn't exist yet:
    // create it and seed a minimal valid empty document (see
    // `createSeededFile`/`emptyDocSeed`) so the editor lands the first
    // key into a parseable buffer. The other structural actions keep the
    // plain open — they edit what's already there and have nothing to
    // seed into a blank file. Two cases refuse the create up front,
    // before any file lands on disk:
    //   - a freshly created file is empty, so its format can't be sniffed
    //     (`--detect`): it must carry a known extension;
    //   - a format with no empty-document form (`emptyDocSeed` == null,
    //     e.g. fig) has nothing valid to seed.
    // When targeting an embed, the host is seeded empty ("") and the
    // embed machinery (`initRegion`) synthesizes the inner block itself.
    var created = false;
    const input = fileio.getInput(io, opts.file, .read_write) catch |err| switch (err) {
        error.FileNotFound => blk: {
            if (std.mem.eql(u8, opts.file, "-")) return err;
            if (opts.detect) {
                try stderr_term.writer.print("error: cannot create {s}: an unrecognized extension gives no format to seed. Use a known extension (.json/.jsonc/.yaml/.toml/.zon) or an existing file.\n", .{opts.file});
                try stderr_term.writer.flush();
                std.process.exit(2);
            }
            const seed: []const u8 = if (opts.embed != null or opts.detect_embed) "" else edit_ops.emptyDocSeed(opts.format) orelse {
                try stderr_term.writer.print("error: cannot create {s}: {s} has no empty-document form to seed a new file. Start from an existing file.\n", .{ opts.file, @tagName(opts.format) });
                try stderr_term.writer.flush();
                std.process.exit(2);
            };
            created = true;
            break :blk try fileio.createSeededFile(io, opts.file, seed);
        },
        else => return err,
    };
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
    // `--seq` reconciles the sequence at `path`; otherwise upsert a scalar.
    // Both flow through the shared structural-edit router, so the embed
    // path (which open-or-inits a missing block) is reused for free.
    const op: EditOp = if (opts.seq) .{ .set_sequence = opts.values } else .set;
    const text: []const u8 = if (opts.seq) "" else opts.value;
    // On a from-scratch create, roll the new file back if the edit fails
    // (e.g. a path whose parent segment resolves to a scalar, which `set`
    // refuses rather than clobber) so a failed `set` leaves no bare seed
    // behind — matching how it leaves an existing file untouched on
    // failure. A merely-missing parent map is no longer a failure: `set`
    // auto-vivifies it (see `Editor.set`).
    const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
    edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path, text, op) catch |err| {
        if (created) fileio.deleteCreatedFile(io, opts.file);
        return err;
    };
}

pub fn runInsert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.InsertOptions) !void {
    if (opts.requested_help) {
        try Help.insert(stdout_term, binary_name);
        return;
    }
    const input = try fileio.getInput(io, opts.file, .read_write);
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    // The destination is the *parent* container plus the trailing slot.
    // A trailing key inserts into a mapping; a trailing index pre/appends
    // to a sequence. An empty parent is the root container.
    if (opts.path.len == 0) {
        try stderr_term.writer.print("error: insert needs a destination path (e.g. a.b.newkey or list[-]).\n", .{});
        try stderr_term.writer.flush();
        std.process.exit(2);
    }
    const parent = opts.path[0 .. opts.path.len - 1];
    const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
    const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
    switch (opts.path[opts.path.len - 1]) {
        .key => |key| try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, parent, opts.value, .{ .insert_key = key }),
        .index => |index| {
            // The editor only prepends or appends; an addressable middle
            // index has no primitive, so reject it rather than guess.
            const op: EditOp = if (index == 0)
                .prepend_seq
            else if (index == append_index)
                .append_seq
            else {
                try stderr_term.writer.print("error: sequence insert supports only [0] (prepend) or [-]/[$] (append); mid-sequence insert is not yet available.\n", .{});
                try stderr_term.writer.flush();
                std.process.exit(2);
            };
            try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, parent, opts.value, op);
        },
    }
}

pub fn runDelete(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.DeleteOptions) !void {
    if (opts.requested_help) {
        try Help.delete(stdout_term, binary_name);
        return;
    }
    const input = try fileio.getInput(io, opts.file, .read_write);
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    if (opts.path.len == 0) {
        try stderr_term.writer.print("error: delete needs a path to the entry or item to remove.\n", .{});
        try stderr_term.writer.flush();
        std.process.exit(2);
    }
    const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;
    const embed = try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed);
    // A trailing index removes that item from the parent sequence; a
    // trailing key deletes the mapping entry named by the full path.
    switch (opts.path[opts.path.len - 1]) {
        .index => |index| try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path[0 .. opts.path.len - 1], "", .{ .remove_seq_item = index }),
        .key => try edit_ops.applyStructuralEdit(a, io, input, resolved, embed, opts.path, "", .delete_key),
    }
}

pub fn runGet(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.GetOptions) !void {
    if (opts.requested_help) {
        try Help.get(stdout_term, binary_name);
        return;
    }
    const input = try fileio.getInput(io, opts.file, .read_only);
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    // `--body`: print the host prose OUTSIDE the fences (the region's
    // `body` span) — the complement of extracting the embed content. With
    // no such region the whole file is the body.
    if (opts.body) {
        const content = try fileio.readAll(a, io, input);
        const embed_type = args_mod.resolveEmbedTypeFromContent(content, opts.embed, opts.detect_embed) orelse fig.Embed.Type.FrontmatterYaml;
        if (fig.Embed.locateRegion(content, embed_type)) |region| {
            try stdout_term.writer.writeAll(content[region.body.start..region.body.end]);
        } else |err| switch (err) {
            error.NotFound => try stdout_term.writer.writeAll(content),
            else => return err,
        }
        try stdout_term.writer.flush();
        return;
    }

    // Resolved input/output formats. They equal the parsed options unless
    // the input format has to be sniffed from the file's contents (no
    // `--input`, unrecognized extension): detection overwrites `from`, and
    // — when no `--output` was given — `to` follows it (an echo round-trip
    // rather than a silent convert-to-JSON).
    var from = opts.from;
    var to = opts.to;

    const doc = if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| blk_embed: {
        // `embed_type` may have just been sniffed at runtime
        // (`detect_embed`), in which case the parse-time
        // placeholder `from`/`to` (the extension's guess, not the
        // real archetype) needs correcting: the inner format
        // always follows the archetype outright, and — same as
        // the whole-file `detect` echo below — an unpinned
        // output follows it too rather than silently defaulting
        // to something else (e.g. `.yaml` for a `.md` file whose
        // actual frontmatter turned out to be fig or JSON).
        from = args_mod.embedFormat(embed_type);
        if (!opts.output_explicit) to = from;
        break :blk_embed try edit_ops.parseEmbeddedFromFile(a, io, input, embed_type);
    } else blk: {
        // Read once so detection and parsing share the same bytes — a
        // piped stdin can only be consumed a single time.
        const content = try fileio.readAll(a, io, input);
        if (opts.detect) {
            from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);
            if (!opts.output_explicit) to = from;
        }
        var fig_report: fig.Language.FIG.Parser.Report = .{};
        var json_report: fig.Language.JSON.Parser.Report = .{};
        var toml_report: fig.Language.TOML.Parser.Report = .{};
        const parsed = parse_dispatch.parseSliceAs(from, .{}, a, content, false, .{ .fig = &fig_report, .json = &json_report, .toml = &toml_report }) catch |err| {
            // A parse failure renders as a `file:line:col` teaching
            // message (DESIGN.md: every diagnostic names the fix) and
            // exits cleanly — no error-return trace for a user typo.
            // Only fig/JSON/TOML fill a report so far; every other
            // format still falls through to the bare `return err`.
            if (fig_report.diag) |d|
                try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.FIG.Parser.describe(d.code), fig.Language.FIG.Parser.shortLabel(d.code));
            if (json_report.diag) |d|
                try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.JSON.Parser.describe(d.code), fig.Language.JSON.Parser.shortLabel(d.code));
            if (toml_report.diag) |d|
                try diag_report.reportParseError(stderr_term, content, opts.file, d.offset, d.end, fig.Language.TOML.Parser.describe(d.code), fig.Language.TOML.Parser.shortLabel(d.code));
            return err;
        };
        // Authoring-time lints (parse-time warnings) ride the same
        // `--quiet`/`--strict` contract as the serialize-side
        // diagnostics below: quiet silences, strict aborts.
        try diag_report.handleParseWarnings(stderr_term, content, opts.file, "fig authoring", fig_report.warnings, fig.Language.FIG.Parser.Warning.describeWarning, fig.Language.FIG.Parser.Warning.shortLabel, opts.quiet, opts.strict);
        try diag_report.handleParseWarnings(stderr_term, content, opts.file, "JSON authoring", json_report.warnings, fig.Language.JSON.Parser.Warning.describeWarning, fig.Language.JSON.Parser.Warning.shortLabel, opts.quiet, opts.strict);
        try diag_report.handleParseWarnings(stderr_term, content, opts.file, "TOML authoring", toml_report.warnings, fig.Language.TOML.Parser.Warning.describeWarning, fig.Language.TOML.Parser.Warning.shortLabel, opts.quiet, opts.strict);
        break :blk parsed;
    };

    // Converting YAML to a non-YAML format resolves the reference layer
    // first (aliases → copies, merges → flattened, tags applied/dropped).
    // YAML→YAML keeps it intact for round-trip; JSON never has it.
    const src_is_yaml = from == .yaml or from == .yml;
    const dst_is_yaml = to == .yaml or to == .yml;
    const base_ast: *const fig.AST = if (src_is_yaml and !dst_is_yaml) blk: {
        // Reachable only when the source is YAML, so YAML is compiled in;
        // the comptime guard keeps `Language.YAML` out of the gated build.
        if (comptime build_options.lang_yaml) {
            const mode: fig.Language.YAML.TagMode = if (opts.lax_tags) .lax else .strict;
            const mat = try a.create(fig.AST);
            mat.* = try fig.Language.YAML.materialize(a, &doc.ast, mode);
            break :blk mat;
        } else unreachable;
    } else &doc.ast;

    // Lossless mode: decode any `$fig` envelopes in the input back to
    // their real node kinds, then re-encode for the target format. Skipped
    // for YAML→YAML, whose reference layer (anchors/tags) lives in
    // side-tables the core-AST passes would strip — and which round-trips
    // losslessly already. The passes operate on a core AST, so any
    // non-YAML source (or a materialized YAML source) is safe.
    const ast: *const fig.AST = if (opts.lossless and !(src_is_yaml and dst_is_yaml)) blk: {
        const maybe_target: ?fig.Lossless.Target = switch (to) {
            // JSON5 reuses the JSON envelope target. It could hold
            // Infinity/NaN natively, so this is conservative (those ride
            // in a `$fig` envelope) but still fully lossless.
            // gron's value layer is JSON, so it shares the JSON envelope
            // target: an unrepresentable value (a TOML datetime, etc.)
            // rides in a `$fig` envelope that prints as a JSON object.
            .json, .jsonc, .json5, .gron => .json,
            .yaml, .yml => .yaml,
            .toml => .toml,
            .zon => .zon,
            // Canonical encodes every node kind directly, so no envelope
            // is needed on output — only decode envelopes found in input.
            // fig shares canonical's node model (same AST, no distinct
            // envelope value-space of its own), so it gets the same
            // decode-only treatment; a node fig can't natively spell
            // (non-string key, YAML alias, scalar root) is the
            // documented "no authoring spelling" gap — fall to
            // `canonical`/`--lossless` with a different `--to` instead.
            // (A scalar root specifically makes the fig printer hard-error
            // with `FigUnrepresentableRoot` rather than emit non-conforming
            // text — see `languages/fig/printer.zig`'s `root`.)
            // XML has no envelope of its own either: every scalar
            // collapses to element/attribute text regardless (see
            // `languages/xml/printer.zig`), so an envelope couldn't
            // preserve anything a plain print doesn't already lose.
            .canonical, .fig, .xml => null,
        };
        const decoded = try a.create(fig.AST);
        decoded.* = try fig.Lossless.decode(a, base_ast);
        const target = maybe_target orelse break :blk decoded;
        const encoded = try a.create(fig.AST);
        encoded.* = try fig.Lossless.encode(a, decoded, target);
        break :blk encoded;
    } else base_ast;

    const node_id = if (opts.path) |p| (try ast.getValByPath(p)).id else ast.root;

    // gron is a CLI-only projection that derives straight from the AST,
    // so it has no `SerializeFormat`: print it here and return, bypassing
    // the serializer dispatch, the lossy/lossless diagnostics below, and
    // the C ABI entirely. YAML aliases are already materialized above.
    if (to == .gron) {
        if (comptime build_options.lang_json) {
            try gron.printNode(stdout_term.writer, ast, node_id, opts.gron_projection);
            try stdout_term.writer.flush();
        } else return error.FormatDisabled;
        return;
    }

    const target: fig.AST.SerializeFormat = switch (to) {
        .json => .json,
        .jsonc => .jsonc,
        .json5 => .json5,
        .yaml, .yml => .yaml,
        .toml => .toml,
        .zon => .zon,
        .canonical => .canonical,
        .fig => .fig,
        .xml => .xml,
        .gron => unreachable, // handled by the early return above
    };

    // Surface everything the conversion would silently lose (comments
    // dropped/degraded, values dropped/degraded) — unless `--quiet`. The
    // pass is read-only and runs on the AST as it will be printed: under
    // `--lossless` the lossy nodes are already enveloped, so no value
    // warnings fire. `--strict` turns any warning into a hard failure.
    if (!opts.quiet or opts.strict) {
        const warnings = try fig.Diagnostics.analyze(a, ast, node_id, target, .{
            .pretty = opts.serialize.pretty,
            .strip_comments = opts.serialize.strip_comments,
            .lossless = opts.lossless,
        });
        // The CLI only surfaces losses the FORMAT forced. A loss the user
        // explicitly asked for (e.g. `--strip-comments`) carries
        // `explicit_option` and is not surprising, so it neither warns nor
        // trips `--strict` — it just rides through on the warning layer for
        // a library consumer that wants it.
        var surfaced: usize = 0;
        for (warnings) |w| {
            if (w.cause != .format_limitation) continue;
            surfaced += 1;
            if (!opts.quiet) {
                try stderr_term.setColor(.yellow);
                try stderr_term.writer.writeAll("warning: ");
                try stderr_term.setColor(.reset);
                try w.render(stderr_term.writer, target);
                try stderr_term.writer.writeByte('\n');
            }
        }
        if (!opts.quiet) try stderr_term.writer.flush();
        if (opts.strict and surfaced > 0) {
            try stderr_term.writer.print("error: {d} lossy conversion warning(s); --strict aborts.\n", .{surfaced});
            try stderr_term.writer.flush();
            std.process.exit(1);
        }
    }

    if (target == .toml and !opts.lossless) {
        // TOML has no null. In lossy mode, rather than the printer
        // aborting mid-document on one, strip unrepresentable values up
        // front so output stays valid and complete (the warnings above
        // already reported them). `lossyStrip` re-roots at `node_id`, so
        // the result serializes whole.
        const result = try fig.Lossless.lossyStrip(a, ast, node_id, .toml);
        if (result.ast) |stripped| {
            try stripped.serializeWith(stdout_term.writer, .toml, opts.serialize);
        }
    } else if (opts.path == null) {
        ast.serializeWith(stdout_term.writer, target, opts.serialize) catch |err| switch (err) {
            error.FigUnrepresentableRoot => diag_report.reportFigUnrepresentableRoot(stderr_term),
            else => return err,
        };
    } else {
        ast.serializeNodeWith(stdout_term.writer, target, node_id, opts.serialize) catch |err| switch (err) {
            error.FigUnrepresentableRoot => diag_report.reportFigUnrepresentableRoot(stderr_term),
            else => return err,
        };
    }
    try stdout_term.writer.flush();
}

pub fn runComment(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.CommentOptions) !void {
    if (opts.requested_help) {
        try Help.comment(stdout_term, binary_name);
        return;
    }
    // `--get` only reads: open read-only and never write back.
    const input = try fileio.getInput(io, opts.file, if (opts.get) .read_only else .read_write);
    defer if (!std.mem.eql(u8, opts.file, "-")) input.close(io);

    const resolved = if (opts.detect) try parse_dispatch.detectFileFormat(io, a, opts.file) else opts.format;

    if (opts.get) {
        const comment = if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type|
            try edit_ops.getCommentFromEmbed(a, io, input, embed_type, opts.path, opts.inline_comment)
        else switch (resolved) {
            // Strict JSON has no comment syntax: there can be nothing to get.
            .json => {
                try stderr_term.writer.print("error: strict JSON has no comments; use a .jsonc or .json5 file instead.\n", .{});
                try stderr_term.writer.flush();
                std.process.exit(2);
            },
            .jsonc, .json5 => |f| if (comptime build_options.lang_json) try edit_ops.getCommentFromFile(fig.Language.JSON, a, io, input, opts.path, opts.inline_comment, edit_ops.jsonDialect(f)) else return error.FormatDisabled,
            .yaml, .yml => if (comptime build_options.lang_yaml)
                try edit_ops.getCommentFromFile(fig.Language.YAML, a, io, input, opts.path, opts.inline_comment, fig.Language.YAML.default_type)
            else
                return error.FormatDisabled,
            .toml => if (comptime build_options.lang_toml)
                try edit_ops.getCommentFromFile(fig.Language.TOML, a, io, input, opts.path, opts.inline_comment, fig.Language.TOML.default_type)
            else
                return error.FormatDisabled,
            .zon => if (comptime build_options.lang_zon)
                try edit_ops.getCommentFromFile(fig.Language.ZON, a, io, input, opts.path, opts.inline_comment, fig.Language.ZON.default_type)
            else
                return error.FormatDisabled,
            .xml => return error.UnsupportedXmlEdit,
            .canonical => return error.UnsupportedCanonicalEdit,
            .fig => if (comptime build_options.lang_fig)
                try edit_ops.getCommentFromFile(fig.Language.FIG, a, io, input, opts.path, opts.inline_comment, fig.Language.FIG.default_type)
            else
                return error.FormatDisabled,
            .gron => return error.UnsupportedGronEdit,
        };
        // Print the comment followed by a newline. An absent comment (null)
        // and a present-but-empty one both print just the newline — the CLI
        // can't distinguish them, but the bindings can (Option / null).
        try stdout_term.writer.print("{s}\n", .{comment orelse ""});
        try stdout_term.writer.flush();
        return;
    }

    // Pick the op from the two flags: `--inline` selects the trailing
    // (same-line) comment vs the leading block; `--delete` removes it
    // rather than adding/setting. The marker (`#`, `//`) is the editor's
    // job.
    const op: EditOp = if (opts.delete)
        (if (opts.inline_comment) .delete_trailing_comment else .delete_leading_comments)
    else
        (if (opts.inline_comment) .set_trailing_comment else .add_leading_comment);

    if (try args_mod.resolveEmbedType(io, a, input, opts.embed, opts.detect_embed)) |embed_type| {
        try edit_ops.applyToEmbed(a, io, input, embed_type, opts.path, opts.text, op);
    } else switch (resolved) {
        // Strict JSON has no comment syntax: fail with a clear message
        // rather than letting the editor surface a bare error.
        .json => {
            try stderr_term.writer.print("error: strict JSON has no comments; use a .jsonc or .json5 file instead.\n", .{});
            try stderr_term.writer.flush();
            std.process.exit(2);
        },
        // JSONC/JSON5 accept `//` comments (reparsed under the dialect).
        .jsonc, .json5 => |f| if (comptime build_options.lang_json) try edit_ops.applyToFile(fig.Language.JSON, a, io, input, opts.path, opts.text, op, edit_ops.jsonDialect(f)) else return error.FormatDisabled,
        .yaml, .yml => if (comptime build_options.lang_yaml)
            try edit_ops.applyToFile(fig.Language.YAML, a, io, input, opts.path, opts.text, op, fig.Language.YAML.default_type)
        else
            return error.FormatDisabled,
        .toml => if (comptime build_options.lang_toml)
            try edit_ops.applyToFile(fig.Language.TOML, a, io, input, opts.path, opts.text, op, fig.Language.TOML.default_type)
        else
            return error.FormatDisabled,
        .zon => if (comptime build_options.lang_zon)
            try edit_ops.applyToFile(fig.Language.ZON, a, io, input, opts.path, opts.text, op, fig.Language.ZON.default_type)
        else
            return error.FormatDisabled,
        .xml => return error.UnsupportedXmlEdit,
        .canonical => return error.UnsupportedCanonicalEdit,
        .fig => if (comptime build_options.lang_fig)
            try edit_ops.applyToFile(fig.Language.FIG, a, io, input, opts.path, opts.text, op, fig.Language.FIG.default_type)
        else
            return error.FormatDisabled,
        .gron => return error.UnsupportedGronEdit,
    }
}

pub fn runCheck(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.CheckOptions) !void {
    if (opts.requested_help) {
        try Help.check(stdout_term, binary_name);
        return;
    }

    // Validate every file, reporting each independently, so one bad file
    // doesn't hide the status of the rest. Success lines go to stdout
    // (silenced by `--quiet`); failures always go to stderr. A single
    // bad file makes the whole run exit non-zero — the CI contract.
    var any_failed = false;
    for (opts.files) |file| {
        var diag_source: ?[]const u8 = null;
        var diag_errors: ?[]const fig.ParseDiagnostic.Rendered = null;
        var diag_warnings: ?[]const fig.ParseDiagnostic.Rendered = null;
        if (parse_dispatch.checkOne(a, io, file, opts.format, opts.spec, &diag_source, &diag_errors, &diag_warnings)) |fmt| {
            if (!opts.quiet) {
                try stdout_term.setColor(.green);
                try stdout_term.writer.writeAll("ok");
                try stdout_term.setColor(.reset);
                // Echo the pinned version alongside the format when one
                // was requested, so `ok` states exactly what was checked.
                if (opts.spec) |spec|
                    try stdout_term.writer.print(": {s} ({s} {s})\n", .{ file, @tagName(fmt), spec })
                else
                    try stdout_term.writer.print(": {s} ({s})\n", .{ file, @tagName(fmt) });
                // Authoring-time lints: the file is valid (still `ok`),
                // but likely-mistake lines print right below it. Rendered
                // live against the real terminal (not buffered into a
                // string — see `printDiag`), then flushed immediately so
                // it can't interleave with the next file's logging.
                if (diag_warnings) |ws| {
                    for (ws) |w| try diag_report.printDiag(stderr_term, diag_source.?, file, w.offset, w.end, "warning", .yellow, w.message, w.short_label);
                    try stderr_term.writer.flush();
                }
            }
        } else |err| {
            any_failed = true;
            // A covered-language failure renders as a full
            // `file:line:col: error: …` teaching report per error
            // (recovery collects every error in the file, not just the
            // first) instead of the generic `file: ErrorName` line.
            if (diag_errors) |errs| {
                for (errs) |d| try diag_report.printDiag(stderr_term, diag_source.?, file, d.offset, d.end, "error", .red, d.message, d.short_label);
                try stderr_term.writer.flush();
                continue;
            }
            try stderr_term.setColor(.red);
            try stderr_term.writer.writeAll("error");
            try stderr_term.setColor(.reset);
            switch (err) {
                // A spec mismatch is a CLI usage error, not a malformed
                // document — say so plainly with the offending version.
                error.UnsupportedSpec => try stderr_term.writer.print(
                    ": {s}: --spec '{s}' is not valid for this format\n",
                    .{ file, opts.spec.? },
                ),
                else => try stderr_term.writer.print(": {s}: {s}\n", .{ file, @errorName(err) }),
            }
        }
    }
    try stdout_term.writer.flush();
    try stderr_term.writer.flush();
    if (any_failed) std.process.exit(1);
}

pub fn runFmt(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.FmtOptions) !void {
    if (opts.requested_help) {
        try Help.fmt(stdout_term, binary_name);
        return;
    }
    // `--diff` is a preview mode just like `--dry-run` — nothing is
    // ever written to `file` under either.
    const preview_only = opts.dry_run or opts.diff;
    const is_stdin = std.mem.eql(u8, opts.file, "-");
    if (!preview_only and is_stdin) {
        try stderr_term.writer.print(
            "error: cannot format stdin in place; pass --dry-run or --diff to print the formatted result instead.\n",
            .{},
        );
        try stderr_term.writer.flush();
        std.process.exit(2);
    }

    // Read-only when only previewing (`--dry-run`/`--diff`): no need to
    // open for writing what will never be written. Otherwise read_write,
    // like `edit`/`set`/`insert`/`delete` — read the whole file first,
    // then splice the same handle in place (never via shell redirection,
    // which truncates a `> file` target before this process ever runs).
    const input = try fileio.getInput(io, opts.file, if (preview_only) .read_only else .read_write);
    defer if (!is_stdin) input.close(io);

    const content = try fileio.readAll(a, io, input);

    // `--embed`: only the region between the fences is reformatted; the
    // rest of the host document is carried through byte-identical.
    if (args_mod.resolveEmbedTypeFromContent(content, opts.embed, opts.detect_embed)) |embed_type| {
        const region = try fig.Embed.locateRegion(content, embed_type);
        const inner = content[region.content.start..region.content.end];
        const reformatted_inner = try reformat.reformatSlice(a, stderr_term, opts.file, args_mod.embedFormat(embed_type), inner, opts.serialize, opts.quiet, opts.strict);

        var out: std.ArrayList(u8) = .empty;
        defer out.deinit(a);
        try out.appendSlice(a, content[0..region.content.start]);
        try out.appendSlice(a, reformatted_inner);
        try out.appendSlice(a, content[region.content.end..]);

        const changed = !std.mem.eql(u8, content, out.items);
        if (opts.diff) {
            try diff.unifiedDiff(a, stdout_term.writer, opts.file, content, out.items, 3);
            try stdout_term.writer.flush();
            if (changed) std.process.exit(1);
        } else if (opts.dry_run) {
            try stdout_term.writer.writeAll(out.items);
            try stdout_term.writer.flush();
            if (changed) std.process.exit(1);
        } else if (changed) {
            try input.writePositionalAll(io, out.items, 0);
            try input.setLength(io, out.items.len);
        }
        return;
    }

    var from = opts.from;
    if (opts.detect) from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);

    const reformatted = try reformat.reformatSlice(a, stderr_term, opts.file, from, content, opts.serialize, opts.quiet, opts.strict);
    const changed = !std.mem.eql(u8, content, reformatted);

    if (opts.diff) {
        try diff.unifiedDiff(a, stdout_term.writer, opts.file, content, reformatted, 3);
        try stdout_term.writer.flush();
        if (changed) std.process.exit(1);
    } else if (opts.dry_run) {
        try stdout_term.writer.writeAll(reformatted);
        try stdout_term.writer.flush();
        if (changed) std.process.exit(1);
    } else if (changed) {
        // Read-then-splice-same-handle (never shell redirection): the
        // in-memory `content` above was read before this write touches
        // the file, so there is no truncate-before-read race.
        try input.writePositionalAll(io, reformatted, 0);
        try input.setLength(io, reformatted.len);
    }
}

/// Finish a `convert` invocation given the original `content` and the
/// `result` it converts to: write `result` back to `input` in place when
/// `write` is set (skipped if the bytes are already identical), then print
/// either a unified diff (`show_diff`) or the whole `result` to stdout — the
/// whole result only when neither `write` nor `show_diff` fired, so `--write`
/// alone stays silent (like `fmt`) and `--write --diff` shows what changed
/// without also dumping the full file. Shared by both of `runConvert`'s modes
/// (whole-file and `--to-embed`), which differ only in how `result` is produced.
fn finishConvert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, input: Io.File, file_path: []const u8, content: []const u8, result: []const u8, write: bool, show_diff: bool) !void {
    const changed = !std.mem.eql(u8, content, result);
    if (write and changed) {
        try input.writePositionalAll(io, result, 0);
        try input.setLength(io, result.len);
    }
    if (show_diff) {
        try diff.unifiedDiff(a, stdout_term.writer, file_path, content, result, 3);
        try stdout_term.writer.flush();
    } else if (!write) {
        try stdout_term.writer.writeAll(result);
        try stdout_term.writer.flush();
    }
}

pub fn runConvert(a: std.mem.Allocator, io: Io, stdout_term: *Io.Terminal, stderr_term: *Io.Terminal, binary_name: []const u8, opts: types.ConvertOptions) !void {
    if (opts.requested_help) {
        try Help.convert(stdout_term, binary_name);
        return;
    }
    const is_stdin = std.mem.eql(u8, opts.file, "-");
    if (opts.write and is_stdin) {
        try stderr_term.writer.print(
            "error: cannot write stdin in place; omit --write to print the converted result instead.\n",
            .{},
        );
        try stderr_term.writer.flush();
        std.process.exit(2);
    }

    const input = try fileio.getInput(io, opts.file, if (opts.write) .read_write else .read_only);
    defer if (!is_stdin) input.close(io);

    const content = try fileio.readAll(a, io, input);

    if (opts.to_embed) |to_embed_type| {
        // Embed-archetype mode: resolve the SOURCE archetype (--embed,
        // else content-sniffed with `Embed.detect` — extension-derived
        // defaults were already folded into `opts.embed` at parse time),
        // convert the region's inner content, then rehouse it under the
        // target archetype's fences, preserving the host prose exactly.
        const source_type = opts.embed orelse (if (opts.detect_embed) fig.Embed.detect(content) else null) orelse {
            try stderr_term.writer.print(
                "error: could not detect an embedded region in `{s}`; pass --embed explicitly.\n",
                .{opts.file},
            );
            try stderr_term.writer.flush();
            std.process.exit(2);
        };
        const region = try fig.Embed.locateRegion(content, source_type);
        const inner = content[region.content.start..region.content.end];
        const converted_inner = try reformat.convertSlice(
            a,
            stderr_term,
            opts.file,
            args_mod.embedFormat(source_type),
            args_mod.embedFormat(to_embed_type),
            inner,
            opts.serialize,
            opts.lossless,
            opts.lax_tags,
            opts.quiet,
            opts.strict,
        );
        const out = try fig.Embed.retype(a, content, region, to_embed_type, converted_inner);
        try finishConvert(a, io, stdout_term, input, opts.file, content, out, opts.write, opts.diff);
        return;
    }

    var from = opts.from;
    if (opts.detect) from = try parse_dispatch.resolveFormatFromContent(a, content, opts.file);

    const converted = try reformat.convertSlice(
        a,
        stderr_term,
        opts.file,
        from,
        opts.to,
        content,
        opts.serialize,
        opts.lossless,
        opts.lax_tags,
        opts.quiet,
        opts.strict,
    );
    try finishConvert(a, io, stdout_term, input, opts.file, content, converted, opts.write, opts.diff);
}