Haskellでコマンドラインをパースする
この記事では、optparse-applicativeというライブラリを用いてHaskellでコマンドラインのパースを行う方法を紹介します。
これは、柔軟なオプションの作成が可能でありながら、モジュールの分割が容易かつ型安全、小さいパーサーを組み合わせて大きなパーサーを作っていくのがコンセプトのHaskellらしいライブラリです。
optparse-applicativeのえらい点
- ヘルプの自動生成
- 補完の自動生成
- サブコマンドの作成が容易
目次
- 基本
- コマンドライン引数とパーサーの対応付け
- こんなときどうする?
- おわりに
基本
optparse-applicativeの基本は、コマンドラインをパースして、コマンドラインを表現する代数的データ型に格納する、というものです。
まずはどのようなコマンドラインを受け付けるか設計して、それを表現する型を作成します。ここではecho2
という革新的なコマンドを作りましょう。
echo2 [-n] [STRING]
このコマンドラインオプションに対応する型を考えます。
data Argument = Argument
{ noTrailingNewline :: Bool
, text :: String
} deriving (Read, Show)
そうしたらパーサーを書きます。
optparse-applicativeの名の通り、アプリカティブスタイルでArgument
を組み立てていきます。またここで各引数についての追加の情報をMonoid
で指定します。
switch
がフラグによるBool
の指定を、strArgument
が文字列の引数を表します。また、フラグに使う文字、ヘルプに表示するオプションの説明、metavar, value (デフォルト値) を追加の情報として指定しました。
import Options.Applicative
argumentParser :: Parser Argument
argumentParser = Argument
<$> switch (short 'n' <> help "Do not output the trailing newline")
<*> strArgument (metavar "STRING" <> value "")
次はこれをParserInfo
型に変換する必要があります。これにはinfo
関数を使います。info
とヘルプを生成するhelper
を合成し、パーサーとその説明を渡すとParserInfo
を渡してくれる関数withInfo
を作成しました。
withInfo :: Parser a -> String -> ParserInfo a
withInfo p = info (p <**> helper) . progDesc
argumentParserInfo :: ParserInfo Argument
argumentParserInfo = argumentParser `withInfo` "display a line of text"
後はexecParser
を使って実際にコマンドライン引数をパースし、それをArgument -> IO ()
型を持つ関数に渡してあげれば完成です。
main :: IO ()
main = execParser argumentParserInfo >>= runMain
runMain :: Argument -> IO ()
runMain Argument {..} = do
putStr text
unless noTrailingNewline $
putChar '\n'
これがoptparse-applicativeの基本です。
ヘルプを見てみましょう。この--help
オプションは、withInfo
関数の中で使ったhelper
が生成したものです。
$ echo2 --help
Usage: echo2 [-n] [STRING]
display a line of text
Available options:
-h,--help Show this help text
-n Do not output the trailing newline
なかなか美しいヘルプテキストに見えます。
コマンドライン引数とパーサーの対応付け
optparse-applicativeは、コマンドライン引数を下の4つに分類して、それぞれに対してパーサーを用意しています。
自分が欲しいコマンドライン引数がどれに対応するかを考えてパーサーを書く必要がありますね。
-
argument: ハイフンの付かない引数
- 文字列に特殊化したstrArgumentがある
- 文字列以外をパースするときは、Read型クラスのインスタンスであれば
auto
が使える-
argument auto
という形
-
-
flag:
-n
みたいなやつ- Boolにパースするときは
switch = flag False True
が便利
- Boolにパースするときは
-
option:
-f FILE_PATH
みたいなやつ- 文字列に特殊化したstrOptionがある
-
command: サブコマンド
- 各サブコマンドは独立した
ParserInfo
を持つ
- 各サブコマンドは独立した
こんなときどうする?
オプション(引数をつきフラグ)が欲しい
革新的なecho2
を、なんとファイルの内容表示することもできるようにしたいと思います。
次のような感じですね。
echo2 [-n] [-f FILE_PATH] [STRING]
これに合わせて、Argument
型を修正します。
data Argument = Argument
{ noTrailingNewline :: Bool
, filePath :: Maybe FilePath
, text :: String
} deriving (Read, Show)
文字列の引数つきオプションをパースするには、strOptionを使います。
また、オプショナルな引数を使う時はControl.Applicative.optional
を使います。
オプションの設定が長くなってきたら、fold
やWriter
モナドを使いましょう。
import Control.Monad.Writer.Strict
import Options.Applicative
argumentParser :: Parser Argument
argumentParser = Argument
<$> switch (short 'n' <> help "Do not output the trailing newline")
<*> optional (strOption . execWriter $ do
tell $ short 'f'
tell $ metavar "FILE_PATH"
tell $ help "Display a content in a file"
)
<*> strArgument (metavar "STRING" <> value "")
特にApplicativeDo
、RecordWildCards
、Writerモナドの組み合わせは抜群に使い心地がいいです。
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE RecordWildCards #-}
import Control.Monad.Writer.Strict
import Options.Applicative
argumentParser :: Parser Argument
argumentParser = do
noTrailingNewline <- switch (short 'n' <> help "Do not output the trailing newline")
filePath <- optional . strOption . execWriter $ do
tell $ short 'f'
tell $ metavar "FILE_PATH"
tell $ help "Display a content in a file"
text <- strArgument (metavar "STRING" <> value "")
pure Argument {..}
ヘルプを表示は次のようになりました。
$ echo2 --help
Usage: echo2 [-n] [-f FILE_PATH] [STRING]
display a line of text
Available options:
-h,--help Show this help text
-f Display a content in a file
-n Do not output the trailing newline
オプショナルな引数や、複数回設定できる引数を実現したい
先ほどちらっと出てきましたが、オプショナルな引数にはControl.Applicative
モジュールのoptional
が使えます。
さらに、複数回受け取れる引数にはmany
やsome
が使えます。
-f FILE_PATH
がオプショナルな引数、STRING...
が0個以上の文字列としましょう。
echo2 [-n] [-f FILE_PATH] [STRING...]
オプショナルな-f
はMaybe FilePath
, 複数回受け取れるtextは[String]
にします。
data Argument = Argument
{ noTrailingNewline :: Bool
, filePath :: Maybe FilePath
, text :: [String]
} deriving (Read, Show)
パースする部分では、optional
とmany
を使います。many
は0個以上の場合に、some
は1つ以上の場合に使います。
many
やsome
を使う場合、value
を併用しないよう気を付けてください。無限にデフォルト値を取ってきてしまいます。
import Control.Monad.Writer.Strict
argumentParser :: Parser Argument
argumentParser = Argument
<$> switch (short 'n' <> help "Do not output the trailing newline")
<*> optional (strOption . execWriter $ do
tell $ short 'f'
tell $ metavar "FILE_PATH"
tell $ help "Display a content in a file"
)
<*> many (strArgument $ metavar "STRING...")
ヘルプを表示すると、次のような感じです。
$ echo2 --help
Usage: echo2 [-n] [-f FILE_PATH] [STRING...]
display a line of text
Available options:
-h,--help Show this help text
-n Do not output the trailing newline
-f FILE_PATH Display a content in a file,
また、実際にecho2
には複数の文字列を渡すことができます。
$ echo2 "Hello," "World!"
Hello, World!
サブコマンドを実現したい
サブコマンドを実現するには、command
パーサーにサブコマンドのParseInfo
を渡します。
toolbox
というコマンドを新たに作り、そのサブコマンドとしてecho2
とcat2
を作ってみましょう。
toolbox
コマンドは、サブコマンドとしてecho2
あるいはcat2
を持つので、パース結果は下のようになります。
data ToolBox
= Echo2 Echo2Argument
| Cat2 Cat2Argument
deriving (Read, Show)
echo2
は今までのものを流用します。cat2
は引数にファイルパスを受け取るものとしましょう。
data Echo2Argument = Echo2Argument
{ noTrailingNewline :: Bool
, text :: [String]
} deriving (Read, Show)
newtype Cat2Argument = Cat2Argument
{ filePath :: [FilePath]
} deriving (Read, Show)
echo2
, cat2
それぞれのパーサーを書きます。
echo2ArgumentParser :: Parser Echo2Argument
echo2ArgumentParser = Echo2Argument
<$> switch (short 'n' <> help "Do not output the trailing newline")
<*> many (strArgument $ metavar "STRING...")
echo2ArgumentParserInfo :: ParserInfo Echo2Argument
echo2ArgumentParserInfo = echo2ArgumentParser `withInfo` "display a line of text"
cat2ArgumentParser :: Parser Cat2Argument
cat2ArgumentParser = Cat2Argument
<$> many (strArgument $ metavar "FILE_PATH...")
cat2ArgumentParserInfo :: ParserInfo Cat2Argument
cat2ArgumentParserInfo = cat2ArgumentParser `withInfo` "concatenate files and print on the standard output"
あとはそれをsubcommand
を使って繋げれば完成です。
見ての通り小さいパーサーはそれぞれ独立しているので、モジュール分割も容易です。
toolBoxParser :: Parser ToolBox
toolBoxParser = subparser $ echo2 <> cat2
where
echo2 = command "echo2" $ Echo2 <$> echo2ArgumentParserInfo
cat2 = command "cat2" $ Cat2 <$> cat2ArgumentParserInfo
toolBoxParserInfo :: ParserInfo ToolBox
toolBoxParserInfo = toolBoxParser `withInfo` "a minimum box with basic tools"
ヘルプを見てみましょう。
$ toolbox --help
Usage: toolbox COMMAND
a minimum box with basic tools
Available options:
-h,--help Show this help text
Available commands:
echo2 display a line of text
cat2 concatenate files and print on the standard output
これはえらいですね。cat2
のヘルプも見てみましょうか。
toolbox cat2 [FILE_PATH...]
concatenate files and print on the standard output
Available options:
-h,--help Show this help text
補完を使いたい
optparse-applicativeは補完も指定することができます。
その前に、おまじないをかけておきましょう。yourprogram
は適宜置き換えてください。
$ source <(yourprogram --bash-completion-script $(which yourprogram))
bashの他にはzshとfishの補完がサポートされているようです。
- --zsh-completion-script: which is analogous for zsh;
- --fish-completion-script: which is analogous for fish shell
補完はargument
やoption
で使えます。
completeWith
最も簡単なのはcompleteWithで、補完候補をリストで与えます。
someParser :: Parser String
someParser = strArgument (completeWith ["foo", "bar", "buz"])
action
次はbashの補完を利用できるactionです。
complete
コマンドの-A
オプションが使えます。
http://www.gnu.org/software/bash/manual/html_node/Programmable-Completion-Builtins.html#Programmable-Completion-Builtins
someParser :: Parser String
someParser = strArgument (action "file")
mkCompleter
最も柔軟なのは、completer . mkCompleterです。
次のようにして、TABを押した時点での文字列を受け取って候補のリストを返すことができます。
mkCompleter
はString -> IO [String]
という型を持つので、IO
操作を行うこともできます。
something :: IO [String]
something = undefined
someParser :: Parser String
someParser = strArgument (execWriter $ do
tell . completer . mkCompleter $ \arg ->
filter (arg `isPrefixOf`) <$> something
)
おわりに
optparse-applicativeというHaskellのコマンドライン引数パーサーを紹介し、その魅力と使いかたを紹介しました。
誰かしらの何らかの助けになれば幸いです。