V2 Component Builder (cabal v2-build / slice-based)
The v2 builder is an alternative to the v1 (Setup.hs-based) builder.
Where v1 produces one nix derivation per Cabal component by invoking
Setup.hs directly (see builder-v1.md), v2 produces one nix
derivation per Cabal component by running cabal v2-build inside an
isolated, pre-populated cabal store and capturing the new entries
that invocation produced.
The unit of output is a slice — a directory laid out exactly the
way cabal’s ~/.cabal/store/ghc-X.Y.Z/ is, holding only the units
this component caused cabal to install. Composing a downstream
slice’s starting store from upstream slices’ $out/store/ (via
lndir) lets cabal reuse those units instead of rebuilding.
Switch builders at the project level by setting builderVersion = 2;
(the default). The component derivation at
hsPkgs.<pkg>.components.<kind>.<n> is whichever builder
builderVersion selects — there’s no per-component opt-in.
Why v2 exists
- Sublibs work in the dev shell. The v1 shell wraps GHC with a
pre-populated package db, which doesn’t expose Cabal public sublibs
(
library <n>stanzas) to a downstreamcabal v2-build— the user’s cabal solver re-builds the sublib from source even though we have it pre-built. The v2 shell pre-populates the user’s cabal store instead, so cabal sees every component (main lib + sublibs + exe-only units) as already-installed and reuses them. - Match what users see in development.
cabal v2-buildis the command users run interactively. Producing a slice with the same command (and the same UnitId hash inputs) means the units we pre-populate in~/.cabal/store/can be reused by a plaincabal v2-buildinside the shell.
What v2 produces
For each component (lib, lib:<sublib>, exe:<name>,
test:<name>, bench:<name>), v2 produces a single nix derivation
whose $out looks like:
$out/
├── store/
│ └── ghc-<ver>-inplace/
│ ├── package.db/<unit-id>.conf … (new units installed by this slice)
│ ├── <unit-id>/… (new unit dirs)
│ └── lib/lib<unit-id>-…dylib (flat dylibs cabal emits)
├── dist-newstyle/ (full cabal dist tree)
├── unit-ids (newline-separated list of captured units)
├── nix-support/propagated-build-inputs (pkg-config / framework / windows-lib deps)
└── bin/<exe-name> (only for exe/test/bench)
$out/store/<ghcDir>/ is the cabal-store layout cabal expects.
Composing many slices via lndir produces a merged store whose
<unit-id>.conf files come from whichever slice produced each unit;
ghc-pkg recache over the merged db makes the whole set queryable.
A v2 slice’s UnitId is required to equal the plan-id plan-nix
recorded for that component (expectedUnitId check, see below).
The captured set in $out/store/ must equal exactly
[expectedUnitId]; any extras are an error.
High-level flow
-
modules/component-driver.nixdrivesconfig.hsPkgsexactly the same way v1 does. It callsbuilder.build-package config pkgfor each package and stores the result inconfig.hsPkgs. -
builder/hspkg-builder.nixreceivespkgand walks every buildable component. For each one it computes:homeDependIds— sibling-component plan-json deps for cabal’s re-solve inside the slice (so the slicing repo holds every tarball cabal might need).pkgSet,packageIdsByName,planJsonByPlanId— the canonical-name + plan-id keyed view of the install plan that comp-v2-builder uses to find per-plan-unit configure-args.planJsonByPlanIdis the project-levelconfig.plan-json-by-idindex so per-component lookups are O(log N) attrset hits rather than linear scans over the install-plan list.
Then it calls
comp-v2-builderfor each component and returns the result. UnderbuilderVersion = 2, that derivation IS the primary value athsPkgs.<pkg>.components.<kind>.<n>. -
builder/comp-v2-builder.nixcomputes the slice’scabal.project, the slicing repo (a derivation with all the<pkg>-<ver>.tar.gzfiles cabal might need + an00-index.tar.gz), the dep slices to compose, and the slice’sname/target/expectedPackage/expectedUnitId, then callsbuildCabalStoreSlice. -
builder/build-cabal-slice.nix(aliasbuildCabalStoreSlice) is the slice derivation itself. It composes dep slices vialndirinto a starting cabal store — walkspkgsHostTargetfor direct deps and follows each entry’s$out/nix-support/transitive-depsfor the rest of the closure, so transitive dep slices stay out of nix-sidebuildInputs— runscabal v2-build --dry-run(and validates the plan againstexpectedPackage), then runscabal v2-buildfor real, captures the new units into$out/store/, and writes the slice’s ownnix-support/transitive-depsso downstream consumers can follow the closure the same way. TheexpectedUnitIdcheck at the end requires the captured set to equal[expectedUnitId]exactly. -
builder/compose-store.nixis a tiny helper that lndirs slices into a single cabal-store layout, walkingpkgsHostTargetand followingnix-support/transitive-depsfiles exactly like the slice’s own buildPhase. Used by the per-component.storeattribute (the slice’s deps, not the slice itself) and by the v2 shell’s composed store.
Data flow per component
plan.json + module config
│
▼
componentTarball (incl. patches)
│
▼
slicingRepo (every dep's source tarball + this component's tarball,
│ plus a synthesised 00-index.tar.gz that cabal can read
│ via `repository … url: file://… secure: False`)
│
▼
cabal.project (with-compiler, active-repositories, allow-newer,
│ extra-packages: <target> + <constrained deps>,
│ constraints: any.<target> source, <pin> ==<ver> source,
│ per-package ghc-options / configure-options / flags,
│ per-package extra-(include|lib|framework)-dirs for
│ `c.frameworks` only — `c.libs` and `c.pkgconfig`
│ are deliberately excluded (their nix-store paths
│ would land in `pkgHashExtra(Include|Lib)Dirs` and
│ fork the slice's UnitId from plan-nix's, since
│ plan-to-nix's compute never sees the
│ haskell.nix-injected paths). The cc wrapper
│ still finds these libs via NIX_*_FOR_TARGET — the
│ slice has them in `extraBuildInputs`,
│ project-level cabal pragmas extracted from plan.json's
│ configure-args)
│
▼
buildCabalStoreSlice
├─ lndir each depSlice's $out/store into $storeDir
├─ ghc-pkg recache (merged db)
├─ cabal v2-build --dry-run → validate against expectedPackage
└─ cabal v2-build → produce new units
│
▼
$out/store/<ghcDir>/… (this slice's captured units)
$out/dist-newstyle/… (full cabal dist tree)
$out/bin/<exe-name> (extracted from the captured store)
How cabal’s solve is constrained
There’s no “shim” package. Each slice’s cabal.project has:
- No
packages:line. Cabal v2-build accepts a project with onlyextra-packages:so long as the targets use the remote-package qualifier (:pkg:foo:lib:bar). extra-packages:lists the slice’s target package + every entry inlibConstraintPins(the lib-dep closure of this component’s plan units). Without listing them as goals, cabal silently drops<pkg> sourceconstraints on transitively- introduced packages.constraints:has one line per pinned dep:
Pre-existing (boot) packages are skipped — re-pinning them tends to push cabal off the legacy-tool PATH fallback for tools likeconstraints: any.<target> source constraints: <dep> ==<ver>, <dep> source …hsc2hs, and GHC’s bundled package db already fixes their versions.active-repositories: hackage.haskell-nixso cabal only sees the slicing repo (the user’s real hackage is invisible).allow-newer: *:*so stale upper bounds in.cabalfiles don’t reject the single candidate version we ship per package.- Project-level
package *block carrying the--enable-shared/--enable-static/ etc. toggles plan-nix recorded in itsconfigure-args— without this, cabal’s defaults forkpkgHashEnableSharedLiband friends from plan-nix.
The slice targets the package’s component directly via cabal’s
:pkg: qualifier (:pkg:foo:lib:bar). cabal-install bug #8684
makes the bare form fail when the package isn’t in packages:, so
the qualifier is always required.
How dep slices compose
comp-v2-builder collects every slice the current component needs:
directDepSlices— every lib dep of this component, fromcomponent.depends. Slices for sibling components of the same package (e.g. a sublib) are kept; only a same-package self-reference (added bylookupDependencyfor Custom-build-type packages) is dropped.ownLibSlice— when this is a non-library component (test, bench, exe) of a Cabal-spec ≥ 2.0 package, the same package’s library slice goes into the starting store too, so cabal reuses the lib unit instead of rebuilding it.buildToolSlices— every v2 exe slice resolved frombuild-tool-depends(alex, happy, hsc2hs, …). Composing them into the starting store lets cabal’s solver find them as already-installed.homeDepSlices— sibling-component plan-json deps that aren’t incomponent.depends(e.g. tests’ deps when building a lib).
Only the direct dep slices are passed in nix-side
buildInputs (deduped via haskellLib.uniqueWithName, which
partitions by identifier.unit-id so two incarnations of the
same name-version land in separate buckets). The slice’s
buildPhase walks pkgsHostTarget for the direct entries and
follows each one’s $out/nix-support/transitive-deps to pull
in the rest of the closure at build time, then lndirs every
unique entry’s $dep/store into $storeDir before
cabal v2-build runs. cabal sees the dep units as
already-installed and skips them.
Hoisting the closure walk out of the nix evaluator and into
bash (with the closure recorded per-slice in
nix-support/transitive-deps) keeps buildInputs lists short
and avoids the O(N²) heap blow-up the previous nix-side
passthru.transitiveStoreSlices walk caused on big projects.
UnitId stability
cabal v2-build hashes a fixed set of inputs to compute each
component’s UnitId (pkgHashConfigInputs):
pkgHashCompilerId(with-compiler:text in cabal.project),pkgHashFlagAssignment(per-packageflags:),pkgHashProgramArgs(--with-PROG=flags + per-packageghc-options:),pkgHashConfigureScriptArgs(per-packageconfigure-options:),pkgHashExtra(Include|Lib)Dirs(per-packageextra-(include|lib)-dirs:),pkgHashEnableSharedLib/pkgHashEnableStaticLib/pkgHashEnableLibraryForGhci/ profiling / split-sections / split-objs toggles.
For the units we pre-install via depSlices to be reused inside
the slice’s own cabal v2-build, every hash input listed above
has to be identical between the producing slice and this slice.
That’s why comp-v2-builder is careful about:
- Reading
ghcOptions/configureOptionsfrom bothpackages.<n>.<field>(set by the user viamodules) andpackages.<plan-id>.<field>(set byconfigure-args.nixfrom plan.json’sconfigure-args). SeeperPackageOptionOf. - Filtering
-hide-all-packagesfrom extracted ghc-options under v2 only (configure-args.nix): cabal injects it on everySetup configurecall, so round-tripping it through the v2 builder’s per-packageghc-options:block would produce a duplicate--ghc-option=-hide-all-packagesentry inpkgHashProgramArgs, while a downstreamcabal v2-buildoutside the slice only sees it once — forking the UnitId hash and defeating cache reuse. v1 doesn’t round-trip through cabal.project, so it leaves the option alone. - Emitting per-package
extra-(include|lib|framework)-dirsblocks for each dep’s ownc.frameworksonly.c.libsandc.pkgconfigdeliberately do not land here — their nix-store paths would feedpkgHashExtra(Include|Lib)Dirsand fork unit-ids from plan-nix’s (plan-to-nix’s compute never sees the haskell.nix-injected paths). cabal still finds these libs via the wrapped cc / pkg-config — the slice’sextraBuildInputscarries them soNIX_CFLAGS_COMPILE_FOR_TARGET/NIX_LDFLAGS_FOR_TARGETroute them in at compile / link time. SeepkgcfglessSysLibs. - Keeping the dummy GHC’s
--infocapability fields aligned with the real GHC’s (seelib/call-cabal-project-to-nix.nix). WithoutSupport dynamic-too: YES/RTS ways: … dynin the dummy, plan-to-nix records--disable-sharedwhile the real cabal v2-build picks--enable-shared, forkingpkgHashEnableSharedLib. ghcjs / wasm cross targets keep the more restricted capability set since their cross GHCs really don’t ship a dynamic RTS.
After cabal v2-build --dry-run the slice cross-checks the plan
against expectedPackage and exits early if cabal is about to
build any package other than the slice’s target — that almost
always means a hash input has drifted. After the build, the
captured unit-ids must equal [expectedUnitId] exactly.
checkAgainstPlan — diagnosing divergence
Each slice has a sibling derivation at
hsPkgs.<pkg>.components.<kind>.<n>.checkAgainstPlan. It re-runs
just the slice’s cabal v2-build --dry-run step, captures the
slice’s dist-newstyle/cache/plan.json, and diffs each entry
against the corresponding entry in plan-nix.json (matched by id,
otherwise by pkg-name + component-{name,type}). Both are
pretty-printed with jq -S and diffed with full-file context so
the diverging field (compiler-id, flags, dep unit-ids, …) is
visible against the rest of the entry.
Plan-nix.json is plumbed in via pkgsBuildBuild.writeText only on
checkAgainstPlan itself — it’s never a dep of the slice.
nix build .#…hsPkgs.<pkg>.components.library.checkAgainstPlan
cat result/diff-report.txt # per-pkg full-context diffs
cat result/cabal.project # the slice's exact cabal.project
cat result/build.log # cabal v2-build --dry-run output
$out also contains the slice’s full plan.json, a copy of
plan-nix.json, and the slice’s build.log.
Per-component .store attribute
comp-v2-builder attaches .store to each slice — a derivation
holding the lndir’d composition of that component’s depSlices
(the deps, not the slice itself). Useful for inspection:
nix-build -A 'foo.components.library.store'
The same composeStore helper builds the v2 shell’s composed dep
store; both attach a package.cache produced by ghc-pkg recache.
Dev shell
When a project sets builderVersion = 2;, project.shell and
project.shellFor { ... } automatically dispatch to the v2
implementation in builder/shell-for-v2.nix (see the dispatch in
builder/default.nix). Users opting into v2 wholesale don’t have
to change their shell call site.
Where the v1 shell wraps GHC with a pre-populated package db, the
v2 shell pre-populates the user’s cabal store
(~/.cabal/store/<ghcDir>/) so a plain cabal v2-build inside
the shell reuses our slices instead of rebuilding from source —
and so public sublibs that the v1 GHC-package-db approach can’t
expose to cabal show up correctly.
The hook (haskell-nix-cabal-store-sync):
- Writes a
nix-store --add-root --indirectgcroot for the composed dep store under~/.cabal/store/.haskell-nix-gcroots/sonix-collect-garbagecan’t drop the slices our links point at. - Scans every
<unit-id>.conf/ unit-dir / lib file in the composed store against the user’s existing~/.cabal/store/<ghcDir>/, classifying each as new, same, or conflict. - If conflicts exist and
--forcewasn’t passed, errors out with a summary (and instructions to re-run with--force). - Symlinks new (and, with
--force, conflicting) entries from composedStore into the user’s store vialn -s(single files) orlndir(unit dirs). - Runs
ghc-pkg recacheon each touched<ghcDir>/package.db.
The script is exposed standalone as haskell-nix-cabal-store-sync,
so a user can re-run it with --force from inside the shell.
Cross compilation
For each cross target (crossPlatforms = p: [ p.ghcjs ]; …) the
overlay adds the cross project’s shell to the main shell’s
inputsFrom. shell-for-v2.nix:
- Routes
cabalStoreSyncthroughpkgs.pkgsBuildBuild.writeShellScriptBinso the cross shell evaluates without a host CC (cross targets like ghcjs have none). - Resolves
shellGhcto the cross-compiling GHC atpkgs.buildPackages.haskell-nix.compiler.<n>(build → host) — i.e. a GHC that runs on the build platform but targets the cross host. - Builds a
${prefix}cabalwrapper (e.g.javascript-unknown-ghcjs-cabal) that invokes plaincabalwith--with-compiler=<ghcShim>/bin/${prefix}ghcso the user doesn’t have to remember the cross-GHC paths.ghcShimis a tiny symlink farm exposing both prefixed and unprefixed GHC binaries so cabal v2-build’s “near compiler” lookup finds plainghc-pkg. - Merges
passthru.depSlicesfrom everyinputsFromshell into this shell’scomposedStoreso cross + native slices both seed~/.cabal/store.
How configure-args.nix integrates
Cabal’s plan.json records the exact Setup configure command
line it would invoke for each plan unit.
modules/install-plan/configure-args.nix parses those:
--ghc-option=X/--ghcjs-option=X→packages.<plan-id>.ghcOptions--configure-option=X→packages.<plan-id>.configureOptions- other
--PROG-option=X→packages.<plan-id>.configureFlags
These get merged with the canonical-name-keyed entry that the user
writes via modules. comp-v2-builder.nix’s
perPackageOptionOf reads both and complains if components / plan
units disagree (cabal’s package <n> block applies to all
components — there’s no way to emit per-component values via
cabal.project).
Testing & checking
The slice itself only builds a test/bench’s binary — actually
running it is left to lib/check.nix (via haskellLib.check),
mirroring v1’s split between build-time and check-time. For
cross-host targets that need a launcher (windows: wine; ghcjs:
node), the launcher prefix is taken from the slice’s
passthru.config.testWrapper (set by the windows / ghcjs default
modules).
Key files
| Path | Role |
|---|---|
builder/comp-v2-builder.nix | per-component: cabal.project + slicingRepo + plumbing |
builder/build-cabal-slice.nix | the slice derivation (compose + cabal v2-build + capture) |
builder/compose-store.nix | lndir helper for .store and shell composed store |
builder/shell-for-v2.nix | v2 nix-shell (haskell-nix-cabal-store-sync hook) |
builder/cabal-install-patches/ | cabal-install patches (sublib visibility, completed-revdeps) |
lib/call-cabal-project-to-nix.nix | dummy GHC’s --info (must mirror real GHC’s capability fields) |
modules/install-plan/configure-args.nix | extract ghc/configure args from plan.json |
modules/install-plan/planned.nix | mark planned components + populate plan-id pkg.identifier |
modules/install-plan/override-package-by-name.nix | package-ids-by-name + plan-id alias |
When to prefer v2
- Your project (or any of its deps) uses Cabal public sublibs
(
library <n>stanzas). v1’s shell can’t expose those to cabal; v2’s shell can. - You want a plain
cabal v2-buildinside the dev shell to reuse our pre-built slices instead of rebuilding from source. - You care about UnitId stability between haskell.nix-built units
and the units a plain
cabal v2-buildinside the shell would produce.
v1 is still supported — set builderVersion = 1; on the project.
See builder-v1.md.