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:
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
- Be sure to monitor the disk usage of
/nix/store
and runnix-collect-garbage
periodically - If Cargo suddenly starts to recompile more crates than normally needed, do a
sanity check and see if the paths of
cargo
,rustc
, andrust-analyzer
are correct. - Cargo’s reason of recompilation can be found by setting the environment variable
CARGO_LOG=cargo::core::compiler::fingerprint=info
and is useful for triaging in Nix environments. - 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.