❄️

Nix flakeのフォーマッタを自分で書く

に公開

nix fmtあるいはnix formatter run

Nix flakeのプロジェクトでコードのフォーマッタを実行するコマンドです。

公式ドキュメント:

nix fmt (an alias for nix formatter run) calls the formatter specified in the flake.

Flags can be forwarded to the formatter by using -- followed by the flags.

Any arguments will be forwarded to the formatter. Typically these are the files to format.

...

Example

To use the official Nix formatter:

# flake.nix
{
  outputs = { nixpkgs, self }: {
    formatter.x86_64-linux = nixpkgs.legacyPackages.${system}.nixfmt-tree;
  };
}

これはNixで書かれたファイルに限らず、何でもフォーマットできます。

もひとつ引用。Vladimir TimofeenkoさんのPractical Nix flake anatomy:

The formatter is a single derivation that is run when nix fmt is executed. Most often, one of pkgs.alejandra, pkgs.nixfmt or pkgs.nixpkgs-fmt is used. ... this attribute is not limited to formatting only nix code. It is possible to use a tool like treefmt to format code in arbitrary language when nix fmt is called.

treefmt(およびtreefmt-nix)を利用しているNixユーザは多いと思います。しかしこういった便利なflakeを使いたくないシチュエーションもあるでしょう。たとえば、公開していて多くの人に配布したいプロジェクトで依存関係を極力減らしたい、など。

Flakeの解説記事はちらほら見かけますが、formatterについて深堀りしたものはあまりない気がするので、実例も交えて紹介しようとこの記事を書いています。

え?そんなのもう知ってるよ、バカにすんじゃねえって? お呼びでない、こりゃまた失礼しました。

先人に学ぶ

treefmt-nix

たとえばtreefmt-nixでformatterがどう定義されているかを見てみましょう。

module-options.nix (lines: 213–255):

      wrapper = mkOption {
        description = ''
          The treefmt package, wrapped with the config file.
        '';
        type = types.package;
        defaultText = lib.literalMD "wrapped `treefmt` command";
        default =
          let
            code =
              if builtins.compareVersions "2.0.0-rc4" config.package.version == 1 then
                ''
                  set -euo pipefail
                  find_up() {
                    ancestors=()
                    while true; do
                      if [[ -f $1 ]]; then
                        echo "$PWD"
                        exit 0
                      fi
                      ancestors+=("$PWD")
                      if [[ $PWD == / ]] || [[ $PWD == // ]]; then
                        echo "ERROR: Unable to locate the projectRootFile ($1) in any of: ''${ancestors[*]@Q}" >&2
                        exit 1
                      fi
                      cd ..
                    done
                  }
                  tree_root=$(find_up "${config.projectRootFile}")
                  exec ${config.package}/bin/treefmt --config-file ${config.build.configFile} "$@" --tree-root "$tree_root"
                ''
              # treefmt-2.0.0-rc4 and later support the tree-root-file option
              else
                ''
                  set -euo pipefail
                  unset PRJ_ROOT
                  exec ${config.package}/bin/treefmt \
                    --config-file=${config.build.configFile} \
                    --tree-root-file=${config.projectRootFile} \
                    "$@"
                '';
            x = pkgs.writeShellScriptBin "treefmt" code;
          in
          (x // { meta = config.package.meta // x.meta; });
      };

writeShellScriptBinでシェルスクリプトを作成しています。何をやっているのかというと、プロジェクトのルートディレクトリを探し、そこをターゲットにtreefmtを実行しています。しかしtreefmtがブラックボックスなので、これだけではよく分からない。

flakelight

flakelightでも軽量版のtreefmtのようなフォーマッタを利用できます。どのように実装されているのか見てみましょ。

builtinModules/formatter.nix (lines: 30–65):

      outputs.formatter = mkDefault (genSystems
        ({ pkgs, lib, fd, coreutils, ... }:
          let
            inherit (lib) attrValues makeBinPath;
            formatters = config.formatters pkgs;
            fullContext = all hasContext (attrValues formatters);
            packages =
              if config.devShell == null then [ ]
              else (config.devShell pkgs).packages pkgs;
            caseArms = toString (mapAttrsToList
              (n: v: "\n      ${n}) ${v} \"$f\" & ;;")
              formatters);
          in
          pkgs.writeShellScriptBin "formatter" ''
            PATH=${if fullContext then "" else makeBinPath packages}
            if [ $# -eq 0 ]; then
              flakedir=.
              while [ "$(${coreutils}/bin/realpath "$flakedir")" != / ]; do
                if [ -e "$flakedir/flake.nix" ]; then
                  exec "$0" "$flakedir"
                fi
                flakedir="$flakedir/.."
              done
              echo Failed to find flake root! >&2
              exit 1
            fi
            for f in "$@"; do
              if [ -d "$f" ]; then
                ${fd}/bin/fd "$f" -Htf -x "$0" &
              else
                case "$(${coreutils}/bin/basename "$f")" in${caseArms}
                esac
              fi
            done &>/dev/null
            wait
          ''));

プロジェクトのルートディレクトリ(flake.nixがある場所)を探し、fdでファイルを探して、case文でマッチしたファイル毎にそれぞれフォーマッタを適用しています。なんだ、簡単じゃん。

自分で書く

formatter.${system}

これは拙作のNeovimプラグインのCIで使っているflake.nixの抜粋ですが、

        formatter = final.writeShellApplication {
          name = "${pname}-formatter";
          runtimeInputs = with final; [
            stylua
            selene
            nixfmt-rfc-style
          ];
          text = ''
            mapfile -t files < <(git ls-files --exclude-standard)
            for file in "''${files[@]}"; do
                case "''${file##*.}" in
                    lua)
                        stylua "$file" &
                        selene -n "$file" &
                        ;;
                    nix)
                        nixfmt -w80 "$file" &
                        ;;
                esac
            done
          '';
        };

こんだけ。簡単でしょ?

checks.${system}.formatting

せっかくフォーマッタを用意できたし、nix flake checkでフォーマットをチェックしたいですよね。これはどう書いたらいいんでしょ。

また先人に学んでみましょう。

blueprint

formatter.nix (lines: 37–64):

  check =
    pkgs.runCommand "format-check"
      {
        nativeBuildInputs = [
          formatter
          pkgs.git
        ];


        # only check on Linux
        meta.platforms = pkgs.lib.platforms.linux;
      }
      ''
        export HOME=$NIX_BUILD_TOP/home

        # keep timestamps so that treefmt is able to detect mtime changes
        cp --no-preserve=mode --preserve=timestamps -r ${flake} source
        cd source
        git init --quiet
        git add .
        shopt -s globstar
        ${pname} **/*.nix
        if ! git diff --exit-code; then
          echo "-------------------------------"
          echo "aborting due to above changes ^"
          exit 1
        fi
        touch $out
      '';

やっていることは、

  • ソースコードを一時ディレクトリにコピー
  • Gitリポジトリとして初期化(git init)し、コードをインデックスに
    登録(git add
  • フォーマッタを実行(${pname} **/*.nix
  • Gitで変更があるかチェック(git diff)し、変更があればフォーマット
    されてなかったってことなので、exit 1

なるほどねえ。

自分で書く

再び拙作flake.nixの抜粋ですが、

        formatting =
          pkgs.runCommandLocal "check-formatting"
            {
              buildInputs = [
                pkgs.gitMinimal
                pkgs.formatter
              ];
            }
            ''
              cp -r --no-preserve=mode ${self} source
              cd source
              git init --quiet && git add .
              ${pkgs.formatter.name}
              test $? -ne 0 && exit 1
              touch $out
            '';

あとは、これをchecks.${system}の下に置いてあげればOK。ちなみにアトリビュート名はformattingでなくともなんでもよいです。

おわりに

Nix flakeのformatterの書き方、及びそのchecksへの登録の仕方の一例を紹介しました。

ここではシェルスクリプトで書きましたが、そもそも1つのフォーマッタで事足りるのなら単にそのフォーマッタをformatterに登録すればよいし、シェルスクリプトでない別の言語やツールを使ってもよいし、とにかくフォーマットを実行する何かを据えてあげればよいのです。

Perlではありませんが、NixはThere's More Than One Way To Do It.なところがあり、私は好きです。

Discussion