Adding the Devshell Block

As we begin iterating on our project, we'll come across this common theme: to add new functionality to our project, simply add new cell blocks. In the case of devshell, this remains true: to begin, we'll add a new block to our flake.nix:

{
  inputs.std.url = "github:divnix/std";
  inputs.nixpkgs.url = "nixpkgs";
  inputs.rust-overlay.url = "github:oxalica/rust-overlay";

  outputs = { std, ... } @ inputs:
    std.growOn
      {
        inherit inputs;
        cellsFrom = ./nix;
        cellBlocks = [
          (std.blockTypes.runnables "apps")

          # The `devshell` type will allow us to have "development shells"
          # available. These are managed by `numtide/devshell`.
          # See: https://github.com/numtide/devshell
          (std.blockTypes.devshells "devshells")

          # The `function` type is a generic block type that allows us to define
          # some common Nix code that can be used in other cells. In this case,
          # we're defining a toolchain cell block that will contain derivations
          # for the Rust toolchain.
          (std.blockTypes.functions "toolchain")
        ];
      }
      {
        packages = std.harvest inputs.self [ "example" "apps" ];

        # We want to export our development shells so that the following works
        # as expected:
        #
        # > nix develop
        #
        # Or, we can put the following in a .envrc:
        #
        # use flake
        devShells = std.harvest inputs.self [ "example" "devshells" ];
      };
}

The first thing to notice is we've added a new input for the rust-overlay flake. This flake provides an overlay attribute that we can overlay on top of nixpkgs to enable fetching specific versions of the Rust toolchain. We'll use this below in our toolchain cell block to make fetching the latest version of the toolchain trivial.

We've added two new cell blocks above: one of the devshells type and the other of the functions type. Our development shells will be defined in /nix/example/devshells.nix and our toolchain will be defined in /nix/example/toolchain.nix. The devshells type should be self-explanatory at this point: this is where we will define the development shells available in our project. The functions type is a bit unique and serves as a general type for storing cell-specific Nix expressions. In this case, we're creating a toolchain cell block that will contain a Nix expression that evaluates to the latest version of the Rust toolchain.

In addition to adding the above two cell blocks, we've also expanded the second argument to our growOn function so that the development shells we define are available under the devShells flake output. This provides compatibility with the Nix CLI and anyone using nix flake show to inspect the repository.

Defining our Devshell

We're going to define a single development shell in /nix/example/devshells.nix:

# Just like we place buildables in `apps.nix`, it's standard to place our
# development shells in a `devshells.nix` cell block.
#
# This cell block is used to define the development shells that are available to
# consumers of our repository. If you're not familiar with the idea of a
# development shell, it's essentially a self-contained environment that can be
# configured to provide all the tools and dependencies needed to work on our
# project. It solves the vital problem of, "works on my machine."
{ inputs
, cell
}:
let
  inherit (inputs) nixpkgs std;
  l = nixpkgs.lib // builtins;
in
# Here we map an attribute set to the `std.std.lib.mkShell` function.
  # This is a small wrapper around the numtide/devshell `mkShell` function and
  # provides integration with `nixago`, which we'll see in a later part. The
  # result of this map is a attribute set where the value is a proper
  # development shell derivation.
l.mapAttrs (_: std.std.lib.mkShell) {
  # This is our only development shell, so we name it "default". The
  # numtide/devshell `mkShell` function uses modules, so the `{ ... }` here is
  # simply boilerplate.
  default = { ... }: {
    # The structure of this attribute set is defined here:
    # https://github.com/numtide/devshell/tree/master/modules
    #
    # Familiarity with the devshell system is likely valuable here, but it's
    # intuitive enough to understand without it.

    # This is the name of our development shell. When a user enters the shell,
    # a MOTD style heading is printed to stdout with this name.
    name = "example devshell";

    # Since we're using modules here, we can import other modules into our
    # final configuration. In this case, we import the `std` default development
    # shell profile which will, among other things, automatically include the
    # `std` TUI in our environment.
    imports = [ std.std.devshellProfiles.default ];

    # This is a list of packages that will be available in our development
    # environment. In this case, we're pulling in the rust toolchain from our
    # `toolchains` cell block.
    #
    # Notice the magic here. We can extrapolate the rust toolchain to a separate
    # cell block and then access it from `cell.toolchain`. This is a direct
    # benefit from standardizing our project!
    packages = [
      cell.toolchain.rust.stable.latest.default
    ];

    # This is a list of "commands" that will be available inside our development
    # environment. One of the features of numtide/devshell is that it provides
    # a `menu` command that will list all of the commands we define below. This
    # allows consumers to easily understand what development tasks are available
    # to them from the CLI. For example, running `tests` in side of our shell
    # will in turn call `cargo test` for us.
    commands = [
      {
        name = "tests";
        command = "cargo test";
        help = "run the unit tests";
        category = "Testing";
      }
    ];
  };
}

There appears to be a lot going on here, but it's fairly straightforward to follow. First of all, we have our typical cell block structure at the top of the file with the inputs and cell arguments. We do some simple setup in the let statement and then we perform a map, utilizing std.std.lib.mkShell as the function. The mkShell function offered by std is a wrapper around the mkShell function provided by devshell. The primary benefit of using the wrapper will be revealed in a future chapter, but for now, it's simple enough to understand that it does the work of making the development shell derivation that is consumed by Nix.

The mkShell function accepts a single parameter, a module function. In this case, we don't need access to any of the standard module arguments, so we can compress them with ellipses.

The available options for the module are defined here. The most common options are discussed below:

  • name: The name of the development shell. Appears in the MOTD.
  • packages: A list of Nix packages that should be made available in the development shell.
  • commands: A list of custom commands that should be made available in the development shell. The suboptions can be found here.

In the example above, we define a single development shell (default), give it a name, add the Rust toolchain from our toolchain cell block (discussed below), and provide a single custom command which runs our unit tests using cargo.

It's worth noticing the utility of this expression:

{
    # ...
    packages = [
      cell.toolchain.rust.stable.latest.default
    ];
    # ...
}

As mentioned in chapter 1, the cell argument allows us to access all the cell blocks available within our local cell. In this case, we utilize it to access the toolchain cell block to bring our Rust toolchain expression into the scope of the development shell. This is a neat trick that std allows us to do!

The remaining imports attribute is a Nix module feature that allows importing additional modules into our final configuration. Here we import a development shell profile from the std library which provides us with the following:

  • Includes the std binary into our development environment for us
  • Provides a customized MOTD specific to std

This isn't strictly necessary, and can certainly be omitted.

Defining our Toolchain

The last piece we're missing is defining our toolchain cell block. We'll do this in /nix/example/toolchain.nix:

# This cell block is less idiomatic and is geared towards customizing our
# standardized environment by making an overlayed version of the rust toolchain
# available to our cell. This is the benefit of having some flexibility with how
# we organize our cells and cell blocks.
{ inputs
, cell
}:
{
  # `std` does not support global overlays, so we use `nixpkgs.extend` to make
  # a local overlay.
  # See: https://github.com/divnix/std/issues/117
  rust = (inputs.nixpkgs.extend inputs.rust-overlay.overlays.default).rust-bin;
}

A theme should start appearing now with the structure of cell blocks. In this case, we're given a little freedom with the structure of what it produces. The goal is to create an expression that evaluates to the latest stable version of the Rust toolchain using the rust-overlay flake we imported in our flake.nix.

As the comments make note of, std discourages applying overlays globally and instead recommends defining a local instance of nixpkgs and using the extend function to apply overlays. This is exactly what we do here, with the result being we can access the Rust toolchain via rust.stable.latest.default.