fig 2.2.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
const std = @import("std");

/// The canonical package version, parsed once from `build.zig.zon`'s `.version`
/// so the C ABI's `fig_version*` accessors and the version-drift check both read
/// from a single source instead of a hand-synced trio of integers.
const version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch
    @compileError("invalid `.version` in build.zig.zon");

/// The binary C ABI contract version (see `FIG_ABI_VERSION` in bindings/c/include/fig.h).
/// Canonical source of truth — surfaced to the C ABI as `fig_abi_version()` and
/// pinned to the header macro by `zig build abi-check`. A monotonic counter,
/// bumped ONLY on a breaking ABI change (which fig's forward-compat policy makes
/// rare); decoupled from the marketing `.version` above so a feature release does
/// not move it. `zig build semver-check` requires it to increment whenever the C
/// ABI diff against the last release tag is breaking.
const abi_version: u8 = 1;

/// The `fig` CLI binary's OWN SemVer track — independent of `.version` above,
/// the same way the Rust crate and npm package are independent of it (see
/// "Independent versioning" in docs/VERSIONING.md). The CLI's compatibility
/// contract is its flags/defaults/exit codes, not the library API or the C
/// ABI, so a CLI-only breaking change (e.g. flipping a flag's default) bumps
/// this without forcing a core/ABI release, and vice versa — a core-only
/// change doesn't force a CLI bump. The one invariant tying it to core: it
/// must stay >= `version` above (enforced by `zig build version-floor`,
/// alongside the Rust/npm floor checks), since the CLI always embeds
/// whatever core it's built against. Surfaced via `fig version`, which prints
/// both numbers.
const cli_version = std.SemanticVersion.parse("3.0.1") catch
    @compileError("invalid cli_version");

/// The current "epoch" — a marketing name that changes far less often than
/// `version`'s major (see docs/VERSIONING.md: "major releases are not
/// sacred," so this exists precisely to give users a stable, human-facing
/// handle across a run of otherwise-eager SemVer bumps). Purely cosmetic —
/// no compatibility contract, so it lives here as a bare constant (like
/// `abi_version`/`cli_version`) rather than in build.zig.zon (a
/// toolchain-parsed package manifest with its own schema) or the C ABI (which
/// only ever exposes things a consumer might actually branch on). Surfaced
/// only by the CLI's `fig version`.
const epoch = "Sierra";

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const strip = b.option(bool, "strip", "Strip debug information") orelse (optimize == .ReleaseSmall);
    const resolved_target = target.result;
    const run_conformance = b.option(bool, "json-conformance", "Run JSON conformance tests") orelse false;
    const run_json5_conformance = b.option(bool, "json5-conformance", "Run JSON5 conformance tests") orelse false;
    const run_yaml_conformance = b.option(bool, "yaml-conformance", "Run YAML conformance tests") orelse false;
    const run_toml_conformance = b.option(bool, "toml-conformance", "Run TOML conformance tests") orelse false;
    const run_xml_conformance = b.option(bool, "xml-conformance", "Run XML conformance tests") orelse false;

    // Per-language feature gates. Any format can be compiled out to shrink the
    // binary and drop its parser/printer — including JSON, now that the native
    // `.fig` format exists and `Language.detect()` sniffs every compiled-in
    // language rather than assuming a JSON base. A build with no language at all
    // is rejected at the call sites that need one (e.g. the C ABI editor union).
    // Default: everything on, EXCEPT xml — it stays opt-in (`-Dxml=true`) even
    // in a full build: it is the newest/least battle-tested format (writer
    // added after the others, no conformance harness wired up yet — see
    // `src/languages/xml/conformance.zig`), so it shouldn't ride along silently
    // in every consumer's default build until it has earned that.
    const enable_json = b.option(bool, "json", "Include JSON/JSONC/JSON5 support") orelse true;
    const enable_yaml = b.option(bool, "yaml", "Include YAML support") orelse true;
    const enable_toml = b.option(bool, "toml", "Include TOML support") orelse true;
    const enable_zon = b.option(bool, "zon", "Include ZON support") orelse true;
    const enable_xml = b.option(bool, "xml", "Include XML support (opt-in; default off)") orelse false;
    const enable_fig = b.option(bool, "fig", "Include the fig authoring dialect support") orelse true;

    const options = b.addOptions();
    options.addOption(bool, "json_conformance", run_conformance);
    options.addOption(bool, "json5_conformance", run_json5_conformance);
    options.addOption(bool, "yaml_conformance", run_yaml_conformance);
    options.addOption(bool, "toml_conformance", run_toml_conformance);
    options.addOption(bool, "xml_conformance", run_xml_conformance);
    // Language gates, consumed across the codebase as `build_options.lang_*`.
    options.addOption(bool, "lang_json", enable_json);
    options.addOption(bool, "lang_yaml", enable_yaml);
    options.addOption(bool, "lang_toml", enable_toml);
    options.addOption(bool, "lang_zon", enable_zon);
    options.addOption(bool, "lang_xml", enable_xml);
    options.addOption(bool, "lang_fig", enable_fig);
    // Library version surfaced through the C ABI (`fig_version` /
    // `fig_version_string`). Parsed from `.version` in `build.zig.zon` — the one
    // canonical package version — and split into the components the ABI's
    // packed-integer/string accessors need. `zig build abi-check` separately
    // asserts that bindings/c/include/fig.h's FIG_VERSION_* macros match this same source.
    options.addOption(u8, "version_major", @intCast(version.major));
    options.addOption(u8, "version_minor", @intCast(version.minor));
    options.addOption(u8, "version_patch", @intCast(version.patch));
    // The binary C ABI contract version, surfaced through `fig_abi_version()`.
    // `zig build abi-check` asserts bindings/c/include/fig.h's FIG_ABI_VERSION matches this.
    options.addOption(u8, "abi_version", abi_version);
    // The CLI's own version (see `cli_version`'s doc comment above), surfaced
    // by `fig version` alongside the embedded core version.
    options.addOption(u8, "cli_version_major", @intCast(cli_version.major));
    options.addOption(u8, "cli_version_minor", @intCast(cli_version.minor));
    options.addOption(u8, "cli_version_patch", @intCast(cli_version.patch));
    // The current marketing epoch (see `epoch`'s doc comment above),
    // surfaced only by `fig version` — no ABI/library counterpart.
    options.addOption([]const u8, "epoch", epoch);

    // Build the options module once and share the single instance across every
    // target. Calling `addOptions` per-module would generate a fresh module from
    // the same generated file, which Zig rejects ("file belongs to two modules").
    const options_mod = options.createModule();

    const mod = b.addModule("fig", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
    });
    mod.addImport("build_options", options_mod);
    // `root.zig`'s `test {}` block pulls in `c_api.zig`, which uses
    // `std.heap.c_allocator` on non-wasm targets — that allocator is only
    // available when linking libc. macOS links libc implicitly, so the test
    // suite builds there regardless, but Linux requires it to be explicit or the
    // module-test compile fails. WebAssembly uses `wasm_allocator` and must not
    // link libc, so gate on the architecture.
    mod.link_libc = !resolved_target.cpu.arch.isWasm();

    const exe = b.addExecutable(.{
        .name = "fig",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/cli/main.zig"),
            .target = target,
            .optimize = optimize,
            .strip = strip,
            .imports = &.{
                .{ .name = "fig", .module = mod },
            },
        }),
    });
    exe.root_module.addImport("build_options", options_mod);

    b.installArtifact(exe);

    // fig-lsp: a Language Server (LSP over stdio) that wraps the fig parser to
    // publish its teaching diagnostics to editors. Thin shell over `mod`.
    const lsp_exe = b.addExecutable(.{
        .name = "fig-lsp",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/lsp/main.zig"),
            .target = target,
            .optimize = optimize,
            .strip = strip,
            .imports = &.{
                .{ .name = "fig", .module = mod },
            },
        }),
    });
    lsp_exe.root_module.addImport("build_options", options_mod);
    b.installArtifact(lsp_exe);

    const lsp_run = b.addRunArtifact(lsp_exe);
    const lsp_run_step = b.step("run-lsp", "Run the fig language server (LSP over stdio)");
    lsp_run_step.dependOn(&lsp_run.step);

    const c_lib = b.addLibrary(.{
        .linkage = .static,
        .name = "fig",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/c_api.zig"),
            .target = target,
            .optimize = optimize,
            .strip = strip,
            .link_libc = !resolved_target.cpu.arch.isWasm(),
        }),
    });
    c_lib.root_module.addImport("build_options", options_mod);
    const install_c_lib = b.addInstallArtifact(c_lib, .{});
    b.getInstallStep().dependOn(&install_c_lib.step);

    const install_c_lib_step = b.step("install-c-lib", "Install the C ABI static library");
    install_c_lib_step.dependOn(&install_c_lib.step);

    // Vendor this Zig source into the Rust crate (bindings/rust/fig/zig) so the
    // published crate is self-contained — build.rs compiles the core from there
    // when there is no repo above it. A small Zig program rather than a shell
    // script, so it runs anywhere the crate's own `zig build` already does.
    const vendor_rust = b.addExecutable(.{
        .name = "vendor_rust",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/vendor-rust.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const vendor_rust_run = b.addRunArtifact(vendor_rust);
    vendor_rust_run.addArg(b.pathFromRoot("."));
    vendor_rust_run.addArg(b.pathFromRoot("bindings/rust/fig/zig"));
    const vendor_rust_step = b.step("vendor-rust", "Vendor the Zig source into the Rust crate for publishing");
    vendor_rust_step.dependOn(&vendor_rust_run.step);

    // WebAssembly build for the TypeScript bindings: compile the same C ABI to a
    // freestanding `reactor` module (no `_start`; `rdynamic` keeps every
    // exported `fig_*` symbol). `c_api.zig` already selects `wasm_allocator` and
    // drops logging on wasm, so no libc is needed. `zig build wasm` writes
    // `fig.wasm` into the install prefix's `bin/`.
    const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding });
    const wasm = b.addExecutable(.{
        .name = "fig",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/c_api.zig"),
            .target = wasm_target,
            .optimize = .ReleaseSmall,
            .strip = true,
        }),
    });
    wasm.root_module.addImport("build_options", options_mod);
    wasm.entry = .disabled;
    wasm.rdynamic = true;
    const install_wasm = b.addInstallArtifact(wasm, .{});
    const wasm_step = b.step("wasm", "Build the WebAssembly module for the TypeScript bindings");
    wasm_step.dependOn(&install_wasm.step);

    // WASI build of the *CLI* (`main.zig`), distinct from the freestanding
    // `fig.wasm` above. This is a real `_start` command module: run it with a
    // WASI runtime, e.g. `wasmtime run --dir=.::. zig-out/bin/fig-wasi.wasm get foo.yaml`.
    // File access is capability-gated, so map a dir onto the guest cwd (`--dir`).
    const wasi_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .wasi });
    const wasi_mod = b.addModule("fig-wasi", .{
        .root_source_file = b.path("src/root.zig"),
        .target = wasi_target,
    });
    wasi_mod.addImport("build_options", options_mod);
    const wasi_cli = b.addExecutable(.{
        .name = "fig-wasi",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/cli/main.zig"),
            .target = wasi_target,
            .optimize = .ReleaseSmall,
            .strip = true,
            .imports = &.{
                .{ .name = "fig", .module = wasi_mod },
            },
        }),
    });
    wasi_cli.root_module.addImport("build_options", options_mod);
    const install_wasi = b.addInstallArtifact(wasi_cli, .{});
    const wasi_step = b.step("wasi", "Build the fig CLI as a WASI module (fig-wasi.wasm)");
    wasi_step.dependOn(&install_wasi.step);

    const run_step = b.step("run", "Run the app");

    const run_cmd = b.addRunArtifact(exe);
    run_step.dependOn(&run_cmd.step);

    run_cmd.step.dependOn(b.getInstallStep());

    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    // Dev tool: regenerate the YAML conformance corpus from the yaml-test-suite,
    // parsing the suite meta-files with fig itself (no third-party YAML library).
    //   zig build gen-yaml-conformance -- <path-to-yaml-test-suite> [<fig-root>]
    const gen_yaml = b.addExecutable(.{
        .name = "gen_yaml_conformance",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/gen_yaml_conformance.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "fig", .module = mod }},
        }),
    });
    const gen_yaml_run = b.addRunArtifact(gen_yaml);
    if (b.args) |args| gen_yaml_run.addArgs(args);
    const gen_yaml_step = b.step("gen-yaml-conformance", "Regenerate the YAML conformance corpus");
    gen_yaml_step.dependOn(&gen_yaml_run.step);

    // Dev tool: STRUCTURAL conformance — compare the shape of fig's parse against
    // the suite's canonical `tree:` event stream (the pass/fail scoreboard can't
    // see a wrong-shape accept). zig build check-yaml-trees -- <suite> [<fig-root>]
    const check_trees = b.addExecutable(.{
        .name = "check_yaml_trees",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/check_yaml_trees.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "fig", .module = mod }},
        }),
    });
    const check_trees_run = b.addRunArtifact(check_trees);
    if (b.args) |args| check_trees_run.addArgs(args);
    const check_trees_step = b.step("check-yaml-trees", "Structural-diff fig's parse vs the suite tree");
    check_trees_step.dependOn(&check_trees_run.step);

    // Dev tool: vendor the toml-lang/toml-test corpus into testdata/toml/.
    //   zig build gen-toml-conformance -- <path-to-toml-test> [<version>] [<fig-root>]
    const gen_toml = b.addExecutable(.{
        .name = "gen_toml_conformance",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/gen_toml_conformance.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "fig", .module = mod }},
        }),
    });
    const gen_toml_run = b.addRunArtifact(gen_toml);
    if (b.args) |args| gen_toml_run.addArgs(args);
    const gen_toml_step = b.step("gen-toml-conformance", "Vendor the toml-test corpus");
    gen_toml_step.dependOn(&gen_toml_run.step);

    // Dev tool: vendor the json5/json5-tests corpus into testdata/json5/.
    //   zig build gen-json5-conformance -- <path-to-json5-tests> [<fig-root>]
    const gen_json5 = b.addExecutable(.{
        .name = "gen_json5_conformance",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/gen_json5_conformance.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "fig", .module = mod }},
        }),
    });
    const gen_json5_run = b.addRunArtifact(gen_json5);
    if (b.args) |args| gen_json5_run.addArgs(args);
    const gen_json5_step = b.step("gen-json5-conformance", "Vendor the json5-tests corpus");
    gen_json5_step.dependOn(&gen_json5_run.step);

    // ABI surface check: keep the C ABI implementation (src/c_api.zig), the
    // public header (bindings/c/include/fig.h), and the built library in agreement.
    //   * tools/abi-check.zig diffs the exported `fig_*` symbols against the
    //     header prototypes (both directions), failing on any mismatch — this is
    //     what catches a symbol exported without a header declaration.
    //   * abi_probe.{c,cpp} are compiled against fig.h, as C and as C++, and
    //     linked against the C ABI static library, proving the header parses and
    //     links in both languages.
    // Signatures are not compared (C has no name mangling). Run: zig build abi-check.
    // The pre-commit hook in .githooks/ runs this when an ABI file is staged.
    const abi_check = b.addExecutable(.{
        .name = "abi_check",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/abi-check.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const abi_check_run = b.addRunArtifact(abi_check);
    // Passed as file args so the run is cache-keyed on the files it inspects.
    abi_check_run.addFileArg(b.path("bindings/c/include/fig.h"));
    abi_check_run.addFileArg(b.path("src/c_api.zig"));
    // The canonical version (from build.zig.zon) so the tool can assert that
    // fig.h's FIG_VERSION_* macros have not drifted from it.
    abi_check_run.addArg(b.fmt("{d}.{d}.{d}", .{ version.major, version.minor, version.patch }));
    // The canonical ABI version so the tool can assert fig.h's FIG_ABI_VERSION
    // macro matches the value compiled into `fig_abi_version()`.
    abi_check_run.addArg(b.fmt("{d}", .{abi_version}));

    const abi_probe_c = b.addExecutable(.{
        .name = "abi_probe_c",
        .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
    });
    abi_probe_c.root_module.addCSourceFile(.{ .file = b.path("tools/abi_probe.c") });
    abi_probe_c.root_module.addIncludePath(b.path("bindings/c/include"));
    abi_probe_c.root_module.linkLibrary(c_lib);

    const abi_probe_cpp = b.addExecutable(.{
        .name = "abi_probe_cpp",
        .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true, .link_libcpp = true }),
    });
    abi_probe_cpp.root_module.addCSourceFile(.{ .file = b.path("tools/abi_probe.cpp") });
    abi_probe_cpp.root_module.addIncludePath(b.path("bindings/c/include"));
    abi_probe_cpp.root_module.linkLibrary(c_lib);

    const abi_check_step = b.step("abi-check", "Check the C ABI surface: symbol diff + C/C++ header probe");
    abi_check_step.dependOn(&abi_check_run.step);
    abi_check_step.dependOn(&abi_probe_c.step);
    abi_check_step.dependOn(&abi_probe_cpp.step);

    // SemVer gate: diff the current C ABI against the most recent `v*` git tag
    // and turn the delta into a version verdict (removed/changed symbol -> major,
    // added-only -> minor, none -> patch), then assert build.zig.zon's version is
    // high enough to cover it. Discovers the baseline itself via `git describe` +
    // `git show`, so it needs the repo root and the canonical version. With no git
    // history / no tags it prints a note and passes. Run: zig build semver-check.
    const semver_check = b.addExecutable(.{
        .name = "semver_check",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/semver-check.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const semver_check_run = b.addRunArtifact(semver_check);
    // Cache-key on the header it inspects; the baseline comes from git at run time.
    semver_check_run.addFileArg(b.path("bindings/c/include/fig.h"));
    semver_check_run.addArg(b.fmt("{d}.{d}.{d}", .{ version.major, version.minor, version.patch }));
    semver_check_run.addArg(b.pathFromRoot("."));
    // git state isn't a declared input, so never serve this from cache.
    semver_check_run.has_side_effects = true;
    const semver_check_step = b.step("semver-check", "Diff the C ABI vs the last release tag and verify the version bump");
    semver_check_step.dependOn(&semver_check_run.step);

    // Cross-artifact version floor. fig versions each artifact independently —
    // the Zig core (build.zig.zon), the CLI binary (`cli_version` above, in
    // this file), the Rust crate (bindings/rust/Cargo.toml), the npm package
    // (bindings/typescript/package.json), and the fig-wasi npm package
    // (bindings/wasi/package.json) move on their own SemVer tracks so a
    // binding-only (or CLI-only) change need not bump the core. The one
    // invariant: every artifact's version must be >= the core version it
    // embeds, so a breaking core bump always pulls the others' major up and
    // none can silently ship a newer core behind an older-looking number
    // (fig-wasi additionally must equal `cli_version` exactly, since it's a
    // repackaging of the CLI, not an independent binding). This tool
    // enforces both. Run: zig build version-floor.
    const version_floor = b.addExecutable(.{
        .name = "version_floor",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/version-floor.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const version_floor_run = b.addRunArtifact(version_floor);
    // Cache-keyed on the five manifests it compares (build.zig carries the
    // CLI's own version — see `cli_version` above).
    version_floor_run.addFileArg(b.path("build.zig.zon"));
    version_floor_run.addFileArg(b.path("build.zig"));
    version_floor_run.addFileArg(b.path("bindings/rust/Cargo.toml"));
    version_floor_run.addFileArg(b.path("bindings/typescript/package.json"));
    version_floor_run.addFileArg(b.path("bindings/wasi/package.json"));
    const version_floor_step = b.step("version-floor", "Check each binding's version is >= the core version it embeds");
    version_floor_step.dependOn(&version_floor_run.step);

    // The `.figl` files under figl/ (build.zig.figl, ci.figl, homebrew.figl,
    // release.figl) are the source of truth for their generated counterparts
    // (build.zig.zon, the three .github/workflows/*.yml files) — the inverse
    // of the usual setup,
    // dogfooding fig's own cross-format conversion for fig's own release
    // infra. This tool shells out to the just-built `fig` binary (`fig get -o
    // <format>`) to regenerate them; it never re-implements parsing/printing
    // itself. `sync-figl` writes; `check-figl` (used by CI and the pre-commit
    // hook, via `zig build check`) fails if a destination is stale, without
    // writing. Git state (the destination files) isn't a declared input, so
    // neither run may be served from cache.
    const sync_figl = b.addExecutable(.{
        .name = "sync_figl",
        .root_module = b.createModule(.{
            .root_source_file = b.path("tools/sync-figl.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    const sync_figl_run = b.addRunArtifact(sync_figl);
    sync_figl_run.addArtifactArg(exe);
    sync_figl_run.addArg(b.pathFromRoot("."));
    sync_figl_run.has_side_effects = true;
    const sync_figl_step = b.step("sync-figl", "Regenerate build.zig.zon + the .github/workflows/*.yml files from their .figl sources");
    sync_figl_step.dependOn(&sync_figl_run.step);

    const check_figl_run = b.addRunArtifact(sync_figl);
    check_figl_run.addArtifactArg(exe);
    check_figl_run.addArg(b.pathFromRoot("."));
    check_figl_run.addArg("--check");
    check_figl_run.has_side_effects = true;
    const check_figl_step = b.step("check-figl", "Fail if build.zig.zon / .github/workflows/*.yml are stale relative to their .figl sources");
    check_figl_step.dependOn(&check_figl_run.step);

    // One-stop release gate: every version/ABI guard behind a single command, so
    // "did I bump correctly?" is `zig build check` instead of remembering four
    // separate invocations across two build systems. It depends on the three Zig
    // guards above and additionally shells out to cargo-semver-checks — the only
    // guard that lives in cargo's world rather than zig's (it diffs the native
    // Rust API, which never crosses the C ABI the Zig tools inspect). A
    // TypeScript API guard can hang off this same step later the same way.
    const check_step = b.step("check", "Pre-release gate: zig test + abi/semver/floor/figl guards + cargo-semver-checks + rust & typescript tests (binding suites skip with a note if their toolchain is missing)");
    check_step.dependOn(abi_check_step);
    check_step.dependOn(semver_check_step);
    check_step.dependOn(version_floor_step);
    check_step.dependOn(check_figl_step);

    // cargo-semver-checks guards the native Rust public surface. Mirror CI: pick
    // the most recent `rust/v*` tag as the baseline — the Rust crate's own
    // release line, distinct from `semver-check`'s `core/v*` baseline, since the
    // crate versions independently of the core (see "Independent versioning" in
    // docs/VERSIONING.md) — and skip cleanly when there is none yet (a fresh
    // repo, or one that hasn't cut a Rust release yet, has nothing to diff
    // against). Runs in bindings/rust; requires a nightly toolchain
    // (cargo-semver-checks reads rustdoc JSON) and the cargo-semver-checks
    // subcommand installed. git state is not a declared input, so it must never
    // be served from cache.
    const cargo_semver_script =
        \\set -eu
        \\if ! command -v cargo-semver-checks >/dev/null 2>&1; then
        \\  echo "cargo-semver-checks: not installed — skipping (install: cargo install cargo-semver-checks)."
        \\  exit 0
        \\fi
        \\tag="$(git describe --tags --abbrev=0 --match 'rust/v*' 2>/dev/null || true)"
        \\if [ -z "$tag" ]; then
        \\  echo "cargo-semver-checks: no rust/v* tag found — skipping (nothing to diff against)."
        \\  exit 0
        \\fi
        \\echo "cargo-semver-checks: baseline $tag"
        \\cargo semver-checks --package fig --baseline-rev "$tag"
    ;
    const cargo_semver = b.addSystemCommand(&.{ "sh", "-c", cargo_semver_script });
    cargo_semver.setCwd(b.path("bindings/rust"));
    cargo_semver.has_side_effects = true;
    check_step.dependOn(&cargo_semver.step);

    // Rust binding test suite, gated on a local cargo toolchain. A contributor
    // who only touches the Zig core (and has no Rust installed) still gets a
    // useful `check`; the step warns and skips rather than failing. CI installs
    // cargo, so there the suite runs for real.
    const rust_test_script =
        \\set -eu
        \\if ! command -v cargo >/dev/null 2>&1; then
        \\  echo "rust tests: cargo not found — skipping (install Rust to run them)."
        \\  exit 0
        \\fi
        \\cargo test --workspace
    ;
    const rust_test = b.addSystemCommand(&.{ "sh", "-c", rust_test_script });
    rust_test.setCwd(b.path("bindings/rust"));
    rust_test.has_side_effects = true;
    check_step.dependOn(&rust_test.step);

    // TypeScript binding test suite, gated on a local npm toolchain AND installed
    // deps. The tests import the built wasm module, so build first. Skips (with a
    // note) when npm is absent or `node_modules` hasn't been populated, so the
    // gate degrades gracefully on a partial checkout; CI runs `npm ci` first.
    const ts_test_script =
        \\set -eu
        \\if ! command -v npm >/dev/null 2>&1; then
        \\  echo "typescript tests: npm not found — skipping (install Node.js to run them)."
        \\  exit 0
        \\fi
        \\if [ ! -d node_modules ]; then
        \\  echo "typescript tests: node_modules missing — skipping (run 'npm ci' in bindings/typescript first)."
        \\  exit 0
        \\fi
        \\npm run build
        \\npm test
    ;
    const ts_test = b.addSystemCommand(&.{ "sh", "-c", ts_test_script });
    ts_test.setCwd(b.path("bindings/typescript"));
    ts_test.has_side_effects = true;
    check_step.dependOn(&ts_test.step);

    const test_filters =
        b.option([]const []const u8, "test-filter", "Only run tests matching this filter") orelse &.{};

    const mod_tests = b.addTest(.{
        .root_module = mod,
        .filters = test_filters,
    });
    // `mod` already carries `build_options` (added above); the test artifact
    // reuses that module, so no second `addOptions` is needed here.

    const install_mod_tests = b.addInstallArtifact(mod_tests, .{
        .dest_sub_path = "fig-tests",
    });

    // A run step that will run the test executable.
    const run_mod_tests = b.addRunArtifact(mod_tests);

    const exe_tests = b.addTest(.{
        .root_module = exe.root_module,
        .filters = test_filters,
    });

    const run_exe_tests = b.addRunArtifact(exe_tests);

    const test_step = b.step("test", "Run tests");
    test_step.dependOn(&run_mod_tests.step);
    test_step.dependOn(&run_exe_tests.step);

    // The release gate runs the test suite too, so `zig build check` is the single
    // pre-release command: tests pass AND every version/ABI guard is satisfied.
    check_step.dependOn(test_step);

    const install_tests_step = b.step("install-tests", "Install test executables for debugging");
    install_tests_step.dependOn(&install_mod_tests.step);
}