CLI parsing with CmdArgs.Explicit
by Clement Delafargue on March 5, 2013
Tagged as: haskell, hammertime, fp.
As a CLI program, hammertime’s UI is command-line flags and arguments. The
first iteration was basic pattern matching on the result of getArgs
. Then
Julien refactored it with CmdArgs
, a very
powerful library which allows to create command-line parsers in a very
declarative way. It features a multi-mode option (a bit like git add
, git commit
). It’s very easy to declare subcommands, each with its own set of
options. It automatically creates a nicely formatted --help
output.
Unfortunately, the default (and most documented) way of declaring the options
is impure and uses untracked side-effects, which is generally frowned upon in
the haskell community. It’s also not very typesafe and the parsing easily
breaks at runtime. The CmdArgs.Implicit
module also uses lots of Typeable
dark magic to auto generate options from record fields. The CLI is tightly
coupled to the way you name your data structures. For all these reasons, I’ve
decided to rewrite the CLI from scratch, using CmdArgs.Explicit
, a pure,
non-magic CLI parser generator. The use of CmdArgs.Explicit
is way less
documented than CmdArgs.Implicit
, so here’s how I did it.
You should read the following with the documentation and hammertime’s main in tabs somewhere.
Data structures
The first step is to model what the user asked with a data structure. For hammertime, the user can tell:
- I’ve started an activity
- I’ve stopped the current activity
- Show me a report (with some filters)
- Show me some help
- Show me your version number
data Action = Start { project :: String
name :: String
, tags :: [String]
,
}| Stop
| Report { span_ :: Types.TimeSpan
project_ :: Maybe String
, name_ :: Maybe String
, tag_ :: Maybe String
, type_ :: Types.ReportType
,
}| Help
| Version
deriving (Show)
The underscores in the name fields is because of name conflicts. Haskell’s
records are quite annoying when it comes to namespacing.
The deriving (Show)
is not crucial but quite useful for debugging.
Declaring modes
The Start
, Stop
and Report
actions are subcommands. Help
and Version
are triggered by flags (-?
/--help
and -V
/--version
respectively).
Report mode
The report mode is the easiest to declare.
reportMode :: Mode Action
= mode
reportMode "report"
defaultReport"Generate report for a given time span (default: day)"
"month|week|day")
(flagArg setTimeSpan
[ flagReq"project", "p"]
[
setProjectFilter"PROJECT"
"Filter by project"
, flagReq"activity", "a"]
[
setActivityFilter"ACTIVITY"
"Filter by activity"
, flagReq"tags"]
[
setTagsFilter"TAGS"
"Filter by tag"
, flagReq"type", "t"]
[
setReportType"SIMPLE|TOTAL"
"Report Type (default: simple)"
]
The Mode Action
type simply states that it will extract an Action
from the
arguments. Mode a
is just a record. There are different constructors for
different kinds of modes (modeEmpty
for an empty mode, mode
for a regular
mode or modes
for subcommands).
Here we have a simple mode, so we’ll use mode
:
"report"
: the subcommanddefaultReport
the base value (will be modified by the flags)"Generate report …"
help text(flagArg setTimeSpan "month|week|day")
the argument handler[ flagReq … ]
the flags
The flags act as modifiers. The mode has a start value, which is then modified
by flags. Thus the flags take a Update a
value which takes the value given
to the flag and updates the generated value.
type Update a = String -> a -> Either String a
Every flag has a required value, so we’ll use flagReq
.
"project", "p"] setProjectFilter "PROJECT" "Filter by project" flagReq [
setProjectFilter :: Update Action
= Right $ r { project_ = (Just p) } setProjectFilter p r
We allow both --project
and -p
to be used. setProjectFilter
updates the
Report
record with the given project name.
Anonymous arguments are like flags, they take a value and update the generated action.
Here (flagArg setTimeSpan "month|week|day")
allows the user to choose the
time span used to generate the report.
setTimeSpan
updates the Report
record with the right TimeSpan
value.
Stop mode
The Stop
mode is a bit different. It has no argument, so we’ll need to
slightly alter the value constructed with mode
.
stopMode :: Mode Action
=
stopMode let m = mode "stop" Stop "Stop current activity" dummyArg []
in m { modeArgs = ([], Nothing) }
dummyArg :: Arg a
= flagArg (\_ _ -> Left "") "" dummyArg
The only non-obvious part is dummyArg
. A subcommand can take a various
number of anonymous arguments. mode
allows to specify one. Here, we don’t
want any argument, so we construct the mode with a dummy argument and get rid
of it just after. We also could have used modeEmpty
to construct the mode
only with record updates, but doing it with mode
is shorter and easier to
read.
Anonymous arguments are like flags, they take a value and update the generated
action.
To handle multiple arguments, the mode’s arguments handler is
([Arg a], Maybe (Arg a))
.
The [Arg a]
is for required arguments, the Maybe (Arg a)
for optional arguments.
=
stopMode dummyArg :: Arg a
= flagArg (\_ _ -> Left "") "" dummyArg
Just to make sure we don’t forget a dummyArg
somewhere, we make it fail
every time.
Start mode
The start mode is a bit more complicated, because it can take a various number of arguments (at least two, project and activity name, then some tags).
startMode :: Mode Action
=
startMode let m = mode "start" (Start "" "" []) "Start a new activity" dummyArg []
in m { modeArgs = ([
"PROJECT"),
(flagArg setProject "ACTIVITY")
(flagArg setActivity Just (flagArg addTag "[TAGS]")) } ],
setProject
and setActivity
just set the project and activity with record updates.
addTag
appends its argument to the tag list.
addTag :: Update Action
= Right $ s { tags = (tags s ++ [t]) } addTag t s
Putting everything together
Now we have all of our submodes, we just have to put everything together (and add the version and help flags).
hammertimeModes :: Mode Action
=
hammertimeModes let m = (modes
"hammertime"
defaultReport"Lightweight time tracker"
[startMode, stopMode, reportMode])= flagHelpSimple $ const Help,
helpFlag = flagVersion $ const Version,
versionFlag = m' { modeGroupFlags = toGroup [helpFlag, versionFlag] }
addFlags m' in addFlags m
Now you should be able to create your CLI with CmdArgs.Explicit
. Since it’s
pure and without side effects, it’s testable and it won’t break at compile
time :).