- About this documentation
- Desired properties
- About Stack's Nix integration
- Concerns with Stack's built-in Nix integration
This project discourages use of Stack's built-in Nix integration. This is not done lightly. It's okay to have multiple ways to accomplish a task, measuring the benefits and liabilities of each. But without some care using Stack's built-in Nix integration can lead to undesirable properties. Particularly concerning is the possible loss of a repeatable build that's portable across computers, which is a primary motivation use Nix in the first place.
This document explores problems using Stack's built-in Nix integration. The goal is not to malign Stack as a project, but to save new users time rediscovering the same conclusions through independent exploration.
Here's a few ways with which to measure the strength of any approach we take when building a Stack project with Nix:
-
Building the project, whether with Nix or Stack, should be as reproducible and portable (pure) as possible.
-
When we have both a Stack and Nix build, they should should be consistently configured. It's understandable that the compiled outputs are not bit-for-bit identical, but we should be able to keep build inputs and arguments consistent if we want to.
-
The user experience should work well with command-line defaults:
-
When building with Stack, we should only need a simple
stack build
call (with the allowance of first entering a Nix shell with a simplenix-shell
call). -
When building with Nix, we should only need a simple
nix build
call. -
We should be able to invoke HLS with no arguments (with the allowance of first entering a Nix shell with a simple
nix-shell
call), and not require users to generate an explicithie.yaml
file, if avoidable.
-
-
There should not be special instructions due to a user's operating system. Non-NixOS with a Nix installation should be the same as NixOS, whether MacOS or otherwise.
-
All configurations should have a declarative simplicity without hacks or surprises for intermediate/expert users who know standard practices.
-
We should avoid (or minimize) duplicate configuration between the Stack and Nix builds that can become inconsistent accidentally.
-
We should be able to call Stack commands for working on the project from any directory, not just the root project directory.
-
The cost of evaluating a Nix shell with
nix-shell
to get environment variables- should be possible to eliminate with a tool like Direnv
- should still be reasonably low even if not using a tool like Direnv.
Stack manages Haskell dependencies, but does not control non-Haskell dependencies, such as C libraries needed for FFI bindings. Typically people use traditional package managers to install these dependencies (such as APT, RPM, or Homebrew). However, these installations are generally system-wide, which doesn't address the possibility that projects may need conflicting versions of the same dependency. Traditional package managers can only install one version of a dependency.
Fortunately, Nix shells are able to provide project-local management of non-Haskell dependencies. These shells, generated by nix-shell
, download/build everything that's needed and sets up environment variables (such as PATH
) to point to these builds. Commands can then be run either interactively or non-interactively within these per-project environments.
Using Nix effectively, though, involves learning the Nix language, which is more expressive than a configuration language like YAML.
So Stack starts with a simple YAML specification in a Stack project's stack.yaml
file, builds out the corresponding Nix expression, and invokes nix-shell
as necessary internally with each call of stack
.
This way, the user can get the benefits of Nix without knowing anything about the Nix language or how to call nix-shell
. The user just has to have Nix installed, and put in a small YAML snippet into stack.yaml
, such as the following:
nix:
enable: true
packages: [icu] # depends on the ICU C library
To Stack's credit, this configuration is indeed simple and declarative.
The next few sections covers problems we face when trying to manage non-Haskell dependencies using Stack's built-in Nix integration.
Nix expressions are known to be to evaluate slowly (hopefully the soon-to-release feature of Nix "flakes" can improve this slowness).
We ultimately want to integrate with tools like HLS, which will call a stack
command several times for a multi-package project. If a call of nix-shell
is hidden behind each invocation of stack
, then we'll pay multiple times the cost of evaluating a Nix expression.
Additionally, tools like Direnv can eliminate the cost of evaluating nix-shell
, by caching the resultant environment variables we get when invoking it. But the caching benefits of Direnv can not be utilized by these internal calls of nix-shell
by Stack.
These problems alone can make usage of Stack's built-in Nix integration a non-starter for some. The next few sections cover further problems.
There are details about the Nix expression that Stack builds internally that may surprise a Nix user. The main way people discover these details is by reading the source code of Stack. Hiding these details wouldn't be as much of a problem if the implementation had less need for special attention.
When enabling Stack's Nix integration with --nix
and specifying a non-Haskell dependency with, for example, --nix-packages icu
, we get the following internally generated nix-shell
invocation:
/run/current-system/sw/bin/nix-shell \
--pure -E "
with (import <nixpkgs> {});
let inputs = [icu haskell.compiler.ghc884 git gcc gmp];
libPath = lib.makeLibraryPath inputs;
stackExtraArgs = lib.concatMap (pkg: [
''--extra-lib-dirs=${lib.getLib pkg}/lib''
''--extra-include-dirs=${lib.getDev pkg}/include''
]) inputs;
in runCommand ''myEnv'' {
buildInputs = lib.optional stdenv.isLinux glibcLocales ++ inputs;
STACK_PLATFORM_VARIANT=''nix'';
STACK_IN_NIX_SHELL=1;
LD_LIBRARY_PATH = libPath;
STACK_IN_NIX_EXTRA_ARGS = stackExtraArgs;
https://docs.haskellstack.org/en/stable/nix_integration/LANG=\"en_US.UTF-8\";
} \"\"" \
--run "'/path_to_stack_installation/bin/stack' \
$STACK_IN_NIX_EXTRA_ARGS \
'--internal-re-exec-version=2.5.1' \
'--verbose' \
'build'"
The most critical surprise in Stack's internally generated Nix expression is usage of import <nixpkgs> {}
. There's two problems with this.
First the angle bracket syntax reads the value for nixpkgs
from the environment variable NIX_PATH
. This means that our Nix expression has the potential to not evaluate consistently from machine to machine as this setting of nixpkgs
in this variable could change easily.
Secondly, the default {}
passed to import <nixpkgs>
will configure an impure lookup of both ~/.config/nixpkgs/config.nix
and ~/.config/nixpkgs/overlays
. Each user could differently configure and modify the loading of Nixpkgs. This access again threatens the reproducible loading of Nix.
As a workaround, Stack offers a "path" field in stack.yaml
to override NIX_PATH
. We could set this to a URL of a stable version of Nixpkgs:
nix:
enable: true
packages: [icu]
path: nixpkgs=https://github.com/NixOS/nixpkgs/archive/29e9c10750e2b35a0e47db55f36c685ef9219f4e.tar.gz
But this wouldn't help us with the fact that passing {}
to Nixpkgs makes builds less reproducible.
One way to deal with this is to write our own Nix expression for Nixpkgs, which we could then set as our path:
nix:
enable: true
packages: [icu]
path: nixpkgs=nixpkgs.nix
In our nixpkgs.nix
we could have something like:
_ignored: # ignore arguments, which might be impure
let url = https://github.com/NixOS/nixpkgs/archive/29e9c10750e2b35a0e47db55f36c685ef9219f4e.tar.gz;
nixpkgs = builtins.fetchTarball url;
in import nixpkgs { config = {}; overlays = []; }
One annoyance with this is that we can only now call stack
in the directory where we have our nixpkgs.nix located. This is because we specified it's location as a relative path (path: nixpkgs=nixpkgs.nix
).
stack.yaml
is generally checked into source control, so we wouldn't want to use an absolute path for this file, because the filepath needs to be able to vary across different users' computers.
We can correctly set an absolute path by calling nix-shell
to set up NIX_PATH
(instead of using the "path" field). But once we commit to having the user make an explicit nix-shell
call to address the problems listed above, we must ask ourselves if having Stack call nix-shell
again internally gives us more any benefit.
One remaining benefit of Stack's built-in Nix integration is the concise configuration syntax of specifying non-Haskell dependencies in a Stack YAML file. Maybe an upstream author maintains a list of these dependencies in the provided stack.yaml
file. For example it may contain:
…
nix:
packages:
- icu
Fortunately, it's not much code to parse a YAML file in a Nix expression so we can leave this concise and declarative specification of non-Haskell dependencies in Stack YAML files if we like. This project provides a stackNixPackages
Nix function to do this parsing, and the included example Stack project illustrates how to use it.
So this means that if we are having the user call nix-shell
explicitly to establish some invariants for a reproducible build, then there's no remaining motivation to have Stack call nix-shell
again internally. Doing so just introduces unnecessary complexity.
Stack reads the file /etc/os-release
to determine if the operating system is NixOS. If so, then Stack forcibly enables its Nix integration. This means that Nix users on NixOS will have a different experience than people who have installed Nix on a non-NixOS operating system. One or the other will have to enable or disable Nix with Stack's --nix
or --no-nix
switches. This wouldn't be the case if a project's stack.yaml
enabled Nix for all users, but that seems unlikely. Nix is not that popular yet.
What seems more likely is that project would provide multiple Stack YAML files, but that just leads to annoying configuration duplication.
Furthermore, note that HLS calls stack
with no additional arguments. We can't yet have HLS pass a switch like --nix
or --no-nix
to Stack. And if HLS needs Stack to use an alternate Stack YAML file, we can only specify that with an explicitly generated hie.yaml
file. We really don't want to force a user to have to generate hie.yaml
files for projects if it can be avoided.
Ideally, we can take any project as it comes to use from an upstream author, and put in a default.nix
and shell.nix
files, and get the benefits of Nix. Normal Stack users can just ignore these files. Stack's builtin integration creates a tension of what gets into stack.yaml
.
Note that if your operating system is NixOS, and you accidentally call stack
from outside a Nix shell, you could get a non-reproducible build. By turning on the built-in integration automatically for NixOS, Stack is defaulting users to build with Nix expressions containing the problematic import <nixpkgs> {}
. For this reason, you may prefer to disable Nix in your ~/.stack/config.nix
file, especially if you're on NixOS.
There's three components of our projects that we'd like configured consistently:
- HLS (tested by running
haskell-language-server-wrapper
) - The Stack build for local development (
stack build
) - The Nix build of our project, if we have one (
nix build
)
In particular, there's two pieces of configuration we need to make consistent:
- the version of GHC to target
- the non-Haskell dependencies to be provided by Nix.
And there's two ways we can manage providing these components our configurations:
- Stack YAML
- Nix expressions
With a little parsing of the Stack YAML file, we can make Nix expressions consistent with the YAML file with respect to non-Haskell dependencies provided by Nix. This project provides a small Nix expression called stackNixPackages
to assist with that.
Unfortunately, though the Stack YAML files configure a Stack resolver that could be parsed from YAML, there's no convenient way to correlate the resolver to a GHC version from within a Nix expression.
The means that the GHC version can be specified as follows:
Component | GHC from Stack resolver | GHC from Nix expressions |
---|---|---|
Stack build | ✓ (--nix ) |
✓ (--no-nix --system-ghc ) |
HLS | ✗ | ✓ |
Nix build | ✗ | ✓ |
Users of Stack on non-NixOS systems are probably familiar with Stack's ability to download instances of GHC. This unfortunately doesn't work with NixOS. We definitely don't want to exclude NixOS users, so we don't consider any options where GHC is downloaded directly by Stack. Nix will always provide GHC. The only question is whether Stack selects this instance from Nixpkgs with its --nix
option or whether it delegates to Nix to have it selected outside of Stack with --no-nix --system-ghc
.
Without abandoning Stack (say for Cabal), there's no way getting around building with Stack for local development of a project. But building with Nix is optional. If you do build with Nix, though, it makes sense that exact instance of GHC we use for both should be the same. And for this reason, the --no-nix --system-ghc
option is recommended.
Note, the resolver would still need to be consistent with the instance of GHC selected by Nix. It not, the Nix build would fail. However, it is Nix driving the actual selection of the GHC instance.
Furthermore, by not using --nix
to have Stack select out an instance of GHC from Nixpkgs, we avoid a slew of problems with impure Nix expressions (particularly import <nixpkgs> {}
) discussed in previous sections. It's not that you couldn't work around these problems, but you accrue a lot of incidental complexity for what appears to be no real benefit.