使いやすさを目指したコマンドラインパーザー、optparse-declarative
というのを作りました。
これは何ですか?
コマンドラインオプションを解析して、しかるべき処理を呼び出すためのライブラリです。ちゃんとした機能を備えたコマンドラインパーザーを、とにかく書きやすい形のAPIで提供することを目指して作っています。
コマンドラインパーザーっていうのは、プログラム全体のライフライクルから考えると、いじっている時間は決して長いわけではないので、新しいプログラムをつくろうかとなった時に、はてどうやってライブラリを使うのだったんだろうかと毎回忘れてしまいます。それでもって、それがわかりづらいものだと、その思い出すという事自体がわりと心理的障壁になってしまったりしていけないと思って、そういうところを目指したものを作りました。宣言的で型レベルとタイトルに入っていますが、簡単さを目指していたら勝手にそういう設計になりました。
なんでこんなものを作ったのか?
なんで今更コマンドラインパーザーを作ったのかというと、それは既存のものに不満があったからなのですが、長くならない程度に幾つか要点をかいつまんで説明します。なお既存のパーザーに満足していない人は、ここのセクションは飛ばしてもらって構いません。
Haskellでコマンドラインパーザーで比較的よく使われているのは、cmdargs、optparse-applicativeの二つと、baseパッケージのSystem.Console.GetOptあたりだと思います。
それぞれについてそれぞれ不満があって、
-
cmdargs
なんていうかとにかくわかりづらい。何をどうしたらどうなるのか、ほんとうによくわからない。実装がマジカルすぎて、仕組みが謎。わかりづらさを別にしても、コマンドライン引数のために、レコード型を定義しないといけない。レコード型を定義して、それに対してコマンドラインオプションを構築して、さらにパーズしたあとにコマンド実行のためのディスパッチが必要になる。要するに、最初の実装も、コマンドの追加も、めんどくさい。引数を追加するのに3箇所ぐらいコードをいじらなければならない。サブコマンドを実装するのも面倒くさい。
-
optparse-applicative
Applicativeスタイルでコマンドラインパーザを組み立てていくのだけど、イマイチインデントがしっくりこない。しょうもない問題かもしれないけど、しっくりこない。コマンドライン引数のために、これも基本的にはレコード型を定義して、そのフィールドに情報を埋めていくことになる。パラメータの情報はモノイドになっていて、必要なところだけ埋めていく形になっているが、網羅性に乏しく、これもアレをやるのにナニを使えばいいのか、調べるのがしんどい。実装の面倒臭さはcmdargsと対して変わらない。引数を増やすのに3箇所ほどいじらなければならないのも同様。サブコマンドを作るのもやっぱり面倒。そもそもなんでApplicativeである必要があるのかもよくわからなくなってくる。cmdargsでマジカルにやってる部分をApplicativeなアクションでまかなえているので、実装の怪しさがないというぐらいか。
-
System.Console.GetOpt
デフォルトで使えるモジュールで、GNU getoptライブラリのような機能を提供していて、その機能へのアクセス自体は非常に直接的なインターフェースになっているので、わかりづらいということはないのだけど、そもそもの話抽象度が低すぎてめちゃくちゃ書くのがめんどくさい!
それで、これらに不満を持ちながら使うよりも、何とかしっくり来るものが出来ないだろうかと、そういう試みです。
使い方
インストール
あらかじめGHCの7.8.1以上をインストールしてください。DataKinds
やPolyKinds
など、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
という引数の説明のところに表示されます。
第四引数には、この引数の説明を与えます。これもヘルプのところに表示されます。
最後の引数は、この引数の型を与えます。普通にString
やInt
などを指定することができますが、ここではDef "Hello" String
という型を与えています。これは"Hello"
というデフォルト値を持つString
型を表します。なおこのDef
のような型はライブラリのユーザーによって自由に追加できるようになっています。Int
を指定している場合は、整数以外が渡された時エラーになるなど、引数の値もある程度自動で検証できます。デフォルトの挙動はread
が成功するかどうかですが、これも型を定義することで拡張可能です。
Arg
の方は、引数のタイプを表す文字列と、値の型の二つだけを引数に取ります。
コマンド全体の型はCmd
であらわします。これはただのIO ()
のnewtypeですが、メタ情報としてコマンドのヘルプ文字列を含んでいます。
コマンドの型シグネチャの次は、コマンドの本体です。Cmd
はMonadIO
のインスタンスです。verboseフラグの管理など若干の機能がありますが、特に必要ない場合はIOモナドのプログラムを書いてliftIO
すればよいだけです。引数が複雑な型(例えば変数msg
はFlag "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
まとめ
というわけで、型レベルでコマンドラインパーザーの情報を記述できるようなものができあがりました。皆さん少しでも興味を持っていただけたら、使ってみていただけると幸いです。感想や、バグ報告、プルリクエストなどいずれも大歓迎です。