Integrating crane with treefmt-nix and git-hooks-nix

I’ve been trying to write a small nix flake for a rust project and am struggling getting it to a point where I’m happy with it. I’m aware I’m procrastinating by writing a flake instead of code, but I find it to be an interesting sport.

The problem I’m facing is in trying to make the flake the “central hub” of the project’s development. The package definition itself, devShell, checks, formatting and pre-commit hooks are all things I generally use, and I’m trying to put it all into my flake.

I would like the ability to choose a specific rust toolchain, meaning I want to use something like fenix (rust-overlay would work as well, but I’ve decided on fenix for no particular reason).

I started using crane to avoid issues with nix flake check’s sandboxed environment, particularly because I require pkg-config and some other libs. My understanding is that I should do something like this (note that I use flake-parts):

flake.nix
{
  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    crane.url = "github:ipetkov/crane";
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux"
        "aarch64-linux"
      ];

      perSystem = { inputs', config, pkgs, lib, ... }:
        let
          nativeBuildInputs = [ pkgs.git pkgs.pkg-config ];
          buildInputs = [ pkgs.wayland pkgs.libGL pkgs.vulkan-loader ];
          libraryPath = lib.makeLibraryPath buildInputs;

          rustToolchain = inputs'.fenix.packages.stable.toolchain;
          craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain;

          root = ./.;
          src = lib.fileset.toSource {
            inherit root;
            fileset = lib.fileset.unions [
              (craneLib.fileset.commonCargoSources root)
              (pkgs.lib.fileset.fileFilter (file: file.hasExt "wgsl") root)
            ];
          };

          commonArgs = {
            inherit src;
            strictDeps = true;
            inherit nativeBuildInputs buildInputs;
          };
          cargoArtifacts = craneLib.buildDepsOnly commonArgs;

          myapp = craneLib.buildPackage (
            commonArgs
            // {
              inherit cargoArtifacts;
              postFixup = "patchelf --add-rpath ${libraryPath} $out/bin/myapp";
            }
          );
        in
        {
          checks = {
            inherit myapp;
            myapp-doc = craneLib.cargoDoc (commonArgs // { inherit cargoArtifacts; });
          };

          packages = {
            inherit myapp;
            default = myapp;
          };

          devShells.default = craneLib.devShell {
            checks = config.checks;
            env.LD_LIBRARY_PATH = libraryPath;
          };
        };
    };
}

If I now want to add formatting and pre-commit hooks into the mix, things get confusing. A format check for nix flake check could be added by crane (using craneLib.cargoFmt), by treefmt-nix (using treefmt.programs.rustfmt.enable) or by git-hooks-nix (using pre-commit.settings.hooks.rustfmt).

But if I wanted this formatter to also work with nix fmt, that leaves only treefmt-nix. That will also allow me to add other formatters for markdown etc., if I want. Thankfully, it seems I can integrate treefmt-nix into git-hooks-nix with pre-commit.settings.hooks.treefmt, except now I do duplicate work with nix flake check, as the formatting gets checked twice (once through treefmt, and once through git-hooks) so I have to disable the flake check from git-hooks using pre-commit.check.enable = false;. I believe I also have to care that my chosen toolchain is used using treefmt.programs.rustfmt.package.

updated flake.nix
{
  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    fenix = {
      url = "github:nix-community/fenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    crane.url = "github:ipetkov/crane";

    treefmt = {
      url = "github:numtide/treefmt-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    git-hooks = {
      url = "github:cachix/git-hooks.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [
        inputs.treefmt.flakeModule
        inputs.git-hooks.flakeModule
      ];

      systems = [
        "x86_64-linux"
        "aarch64-linux"
      ];

      perSystem =
        {
          inputs',
          config,
          pkgs,
          lib,
          ...
        }:
        let
          nativeBuildInputs = [
            pkgs.git
            pkgs.pkg-config
          ];
          buildInputs = [
            pkgs.wayland
            pkgs.libGL
            pkgs.vulkan-loader
          ];
          libraryPath = lib.makeLibraryPath buildInputs;

          rustToolchain = inputs'.fenix.packages.stable.toolchain;
          craneLib = (inputs.crane.mkLib pkgs).overrideToolchain rustToolchain;

          root = ./.;
          src = lib.fileset.toSource {
            inherit root;
            fileset = lib.fileset.unions [
              (craneLib.fileset.commonCargoSources root)
              (pkgs.lib.fileset.fileFilter (file: file.hasExt "wgsl") root)
            ];
          };

          commonArgs = {
            inherit src;
            strictDeps = true;
            inherit nativeBuildInputs buildInputs;
          };
          cargoArtifacts = craneLib.buildDepsOnly commonArgs;

          myapp = craneLib.buildPackage (
            commonArgs
            // {
              inherit cargoArtifacts;
              postFixup = "patchelf --add-rpath ${libraryPath} $out/bin/myapp";
            }
          );
        in
        {
          treefmt = {
            programs = {
              nixfmt.enable = true;
              rustfmt = {
                enable = true;
                package = rustToolchain;
              };
              taplo.enable = true;
            };
          };

          pre-commit = {
            check.enable = false;
            settings.hooks = {
              treefmt.enable = true;
            };
          };

          checks = {
            inherit myapp;
            myapp-doc = craneLib.cargoDoc (commonArgs // { inherit cargoArtifacts; });
          };

          packages = {
            inherit myapp;
            default = myapp;
          };

          devShells.default = craneLib.devShell {
            checks = config.checks;
            shellHook = config.pre-commit.shellHook;
            env.LD_LIBRARY_PATH = libraryPath;
          };
        };
    };
}

Lastly I wanted to add a simple cargo-check pre-commit run, and this is where I kind of got stuck. The issue is that I have defined how my app is supposed to be built with crane and set the check to use that package. But now I want a pre-commit cargo check run. If I only integrate pre-commit into the devShell, it works by virtue of cargo check being run inside the devShell which sets up the build environment. But it seems strange to me. I often use Sublime Merge which doesn’t integrate with devShell whatsoever from what I can tell (so direnv is no solution either) and I kind of hoped that I could make use of what I defined with crane from within a pre-commit hook. I.e. that the hook itself gets the correct environment to run the check.

Am I just trying to do too much with my flake here, or am I missing some magical connection between these tools? I’ve had some tough time with pre-commit in another project, because using it directly has it hard-coding its own nix store path, which had me having to reinstall the hooks every time I update my system.

1 Like

How have you tried adding the cargo check pre-commit hook? If it’s a custom hook then you can probably use writeShellApplication with your rust toolchain in runtimeInputs.

I’ve tried this for starters:

pre-commit.settings.hooks.cargo-check = {
  enable = true;
  package = rustToolchain;
};

Weirdly it fails by not finding rustc if I try to run the pre-commit flake check, but as a pre-commit hook it works. Without the package = rustToolchain, the flake check just gets stuck, presumably because it has no network access in the nix flake check sandbox. Neither works when committing outside the devShell.

I’ve tried something like this as well:

pre-commit.settings.hooks.flake-check = {
  entry = "${pkgs.nix} flake check";
  pass_filenames = false;
};

(modulo the experimental-features flags) That works, but it feels kind of backwards.

1 Like

I’d just do a custom writeShellApplication based check probably, like

my-cargo-check = {
  enable = true;
  name = "Working cargo check hook";
  package = pkgs.writeShellApplication {
    name = "cargo-check-hook";
    runtimeInputs = [ rustToolchain ];
    text = "cargo check --your-flags";
  };
  entry = "${config.cargo-check.hooks.custom-hook.package}/bin/cargo-check-hook";
  files = "\\.rs$";
  pass_filenames = false;
};

Based on

and correct usage of extraPackages · Issue #530 · cachix/git-hooks.nix · GitHub.

It really feels like the built-in git-hooks.nix hooks are designed to be ran from inside devShells.

I want crane’s build environment for the hook not just because of the toolchain, but also because of everything else (nativeBuildInputs and buildInputs, as well as whatever the stdenv does to make pkg-config work). Your solution still fails once I’m outside the devShell. Specifically, I want to know if there is a proper way to integrate these things, that doesn’t involve me repeating what crane already does just for a cargo check hook.

That’s very strange. Do you have any error messages? Not that it matters.

If you want exactly what crane does I’d just put a nix build .#default (I’m uncertain the exact crane way to do just a check, seems to involve mkCargoDerivation, been a while since I used crane since switching to crate2nix) as a pre-commit check.

ChatGPT's mkCargoDerivation idea
  cargoCheck = craneLib.mkCargoDerivation (commonArgs // {
    inherit cargoArtifacts;

    pnameSuffix = "-check";
    buildPhaseCargoCommand = "cargo check --locked --workspace --all-targets";

    # A check doesn't need to export target/ for another Crane build.
    doInstallCargoArtifacts = false;
  });