2021-03-17

runhaskellと-package-envを組み合わせるスクリプト

GHC/CabalのPackage environmentとは?

HaskellのデファクトスタンダードであるコンパイラGHCおよびビルドツールCabalは、Package environmentという機能をサポートしている。それをrunhaskellコマンドでHaskellスクリプトを実行するときにも活用できるようにしよう、という話。

Cabalがビルドするライブラリはインライン展開と分割リンクをサポートする都合上、同一バージョンのパッケージであっても依存やフラグの違いを考慮し異なるIDを付ける(この件について分かりやすく説明しているブログを以前読んだが、残念ながら見つけられない)。Package environment機能は、どのIDのパッケージを使用するのかをまとめたファイルであり、一度作成しておけば毎回同じ依存・同じIDのパッケージ群(これを「環境(environment)」と考える)でコンパイルできるという仕組み。

Package environmentファイルはcabal-installツールを使って作成する。例えば、

cabal v2-install --package-env foo --lib extra mwc-random

というコマンドを実行すると、extramwc-randomとその依存ライブラリをまとめたfooという名前の環境が作成される(具体的なバージョンやIDは状況によって異なる)。作成されるファイルは~/.ghc/x86_64-linux-8.10.4/environments/foo(GHCのバージョンが8.10.4の場合)。

この環境を使用してコンパイルする場合はGHCの-package-env引数を使用する。

ghc -package-env foo Main.hs

(cabalの引数はハイフン2個(--package-env)だがghcの引数は1個(-package-env)なので注意)

これらの仕組みを使うと、例えば以下のようなHaskellソースファイルを(cabalプロジェクトを作成せずに)コンパイルできる。

cabal v2-install --package-env foo --lib extra mwc-random
-- Main.hs
import Control.Monad (replicateM)
import Data.List.Extra (takeWhileEnd)
import System.Random.MWC (createSystemRandom, uniformRM)

-- | ランダムなa-zの文字列を100文字生成し、一番最後に出現した「a」より後の文字列だけを出力する。
main :: IO ()
main = do
  g <- createSystemRandom
  str <- replicateM 100 $ uniformRM ('a', 'z') g
  putStrLn $ takeWhileEnd (/= 'a') str
ghc -package-env foo Main.hs && ./Main

いちいちコンパイルしたくない場合、runhaskellコマンドを使えばいきなり実行できる。

runhaskell -package-env=foo Main.hs

(runhaskellを使用する場合は-package-envfooの間の=が必須)

しかし、スクリプトとして使う場合shebangを使いたいが、runhaskellに追加の引数-package-env=fooを与えないといけないのが障害となる。

現状の問題

GHCのコンパイルオプションはOPTIONS_GHCプラグマで指定できる…はずである。GHCの公式docおよびページ上のリンクを追っていくと、「dynamic」な引数に限り指定できる、-package-envはdynamicであることがわかる。しかし、実際にやってみるとうまくいかない。さっきのMain.hsOPTIONS_GHCプラグマを与えて実行してみると:

$ sed -i -e '1i{-# OPTIONS_GHC -package-env=foo #-}' Main.hs
$ runhaskell Main.hs
Main.hs:1:16: error:
    unknown flag in  {-# OPTIONS_GHC #-} pragma: -package-env=foo
  |
1 | {-# OPTIONS_GHC -package-env=foo #-}
  |                ^^^^^^^^^^^^^^^^^^

そんなオプション知らないよというエラーが発生してしまう。これはGHC開発者達に認知されている、ちょっと前からあるバグのようだ。どうも直すのは一筋縄ではいかないらしい。

作ったスクリプト: runhaskell-env

というわけでワークアラウンドとして、Haskellスクリプトに特殊なコメントを書いておくことで、runhaskell-package-env引数を渡して実行してくれるラッパースクリプトを作成した(gist)。

やっていることは単純で、-- runhaskell-env: fooというようなコメントをソースファイルから探し出し、runhaskell-package-env=fooとスクリプトのファイルパスを渡して実行するだけというもの。このファイルを適当な$PATHが通っているディレクトリのどれかに入れ(普通は$HOME/binがベストかな)、実行可能ビットを付けたら準備完了。

後はshebangと特殊コメントを書くだけ。最終的なMain.hsの内容は以下のようになる。

#!/usr/bin/env runhaskell-env
-- runhaskell-env: foo
import Control.Monad (replicateM)
import Data.List.Extra (takeWhileEnd)
import System.Random.MWC (createSystemRandom, uniformRM)

-- | ランダムなa-zの文字列を100文字生成し、一番最後に出現した「a」より後の文字列だけを出力する。
main :: IO ()
main = do
  g <- createSystemRandom
  str <- replicateM 100 $ uniformRM ('a', 'z') g
  putStrLn $ takeWhileEnd (/= 'a') str

これを実行する。例:

$ chmod +x Main.hs
$ ./Main.hs
uuss

(用が済んだ環境ファイルは削除してOK。)

適当に使うだけならPythonとかでいいだろうと思う一方、手軽さはプログラミング言語普及の大事な要素の一つだと思うので、私みたいにHaskellをシェルスクリプト気分で使う人にとっても便利になっていくと嬉しいな。

なお、起動ごとにめちゃ待たされることを厭わないなら、cabalをshebangに書く方法もある。表立って説明されていないので、メインの機能ではなさそうだけど。(参考) 2021-03-21追記 cabal v2-runの公式docにcabalスクリプトの説明がありました。

お問い合わせはこちらまで: @matil019 リプにブログのリンクを含めていただけるとスムーズに会話できます。
記事一覧