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
//! The correctness/release guards and the one-stop `check` gate that folds them
//! all together: the C ABI surface check, the SemVer diff, the cargo-semver-checks
//! pass, and the Rust/TypeScript binding test suites. `check` additionally
//! depends on the `check-figl` and `version-check` steps (from `tools`) and the
//! `test`/`conformance` steps (from `tests`), passed in via
//! `Deps` so this module doesn't reach back into the others.

const std = @import("std");
const Context = @import("Context.zig");
const artifacts = @import("artifacts.zig");

/// Step handles produced by other stages that the `check` gate must depend on.
pub const Deps = struct {
    /// From `tools`: generated-file staleness guard.
    check_figl_step: *std.Build.Step,
    /// From `tools`: the `Language` contract's compile-failure cases.
    validate_check_step: *std.Build.Step,
    /// `vendor-rust --check`: every path the published crate needs is present.
    vendor_check_step: *std.Build.Step,
    /// From `tools`: every file carrying fig's one version agrees with build.zig.zon.
    version_check_step: *std.Build.Step,
    /// From `tests`: the unit-test suite.
    test_step: *std.Build.Step,
    /// From `tests`: the conformance run.
    conformance_step: *std.Build.Step,
};

pub fn add(ctx: Context, arts: artifacts.Result, deps: Deps) void {
    const b = ctx.b;
    const target = ctx.target;
    const optimize = ctx.optimize;
    const ver = ctx.ver;
    const c_lib = arts.c_lib;

    // 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.
    //   * the header's `FIG_FORMAT_*` enumerators are diffed (name and value,
    //     both directions) against the format registry the tool reads out of the
    //     `fig` module below — the format ABI is a typedef'd enum, so a
    //     renumbered format is invisible to the symbol diff but breaks every
    //     compiled caller.
    // 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,
            // The library itself, so the enumerator check reads the SAME
            // `Language.dialects` the library compiles against rather than a
            // copy of it (the pattern the gen-* dev tools use — see
            // build/tools.zig).
            .imports = &.{.{ .name = "fig", .module = arts.fig_mod }},
        }),
    });
    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}", .{ ver.version.major, ver.version.minor, ver.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}", .{ver.abi}));
    // The three binding-side mirrors of `FigFormat`, diffed against the
    // registry the same way fig.h is.
    abi_check_run.addFileArg(b.path("bindings/rust/fig-sys/src/lib.rs"));
    abi_check_run.addFileArg(b.path("bindings/typescript/src/types.ts"));
    abi_check_run.addFileArg(b.path("bindings/rust/fig/src/lib.rs"));
    // The Rust wrapper's `ExtKind`, diffed against the core's with fig.h's
    // `FigExtKind` and the TypeScript `ExtKind`.
    abi_check_run.addFileArg(b.path("bindings/rust/fig/src/value.rs"));

    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);

    // The runtime-language probe is RUN, not only built: a language written in
    // C fills every struct in fig.h's runtime section and the Zig side reads
    // them, which is the layout agreement the symbol diff cannot see.
    const abi_runtime_probe = b.addExecutable(.{
        .name = "abi_runtime_probe",
        .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
    });
    abi_runtime_probe.root_module.addCSourceFile(.{ .file = b.path("tools/abi_runtime_probe.c") });
    abi_runtime_probe.root_module.addIncludePath(b.path("bindings/c/include"));
    abi_runtime_probe.root_module.linkLibrary(c_lib);
    const abi_runtime_probe_run = b.addRunArtifact(abi_runtime_probe);

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

    // SemVer gate: diff the current C ABI against the most recent release tag
    // (`v*`, or the old `core/v*` line before the first one-version 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}", .{ ver.version.major, ver.version.minor, ver.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);

    // 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 Zig
    // guards above and the version sync's `--check`, 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 + conformance suites + abi/semver/version/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(deps.version_check_step);
    check_step.dependOn(deps.check_figl_step);
    check_step.dependOn(deps.vendor_check_step);
    // The negative half of the `Language` contract. `test`/`conformance` cover
    // what a well-formed manifest DOES; this covers what `validate` refuses,
    // which nothing inside a test binary can reach — see tools/validate-check.zig.
    check_step.dependOn(deps.validate_check_step);

    // cargo-semver-checks guards the native Rust public surface. Mirror CI: the
    // baseline is the most recent release tag — `v5.0.0` and later, matched as
    // `v[5-9].*` or `v[1-9][0-9]*` so neither the three bare pre-scheme tags
    // (`v1.0.0`, `v2.0.0`, `v2.5.1`) nor a two-digit major is misread — falling
    // back to the crate's old `rust/v*` line until the first one-version tag
    // exists (see docs/VERSIONING.md), and skipping cleanly when there is none
    // at all. 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 'v[5-9].*' --match 'v[1-9][0-9]*' 2>/dev/null \
        \\  || git describe --tags --abbrev=0 --match 'rust/v*' 2>/dev/null || true)"
        \\if [ -z "$tag" ]; then
        \\  echo "cargo-semver-checks: no release 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.
    //
    // Built from THIS tree's core, never the prebuilt payload archive: with the
    // default feature set `fig-sys` would otherwise link whatever `libfig.a`
    // a `build-payload-lib.sh` run last left under `fig-sys-<target>/lib`,
    // and a `check` that tests the binding against a stale core is not a
    // check. Zig is on PATH here by construction.
    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
        \\FIG_SYS_FORCE_SOURCE=1 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);

    // The CLI's usage errors, through the built binary: an unknown flag or a
    // surplus positional is exit 2 and touches nothing, `--` ends the flags,
    // a missing comment is exit 1 (`tools/cli-args-check.sh`). The unit tests
    // cannot reach these, since the runner fails any test that logs an error.
    const cli_args_check = b.addSystemCommand(&.{ "sh", "tools/cli-args-check.sh" });
    cli_args_check.addArtifactArg(arts.exe);
    cli_args_check.setCwd(b.path("."));
    cli_args_check.has_side_effects = true;
    check_step.dependOn(&cli_args_check.step);

    // The CLI's end of the runtime-language carrier: the Rust crate's
    // `tinykv_helper` example, named in a `languages.figl`, driven through
    // every action of the built `fig` (`tools/cli-lang-check.sh`). The C
    // probe above proves the in-process ABI; this proves the wire and the
    // helper runner, which is what `fig-lua` will be spoken to through.
    // Gated on cargo the same way, since the helper is a cargo example.
    const cli_lang_check = b.addSystemCommand(&.{ "sh", "tools/cli-lang-check.sh" });
    cli_lang_check.addArtifactArg(arts.exe);
    cli_lang_check.setCwd(b.path("."));
    cli_lang_check.has_side_effects = true;
    check_step.dependOn(&cli_lang_check.step);

    // TypeScript binding test suite, gated on a local npm toolchain, a new enough
    // Node, AND installed deps. The tests import the built wasm module, so build
    // first. Skips (with a note) when npm is absent, when Node predates 24, or when
    // `node_modules` hasn't been populated, so the gate degrades gracefully on a
    // partial checkout; CI pins Node 24 and runs `npm ci` first.
    //
    // The Node floor is a test-time requirement only, not a consumer one — shipped
    // output is downlevelled by tsc, so `engines` in package.json is the lower
    // consumer floor. `npm test` type-checks with tsc and then runs the `.ts`
    // sources directly under Node's strip-only TypeScript (the package is
    // written in erasable syntax only — no `enum`, no parameter properties), and
    // the tests' `using` declarations pass through stripping untouched, so the
    // Node running them must support `using` natively: 24 and later.
    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
        \\node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
        \\if [ "$node_major" -lt 24 ]; then
        \\  echo "typescript tests: Node <24 — skipping (the test suite uses 'using' declarations, which Node's type-stripping cannot downlevel)."
        \\  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);

    // 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(deps.test_step);
    // Conformance rides the same gate (~13s on top). Because ci.yml already runs
    // `zig build check`, wiring it here — rather than as its own CI step — is what
    // keeps the corpus from going stale again: a new format's suite is scored by
    // CI the moment it is added to root.zig, with no workflow edit to forget.
    check_step.dependOn(deps.conformance_step);
}