optparse-declarative: 宣言的な型レベルコマンドラインパーザー

  • 61
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

使いやすさを目指したコマンドラインパーザー、optparse-declarativeというのを作りました。

これは何ですか?

コマンドラインオプションを解析して、しかるべき処理を呼び出すためのライブラリです。ちゃんとした機能を備えたコマンドラインパーザーを、とにかく書きやすい形のAPIで提供することを目指して作っています。

コマンドラインパーザーっていうのは、プログラム全体のライフライクルから考えると、いじっている時間は決して長いわけではないので、新しいプログラムをつくろうかとなった時に、はてどうやってライブラリを使うのだったんだろうかと毎回忘れてしまいます。それでもって、それがわかりづらいものだと、その思い出すという事自体がわりと心理的障壁になってしまったりしていけないと思って、そういうところを目指したものを作りました。宣言的で型レベルとタイトルに入っていますが、簡単さを目指していたら勝手にそういう設計になりました。

なんでこんなものを作ったのか?

なんで今更コマンドラインパーザーを作ったのかというと、それは既存のものに不満があったからなのですが、長くならない程度に幾つか要点をかいつまんで説明します。なお既存のパーザーに満足していない人は、ここのセクションは飛ばしてもらって構いません。

Haskellでコマンドラインパーザーで比較的よく使われているのは、cmdargsoptparse-applicativeの二つと、baseパッケージのSystem.Console.GetOptあたりだと思います。

それぞれについてそれぞれ不満があって、

  • cmdargs

    なんていうかとにかくわかりづらい。何をどうしたらどうなるのか、ほんとうによくわからない。実装がマジカルすぎて、仕組みが謎。わかりづらさを別にしても、コマンドライン引数のために、レコード型を定義しないといけない。レコード型を定義して、それに対してコマンドラインオプションを構築して、さらにパーズしたあとにコマンド実行のためのディスパッチが必要になる。要するに、最初の実装も、コマンドの追加も、めんどくさい。引数を追加するのに3箇所ぐらいコードをいじらなければならない。サブコマンドを実装するのも面倒くさい。

  • optparse-applicative

    Applicativeスタイルでコマンドラインパーザを組み立てていくのだけど、イマイチインデントがしっくりこない。しょうもない問題かもしれないけど、しっくりこない。コマンドライン引数のために、これも基本的にはレコード型を定義して、そのフィールドに情報を埋めていくことになる。パラメータの情報はモノイドになっていて、必要なところだけ埋めていく形になっているが、網羅性に乏しく、これもアレをやるのにナニを使えばいいのか、調べるのがしんどい。実装の面倒臭さはcmdargsと対して変わらない。引数を増やすのに3箇所ほどいじらなければならないのも同様。サブコマンドを作るのもやっぱり面倒。そもそもなんでApplicativeである必要があるのかもよくわからなくなってくる。cmdargsでマジカルにやってる部分をApplicativeなアクションでまかなえているので、実装の怪しさがないというぐらいか。

  • System.Console.GetOpt

    デフォルトで使えるモジュールで、GNU getoptライブラリのような機能を提供していて、その機能へのアクセス自体は非常に直接的なインターフェースになっているので、わかりづらいということはないのだけど、そもそもの話抽象度が低すぎてめちゃくちゃ書くのがめんどくさい!

それで、これらに不満を持ちながら使うよりも、何とかしっくり来るものが出来ないだろうかと、そういう試みです。

使い方

インストール

あらかじめGHCの7.8.1以上をインストールしてください。DataKindsPolyKindsなど、Kind周りと型リテラル周りの拡張が使える必要がります。あとは

$ cabal install optparse-declarative

で入ると思います。

簡単な例

あまりおもしろくない例ですが、引数に応じて挨拶を表示するプログラムを書いてみます。

ファイルの先頭でDataKindsを有効にして、モジュールをインポートします。

{-# LANGUAGE DataKinds #-}
import           Options.Declarative

次にコマンドを定義します。コマンドライン引数は、関数の引数の型として与えます。

greet :: Flag "g" '["greet"] "STRING" "greeting message" (Def "Hello" String)
      -> Arg "NAME" String
      -> Cmd "Simple greeting example" ()
greet msg name =
    liftIO $ putStrLn $ get msg ++ ", " ++ get name ++ "!"

このコマンドgreetは、挨拶の言葉と名前の二つの引数を受け取ります。コマンドライン引数はFlagまたはArgのどちらかです。Flagは名前付き引数で、Argは名前無し引数です。

Flagは5つの「型」を引数に取ります。第一引数は「一文字引数名のリスト」を文字列として与えます。ここでは'g'を一文字引数名として指定しています。なにも指定したくない時は空文字列を与えれば良くて、複数指定したいときはその文字を含む文字列を与えれば大丈夫です。ここの"g"というのは値ではなく型で、しかもKindがStringではなくGHCの文字列型リテラルに対するKindのSymbolなので、['g']などという書き方はできません。

第二引数には、フル引数名のリストを与えます。ここも先ほどと同様です。なお少しややこしいのですが、リストリテラルを型レベルに昇格させるため、'["greet"]というふうにクオートを付ける必要があります。

第三引数には、これがどういうタイプの引数なのかを与えます。これはヘルプの--greet=STRINGという引数の説明のところに表示されます。

第四引数には、この引数の説明を与えます。これもヘルプのところに表示されます。

最後の引数は、この引数の型を与えます。普通にStringIntなどを指定することができますが、ここではDef "Hello" Stringという型を与えています。これは"Hello"というデフォルト値を持つString型を表します。なおこのDefのような型はライブラリのユーザーによって自由に追加できるようになっています。Intを指定している場合は、整数以外が渡された時エラーになるなど、引数の値もある程度自動で検証できます。デフォルトの挙動はreadが成功するかどうかですが、これも型を定義することで拡張可能です。

Argの方は、引数のタイプを表す文字列と、値の型の二つだけを引数に取ります。

コマンド全体の型はCmdであらわします。これはただのIO ()のnewtypeですが、メタ情報としてコマンドのヘルプ文字列を含んでいます。

コマンドの型シグネチャの次は、コマンドの本体です。CmdMonadIOのインスタンスです。verboseフラグの管理など若干の機能がありますが、特に必要ない場合はIOモナドのプログラムを書いてliftIOすればよいだけです。引数が複雑な型(例えば変数msgFlag "g" '["greet"] "STRING" "greeting message" (Def "Hello" String)という型)を持っているので、ここからString型の値の値にアクセスするためにgetという関数を使を使う必要があります。つまりget msgで望みの引数が得られるということです。

さて、これで全てです。あとはこのコマンドをrun_に渡してやればコマンドラインプログラムの完成です。

main :: IO ()
main = run_ greet

コンパイルして実行してみましょう。

$ ghc simple.hs

$ ./simple
simple: not enough arguments
Try 'simple --help' for more information.

$ ./simple --help
Usage: simple [OPTION...] NAME
  Simple greeting example

Options:
  -g STRING  --greet=STRING  greeting message
  -?         --help          display this help and exit
  -v[n]      --verbose[=n]   set verbosity level

$ ./simple World
Hello, World!

$ ./simple --greet=Goodbye World
Goodbye, World!

だいたいこういう感じに動作します。

サブコマンド

ナウいコマンドラインプログラムは、複数の機能をサブコマンドによって実行しなければならないものです。例えばgit addとか、git commitとか、はたまた、cabal installだとかcabal sandbox initだとか、そういうやつです。

これもとても簡単に実装できます。コマンドを複数定義して、それらをGroupでまとめるだけです。ネストも自由です。

適当に二つコマンドを定義して、

greet :: Flag "g" '["greet"] "STRING" "greeting message" (Def "Hello" String)
      -> Flag "" '["decolate"] "" "decolate message" Bool
      -> Arg "NAME" String
      -> Cmd "Greeting command" ()
greet msg deco name = liftIO $ do
    let f x | get deco = "*** " ++ x ++ " ***"
            | otherwise = x
    putStrLn $ f $ get msg ++ ", " ++ get name ++ "!"

connect :: Flag "h" '["host"] "HOST" "host name"   (Def "localhost" String)
        -> Flag "p" '["port"] "PORT" "port number" (Def "8080"      Int   )
        -> Cmd "Connect command" ()
connect host port = liftIO $ do
    let addr = get host ++ ":" ++ show (get port)
    putStrLn $ "connect to " ++ addr

グルーピングするだけ。

main :: IO ()
main = run_ $
    Group "Test program for library"
    [ subCmd "greet"   greet
    , subCmd "connect" connect
    ]

実行結果は次のようになります。

$ ./subcmd
subcmd: no command given
Try 'subcmd --help' for more information.

$ ./subcmd --help
Usage: subcmd.hs [OPTION...] <COMMAND> [ARGS...]
  Test program for sub commands

Options:
  -?     --help         display this help and exit
  -v[n]  --verbose[=n]  set verbosity level

Commands:
  greet       Greeting command
  connect     Connect command
  getopt      GetOpt example

$ ./subcmd connect --help
Usage: subcmd.hs connect [OPTION...]
  Connect command

Options:
  -h HOST  --host=HOST    host name
  -p PORT  --port=PORT    port number
  -?       --help         display this help and exit
  -v[n]    --verbose[=n]  set verbosity level

$ ./subcmd connect --port=1234
connect to localhost:1234

まとめ

というわけで、型レベルでコマンドラインパーザーの情報を記述できるようなものができあがりました。皆さん少しでも興味を持っていただけたら、使ってみていただけると幸いです。感想や、バグ報告、プルリクエストなどいずれも大歓迎です。