About this page
A horizon.vx file describes a Haskell project or package set: the set it
builds on, the packages it adds and who owns its flake.nix.
horizon-config
reads the file and writes the Nix that builds it.
This page describes the language of horizon-config 0.15.0 as interpreted by horizon-config-shell 0.24.0. The first sections walk through a minimal and a fuller file; the reference after them lists every statement. For a first project, start with the QuickStart.
Every command on this page runs the released generator:
nix run 'git+https://nixlab.openfp.net/horizon/horizon-config-shell?ref=refs/tags/0.24.0' -- update horizon.vx
The page writes this as horizon-config update horizon.vx. The executable
is horizon-config.
Before you start
You need Nix with flakes enabled, and git. Your project must be a git
repository: Nix reads only files that git tracks, so git add every file
before you build.
A minimal file
This file builds the package in the directory hello against
horizon-platform's compiler and build tools, and gives the repository a
development shell:
do
name "hello"
compiler "ghc-10.0.0.20260917"
on (toolchain (forge "nixlab.openfp.net/package-sets/haskell/horizon-platform") (branch "refs/tags/snapshots/lts/ghc-10.0.x/9"))
flake Generated
local "hello" "hello"
root "hello"
devShell
A file is a do block with one statement per line. Text values are
written in double quotes. Generate the files, lock the flake and build:
git add -A
horizon-config update horizon.vx
git add -A
nix flake lock
git add flake.lock
nix build .#hello
The build has succeeded when ./result/bin/hello runs.
A fuller file
This file also states the repository's licence, language and warnings
contract, adds a package from Hackage, and puts hlint in the
development shell:
do
name "hello"
compiler "ghc-10.0.0.20260917"
description "Says hello."
on (toolchain (forge "nixlab.openfp.net/package-sets/haskell/horizon-platform") (branch "refs/tags/snapshots/lts/ghc-10.0.x/9"))
flake Generated
nixConfig "extra-substituters" "https://cache.openfp.net"
nixConfig "extra-trusted-public-keys" "cache.openfp.net-1:ThN0P23SdgO8mRfZDCr0FZICFv9f2Llv1wqpfDoaMY4="
license "MIT"
language "GHC2024"
warnings "werrorwolf"
hackage "acme-missiles" "0.3"
local "hello" "hello"
root "hello"
root "acme-missiles"
devShellWith ["cabal-install", "hlint"]
license and language are checked against hello/hello.cabal, so that
file must say license: MIT and default-language: GHC2024, or the
update stops. With warnings "werrorwolf", nix flake check also builds
the check hello-0.1.0.0-werror.
Commands
horizon-config update horizon.vx reads the file, fetches the sources it
names, runs the solver if the file says solve, and writes the generated
files and horizon.lock. It reuses what it resolved before for packages
whose inputs have not changed. Run it after changing horizon.vx or a
local package's .cabal file. horizon-config horizon.vx does the same.
horizon-config check horizon.vx confirms that the committed files match
horizon.vx, without fetching anything, running Nix or the solver, or
writing files. Run it in CI. When everything matches, it prints
Horizon snapshot is consistent. It fails, and says what to do, when:
horizon.lockis missing, stale or from a generator before 0.21; runupdate.- a generated file is missing or has been edited, for example
Horizon generated files differ: flake.nix; run horizon-config update horizon.vx. - under
flake Generated,flake.lockis missing:flake.lock is missing beside the generated flake.nix; run nix flake lock. - under
flake Manualwith anon,flake.nixis missing,flake.lockhas no input for the base, or that input follows another repository or branch. The message names the input to declare; see "Who owns flake.nix".
horizon-config --prune horizon.vx lists declared packages that no
root needs. Run it after update, because it reads the generated
package files. For the fuller file without root "acme-missiles", it
reports acme-missiles as unreachable.
nix flake check runs the generated checks: assurances, which fails
unless every root is in the set; treefmt; and, with
warnings "werrorwolf", one -werror build per local package.
Generated files
| File | Written when |
| --- | --- |
| horizon.lock | always |
| nix/horizon/ | always, except with a set base: one module per package, with modules.nix and repository.nix |
| nix/horizon-pkgs/, nix/horizon-overlays.nix, nix/horizon-repository.nix | in place of nix/horizon/, with a set base |
| cabal.project | the file has a local package |
| nix/configuration.nix, nix/horizon.nix | the file has an on |
| nix/horizon/overlays.nix | the file has no on |
| flake.nix | flake Generated |
Commit all of them, with flake.lock. Do not edit them: check reports
an edit, and the next update overwrites it. Other files under nix/ are
yours; the generator neither writes nor checks them.
Who owns flake.nix
flake says who writes flake.nix. Declare it at most once.
flake Generated: the generator writes flake.nix, with the base
named by on as its root input. It needs an on. Run nix flake lock
after the first update and commit flake.lock; check fails without
it, and reports any edit to flake.nix.
flake Manual: the repository writes flake.nix; the generator never
writes or checks it. Use this to add your own inputs or outputs. The
flake must load every module under nix/. With an on, it must also
declare the base as an input named after the base's repository, on the
same branch as horizon.vx. For the minimal file with flake Manual:
{
inputs = {
horizon-platform.url = "git+https://nixlab.openfp.net/package-sets/haskell/horizon-platform?ref=refs/tags/snapshots/lts/ghc-10.0.x/9";
horizon-flake-parts.url = "git+https://nixlab.openfp.net/nix/horizon-flake-parts?ref=refs/tags/0.35.2";
nixpkgs.follows = "horizon-platform/nixpkgs";
horizon-ghc.follows = "horizon-platform/horizon-ghc";
};
outputs =
inputs:
inputs.horizon-flake-parts.inputs.nixica.lib.mkFlake {
inputs = inputs.horizon-platform.inputs // inputs // { inherit (inputs.horizon-flake-parts.inputs) nixica; };
modules = ./nix;
};
}
If the input is missing, check prints the line to add:
flake Manual: flake.lock records no input "horizon-platform"; declare horizon-platform.url = "git+https://nixlab.openfp.net/package-sets/haskell/horizon-platform?ref=refs/tags/snapshots/lts/ghc-10.0.x/9" in flake.nix and run nix flake lock
Without an on, the generated packages are an overlay. Name the set they
extend in a file of your own, such as nix/base.nix, and declare that set
as a flake input. This example extends horizon-core:
do
compiler "ghc-10.0.0.20260917"
flake Manual
local "hello" "hello"
root "hello"
{
inputs = {
horizon-core.url = "git+https://nixlab.openfp.net/package-sets/haskell/horizon-core?ref=refs/tags/snapshots/lts/ghc-10.0.x/8";
horizon-flake-parts.url = "git+https://nixlab.openfp.net/nix/horizon-flake-parts?ref=refs/tags/0.35.2";
nixpkgs.follows = "horizon-flake-parts/nixpkgs";
};
outputs =
inputs:
inputs.horizon-flake-parts.inputs.nixica.lib.mkFlake {
inputs = inputs // { inherit (inputs.horizon-flake-parts.inputs) nixica; };
modules = ./nix;
};
}
# nix/base.nix
local: {
horizon.systems.supported.enable = true;
horizon.systems.supported.targets = builtins.attrNames local.inputs.horizon-core.legacyPackages;
perSystem =
{ system, ... }:
{
horizon.overlay.basePackages = local.inputs.horizon-core.legacyPackages.${system};
};
}
Undeclared: a file with an on is flake Generated, and one without
is flake Manual, as before horizon-config-shell 0.24.0.
The generator refuses flake Generated without an on, and a second
flake statement, before it fetches anything. Switching from
flake Generated to flake Manual leaves the last generated flake.nix
in place for you to edit.
Reference: what the set builds on
on BASE, at most once. The set's base, which is one of:
toolchain (forge "HOST/PATH") (branch "REF"): build the set from scratch with that toolchain's compiler and build tools. The examples on this page use horizon-platform as the toolchain.set "NAME" (forge "HOST/PATH") (branch "REF"): an overlay on a lower package set. The set takes its compiler and packages from the lower set, and asolvekeeps them at the versions that set holds.
branch takes a branch, such as "lts/ghc-10.0.x", or a tag, such as
"refs/tags/snapshots/lts/ghc-10.0.x/9". A branch moves; pin a snapshot
tag to keep builds reproducible. flake.lock records the commit. Without
on, the file describes an overlay for an application; see
"Who owns flake.nix".
compiler "ghc-VERSION": the GHC, such as
compiler "ghc-10.0.0.20260917". Nix builds use the base's compiler,
but the generated cabal.project names this one, so a file with a
local package must state the base's GHC for cabal build to work in
the development shell. Without it, cabal.project names ghc-9.10.1.
flake Generated or flake Manual: who owns flake.nix.
platform "ARCH-OS": the platform whose arch(...) and os(...)
conditions the packages are rendered for, such as
platform "javascript-ghcjs". The default is x86_64-linux.
indexState "TIMESTAMP": read Hackage as it was at that time, such as
indexState "2025-03-01T00:00:00Z", so that later uploads do not change
the set. The default is the newest index on the machine; if that index is
older than the time given, the update stops and asks you to run
cabal update.
Reference: packages
hackage "NAME" "VERSION": a release from Hackage.
git "NAME" "URL" SELECTOR and
gitSubdir "NAME" "URL" SELECTOR "DIR": a package from a git
repository, or from a directory in one. The selector is (tag "NAME") or
(rev "HASH"), a hash of at least seven hexadecimal digits:
gitSubdir "nixlab-dsl" "https://nixlab.openfp.net/nixlab/nixlab-dsl" (tag "0.45.0") "nixlab-dsl"
allowRevs ["NAME", ...]: allow rev for the named git packages.
Without it, the generator refuses a rev. Statements combine. Naming a
package that is not pinned by rev is an error.
tarball "NAME" "URL" and tarballSubdir "NAME" "URL" "DIR": a
package from a tarball, or from a directory in one.
local "NAME" "DIR": a package in this repository. DIR must be
inside the repository and hold one .cabal file, for that package.
openfp "NAME" "VERSION": a published OpenFP package release. The
generator verifies its signed index and archive before using it.
repository "NAME" "URL" THRESHOLD ["KEY", ...]: a secure cabal
repository, with how many of the listed root keys must sign its metadata.
Declare it before the packages that use it.
hackageFrom "REPOSITORY" "NAME" "VERSION": a release from a declared
repository, such as hackageFrom "nixlab" "vortex-trace" "0.3.0".
root "NAME": a package the set exists to provide. The assurances
check fails unless every root is in the set, and --prune reports
declared packages that no root needs.
missing "NAME": a package the set deliberately does not provide. If
a root needs it, the update stops with an error.
Reference: changing a package
These statements name a package declared in the same file.
enableFlag "PACKAGE" "FLAG" and disableFlag "PACKAGE" "FLAG":
turn a cabal flag on or off.
patch "PACKAGE" "FILE": apply a .patch or .diff file, whose path
is relative to horizon.vx. Change a local package's source directly
instead.
systemDeps "PACKAGE" "LIBRARY": pass the nixpkgs library of that
name to the package, such as systemDeps "zlib" "zlib".
systemDepFrom "PACKAGE" "ARGUMENT" "ATTRIBUTE": the same when the
package's argument and the nixpkgs attribute differ, such as
systemDepFrom "cardano-crypto-class" "libblst" "blst".
Reference: set-wide build policy
Each of these takes True or False and applies to every pinned package.
A local package follows its own .cabal file.
doBenchmark, doCheck, doHaddock, doJailbreak, enableProfiling,
includeBenchmarks, includeExecutables, includeTests.
For example, doJailbreak True builds every pinned package with its
version bounds removed.
Reference: solving
Without solve, the set holds exactly the packages the file pins. With
it, cabal's solver adds everything the roots need.
solve: expand the roots into their dependencies and record the result
in horizon.lock. Declare it once.
index "NAME": an index to solve against: hackage, head.hackage,
chap, openfp, or a repository the file declares. Earlier statements
take precedence. Use index "openfp" when solving with openfp packages.
allowNewer "NAME": ignore upper bounds on that package while
solving, such as allowNewer "base" for a compiler newer than packages
expect.
allowOlder "NAME": ignore lower bounds on that package while
solving. Use it only when the older version really works, for example
because a patch restores an older API.
Reference: the repository
name "NAME" and description "TEXT": the project's name and a
one-line description, used in the generated flake and the set's README.
license "SPDX", language "LANGUAGE" and
extension "EXTENSION": facts about every local package. The update
checks each one against the package's .cabal file and stops if they
disagree. Write extension once per extension.
exposed "PACKAGE" "MODULE", internal "PACKAGE" "MODULE" and
suite "PACKAGE" "SUITE" "FILE": an exposed module, an internal
module and a test suite of a local package, checked in the same way.
backend "PACKAGE" "BACKEND": recorded, but builds nothing yet.
warnings "werrorwolf": also build every local package with
-Werror as the check NAME-VERSION-werror. warnings "none" declares
no contract.
devShell: a development shell, nix develop, with GHC, the local
packages' dependencies and cabal-install.
devShellWith ["PROGRAM", ...]: the same with the named nixpkgs
programs in place of cabal-install, such as
devShellWith ["cabal-install", "nodejs-slim"].
nixConfig "KEY" "VALUE": a nixConfig setting in the generated
flake.nix. It takes effect only under flake Generated with a
toolchain base.
Reference: output layout
overlay "FILE" and packageSet "FILE": export the packages as an
overlay, or as a whole package set, to that file.
modules: accepted for older files. Every set is now written as
modules, and the generator refuses it on a set with a set base.
Further reading
The horizon-config-shell README
explains the lock and the checks in detail. horizon-platform's own
horizon.vx
is a full set with solve, patches and version exceptions.