Elm の Bazel ルール

2021/12/02に公開

本記事は「Elmアドベントカレンダー2021」の2日目の記事です。

Bazelというビルドツールがあります。Bazelでは、いわゆるライブラリのようなものを使うことで、簡単にさまざまなプログラムのビルドやテストの実行を同じインターフェースで行うことができます。例えば:

などなど、サードパーティ製も含めて様々なライブラリ(ルール)があります。

しかし、Elmのためのライブラリ(正確にはバージョン0.19.1が動作するライブラリ)が無かったので自作しました:

https://github.com/matsubara0507/rules_elm

という話です。そのため、Elmプログラムなどは一切出てきません。ごめんなさい。

rules_elm

v1.0.0 では以下のルールを用意してあります:

  • elm_make : elm make を実行するルール
  • elm_dependencies : elm_make ルールで依存パッケージのキャッシュをするためのルール
  • elm_test : elm-test-rs を利用してテストを実行するルール

使い方

Bazelで外部のライブラリを利用するには、プロジェクトに対し一つずつ存在するWORKSPACEファイルに設定を書き足します:

http_archive(
    name = "rules_elm",
    sha256 = "a9db7f55e3693ab94a60cbf602221095514aec6541253b21cc89f0ba1365d87c",
    urls = ["https://github.com/matsubara0507/rules_elm/releases/download/v1.0.0/rules_elm-v1.0.0.zip"],
)

load("@rules_elm//elm:repositories.bzl", rules_elm_repositories = "repositories")

rules_elm_repositories()

load("@rules_elm//elm:toolchain.bzl", rules_elm_toolchains = "toolchains")

rules_elm_toolchains(version = "0.19.1")

前述した各種ルールを利用するにはBUILDファイルに記述します:

load("@rules_elm//elm:def.bzl", "elm_dependencies", "elm_make")

elm_dependencies(
    name = "deps",
    elm_json = "elm.json",
)

elm_make(
    name = "index",
    srcs = glob(["**"]),
    elm_home = ":deps",
    elm_json = "elm.json",
    main = "src/Main.elm",
    optimize = True,
    output = "index.html",
)

また、elm_testを利用する場合はNode.jsが必要なため、rules_nodejs-coreWORKSPACEファイルに追記する必要があります:

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "rules_nodejs",
    sha256 = "995eb2fbcd6c0d27faea1f8b362a3a448d98d42b6c0fddc2943b72fe866a9d8e",
    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/4.4.4/rules_nodejs-core-4.4.4.tar.gz"],
)

load("@rules_nodejs//nodejs:repositories.bzl", "rules_nodejs_dependencies", "nodejs_register_toolchains")

rules_nodejs_dependencies()

nodejs_register_toolchains(
    name = "node16",
    node_version = "16.13.0",
)

rules_nodejs-core というのは、rules_nodejs とは異なりnode実行環境だけを提供します(npmコマンドなどを実行するためのルールなどは提供していない)。

例:Go+Elm

とても簡易的なサンプルプロジェクトを作りました。Elmで生成したHTMLファイルをGoのEmbed機能を利用して埋め込んで返すだけの簡単なアプリケーションです:

https://github.com/matsubara0507/sample-go-and-elm

ちなみに、OpenAPIを利用してサーバーとクライアント間で多少やりとりする例はコチラです:

https://github.com/matsubara0507/sample-oapi-with-bazel

Bazel ルールを作る

ここからは rules_elm を自作する部分の話を書きます。

elm makeするルール

まず最初に、elm make するためのルールを作りました。elm make をするためにまずは、Elmコンパイラを取得し、ツールチェイン化する必要があります。また、Windowsでも動作するように少し乱暴な工夫を施しました。

ツールチェイン

Bazelにはツールチェインと呼ばれる機能があります。ツールチェインは、なんらかの実行ファイルをBazelサンドボックスへインストールさせる機能です。その際に、実行したいプラットフォームに対してインストールすべき対象を簡単に切り替える仕組みが提供されています。

プラットフォームに対する分岐パターンを設定するためにまず、toolchain関数を使います。これには exec_compatible_withtarget_compatible_with という引数があり、前者がツールを実行するプラットフォームを指定し、後者がツールからの生成物を利用するプラットフォームを指定できます。その指定によって、インストールすべき対象を選択するわけです(より詳細にはコチラ)。ここで、exec_compatible_withtarget_compatible_with があるのは、Go言語やスマホアプリのようなクロスコンパイルできるツールを想定しているわけです。
Elmの場合は、target_compatible_with は関係ない(生成物がJavaScriptやHTMLなので)はずなので特に指定する必要がありません:

toolchain(
    name = "toolchain",
    toolchain_type = "@rules_elm//elm:toolchain",
    toolchain = "@rules_elm_compiler_mac//:mac_info",
    exec_compatible_with = ["@platforms//os:osx"],
)

toolchain_type引数はrules_elm/elm/BUILD.bazelで定義してあり、以降で作成するルールでElm用のツールチェインを使いたい場合にも指定します。toolchain引数は実際に紐付けるツールチェイン本体、つまりElmコンパイラを指しています。なので、次にElmコンパイラを取得する必要がありますね。

Elmコンパイラの取得

実は、これが少々面倒でした。というのも、GitHubリリースなどにある ziptar.gz ファイルをダウンロードして展開する場合は repository_ctx.download_and_extract をよく使うのですが、Elmコンパイラは gz だけで、なんとこれは download_and_extract で展開できないからです。

しょうがないので、repository_ctx.download でダウンロードしたのちに、自分で gunzip する方法を取ることにしました:

def _elm_compiler_impl(ctx):
    os = ctx.attr.os
    version = ctx.attr.version
    file_name = "elm"
    if os == "windows":
        file_name += ".exe"
    ctx.download(
        url = "https://github.com/elm/compiler/releases/download/{}/binary-for-{}-64-bit.gz".format(version, os),
        sha256 = ctx.attr.checksum,
        output = file_name + ".gz",
    )

    ctx.file(
        "BUILD",
        executable = False,
        content = """
load("@rules_elm//elm:toolchain.bzl", "elm_toolchain", "extract_gzip")
exports_files(["{elm}.gz"])
extract_gzip(name = "{elm}", archive = "{elm}.gz")
elm_toolchain(name = "{os}_info", elm = ":{elm}")
        """.format(os = os, elm = file_name),
    )

extract_gzip は自前で用意したもので gunzip するだけのルールです。余談ですが、元々は雑に ctx.execute([ctx.which("gzip"),...) としていましたが、これだと repository_cache でうまくキャッシュされないことが分かり修正しました

Windows対応

それなりに面倒なのが、Windowsでも動作するようにすることです。シェルスクリプトを使うことで、いろんなルールを簡単に作ることができるのですが、Windowsの場合はこれが動かない場合が多いのです。

rules_haskellを参考にしたところ、Pythonスクリプトを間に噛ませる方法を取っていたので、少々乱暴ですが真似することにしました。

まず、ElmコマンドをラップしたようなPythonスクリプト(のテンプレート)を作成します:

#!/usr/bin/env python3

# elm_wrapper.py ELM_PROJECT_ROOT [ARGS_FOR_ELM...]
#  1引数目の ELM_PROJECT_ROOT だけ Elm プロジェクトへの相対パスで残りは elm コマンドへの引数

import os
import os.path
import subprocess
import sys

def run(cmd, *args, **kwargs):
    try:
        subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, *args, **kwargs)
    except subprocess.CalledProcessError as err:
        sys.stdout.buffer.write(err.stdout)
        sys.stderr.buffer.write(err.stderr)
        raise

elm_runtime_path = os.path.abspath("path/to/elm") # ここはテンプレート
elm_project_root = sys.argv.pop(1)
for i, arg in enumerate(sys.argv):
    if arg == "--output":
        sys.argv[i+1] = os.path.abspath(sys.argv[i+1])

# HOME: getAppUserDataDirectory:getEnv: does not exist (no environment variable)
#  というエラーが出るので適当に定義しておく
os.putenv("HOME", os.getcwd())

os.chdir(elm_project_root)
run([elm_runtime_path] + sys.argv[1:])

そして、このテンプレートを展開してBazelの生成物とし、それをrules_pythonのpy_binaryでBazelルールから実行可能にすることで、Windowsでも同じように動作するようになりました。

elm_makeの定義

そして最後に、PythonでラップしたElmコマンドを呼び出す elm_make を定義します:

def _elm_make_impl(ctx):
    elm_compiler = ctx.toolchains["@rules_elm//elm:toolchain"].elm
    output_file = ctx.actions.declare_file(ctx.attr.output)
    inputs = [elm_compiler, ctx.file.elm_json] + ctx.files.srcs
    arguments = [
        ctx.file.elm_json.dirname,
        "make", ctx.attr.main,
        "--output", output_file.path,
    ]

    ctx.actions.run(
        executable = ctx.executable._elm_wrapper,
        arguments = arguments,
        inputs = inputs,
        outputs = [output_file],
    )
    return [DefaultInfo(files = depset([output_file]))]

elm_make = rule(
    _elm_make_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = True),
        "elm_json": attr.label(
            mandatory = True,
            allow_single_file = True,
        ),
        "main": attr.string(default = "src/Main.elm"),
        "output": attr.string(default = "index.html"),
        "_elm_wrapper": attr.label(
            executable = True,
            cfg = "host",
            default = Label("@rules_elm//elm/private:elm_wrapper"),
        ),
    },
    toolchains = ["@rules_elm//elm:toolchain"],
)

Bazelルールでは、コマンドの実行に必要なファイルを全て与える必要があります。それが actions.runinputs引数です。これで、まず一つ目のルールができました。
しかし、このelm_makeではbazel buildを実行するたびにElmの依存パッケージをインストールし直してしまいます。とても非効率ですね。次はこれを何とかするルールを作ります。

依存パッケージをキャッシュする

Bazelの特徴の一つとして、生成物の依存関係を明示的に管理することで、キャッシュを効率よく行うというのがあります。キャッシュをうまくやるには、ライブラリ側でそういう設計をする必要があります。前述した通り、このままだとelm makeをするたびに依存パッケージのインストールを行うので、うまくキャッシュする仕組みを考えました。それが elm_dependencies です。

Elmのインストール済み依存パッケージはどこか

そもそも、Elmではインストールした依存パッケージをどのように管理しているのでしょうか?elm make などをするとElmプロジェクトの配下に elm-stuff という(基本的には git 管理しない)ディレクトリができますが、中身を見てみるとここにはありません。
Elmコンパイラ(バージョンは 0.19.1)のソースコードを直接呼んだところ、ELM_HOME環境変数に設定したパスのディレクトリに保存されているようでした。ELM_HOME環境変数が設定されてない場合は$HOME/.elmが使われているようです。

$ ls ~/.elm/0.19.1/packages/
bartavelle		elm			elm-community		elm-explorations	justinmimbs		lock			registry.dat		rtfeldman

registry.datファイルには、このディレクトリ配下で既に管理しているパッケージ群が書かれています。lockファイルは、このディレクトリへの書き込みを排他制御するためのもので、filelockパッケージを利用して行っています(ソースコードから抜粋):

compile :: FilePath -> IO (Either Exit.Reactor B.Builder)
compile path =
  do  maybeRoot <- Stuff.findRoot
      case maybeRoot of
        Nothing ->
          return $ Left $ Exit.ReactorNoOutline

        Just root ->
          BW.withScope $ \scope -> Stuff.withRootLock root $ Task.run $ ...

withRootLock :: FilePath -> IO a -> IO a
withRootLock root work =
  do  let dir = stuff root
      Dir.createDirectoryIfMissing True dir
      Lock.withFileLock (dir </> "lock") Lock.Exclusive (\_ -> work)

elm installelm make を実行するとlockファイルによってロックをとり、registry.datファイルを見て対象のパッケージがダウンロード済みかを確認し、なければダウンロードしてくるといった感じです。

registry.datファイルがあるため、依存パッケージ別に保存し再利用することはけっこう難しいです。なので,Bazelのサンドボックス内に保存したELM_HOMEの中身をまるまるドカッと Bazelの生成物として再利用することにしました、この生成物はelm.jsonに依存することにすれば、elm.jsonが変更されない限りは再ダウンロードされません。もちろん、elm.jsonが少しでも変更されると全て再ダウンロードされますが、そこまで時間かからないので取り敢えず目を瞑ることにします。

インストール済み依存パッケージの生成するルール

elm_make のときと同様に、Windowsでも動作させるためにPythonスクリプトで実装をします:

#!/usr/bin/env python3

# elm_dependencies.py ELM_PROJECT_ROOT
import json
import os
import os.path
import shutil
import subprocess
import sys

def run(cmd, *args, **kwargs):
    try:
        subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, *args, **kwargs)
    except subprocess.CalledProcessError as err:
        sys.stdout.buffer.write(err.stdout)
        sys.stderr.buffer.write(err.stderr)
        raise

elm_runtime_path = os.path.abspath("@@ELM_RUNTIME@@") # Bazelのテンプレート機能で後から指定
elm_project_root = sys.argv.pop(1) # トップレベル以外で Elm プロジェクトを指定した場合を考慮
os.environ["ELM_HOME"] = os.path.abspath(os.getenv("ELM_HOME")) # 念のため絶対パスにする

os.chdir(elm_project_root)

# elm.json しか無い前提なので `source-directories` で指定してあるパスを生成しておく
elm_json = json.load(open("elm.json"))
if elm_json["type"] == "application":
    for srcdir in elm_json["source-directories"]:
        os.makedirs(srcdir, exist_ok = True)

# Main.elm はなんでも良いのでコンパクトなのを生成
with open("Main.elm", mode = "w") as f:
    f.write("import Browser\nimport Debug\nmain = Browser.sandbox (Debug.todo \"temp\")")

run([elm_runtime_path, "make", "Main.elm"])

# zip で固める
elm_home = os.getenv("ELM_HOME")
shutil.make_archive(elm_home, "zip", root_dir = elm_home)

指定した elm.json を読んで、必要最小限なElmプロジェクトを作成し、それをとりあえずビルドして作られた ELM_HOMEzip に固めてBazelの生成物としています。かなり無理矢理ですね笑

あとは、elm_makeのときと同じように py_binary を利用して、elm_dependencies を作成します(割愛)。

インストール済み依存パッケージを再利用する

そして、elm_make 側を拡張して、前述したBazelの生成物から ELM_HOME を再利用します:

#!/usr/bin/env python3

# elm_wrapper.py ELM_PROJECT_ROOT [ARGS_FOR_ELM...]
...

if os.getenv("ELM_HOME_ZIP") == None:
    os.putenv("HOME", os.getcwd())
else:
    elm_home = os.getcwd() + "/.elm"
    elm_home_zip = os.getenv("ELM_HOME_ZIP")
    with zipfile.ZipFile(elm_home_zip) as elm_zip:
        elm_zip.extractall(elm_home)
    os.environ["ELM_HOME"] = elm_home

os.chdir(elm_project_root)
...

ELM_HOME_ZIPelm_dependenciesのBazel生成物のパスを設定し、それを unzip して、そのパスを ELM_HOME にしているだけです。zipを挟むことで、パーミッションの問題も解決しています。

テスト用ルールを作る

最後にテストを実行するためのルールを作ります。

ご存知の通り、Elm本体はテストするためのナニガシを提供していません。テスト用のパッケージは提供していますが、今のところ実行方法はサードパーティに委ねています。デファクトスタンダードなのは rtfeldman/node-test-runner を利用して実行する方法だと思います。

しかし、rtfeldman/node-test-runner のコマンドは npm などでインストールし実行するコマンドです。npm を利用する場合、rules_nodejsを利用して面倒な設定をする必要があります。なので、できればシングルバイナリで提供されてるものがあると嬉しいのです。

調べたところなんとありました:

https://github.com/mpizenberg/elm-test-rs

まさかの Rust 製です。これを使うことにします。

Build系ルールとの違い

前述した2つのルールは bazel build で実行することを想定しています。対して、テストは bazel test で実行し、振る舞い的には bazel run と同じです。build は生成までをBazelのサンドボックで行い、そこで必要なファイル群をツールチェインや引数などを利用して用意します。対して、run の場合は最終的な成果物そのものが実行用のサンドボックスだと考えてください。つまり、実行するのに必要なもの(例えば今回の場合はNodeとか)をすべてまとめて生成物にする必要があります。そこが run 系のルールを作る上での注意点です:

def _elm_test_wrapper_impl(ctx):
    elm_compiler = ctx.toolchains["@rules_elm//elm:toolchain"].elm
    elm_test_bin = ctx.toolchains["@rules_elm//elm:toolchain"].elm_test
    nodeinfo = ctx.toolchains["@rules_nodejs//nodejs:toolchain_type"].nodeinfo
    inputs = [
        elm_compiler,
        elm_test_bin,
        ctx.file.elm_json,
    ] + ctx.files.srcs + ctx.files.tests + nodeinfo.tool_files

    test_filepaths = []
    for file in ctx.files.tests:
        test_filepaths.append(file.short_path)

    substitutions = { ... } // 割愛

    if ctx.file.elm_home != None:
        substitutions["@@ELM_HOME_ZIP@@"] = ctx.file.elm_home.short_path
        inputs.append(ctx.file.elm_home)

    elm_wrapper = ctx.actions.declare_file(ctx.attr.src_name + ".py")
    ctx.actions.expand_template(
        template = ctx.file.elm_wrapper_tpl,
        output = elm_wrapper,
        is_executable = True,
        substitutions = substitutions,
    )
    return [DefaultInfo(files = depset([elm_wrapper]), runfiles = ctx.runfiles(files = inputs))]

_elm_test_wrapper = rule(
    _elm_test_wrapper_impl,
    attrs = {
        "src_name": attr.string(),
        "elm_wrapper_tpl": attr.label(allow_single_file = True),
        "tests": attr.label_list(allow_files = True),
        "srcs": attr.label_list(allow_files = True),
        "elm_json": attr.label(
            mandatory = True,
            allow_single_file = True,
        ),
        "elm_home": attr.label(allow_single_file = True),
     },
    toolchains = [
        "@rules_elm//elm:toolchain",
        "@rules_nodejs//nodejs:toolchain_type",
    ]
)

最後の runfiles が前述した「実行するのに必要なものをすべてまとめて生成物にする」をしているところです。この _elm_test_wrapper で作ったBazel生成物を py_test に渡しています(Windows対応)。

symlink問題

かなり試行錯誤したのですが、その中でも厄介だった問題の一つが symlink の問題です。いつものようにelm-test-rsをツールチェイン化して、いざ実行してみたところ次のようなエラーが出ました:

elm-test-rs 1.1.0 for elm 0.19.1
--------------------------------

Generating the elm.json for the Runner.elm
The dependencies picked to run the tests are:
{
  "direct": {
    "elm/browser": "1.0.2",
    "elm/core": "1.0.5",
    "elm/html": "1.0.0",
    "elm/json": "1.1.3",
    "elm-explorations/test": "1.2.2",
    "mpizenberg/elm-test-runner": "4.0.5"
  },
  "indirect": {
    "elm/random": "1.0.0",
    "elm/time": "1.0.0",
    "elm/url": "1.0.0",
    "elm/virtual-dom": "1.0.2"
  }
}
get_module_name of: /path/to/elm-test-rs/tests/example-projects/passing/app/tests/Tests.elm
Error: This file "/path/to/elm-test-rs/tests/example-projects/passing/app/tests/Tests.elm" matches no source directory! Imports wont work then.

elm-test-rsの実装を追ってみたところ、elm-test-rs内でテスト用のElmファイルが、設定されてるディレクトリ内に存在するかどうかをチェックするところで落ちているようでした。 もちろん、ファイルはBazelサンドボックス(runfiles)に渡しています。色々調査した結果、どうやらファイルがsymlinkされたものの場合、このようにエラーとなってしまうらしいです。ファイルがsymlinkになってしまうのはBazelの性質上どうしようもないことです。なので、elm-test-rs側に修正PRを投げて対応してもらいました

正直、普通に使っている限りsymlinkされたファイルを利用することはないはずですが。。。無事マージしてもらえたので気にしないことにします。

おしまい

ElmをBazelでビルドしよう!という人は、まず居ないと思います。しかし、「Bazel使ってフロントエンドしたい」という方がいたら、試しにElmなんてどうでしょうか?
ちなみに、私は会社のBazelを利用しているプロジェクトにElmを導入したくて丸っと作ることになりました笑

Discussion