1

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.

soywod
  • 4,377
  • 3
  • 26
  • 47

1 Answers1

1

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
Jon Purdy
  • 53,300
  • 8
  • 96
  • 166
  • Thank you for the simplification. I'm still new to Haskell, those things don't come easily to my mind yet. What I try to do is to have words and tags mixed, that's why I went for a `parseDescAndTags`. I like your second approach. I will still search a bit more to see if I can't find a better alternative. I would like to print the same output as if the arg was totally missing. – soywod Jul 30 '20 at 18:32
  • I was thinking about throwing an error, and showing the help as you mentioned on catch. Is it considered a good practice in Haskell? – soywod Jul 30 '20 at 19:00
  • 1
    @soywod: If you *expect* an error to happen, you should prefer value-level error handling with e.g. `Maybe`, `Either`, or `Validation`. Here you could parse to an “unvalidated arguments” `newtype` wrapper for `Arg`, then have a function `validate :: UnvalidatedArg -> Either String Arg` to check the invariants that can’t go in the parser directly. Then print the help and the error message if its result is `Left message`. Exceptions (`throw`, `throwIO`) are better for *unexpected* or *unrecoverable* errors; also, you can only catch exceptions in `IO`, so you can’t use them in normal pure code. – Jon Purdy Jul 30 '20 at 21:04