fig-sys 5.0.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
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
//! CLI-only teaching-style diagnostic rendering: cargo/rustc-shaped
//! `file:line:col` reports with a source-line gutter and a colored
//! underline, plus the shared `--quiet`/`--strict` warning contract every
//! action's parse path uses. This is a presentation layer over the
//! language-agnostic `fig.ParseDiagnostic.Rendered` shape — it never knows
//! about a specific language's own `Diagnostic`/`Warning` types beyond the
//! `describe`/`shortLabel` functions passed in by the caller.
const std = @import("std");
const fig = @import("fig");
const Io = std.Io;

const types = @import("types.zig");
const Format = types.Format;
const EditTextKind = types.EditTextKind;

/// Print a teaching report straight to `term`, cargo/rustc-style:
///   <label>: <message>
///   --> <file>:<line>:<col>
///    |
///   7 | <source line>
///    |          ~~~~ <short_label>
/// highlighting the reported `[offset, end)` span (a `~~~~` underline, or a
/// single `^` when `end` is null or the span is one byte), coloring the label
/// word and the highlight+`short_label` in `color`, and the `-->` pointer plus
/// the `N |` gutter in blue. Language-agnostic (every field is plain data —
/// see `fig.ParseDiagnostic.Rendered`), so every covered language (fig, JSON,
/// TOML, INI, dotenv, `.properties`, NestedText, YAML) renders through
/// this one function; only `renderAll`'s
/// per-language `describe`/`shortLabel` calls differ. This is a CLI-only
/// sibling of a language's own `Diagnostic.renderAlloc`/`Warning.renderAlloc`
/// (see `languages/fig/parser.zig`'s private `renderReportAlloc`, which still
/// produces its own plain `file:line:col: <label>: <message>` shape) — not a
/// replacement: the library's `renderAlloc` stays a plain, colorless string for
/// every other caller (the LSP reads the structured `code`/`offset` fields
/// directly and never calls it; the C ABI's `FigWarning`/`FigError` are plain
/// data too), so nothing outside this binary is affected by adding color or
/// reshaping the layout here.
///
/// Deliberately never buffered into an intermediate string: under
/// `Io.Terminal.Mode.windows_api`, `setColor` sets the real console's text
/// attributes via a direct syscall rather than writing escape bytes into the
/// stream, so it only works called live against the real terminal — see
/// `std.Io.Terminal.setColor`.
pub fn printDiag(term: *Io.Terminal, source: []const u8, file: []const u8, offset: usize, end: ?usize, label: []const u8, color: Io.Terminal.Color, message: []const u8, short_label: []const u8) !void {
    const loc = fig.ParseDiagnostic.locateOffset(source, offset);
    try term.setColor(color);
    try term.writer.writeAll(label);
    try term.setColor(.reset);
    try term.writer.print(": {s}\n", .{message});
    try term.setColor(.blue);
    try term.writer.writeAll("--> ");
    try term.setColor(.reset);
    try term.writer.print("{s}:{d}:{d}\n", .{ file, loc.line, loc.column });

    // Mirrors `renderReport`'s source-line + caret, but in the cargo/rustc
    // gutter shape: a blank `|` line, the numbered source line, then a
    // highlight line under the offending span carrying `short_label`. Capped
    // so a pathological line can't flood the terminal; the highlight mirrors
    // tabs in the source to stay aligned under them. The gutter's width
    // tracks the line number's digit count so the blank/highlight `|` lines
    // up under the source line's `|`.
    const max_shown = 160;
    const shown = loc.line_text[0..@min(loc.line_text.len, max_shown)];
    if (shown.len == 0) return; // EOF/blank line: nothing to point into

    var line_num_buf: [20]u8 = undefined;
    const line_num = std.fmt.bufPrint(&line_num_buf, "{d}", .{loc.line}) catch unreachable;

    try term.setColor(.blue);
    try term.writer.splatByteAll(' ', line_num.len);
    try term.writer.writeAll(" |\n");
    try term.writer.print("{s} | ", .{line_num});
    try term.setColor(.reset);
    try term.writer.print("{s}{s}\n", .{ shown, if (shown.len < loc.line_text.len) "…" else "" });

    if (loc.column - 1 <= shown.len) {
        try term.setColor(.blue);
        try term.writer.splatByteAll(' ', line_num.len);
        try term.writer.writeAll(" | ");
        try term.setColor(.reset);
        for (shown[0 .. loc.column - 1]) |c| try term.writer.writeByte(if (c == '\t') '\t' else ' ');
        try term.setColor(color);
        // Highlight the reported `[offset, end)` span rather than a single
        // point: a `~~~~` underline when the parser gave a real multi-byte
        // extent (`end`), a single `^` when it didn't (fall back to "just the
        // start") or when the span is exactly one byte — matching how a `^`
        // and a `~~~~` read identically for a one-character span anyway.
        // Never runs past the portion of the line actually printed above.
        const span_len = if (end) |e| (if (e > offset) e - offset else 1) else 1;
        const draw_len = @max(1, @min(span_len, shown.len - (loc.column - 1)));
        if (draw_len <= 1) {
            try term.writer.writeAll("^");
        } else {
            try term.writer.splatByteAll('~', draw_len);
        }
        try term.writer.print(" {s}\n", .{short_label});
        try term.setColor(.reset);
    }
}

/// Convert a language's own `Diagnostic`/`Warning` slice (each carries a typed
/// `code` that only that language's `describe`/`shortLabel`-shaped functions
/// know how to read) into the language-agnostic `fig.ParseDiagnostic.Rendered`
/// shape `printDiag` and the `check` action work with — computed once, right
/// after parsing, so nothing downstream needs per-language knowledge. `items`
/// is any `[]const T` for a `T` with `{ code, offset, end }` fields (a
/// language's `Diagnostic` or `Warning`); `describeFn`/`labelFn` are that
/// type's own `describe`/`shortLabel`-shaped functions. Allocates with `a`
/// (the CLI's arena — never freed individually, same as the reports this
/// replaces).
pub fn renderAll(a: std.mem.Allocator, items: anytype, comptime describeFn: anytype, comptime labelFn: anytype) ![]const fig.ParseDiagnostic.Rendered {
    const out = try a.alloc(fig.ParseDiagnostic.Rendered, items.len);
    for (items, 0..) |it, i| out[i] = .{ .offset = it.offset, .end = it.end, .message = describeFn(it.code), .short_label = labelFn(it.code) };
    return out;
}

/// Render one parse failure as a `printDiag` teaching report and exit(1) — the
/// `get`-time twin of `check`'s per-error loop, for the single diagnostic a
/// non-recovering parse produces. Shared by every language with a `Report`
/// (every one whose parser declares `parseWithReport`) so `get`'s error path doesn't repeat this
/// print-flush-exit sequence per language.
pub fn reportParseError(term: *Io.Terminal, source: []const u8, file: []const u8, offset: usize, end: ?usize, message: []const u8, short_label: []const u8) !void {
    try printDiag(term, source, file, offset, end, "error", .red, message, short_label);
    try term.writer.flush();
    // A file that does not parse is a failure on the document, not a wrong
    // command line: exit 1, as `check` and the editing actions always did.
    std.process.exit(1);
}

/// The binary's last line of defense: report an error that reached `main`
/// without any handler of its own, then exit(1). Named errors that a user can
/// actually act on get a sentence; the rest print their name (as the escaping
/// path used to) but without the stack trace that came with it. The
/// overwhelmingly common cause, a file that does not parse, never reaches
/// here in a format with a located report: `main` re-parses the target the
/// way `check` does and prints that report instead. See `main`'s call site
/// for why nothing is left to escape.
pub fn reportUnhandled(term: *Io.Terminal, err: anyerror, file: ?[]const u8, binary_name: []const u8) noreturn {
    reportUnhandledImpl(term, err, file, binary_name) catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

fn reportUnhandledImpl(term: *Io.Terminal, err: anyerror, file: ?[]const u8, binary_name: []const u8) !void {
    try term.setColor(.red);
    try term.writer.writeAll("error");
    try term.setColor(.reset);
    switch (err) {
        error.FileNotFound => try term.writer.print(": no such file: {s}\n", .{file orelse "(unknown)"}),
        error.AccessDenied => try term.writer.print(": permission denied: {s}\n", .{file orelse "(unknown)"}),
        error.IsDir => try term.writer.print(": {s} is a directory\n", .{file orelse "(unknown)"}),
        // The editor's navigation failures — the path, not the document.
        error.NotFound => try term.writer.writeAll(": no such path in the document (`get` it to see what's there)\n"),
        error.NotAMapping => try term.writer.writeAll(": a segment of this path is not a mapping, so it has no keys to address\n"),
        error.NotASequence => try term.writer.writeAll(": a segment of this path is not a sequence, so it has no indices to address\n"),
        error.IndexOutOfBounds => try term.writer.writeAll(": that index is past the end of the sequence\n"),
        error.ContainerClosesOnItsLine => {
            try term.writer.writeAll(": that container closes on the line of its last entry or item, so a new one has no line of its own to go on\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.writeAll(": put the container's close on a line of its own first; an entry appended after the line would land in the container around it.\n");
        },
        // The engine's section refusal on a value replace (TOML tables, INI
        // sections, fig block containers). Worth a sentence of its own: the
        // raw error name reads like a limitation of the
        // tool, when what it means is that the path names a header rather than a
        // value — and a bare error name reads like a parse error in a file
        // that parses fine.
        error.CannotReplaceTable, error.CannotReplaceSection, error.CannotReplaceContainer => {
            try term.writer.writeAll(": that path names a whole block table/section, which has no single value to replace\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": a `[table]`/`[section]` owns nothing contiguous but the name in its header — its entries are separate lines, and it may be reopened further down the file. Replacing it as one value would rewrite that NAME and leave the entries where they are.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": edit the keys inside it instead — `{s} set <file> <path>.<key> <value>`.\n", .{binary_name});
        },
        // The same refusal on an op that removes lines — `comment` taking a
        // node out. `delete` itself never reaches here: it hands a section to
        // the whole-container delete.
        error.CannotDeleteTable, error.CannotDeleteSection, error.CannotDeleteContainer => {
            try term.writer.writeAll(": that path names a whole block table/section, whose entries are lines this op cannot take out one by one\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": to remove it whole — header, entries, and every place it is reopened — `{s} delete <file> <path>`.\n", .{binary_name});
        },
        // `insert` naming a key the mapping already holds. The engine
        // refuses it rather than write a second entry of that name.
        error.DuplicateKey => {
            try term.writer.writeAll(": that key already exists, and `insert` only adds new ones\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": to change its value — `{s} set <file> <path> <value>` (or `{s} replace`, which only replaces).\n", .{ binary_name, binary_name });
        },
        // `rename` onto a name another entry of the mapping holds: the
        // engine refuses it rather than write a second entry of that name.
        error.RenameTargetExists => {
            try term.writer.writeAll(": the mapping already has a key of that name, and a rename would give it two\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": `{s} delete <file> <path>` the other one first, or pick another name.\n", .{binary_name});
        },
        // `rename` at a path that ends in an index, or the empty path.
        error.NotAKey => try term.writer.writeAll(": that path names a sequence item or the root, which has no key to rename\n"),
        // `set --embed <archetype>` creating a block at the top of a host
        // whose first line already opens frontmatter of another archetype.
        error.FrontmatterExists => {
            try term.writer.writeAll(": the file already has frontmatter of another kind, and a new block at the top would push it off the first line\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": to edit the frontmatter it has, leave out --embed (a .md file's is sniffed) or name that archetype; to change its archetype, `{s} convert --to-embed <archetype> --write <file>`.\n", .{binary_name});
        },
        // An insert under a section that is only ever named as the prefix
        // of its children's headers — TOML's implicit `a` made by `[a.b]`.
        error.ImplicitSection => {
            try term.writer.writeAll(": that section has no header line of its own — it is only named as part of its children's headers — so there is no line to put the new entry under\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": writing it after the first of those headers would put it in that child instead.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.writeAll(": give the section a header of its own first — `[a]` for TOML's `[a.b]` — and try again.\n");
        },
        // `fig comment` on an element or entry of a one-line flow collection.
        // Worth its own sentence for the same reason as the arms above: the
        // error is about the PATH, and a bare error name reads like a parse
        // error in a file that parses fine. The old behaviour was worse than a refusal — it edited the
        // parent's comment through the item.
        error.CommentsUnanchored => {
            try term.writer.writeAll(": that path names an item of a one-line `[...]`/`{...}`, which shares its parent's line and so owns no comment of its own\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": a comment written inside a flow collection is discarded when the file is read back, so there is nowhere on that line to put one that would survive — and the line above it, and its end, belong to the key the collection is the value of.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": comment the whole collection instead — `{s} comment <file> <path-without-the-index> <text>` — or rewrite it with one item per line, where each item does own its line.\n", .{binary_name});
        },
        // `--seq` (`Editor.setSequence`) is the only caller that reaches here,
        // so this speaks in its terms: the other producer, `Editor.kvSep`, is
        // kept unreachable by a `language.validate` rule (a null `kv_sep` must
        // come with an `insertKey` hook — see `tools/validate-check.zig`).
        // Its by-value matching needs every item text to parse as a standalone
        // document, which no TOML scalar does — so TOML declines on every
        // input, and a bare error name reads like a parse error in a file
        // that parses fine.
        error.UnsupportedShape => {
            try term.writer.writeAll(": this is not a flat list of scalars, so `--seq` cannot diff it\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": `--seq` pairs new items with current ones by value, so both lists must be non-empty and every item on each side must be a scalar that parses on its own. TOML never qualifies — its scalars cannot stand alone as a document.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": replace the whole list instead — `{s} set <file> <path> '[...]'`. On TOML that loses nothing, as its arrays carry no per-item comments.\n", .{binary_name});
        },
        // A runtime language's renderer declined the text it was handed — a
        // key that needs a spelling the slot cannot take, a value the format
        // has no form for. The helper said why, in its own words.
        error.RendererRefused => {
            try term.writer.print(": the language declined to spell this edit: {s}\n", .{fig.Runtime.lastRefusal()});
        },
        // The editor's uncomment refusal. Reachable through the library (and
        // any `fig-<action>` program built on it) rather than through a
        // built-in action today, but it is an editor error like the ones above
        // and reads as a tool limitation without a sentence: what it means is
        // that the lines named were not the entry they were taken for.
        error.CommentNotAnEntry => {
            try term.writer.writeAll(": those comment lines do not come back as an entry — uncommenting them changed nodes elsewhere in the document\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": nothing was written — the edit was rolled back, and the file is byte-for-byte as it was.\n");
        },
        // The refusals `fig.Patch` makes rather than guessing (see its module
        // doc). Each names a shape the caller has to resolve in one of the two
        // documents; a bare error name reads like a parse error in two files
        // that both parse fine.
        error.PatchThroughAlias => {
            try term.writer.writeAll(": the patch targets a value that is a YAML alias (`*name`), which belongs to whatever defined its anchor\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": writing there would either change every other user of that anchor or silently shadow it, and nothing in the two files says which was meant.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": patch the anchor's own definition instead, or give the alias a local value first — `{s} set <file> <path> <value>`.\n", .{binary_name});
        },
        error.PatchRootNotMergeable => {
            try term.writer.writeAll(": a patch whose root is not a mapping has nothing to merge INTO the document root\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(": name where it lands — `{s} patch <file> <patch-file> --at <path>` — or, to take the patch document whole, copy it.\n", .{binary_name});
        },
        error.NonStringPatchKey => {
            try term.writer.writeAll(": the patch has a mapping key that is not a string, and paths are string-keyed, so there is no way to address that entry\n");
        },
        error.PatchRenderRejected => {
            try term.writer.writeAll(": a value from the patch has no spelling the target format can read back\n");
            try term.setColor(.blue);
            try term.writer.writeAll("note");
            try term.setColor(.reset);
            try term.writer.writeAll(": nothing was written — the edit was rolled back where it failed.\n");
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.writeAll(": --lossless carries values the target has no native form for (a null into TOML, a datetime into JSON) through a $fig envelope.\n");
        },
        // No pointer at `fig check` here: `main` has already run the parse
        // `check` would, and found nothing it could locate — so `check` would
        // print this same name, and sending the user to it said nothing new.
        else => try term.writer.print(": {s}\n", .{@errorName(err)}),
    }
}

/// Report a document the reference-layer pass (`fig.Materialize`) refused on
/// its way out of YAML, and exit(1). `node` is the source node the pass
/// failed on, when its span is an offset into `file` (see
/// `parse_dispatch.materializeFor`); the report then points at the tag — or,
/// for a cyclic alias, at the alias — with the source line under it. `target`
/// names the format being converted to.
pub fn reportMaterializeError(term: *Io.Terminal, err: fig.Materialize.Error, doc: *const fig.Document, node: ?fig.AST.Node.Id, file: []const u8, target: []const u8) noreturn {
    reportMaterializeErrorImpl(term, err, doc, node, file, target) catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

fn reportMaterializeErrorImpl(term: *Io.Terminal, err: fig.Materialize.Error, doc: *const fig.Document, node: ?fig.AST.Node.Id, file: []const u8, target: []const u8) !void {
    const source = doc.source;
    // The span to point at: the tag for a tag failure, the alias itself for
    // a cycle. Null when there is nothing to point into.
    const span: ?fig.Span = if (node) |id| switch (err) {
        error.AliasCycle => if (id < doc.node_spans.len) doc.node_spans[id] else null,
        else => doc.tagSpan(doc.ast.nodes[id]),
    } else null;
    const tag: ?[]const u8 = if (span) |s| (if (err != error.AliasCycle and s.end <= source.len) source[s.start..s.end] else null) else null;

    var buf: [512]u8 = undefined;
    const message: []const u8, const short_label: []const u8 = switch (err) {
        error.UnknownTag => .{
            if (tag) |t|
                std.fmt.bufPrint(&buf, "`{s}` is a custom YAML tag, and {s} has no way to carry one", .{ t, target }) catch "a custom YAML tag has no representation in the output format"
            else
                std.fmt.bufPrint(&buf, "this document has a custom YAML tag, and {s} has no way to carry one", .{target}) catch "a custom YAML tag has no representation in the output format",
            "unknown tag",
        },
        error.TagTypeMismatch => .{
            if (tag) |t|
                std.fmt.bufPrint(&buf, "this value does not read as the type its `{s}` tag names", .{t}) catch "a value does not read as the type its tag names"
            else
                "a value in this document does not read as the type its `!!` tag names",
            "tag does not fit value",
        },
        error.AliasCycle => .{
            std.fmt.bufPrint(&buf, "this alias sits inside the node it refers to, so it cannot be expanded into {s}, which has no references", .{target}) catch "a cyclic alias cannot be expanded",
            "cyclic alias",
        },
        else => unreachable,
    };

    if (span) |s| {
        try printDiag(term, source, file, s.start, s.end, "error", .red, message, short_label);
    } else {
        try term.setColor(.red);
        try term.writer.writeAll("error");
        try term.setColor(.reset);
        try term.writer.print(": {s}: {s}\n", .{ file, message });
    }
    switch (err) {
        error.UnknownTag => {
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.writeAll(": pass --lax-tags to drop unknown tags and keep each value as it was parsed, or convert to YAML, which keeps them.\n");
        },
        error.TagTypeMismatch => {
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.writeAll(": change the value to one the tag's type can read, or remove the tag.\n");
        },
        else => {},
    }
}

/// How a format takes the caller's text, which decides what the fix is when the
/// text turns out not to fit: spliced in verbatim as source (so a string needs
/// its own quotes), wrapped as a JSON string (so `"`/`\` need escaping), or
/// written as raw characters (so only the format's own separators can break
/// it). Read straight off the format registry, which is also what
/// `edit_ops.route` acts on — so the note printed here and the splice the user
/// actually got cannot describe two different things.
const SpliceStyle = fig.Language.SpliceStyle;

fn spliceStyle(format: Format) SpliceStyle {
    @setEvalBranchQuota(30_000);
    return switch (format) {
        // Neither has an in-place editor, so no edit text ever reaches here.
        .canonical, .gron => .literal,
        _ => if (types.runtimeEntry(format)) |e| e.splice else .literal,
        inline else => |f| comptime fig.Language.entryFor(@tagName(f)).splice,
    };
}

/// Report an edit whose *argument* — not the file — is what doesn't parse, and
/// exit: 2 when the text was source the user typed, 1 when it was a fig value
/// the document refused (see below). `replace`/`rename`/`set`/`insert` splice the text
/// into the document, so when the reparse fails the underlying error describes the
/// spliced bytes ("not a valid TOML number" for a git sha) while pointing at a
/// file the user believes is fine. `edit_ops.applyEdit` turns that case into
/// `error.InvalidEditText` (it knows the document parsed before the splice);
/// this is where it becomes a message that names the argument and the fix.
///
/// `format` is null under `--detect`, where the resolved format isn't known
/// here — the wording then stays format-agnostic rather than risk naming the
/// wrong one. `text` is null for `set --seq`, whose several values give
/// nothing single to quote back.
pub fn reportBadEditText(term: *Io.Terminal, file: []const u8, format: ?Format, kind: EditTextKind, text: ?[]const u8, rendered: ?[]const u8) noreturn {
    reportBadEditTextImpl(term, file, format, kind, text, rendered) catch {};
    term.writer.flush() catch {};
    // Text the user wrote as source (a `--raw` value, a key, a comment) that
    // does not parse is the command line's fault: exit 2. A fig value was
    // already read cleanly; the document refusing its spelling is exit 1.
    std.process.exit(if (kind == .value) 1 else 2);
}

fn reportBadEditTextImpl(term: *Io.Terminal, file: []const u8, format: ?Format, kind: EditTextKind, text: ?[]const u8, rendered: ?[]const u8) !void {
    // Long text (a pasted blob, a whole inline table) would bury the message;
    // enough is shown to recognize which argument is meant.
    const max_shown = 120;
    const shown: ?[]const u8 = if (text) |t| t[0..@min(t.len, max_shown)] else null;
    const elided = if (text) |t| t.len > max_shown else false;

    try term.setColor(.red);
    try term.writer.writeAll("error");
    try term.setColor(.reset);
    if (shown) |s|
        try term.writer.print(": `{s}{s}` is not a valid {s} ", .{ s, if (elided) "…" else "", kind.noun() })
    else
        try term.writer.print(": one of the new values is not valid ", .{});
    if (format) |f|
        try term.writer.print("for {s} ({s})\n", .{ file, types.name(f) })
    else
        try term.writer.print("for {s}\n", .{file});

    const style = if (format) |f| spliceStyle(f) else .literal;
    try term.setColor(.blue);
    try term.writer.writeAll("note");
    try term.setColor(.reset);
    if (kind == .value) {
        // Read as a fig value and spelled as this format spells it: the
        // spelling was refused where it landed, not the user's text. Show
        // that spelling when it differs from what was typed.
        const fmt_name = if (format) |f| types.name(f) else "the file's format";
        if (rendered) |r| if (text == null or !std.mem.eql(u8, r, text.?)) {
            const r_shown = r[0..@min(r.len, max_shown)];
            try term.writer.print(
                ": the value was read as a fig value and written as {s} writes it — `{s}{s}` — and the document would not take that there; --raw splices your text as it stands.\n",
                .{ fmt_name, r_shown, if (r.len > max_shown) "…" else "" },
            );
            return;
        };
        try term.writer.print(
            ": the value was read as a fig value and written as {s} writes it, and the document would not take it there; --raw splices your text as it stands.\n",
            .{fmt_name},
        );
        return;
    }
    switch (style) {
        // The JSON family splices its keys, comments and `--raw` values as
        // source, like every other literal format.
        .literal, .json_string => try term.writer.print(
            ": the {s} is spliced in verbatim, as source text — so it has to stand on its own as a valid {s} literal.\n",
            .{ kind.noun(), if (format) |f| types.name(f) else "document" },
        ),
        .raw => try term.writer.print(
            ": the {s} is written out as-is, so it cannot contain a line break or this format's own separators.\n",
            .{kind.noun()},
        ),
    }

    // The overwhelmingly common case: text that needed quotes and lost the
    // ones the shell ate. Only offered when the text isn't already quoted —
    // re-suggesting quotes on `"..."` would just be wrong.
    if (style != .raw and kind == .raw_value) if (shown) |s| {
        if (s.len > 0 and s[0] != '"' and s[0] != '\'') {
            try term.setColor(.blue);
            try term.writer.writeAll("help");
            try term.setColor(.reset);
            try term.writer.print(
                ": for a string, drop --raw, or pass the quotes too — your shell strips the ones you type: '\"{s}{s}\"'\n",
                .{ s, if (elided) "…" else "" },
            );
        }
    };
}

/// A value argument the file's format has no spelling for — `null` into
/// TOML, a sequence into dotenv — refused before anything was written, and
/// exit(1): the command line was fine, the document cannot hold it.
pub fn reportUnwritableValue(term: *Io.Terminal, file: []const u8, err: anyerror) noreturn {
    const why: []const u8 = switch (err) {
        error.UnwritableNull => "this format has no null; to leave the key without a value, `delete` it",
        error.UnwritableNested => "this format holds only flat values there, so a sequence or a table cannot be written",
        error.UnwritableKey => "the value has a key this format cannot spell",
        else => @errorName(err),
    };
    term.setColor(.red) catch {};
    term.writer.writeAll("error") catch {};
    term.setColor(.reset) catch {};
    term.writer.print(": the value cannot be written to {s}: {s}\n", .{ file, why }) catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

/// A scalar/null value reaching the fig printer as a document root has no
/// authoring spelling there (`languages/fig/printer.zig`'s `root` hard-errors
/// with `FigUnrepresentableRoot` rather than emit non-conforming output) — print
/// the teaching message and exit(1) here rather than let the raw error escape
/// to `main`'s top level. Letting it escape would still work, but would print
/// nothing but a bare Zig stack trace: `main`'s return-error path and this
/// function share one positional writer over stderr's fd, while an escaping
/// error is reported through the Zig runtime's OWN separate stderr writer (the
/// same `debug_io`-vs-`stderr_terminal` split documented in `cli/main.zig` for
/// `std.log`) — on redirection, whichever writes second silently clobbers the
/// first from byte 0, so any warning already printed disappears too. Exiting
/// here, like every other user-facing CLI failure in this binary, sidesteps
/// that entirely.
pub fn reportFigUnrepresentableRoot(term: *Io.Terminal) noreturn {
    term.writer.writeAll("error: a scalar value cannot be the root of a .fig/.figl document; use canonical form or another output format instead (see docs/spec.md § 2).\n") catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

/// Every OTHER way a printer can fail — a value/shape the target format has no
/// spelling for at all (an array/nested table reaching INI/dotenv/`.properties`,
/// a non-identifier dotenv key, a non-string mapping key reaching TOML/ZON,
/// ...). Exhaustive over
/// `fig.AST.SerializeError` so a NEW variant is a compile error here rather
/// than silently falling through to a crash. `FigUnrepresentableRoot` is
/// included for completeness (a call site that forgets to special-case it
/// separately still gets a decent message) even though every current call
/// site intercepts it first via `reportFigUnrepresentableRoot`'s more specific
/// wording. Same reasoning as that function for why this exits here instead
/// of letting the error escape to `main`'s top level: an escaping error
/// prints nothing but a bare, unreadable Zig stack trace (see its doc).
pub fn reportSerializeError(term: *Io.Terminal, err: fig.AST.SerializeError) noreturn {
    const message: []const u8 = switch (err) {
        error.WriteFailed => "failed to write output",
        error.UnresolvedAlias => "an unresolved YAML alias reached the printer (internal error — please report this)",
        error.NullUnsupported => "a `null` value has no representation in this output format",
        error.NonStringKey => "a non-string mapping key has no representation in this output format",
        error.FormatDisabled => "the requested format was not compiled into this build",
        error.NestingTooDeep => "this document nests too deeply for the canonical printer's depth guard",
        error.UnexpectedNodeKind => "an internal fig printer error occurred (please report this)",
        error.FigUnrepresentableRoot => "a scalar value cannot be the root of a .fig/.figl document; use canonical form or another output format instead (see docs/spec.md § 2)",
        error.UnsupportedValue => "this document contains an array, or a table nested deeper than this format allows (INI: one level of `[section]`; dotenv/`.properties`: none)",
        error.InvalidKey => "a mapping key is not valid in this output format (a dotenv key must be a bash identifier: `[A-Za-z_][A-Za-z0-9_]*`)",
    };
    term.writer.print("error: {s}\n", .{message}) catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

/// `reportSerializeError` for a runtime language's print: the language
/// refused the tree (its own reason was reported through the helper's
/// stderr, which the CLI leaves connected), or it declares no printer.
pub fn reportRuntimePrintError(term: *Io.Terminal, err: anyerror) noreturn {
    const message: []const u8 = switch (err) {
        error.RuntimePrintFailed => "the language's printer refused this document",
        error.FormatNotSerializable, error.FormatDisabled => "this language declares no printer; it can be read but not written",
        error.WriteFailed => "failed to write output",
        else => @errorName(err),
    };
    term.writer.print("error: {s}\n", .{message}) catch {};
    term.writer.flush() catch {};
    std.process.exit(1);
}

/// Print every parse-time authoring warning in `warnings` (unless `--quiet`),
/// then exit(1) if `--strict` and any fired — `get`'s shared `--quiet`/
/// `--strict` contract for a language's authoring-time lints (fig's, JSON's
/// `duplicate_key`, …), so each language's call site is one line instead of
/// repeating the print/flush/strict-abort sequence.
pub fn handleParseWarnings(term: *Io.Terminal, source: []const u8, file: []const u8, kind_name: []const u8, warnings: anytype, comptime describeFn: anytype, comptime labelFn: anytype, quiet: bool, strict: bool) !void {
    if (warnings.len == 0) return;
    if (!quiet) {
        for (warnings) |w| try printDiag(term, source, file, w.offset, w.end, "warning", .yellow, describeFn(w.code), labelFn(w.code));
        try term.writer.flush();
    }
    if (strict) {
        try term.writer.print("error: {d} {s} warning(s); --strict aborts.\n", .{ warnings.len, kind_name });
        try term.writer.flush();
        // The document's lints, like `get`'s lossy `--strict`: exit 1.
        std.process.exit(1);
    }
}