Watch
1
0
Fork
You've already forked zon2nix
0
Convert build.zig.zon to a Nix derivation (or several other formats)
  • Zig 86.8%
  • Nix 13.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 723bddc9c5
only put valgrind in the devshell on Linux
The flake offers an aarch64-darwin devshell, and valgrind is not
available there, so `nix develop` could not evaluate at all on a Mac.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019yytTxX3WThTNjSiHESh9Y
2026-09-19 21:41:54 -05:00
LICENSES add SPDX headers and make the project REUSE compliant 2026-08-28 13:05:35 -05:00
src refuse to write an output over a manifest 2026-09-19 19:18:28 -05:00
.gitattributes add SPDX headers and make the project REUSE compliant 2026-08-28 13:05:35 -05:00
.gitignore fixes 2026-09-01 12:04:39 -05:00
build.zig refuse to write an output over a manifest 2026-09-19 19:18:28 -05:00
build.zig.zon Bump version to 0.7.3 2026-09-19 21:37:55 -05:00
flake.lock update nixpkgs 2026-08-31 22:48:52 -05:00
flake.nix only put valgrind in the devshell on Linux 2026-09-19 21:41:54 -05:00
LICENSE Initial commit 2024-01-11 20:15:31 +09:00
package.nix add SPDX headers and make the project REUSE compliant 2026-08-28 13:05:35 -05:00
README.md refuse to write an output over a manifest 2026-09-19 19:18:28 -05:00
REUSE.toml add SPDX headers and make the project REUSE compliant 2026-08-28 13:05:35 -05:00
typos.toml add SPDX headers and make the project REUSE compliant 2026-08-28 13:05:35 -05:00

zon2nix

Convert the dependencies in a Zig build.zig.zon file into a Nix expression, so that Zig projects can be built with Nix without network access.

zon2nix reads one or more build.zig.zon files, recursively discovers all transitive dependencies (including .path-based local dependencies), fetches each one to compute the hashes that Nix needs, and writes the results in one or more output formats.

License: MIT

Requirements

  • Nix with flakes enabled (to run the packaged version).
  • Network access while running zon2nix (dependencies are downloaded in order to compute their hashes).

If you build zon2nix yourself instead of using the flake, the following tools must be available at runtime: nix-prefetch-git, nix-prefetch-url, and nixfmt. The Nix package wires in absolute paths to these automatically; a hand-built binary looks for them in PATH (or at the paths given by the -Dnix-prefetch-git=, -Dnix-prefetch-url=, and -Dnixfmt= build options).

Usage

Run it from the flake in the directory containing your build.zig.zon:

nix run github:jcollie/zon2nix#zon2nix -- --nix=build.zig.zon.nix build.zig.zon

The general form is:

zon2nix [options] [path ...]

Each path is a build.zig.zon file to process. If no paths are given, zon2nix looks for build.zig.zon in the current directory. Transitive dependencies are followed automatically, so you only need to point it at your top-level file(s).

Output options

At least one output option is normally given; each writes a different format, and they can be combined in a single run. Options take a value either as --nix=FILE or --nix FILE.

Option Output
--nix=FILE A Nix expression (formatted with nixfmt) that fetches every dependency — see below.
--json=FILE A JSON object mapping each Zig package hash to its name, URL, and Nix hash.
--txt=FILE A plain list of dependency URLs, one per line.
--flatpak=FILE A JSON sources array for use in a flatpak-builder manifest, with each dependency placed under vendor/p/<hash>.

With only --txt, no hashes are computed, so the run is much faster.

Every one of these names the file to write, which is worth saying because the --txt FILE form reads so naturally as the file to read: zon2nix --txt build.zig.zon is a request to replace the manifest with a list of URLs, not to list the URLs in it. zon2nix refuses an output that is named build.zig.zon, or that resolves to a manifest it is about to read, rather than doing what it was asked.

Zig version selection

The generated Nix expression uses Zig itself to unpack fetched artifacts, so it must reference the matching Zig package from nixpkgs:

  • --15 — generated expression uses zig_0_15
  • --16 — generated expression uses zig_0_16 (default)

It also decides where the generated packages have to be put at build time, which the two versions do differently — see below.

Fetching

Packages are fetched several at a time. Each one costs a download, a zig fetch and a nix-prefetch-* run, nearly all of which is waiting, so this is most of the wall-clock time of a run: eight dependencies that take 12 seconds one after another take 4 fetched eight at a time.

  • --jobs=N — fetch N packages at once (default 8)

Manifests are read one at a time, a level of the dependency graph at a time, so what comes out does not depend on N — only how long it takes to produce. Raising it much past the default tends not to help, since by then the work is waiting on whoever is serving the packages rather than on zon2nix.

Logging options

  • --quiet — decrease verbosity (may be repeated)
  • --verbose — increase verbosity (may be repeated)
  • --debug — maximum verbosity

Using the generated Nix expression

The file written by --nix is a function suitable for callPackage. It evaluates to a directory holding one subdirectory per dependency, named by Zig package hash — the layout Zig expects of its unpacked packages.

Where those packages have to be put depends on the Zig version, because 0.16 moved them: 0.15 keeps unpacked packages in p/ under the global cache, while 0.16 keeps only the fetched tarballs there and unpacks into a zig-pkg directory beside the sources being built.

Zig 0.16

Hand the packages over with --system:

{
  stdenvNoCC,
  callPackage,
  zig_0_16,
}:
stdenvNoCC.mkDerivation (finalAttrs: {
  pname = "my-zig-project";
  version = "0.1.0";
  src = ./.;

  nativeBuildInputs = [ zig_0_16 ];

  zigBuildFlags = [
    "--system"
    "${callPackage ./build.zig.zon.nix { }}"
  ];
  # The check phase assembles its own flags rather than reusing the build's,
  # so without this a package with `doCheck = true` runs `zig build test`
  # without `--system`, tries to fetch, and fails in the sandbox.
  zigCheckFlags = finalAttrs.zigBuildFlags;
})

--system does more than point Zig at the packages: it forbids fetching outright, so a package missing from the farm is an error naming it rather than a silent attempt to reach the network, and it turns on every systemIntegrationOption by default, which is usually what a distribution build wants. Neither of those comes with the alternatives below.

Dependencies that have .path dependencies of their own

On Zig 0.16.0, zig build --system never finishes if any fetched package declares a .path dependency — the kind a large project uses to vendor its own subpackages, as ghostty does with .freetype = .{ .path = "./pkg/freetype" }. It does not fail: the main thread spins in userspace at a hundred per cent of one core, indefinitely, having stopped reading files altogether and with every worker thread idle. It is past the fetch — which on its own, with zig build --fetch --system, completes in milliseconds — and has not yet begun to compile anything. One such dependency anywhere in the graph is enough, lazy or not, and no arrangement of the farm avoids it: the same hang follows the packages into the build directory, and into the global cache.

The cause is inside Zig rather than in anything the farm does. A .path dependency's identity is hashed together with a flag saying whether its package root lies inside the cache root, and --system makes those two questions disagree: the fetch computes the hash against the farm, which is the cache root in this mode, and the later pass that wires up each package's build.zig module computes it against the real global cache, which is not. The second lookup therefore misses, and the release compiler has no safety check there to say so.

This appears to be fixed in Zig after 0.16.0: the restructuring that moved zig build out of the compiler and into lib/compiler/Maker.zig gave the system package directory a field of its own instead of aliasing it onto the global cache, so both passes now hash against the same directory. Worth re-testing when 0.17 arrives — if it is fixed there, the forking below becomes dead weight for anyone building with it.

Forking the offending package past the farm gets --system working again, because a forked package is rooted outside the farm and both hashes then agree. The fork has to live inside the build root, and be given as a relative path.

zon2nix reads every fetched manifest, so it knows which packages these are and names them: the generated expression carries the list as pathDependencyPackages, and a package can write both the copies and the flags out of it rather than pasting hashes by hand.

let
  zigDeps = callPackage ./build.zig.zon.nix { };
in
stdenv.mkDerivation (finalAttrs: {
  # ...

  postPatch = lib.concatMapStrings (p: ''
    cp -rsL --no-preserve=mode ${zigDeps}/${p} fork-${p}
  '') zigDeps.pathDependencyPackages;

  zigBuildFlags = [
    "--system"
    "${zigDeps}"
  ] ++ map (p: "--fork=fork-${p}") zigDeps.pathDependencyPackages;
  zigCheckFlags = finalAttrs.zigBuildFlags;
})

On a graph with nothing to fork the list is empty, both the postPatch and the extra flags vanish, and what is left is the plain --system build above — so this is safe to write once and leave in place.

Zig reports fork <path> matched 1 <name> packages, and fails the build if a fork matches nothing, so a package that stops needing one does not pass unnoticed. Note that a fork replaces the package without checking its hash; here the contents come from the same store path either way.

Or give up --system

The other way is to put the packages where Zig looks for them itself and not pass the flag at all. Zig 0.16 unpacks into a zig-pkg directory beside the sources, so:

  postPatch = ''
    cp -rsL --no-preserve=mode ${callPackage ./build.zig.zon.nix { }} zig-pkg
  '';

This avoids the bug entirely, at the price of what --system was providing: fetching is no longer forbidden, so a missing package is a failed download rather than a clear error, and every systemIntegrationOption goes back to defaulting off.

cp -rs in both recipes makes real directories holding symlinks to the files, which costs nothing and is what Zig needs: a dependency's own build steps reach the cache by a path relative to their package directory, so a package directory that is itself a symlink into the store resolves ../../.zig-cache to somewhere near the root of the filesystem, and the build fails to spawn a generator it has just finished building.

Zig 0.15

Link the packages into Zig's global cache before building:

postPatch = ''
  ln -s ${callPackage ./build.zig.zon.nix { }} "$ZIG_GLOBAL_CACHE_DIR/p"
'';

or hand them over with zig build --system <dir>:

zigBuildFlags = [
  "--system"
  "${callPackage ./build.zig.zon.nix { }}"
];

Note that stdenv's check phase assembles its own flags rather than reusing the build's, so a package with doCheck = true wants zigCheckFlags = finalAttrs.zigBuildFlags; as well, or zig build test runs without --system, tries to fetch, and fails in the sandbox.

Whenever you add, remove, or update a dependency in build.zig.zon, re-run zon2nix to regenerate the file and commit the result.

Cloning with Radicle

The repository is published on Radicle, a peer-to-peer code forge. To clone it:

rad clone rad:z4QfQ4qG1WzhFeo7ktFHc3VhZ1KuY

If you don't have Radicle set up yet, install the rad CLI and create an identity first:

curl -sSf https://radicle.xyz/install | sh
rad auth

Cloning also seeds the repository, helping keep it available on the network. The repository is additionally mirrored at github.com/jcollie/zon2nix and codeberg.org/jcollie/zon2nix.

Development

A development shell with Zig, nix-prefetch-git, nixfmt, and valgrind is provided:

nix develop

Common tasks:

zig build run -- --nix=build.zig.zon.nix   # build and run
zig build test                             # run the unit tests
zig build test-valgrind                    # run the tests under valgrind

License

MIT