Adding a Nixago Block

As with any cell block, we'll first add it 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")
          (std.blockTypes.devshells "devshells")
          (std.blockTypes.functions "toolchain")

          # The `nixago` type is used for holding Nixago configurations. We name
          # it configs to remove some ambuiguity.
          (std.blockTypes.nixago "configs")
        ];
      }
      {
        packages = std.harvest inputs.self [ [ "example" "apps" ] ];
        devShells = std.harvest inputs.self [ "example" "devshells" ];
      };
}

This is a fairly trivial addition, the only thing worth noting is that we call it configs to reduce ambiguity because most people are not aware of what Nixago does.

Defining the Block

The cell block is where the meat of our configuration lies. We'll add the following to /cells/example/configs.nix:

# This cell block holds our Nixago expressions for generating configuration
# files for the various tools we want to configure in our repository. We title
# it `configs.nix` because Nixago is less well-known and this name points to the
# purpose of the cell block.
#
# For an introduction to Nixago, see here:
# https://nix-community.github.io/nixago/
{ inputs
, cell
}:
let
  inherit (inputs) nixpkgs std;
  l = nixpkgs.lib // builtins;
in
# The structure is an attribute set where the value is an attribute set that
  # is ultimately passed to the `make`[1] function from Nixago. The available
  # arguments for the `make` function can be seen here[2].
  #
  # `std` allows us to pass additional pass-through arguments that can influence
  # the behavior of our development shells. This is primarily used so we can
  # include the necessary packages for the tools we want to configure into the
  # development environment.
  #
  # Additionally, `std` automatically includes any shell hooks generated by Nixago
  # into the appropriate `devshell` option. This is ultimately what allows Nixago
  # to generate the configurations when we enter the development shell.
  #
  # [1]: https://github.com/nix-community/nixago/blob/master/lib/make.nix
  # [2]: https://github.com/nix-community/nixago/blob/master/modules/request.nix
{
  # The `std` framework ships with some "pre-configured" services that we can
  # import and use here. For a list of all of them, see here[1]. These are setup
  # such that we can use a functor to dynamically extend them with additional
  # attributes or overrides. This is why they appear to look like functions.
  #
  # In most cases, when using these pre-configured services, we only need to be
  # concerned with setting the `configData` attribute. This is what ultimately
  # ends up in the generated configuration file and is dependent on what tool
  # is being configured.
  #
  # Conform[2] is a tool that allows us to enforce policies on our commit
  # messages. We configure it here to only allow commits that follow the
  # Conventional Commits specification[3].
  #
  # [1]: https://github.com/divnix/std/tree/main/cells/std/nixago
  # [2]: https://github.com/siderolabs/conform
  # [3]: https://www.conventionalcommits.org/en/v1.0.0/
  conform = std.std.nixago.conform {
    # The configuration of Conform is a bit different than the expected file
    # format. This is to prevent excessive nested attribute sets. In this case,
    # we only need to specify either a `commit` or `license` parent attribute
    # and then the child contents match what is specified in the Conform README.
    configData = {
      commit = {
        header = { length = 89; };
        conventional = {
          # Only allow these types of conventional commits (inspired by Angular)
          types = [
            "build"
            "chore"
            "ci"
            "docs"
            "feat"
            "fix"
            "perf"
            "refactor"
            "style"
            "test"
          ];
        };
      };
    };
  };
  # Lefthook is a pre-commit hook manager.
  lefthook = std.std.nixago.lefthook {
    configData = {
      commit-msg = {
        commands = {
          # Runs conform on commit-msg hook to ensure commit messages are
          # compliant.
          conform = {
            run = "${nixpkgs.conform}/bin/conform enforce --commit-msg-file {1}";
          };
        };
      };
      pre-commit = {
        commands = {
          # Runs treefmt on pre-commit hook to ensure checked-in source code is
          # properly formatted.
          treefmt = {
            run = "${nixpkgs.treefmt}/bin/treefmt {staged_files}";
          };
        };
      };
    };
  };
  # Prettier is a multi-language code formatter.
  prettier = std.lib.dev.mkNixago {
    # We mainly use it here to format the Markdown in our README.
    configData = {
      printWidth = 80;
      proseWrap = "always";
    };
    output = ".prettierrc";
    format = "json";
  };
  # Treefmt is an aggregator for source code formatters. Our codebase has
  # markdown, Nix, and Rust, so we configure a formatter for each.
  treefmt = std.std.nixago.treefmt {
    configData = {
      formatter = {
        nix = {
          command = "nixpkgs-fmt";
          includes = [ "*.nix" ];
        };
        prettier = {
          command = "prettier";
          options = [ "--write" ];
          includes = [ "*.md" ];
        };
        rustfmt = {
          command = "rustfmt";
          options = [ "--edition" "2021" ];
          includes = [ "*.rs" ];
        };
      };
    };
    # This is the pass-through feature where we can pass attributes to devshell.
    # In this case, we're asking devshell to include the `nixpkgs-fmt` and
    # `prettier` packages in the development environment. The `rustfmt` package
    # is already included within the Rust toolchain (see toolchain.nix).
    packages = [
      nixpkgs.nixpkgs-fmt
      nixpkgs.nodePackages.prettier
    ];
  };
}

This is significantly more code, but a majority of it is the actual configuration data we're using to generate our files.

The output structure of the cell block is an attribute set where the value is another attribute set that must conform to two things:

  1. The module structure enforced by Nixago
  2. Any additional pass-through data, which in this case means data intended for devshell

Module Structure

The module structure is fairly easy to grasp and a quick overview can be seen in the Nixago quick start guide. The three main options are:

  1. configData: The raw configuration data used to generate the output file
  2. output: The name of the output file
  3. format: The format of the output file

There are a few additional advanced options, but the above three options are enough to cover a majority of use cases.

The std framework provides several "pre-configured" expressions that we can make use of to lessen the verbosity of our cell block. These expressions can be found here. Attentive readers will notice that it appears we are "calling" these expressions as if they were functions. This is because they use functors to allow dynamically merging/overriding the arguments passed to them. So in many of the examples seen in our code, we're essentially extending the existing structure given to us by std and adding our raw configuration data to it. It's not strictly necessary to do this, we could just define the whole structure ourselves, but using these shortcuts helps us type a bit less.

Pass-Through

To get our Nixago configurations to generate, we must pass off the shell hooks to devshell. We'll tackle this part soon, but the important thing to understand is that these data structures will eventually pass through to devshell. What this means is that we can add additional attributes to our data structure that will in turn provide additional configuration to devshell.

If you examine the treefmt configuration in our example, you'll see that it also includes the packages attribute which is not a part of the Nixago module structure. Nixago will ignore this attribute, but when devshell sees it, it will automatically include those packages in our environment. This allows us to define our dependency as close to our configuration as possible while also ensuring our configuration works as expected (i.e. treefmt needs prettier to work as expected).

The Tools

The remainder of the code is responsible for configuring the actual tools we'll be using. Each of these will be briefly discussed below.

Conform

The conform tool allows us to specify policies that will be enforced against our commits. This is an invaluable tool in open-source projects and can help bring uniformity to commit messages and improve the generation of change logs.

In our case, we're specifying that commit message headers can be no longer than 89 characters and that the message itself must conform (ha!) to the conventional commit specification. Additionally, the type section of the conventional commit header must be one of the items given in the list (the list itself is inspired by the Angular project).

Lefthook

The lefthook tool automatically manages git hooks for us. These are often referred to as pre-commit hooks and have been a best practice in many projects for reducing the feedback cycle when developing against a project.

In our case, we use lefthook to enforce our commit messages using the policy specified with conform. Additionally, we automatically call treefmt on the files being checked in to ensure that all of our revision-controlled source code is properly formatted.

Prettier

The prettier tool is a general-purpose code formatter that supports several languages.

In our case, we're simply using it to format our markdown files. The primary benefit is that it can help enforce the 80-character line limit being imposed across all of our code.

Treefmt

The treefmt tool acts as an aggregator for code formatters. Instead of having to call each formatter individually, we instruct treefmt which formatters we want to run on which types of files and it will handle the rest for us.

In our case, we're adding formatters for the three primary languages that exist in our repository: Rust, Nix, and Markdown.

Devshell Integration

As mentioned earlier, the last thing we need to do is to inform our devshell configuration about our newly added Nixago configurations. We'll add the following to our /nix/example/devshells.nix file:

{ inputs
, cell
}:
let
  inherit (inputs) nixpkgs std;
  l = nixpkgs.lib // builtins;
in
l.mapAttrs (_: std.std.lib.mkShell) {
  default = { ... }: {
    # ...

    # Nixago uses shell hooks for generating configuration files. In order for
    # that to work, devshell must add them to its own configuration. To ensure
    # this happens, we specify the configurations we would like generated using
    # the `nixago` attribute.
    nixago = [
      cell.configs.conform
      cell.configs.lefthook
      cell.configs.prettier
      cell.configs.treefmt
    ];

    # ...
  };
}

With this complete, we can now reload our development shell and watch Nixago generate all of our configuration files for us:

$ direnv reload
# ...
nixago: updating repositoriy files
nixago: '.conform.yaml' link updated
nixago: '.conform.yaml' added to .gitignore
nixago: 'lefthook.yaml' link updated
nixago: 'lefthook.yaml' added to .gitignore
nixago: '.prettierrc' link updated
nixago: '.prettierrc' added to .gitignore
nixago: 'treefmt.toml' link updated
nixago: 'treefmt.toml' added to .gitignore