robb.re

Optparse applicative

Posted on August 9, 2016

An example of using sub-parsers with optparse applicative. Largely based on https://github.com/rob-b/pivotal

The first things we define are our data types.

data StoriesOption = StoriesDetail Integer
                   | StoriesList (Maybe B.ByteString) (Maybe B.ByteString)
    deriving (Show)

data Command = Stories StoriesOption
             | Profile
             | Projects
    deriving (Show)

data Options = Options { optionsProjectId :: (Maybe ProjectId)
                       , optionsToken     :: (Maybe Token)
                       , optionsCommand   :: Command
                       }

When defining the args parser, we start with optionsWithInfo. Almost all of my cli apps have this function; it’s the top-level entry that gets executed by execParser. Here we setup help, info, and description for our argument parser - parseCommand

optionsWithInfo :: ParserInfo Options
optionsWithInfo = info (helper <*> parseCommand)
        (fullDesc
        <> progDesc "Some example cli app")

We define parseCommand to add two option flags, both of which are optional, as well as a sub-command commandParser.

parseCommand :: Parser Options
parseCommand = Options <$> optional (option readerText (long "project-id" <> help "Project id" <> metavar "PROJECTID"))
                       <*> optional (option readerByteString (long "token" <> help "API token" <> metavar "TOKEN"))
                       <*> commandParser

We use a couple of custom Readers in this app so lets look at those

readerText :: ReadM T.Text
readerText = do
  s <- readerAsk
  return $ T.pack s

readerByteString :: ReadM BC.ByteString
readerByteString = do
  s <- readerAsk
  return $ BC.pack s

readerEnum :: Foldable t => t B.ByteString -> ReadM B.ByteString
readerEnum xs = eitherReader pred'
  where
    pred' arg = let x = BC.pack arg
               in
                   if x `elem` xs
                   then return x
                   else Left $ "cannot parse value `" ++ arg ++ "'"

Optparse applicative has a few default readers of which I most commonly use auto and str to parse options as Integers and Strings. In this case I was using functions internally that required ByteString and Text so I wrote a couple of new readers in order to convert my appliction’s options to the correct types at the fringes. I also used readerEnum to ensure that certain options only support certain pre-determined values.

The sub-parsers look as follows.

storiesParser :: Parser Command
storiesParser = Stories <$> (storiesDetailParser <|> storiesListParser)

storiesDetailParser :: Parser StoriesOption
storiesDetailParser = StoriesDetail <$> argument auto (metavar "story id")

storiesListParser :: Parser StoriesOption
storiesListParser = StoriesList <$> optional (option (readerEnum storyStatuses) (long "status" <> help "Filter by status" <> metavar "status"))
                                <*> optional (option (readerEnum storyKinds) (long "kind" <> help "Filter by kind"))

The stories command is designed to either operate on a list of stories or, given a story id, one specific story. This is the reason that StoriesOption is a sum type of either StoriesDetail or StoriesList.

And here we put together the commandParser sub-command. The sub-commands use a little helper function withInfo (taken from here) that, like optionsWithInfo, adds help, info and descriptions to each sub-command.

For the Profile and Projects data types we don’t require any kind of parsing as they do not take any form of further args and so for those we simply use pure to life them into the Parser.

commandParser :: Parser Command
commandParser = subparser $
           command "stories" (withInfo storiesParser "View story")
        <> command "profile" (withInfo (pure Profile) "View user's profile")
        <> command "projects" (withInfo (pure Projects) "View user's projects")