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のコマンドライン引数パーサーを紹介し、その魅力と使いかたを紹介しました。
誰かしらの何らかの助けになれば幸いです。