Haskell Development with Nix and Dante

Table of Contents

I recently learned enough about Nix to try it for my everyday Haskell development. Below describes my entire setup and workflow.

1 Initial Setup

I'm on Arch Linux and so reached for the nix package in the AUR first, but it doesn't seem to set up the Nix environment properly. The official install method mostly worked:

curl https://nixos.org/nix/install | sh

You may have to manually fiddle with the write permissions of the newly created /nix directory usind chmod.

To view a script that sets environment variables:

less /home/colin/.nix-profile/etc/profile.d/nix.sh

This script assumes Bash, and I use fish, so I've set the following the variables myself in my /home/colin/.config/fish/config.fish:

set -x PATH ... '/home/colin/.nix-profile/bin'

# Nix
set -x NIX_PATH 'nixpkgs=/home/colin/.nix-defexpr/channels/nixpkgs'
set -x NIX_SSL_CERT_FILE '/etc/ssl/certs/ca-certificates.crt'

The last line is distribution specific, so make sure to check what's right for you in the nix.sh script mentioned above.

2 Haskell Specifics

In general, Gabriel Gonzales' beginner guide is good for understanding how to structure typical project config.

I've ignored tinc and styx, two third-party tools for Haskell/Nix integration, since I wanted the vanilla experience.

The advice here otherwise assumes a straight-forward Haskell project - a single simple library or executable without multiple subprojects or dependency overrides. It demonstrates how to benchmark, profile, integrate with Travis, and integrate with Emacs/Spacemacs.

2.1 Auto-generate Nix Config

cabal2nix can be used to generate nix expressions from .cabal files. This can be installed via:

nix-env -i cabal2nix

Then to generate a nixified version of a .cabal file (we'll need to do this every time our .cabal changes):

cabal2nix . > default.nix

If you prefer hpack, grab it via:

nix-env --install --attr nixpkgs.haskellPackages.hpack

And generate our default.nix via:

hpack && cabal2nix . > default.nix

or in fish:

hpack; cabal2nix . > default.nix

2.2 Developing in a Nix Shell

A full nix-based project is defined by three files:

  • default.nix
  • shell.nix
  • release.nix

But only the first two are needed for usual development. The command nix-shell invokes a jailed environment where all our project's dependencies are visible to cabal, so we can cabal test and cabal bench as usual. The following shell.nix file will allow nix-shell to load all necessary dependencies:

let nixpkgs = import <nixpkgs> {};
    orig = nixpkgs.pkgs.haskellPackages.callPackage ./default.nix {};
in (nixpkgs.pkgs.haskell.lib.doBenchmark orig).env

Notice that provided we have a default.nix (which is the usual name for the file output by cabal2nix), there is nothing project-specific about this. So, this snippet can be safely cargo-culted around to our various projects.

Without the doBenchmark line, our benchmark dependencies would not be visible inside the Nix Shell.

2.3 Defining a release.nix

Generally necessary for Travis, and for actually installing your package into some Nix store. Assuming a package called vectortiles:

let
  config = {
    packageOverrides = pkgs: {
      haskellPackages = pkgs.haskellPackages.override {
        overrides = haskellPackagesNew: haskellPackagesOld: {
          vectortiles = haskellPackagesNew.callPackage ./default.nix { };
        };
      };
    };
  };

  pkgs =
    import <nixpkgs> { inherit config; };

in
  { vectortiles = pkgs.haskellPackages.vectortiles;
  }

There are shorter forms, but this one allows for easy overriding of specific settings or dependencies, should we need to do that.

To build some full project:

nix-build release.nix

To build some "sub-derivation" within a larger, composite project:

nix-build --attr my-subproject release.nix

There are a number of other ways to customize a Haskell build, which are listed here.

2.4 Profiling

Nix makes this fairly easy. First, we add the following to a ~/.config/nixpkgs/config.nix:

{
  packageOverrides = super: let self = super.pkgs; in
  {
    profiledHaskellPackages = self.haskellPackages.override {
      overrides = self: super: {
        mkDerivation = args: super.mkDerivation (args // {
          enableLibraryProfiling = true;
        });
      };
    };
  };
}

Now in the project we want to profile, we create a new profiling-shell.nix:

let nixpkgs = import <nixpkgs> {};
    orig = nixpkgs.pkgs.profiledHaskellPackages.callPackage ./default.nix {};
in (nixpkgs.pkgs.haskell.lib.doBenchmark orig).env

Almost identical to our normal shell.nix, except for the usage of profiledHaskellPackages, which we just defined globally. Now, an invocation of nix-shell profiling-shell.nix will rebuild every dependency in our project with profiling enabled. The first time this is done it will take quite a long time. Luckily this doesn't corrupt our Nix store - a vanilla nix-shell does seem to present us with our regular dependencies again, without redownloading or rebuilding.

WARNING: A nix-collect-garbage -d will wipe away all the custom-built libs from our Nix Store, and we'd have to build them again if they're needed.

If we're writing a library, the closest executable on hand that we could profile would be our benchmark suite. To do that:

  • Add -prof and -fprof-auto to our benchmark's GHC options
  • Regenerate default.nix
  • Enter our profiling shell: nix-shell profiling-shell.nix
  • cabal configure --enable-library-profiling --enable-benchmarks
  • cabal build
  • dist/build/projname/projname-bench +RTS -p
  • Look at the produced projname-bench.prof file

Based on the results, we can make code changes, remove the profiling options, regenerate default.nix, and benchmark as usual in our normal Nix Shell.

3 Emacs Integration

3.1 Installing Dante

If using vanilla Emacs, follow Dante's own installation instructions.

If using Spacemacs, the haskell layer on the develop branch has support for Dante, which automatically uses nix if it detects a shell.nix and a default.nix. Our haskell layer line should look like this:

(haskell :variables haskell-completion-backend 'dante)

Now we add the following to the dotspacemacs/user-config section of our .spacemacs:

(add-hook 'dante-mode-hook 'flycheck-mode)

or else Dante might not activate itself properly when opening a Haskell file.

Dante provides no REPL, and the usual SPC m s b doesn't work. To test out some Haskell, we can do this instead:

-- >>> foo 5
foo :: Int -> String
foo = show

By running SPC m s e on the comment line, our code will transform into this:

-- >>> foo 5
-- "5"
foo :: Int -> String
foo = show

Together with Flycheck, this (in theory) obviates the need for an actual REPL.

3.2 Getting Dante to work with Test and Benchmark suites

Out of the box, Dante doesn't automatically handle multiple compilation targets. By this, I mean a single .cabal that specifies a library, test suite, and benchmark suite, say. By default, when navigating to our test and benchmark code, Flycheck will complain that many of our imported libraries aren't in scope.

The solution to this is to use file-local emacs variables in our test/bench source files. We can use the emacs function add-file-local-variable-prop-line to set dante-target to the name of our test/benchmark suite names. So for a test suite named vectortiles-test, we'd see the following Haskell comment on the first line of our Test.hs file:

-- -*- dante-target: "vectortiles-test"; -*-

Navigating to Test.hs will then boot an extra Nix'd cabal repl and flycheck session specifically for this file.

4 CI Services

Of the available choices, good ol' Travis seems to have the simplest setup.

4.1 Travis

Official instructions here.

Pretty low friction. For a package named vectortiles, a complete .travis.yml boils down to:

sudo: true  # Runs the build without Docker. It's *much* faster this way.
language: nix
script: nix-build --attr vectortiles release.nix

This setup will have our CI fail properly when tests fail. It should run in only a few minutes, even though every dependency is redownloaded every time.

If you're an hpack fan and never commit your auto-generated .cabal, here's a .travis.yml that will help:

sudo: true

language: nix

before_install: nix-env --install --attr nixpkgs.haskellPackages.hpack

script:
  - hpack
  - nix-build --attr vectortiles release.nix

4.2 Hercules

Github repository.

It's a new CI system for Nix projects that aims to replace Hydra, but it still seems distant from its 1.0 release.

4.3 CircleCI

CircleCI has Haskell support, but Nix support doesn't seem to be first-class. Here's some evidence that someone has it at least partly working.

4.4 Self-run Hydra

Official site.

While the "native" choice for Nix, this seems way too complicated for the lay library author.

5 Comparison to Stack

I wanted to give Nix/Dante a fair shot. In terms of personal programming philosophy, I'm motivated by high power-to-simplicity ratios (hence Haskell). After porting a project to use Nix/Dante from Stack/Intero, here are the advantages that I see:

  • Much faster "first compile" thanks to remotely cached, prebuilt libraries (this also affects CI runs, Heroku deploys, etc.)
  • "Auto-updating" resolvers (i.e. no need to manually increment the "nightly" date)
  • Simpler Travis config
  • Non-haskell dependency management
  • Convenient connection with doctest. If you've added a comment that's runnable by Dante into a docstring, say:
-- | Very well-worded docstring here.
--
-- >>> inc 1
-- 2
inc :: Int -> Int
inc n = n + 1

This is also the syntax that's understood by doctest as being a proof of correctness, so future changes to inc will have to pass the test that you originally set via a live Dante run (and not something hand-written).

And the advantages of Stack/Intero:

  • Vastly simpler initial setup for Haskell beginners (i.e. just stack build)
  • Overall simpler project config (a minimal legal stack.yaml is one line long).
  • Slightly stronger "it will always build" guarantee. A stack.yaml seems like a stronger constract for a sane environment when it comes to GHC and library versions.
  • intero has a simpler setup, a REPL, better support for auto-completion, and auto-filling of holes.
  • stack 's various UX improvements:
    • File watching: stack build --haddock-deps --test --file-watch --fast
    • Non-fiddly stack test and stack bench
    • Offline docs: stack haddock --open foobar
    • Dependency graphs: stack dot --external --prune base,ghc-prim,integer-gmp,deepseq,array | dot -Tjpg -o deps.jpg
    • stack upload .

6 Resources

Date: 2018-01-10

Author: Colin

Created: 2018-02-22 Thu 15:41

Validate