This is the first part of a series of devlogs on Trillium, the MATP environment written in Rust and Lean.

The development of my MATP project required multiple language components to interact with each other and has a non-trivial dependency tree. To avoid polluting the system environment and ensure reproducibility, I created a Nix flake to help with development. Before anyone attempts to do this, however, I have to give a warning:

At the time of writing this article, Nix Flake is still an experimental feature. There are efforts to stabilize it.

Due to the experimental nature of Nix and the Principle of Least Power, I’ll adhere to the following principle: The user should be able to develop and use the project without touching anything Nix. In this case the user has the burden of installing every package at the correct version.

Update: The solution below does not have clippy and llvm-cov. The Crane library addresses this issue and could be used instead.

Nix run

With this out of the way, we shall get started. Create a barebone Rust project with one dependency:

# Cargo.toml
[package]
name = "rustnix"
description = "An example project about rust and nix"
publish = false
version = "0.1.0"
edition = "2021"

[dependencies]
colored = "2.0.4"
// src/main.rs
use colored::Colorize;

fn main() {
   println!("{}", "Hello Nix flake!".cyan());
}

Now with

$ cargo run
Hello Nix flake!

As the project dependency grows, preferably we do not want to recompile all dependencies when the source code changes.

The first two tools we will use are flake-parts and crate2nix. flake-parts is for generating per-system derivations, and crate2nix converts the Cargo.lock expression to Nix so it can be integrated into the Nix flake. The general method to do this is

let
  crateTools = pkgs.callPackage "${rust-crate2nix}/tools.nix" { inherit pkgs; };
in import (crateTools.generatedCargoNix {
  name = "project-name";
  src = ./.;
}) {};

With this, we can write a Nix Flake with the Rust project as its default target.

{
  description = "Rust-Nix";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
    rust-crate2nix = {
      url = "github:kolloch/crate2nix";
      flake = false;
    };
  };

  outputs = inputs @ {
    self,
    nixpkgs,
    flake-parts,
    rust-crate2nix,
    ...
  } : flake-parts.lib.mkFlake { inherit inputs; } {
    flake = {
    };
    systems = [
      "x86_64-linux"
      "x86_64-darwin"
    ];
    perSystem = { system, pkgs, ... }: let
      # Main build target
      project = let
        crateTools = pkgs.callPackage "${rust-crate2nix}/tools.nix" { inherit pkgs; };
      in import (crateTools.generatedCargoNix {
        name = "rustnix";
        src = ./.;
      }) {
        inherit pkgs;
      };
    in rec {
      packages = {
        rustnix = project.rootCrate.build;
        default = packages.rustnix;
      };
    };
  };
}

Development Shell

The next step is to create a development environment where the cargo and rust-analyzer binaries match the Rust version used by the project. We use oxalica/rust-overlay to achieve this. First we specify the toolchain:

# rust-toolchain.toml
[toolchain]
channel = "nightly"

If you are using Git, don’t forget to track this file in the Git repository. Otherwise Nix will not find it. I use the nightly toolchain here to demonstrate that it is possible to use nightly features.

Then we add the necessary inputs:

{
  ...

  inputs = {
    ...
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs = inputs @ {
    rust-overlay,
    ...
  } : flake-parts.lib.mkFlake { inherit inputs; } {
    ...
    perSystem = { system, pkgs, ... }: let
      overlays = [
        (import rust-overlay)
        (self: super: let
          toolchain = super.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
        in {
          rustc = toolchain;
        })
      ];
      pkgs = import nixpkgs { inherit system overlays; };
	  ...
    in ...;
  };
}

This pins rustc to the toolchain version specified in rust-toolchain.toml. Moreover, the .override function can be used to add extensions to the toolchain. e.g. (super.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml).override { extensions = [ "rust-src" ]; };. In this case it makes the Rust source code available for development purposes. Creating a development shell is very simple:

{
  outputs = {
    ...
    perSystem = { system, pkgs, ... }: let
        ...
    in
        devShells.default = pkgs.mkShell {
		  buildInputs = [pkgs.rustc];
        };
  }
}

Due to the overlay, the Rust version has been modified. The next nix run execution will take a while as a result of recompilation. We can enter the development shell by

$ nix develop

For a sanity check, we ask where are the Rust executables:

$ which cargo
/nix/store/9xf4yr7ckqjivgrdkvs8r9lm9kykyhkp-rust-default-1.75.0-nightly-2023-10-21/bin/cargo
$ which rustc
/nix/store/9xf4yr7ckqjivgrdkvs8r9lm9kykyhkp-rust-default-1.75.0-nightly-2023-10-21/bin/rustc

This shows the new cargo and rustc versions have been overlaid on pkgs.

We can use cargo run and nudge the source code a little bit to see that it is not recompiling every package:

$ cargo run
...
$ (modify source code a bit)
$ cargo run
...

Direnv and Emacs

Install direnv and create a .envrc file:

# .envrc
use flake

This automatically sets the right environment variable in a development shell. In Emacs, running envrc-reload loads the current .envrc file.

Note that when the flake is updated, it may take a while for the shell to be available for executing the next command.

The full example in this tutorial can be seen here on git.leni.sh.

Build Inputs

Sometimes a Rust package needs external libraries to be built. In this case, add them to pkgs.defaultCrateOverrides in project:

      let
        crateTools = pkgs.callPackage "${rust-crate2nix}/tools.nix" { inherit pkgs; };
      in import (crateTools.generatedCargoNix {
        name = "project-name";
        src = ./.;
      }) {
        inherit pkgs;
        defaultCrateOverrides = pkgs.defaultCrateOverrides // {
          project-name = attrs: {
            nativeBuildInputs = [ ... ];
            buildInputs = [ ... ];
          };
        };
      };

Maintenance and Troubleshooting

  1. Be sure to monitor the disk usage of /nix/store and run nix-collect-garbage periodically
  2. If Cargo suddenly starts to recompile more crates than normally needed, do a sanity check and see if the paths of cargo, rustc, and rust-analyzer are correct.
  3. Cargo’s reason of recompilation can be found by setting the environment variableCARGO_LOG=cargo::core::compiler::fingerprint=info and is useful for triaging in Nix environments.
  4. If your IDE’s Rust analyzer is invoking a version of rustc that is not from the Nix Flake, it will cause dependency recompilation. In this case a reload of the environment is necessary.