soywod
soywod

Reputation: 4520

Optparse-applicative: consecutive parsing (ReadM)

I have a basic command add that takes 2 kind of arguments: a word or a tag. A tag is just a word starting by +. A word is just a String. It can contain at least one argument (I use some for this).

data Arg = Add AddOpts

data AddOpts = AddOpts
  { desc :: String,
    tags :: [String]
  }
  deriving (Show)

addCommand :: Mod CommandFields Arg
addCommand = command "add" (info parser infoMod)
  where
    infoMod = progDesc "Add a new task"
    parser = Add <$> parseDescAndTags <$> partition isTag <$> some (argument str (metavar "DESC"))
    parseDescAndTags (_, []) = FAIL HERE
    parseDescAndTags (tags, desc) = AddOpts (unwords desc) (map tail tags)

I want to add another rule: the add command should receive at least one word (but 0 or more tags). For this, I need to check after the first parsing the word list. If it's empty, I would like to fail as if the add commands received no argument, but I can't figure out how to do.

Upvotes: 1

Views: 218

Answers (1)

Jon Purdy
Jon Purdy

Reputation: 55059

parseDescAndTags is currently a pure function, so there’s no way for it to cause parsing to fail. Just to get this out of the way, I should also note that in this code:

Add <$> parseDescAndTags <$> partition isTag <$> some (argument str (metavar "DESC"))

The operator <$> is declared infixl 4, so it’s left-associative, and your expression is therefore equivalent to:

((Add <$> parseDescAndTags) <$> partition isTag) <$> some (argument str (metavar "DESC"))

You happen to be using <$> in the “function reader” functor, (->) a, which is equivalent to composition (.):

Add . parseDescAndTags . partition isTag <$> some (argument str (metavar "DESC"))

If you want to use ReadM, you need to use functions such as eitherReader to construct a ReadM action. But the problem is that you would need to use it as the first argument to argument instead of the str reader, and that’s the wrong place for it, since some is on the outside and you want to fail parsing based on the accumulated results of the whole option.

Unfortunately that kind of context-sensitive parsing is not what optparse-applicative is designed for; it doesn’t offer a Monad instance for parsers.

Currently, your parser allows tags and descriptions to be interleaved, like this (supposing isTag = (== ".") . take 1 for illustration):

add some .tag1 description .tag2 text

Producing "some description text" for the description and [".tag1", ".tag2"] as the tags. Is that what you want, or can you use a simpler format instead, like requiring all tags at the end?

add some description text .tag1 .tag2

If so, the result is simple: parse at least one non-tag with some, then any number of tags with many:

addCommand :: Mod CommandFields Arg
addCommand = command "add" (info parser infoMod)
  where
    infoMod = progDesc "Add a new task"
    parser = Add <$> addOpts
    addOpts = AddOpts
      <$> (unwords <$> some (argument nonTag (metavar "DESC")))
      <*> many (argument tag (metavar "TAG"))

    nonTag = eitherReader
      $ \ str -> if isTag str
        then Left ("unexpected tag: '" <> str <> "'")
        else Right str

    tag = eitherReader
      $ \ str -> if isTag str
        then Right $ drop 1 str
        else Left ("not a tag: '" <> str <> "'")

As an alternative, you can parse command-line options with optparse-applicative, but do any more complex validation on your options records after running the parser. Then if you want to print the help text manually, you can use:

printHelp :: ParserPrefs -> ParserInfo a -> IO a
printHelp parserPrefs parserInfo = handleParseResult $ Failure
  $ parserFailure parserPrefs parserInfo ShowHelpText mempty

Upvotes: 1

Related Questions