Mangrove

Mangrove is a library for building command line argument parsers using
Haskell's Applicative interface. It provides parsers for UNIX-style
command line syntax, including positional parameters, named options,
and commands, as well as complex subparameters and suboptions (e.g.
--mount src=/webroot,dst=/var/www,rw). It is also extensible, so you
can define alternative command line syntaxes.
Building
This project uses stack as its primary build system (though building
with cabal should also work). As usual, you can build the library
by running stack build and you can install it with stack install.
Use stack haddock to build the API documentation.
Test Suite
Mangrove has a test suite using HSpec. Run stack test to build and
run the tests. The main branch should always pass all tests, so if
something fails, please make an issue!
Project Roadmap
This project is currently under active development. Goals currently on
the horizon include:
- Stabilize the client-facing API
- Simplify the code structure
- Improve test suite coverage
- Profiling & optimization
Once these are addressed, a 1.0.0 release will be appropriate.
Tutorial
Imagine the roots of a plant branching out like a tree as they
descend. Eventually, they dip into a stream. The roots collect water
and nutrients from the flowing stream. These resources travel back up
the structure toward the plant, combining along the way.
This is kind of like how the Mangrove library works. We build a
tree-shaped parser from simple applicative combinators, then feed it a
sequence of CLI arguments. Simple parsers stationed at the bottom of
the tree consume these arguments and produce values which are then
passed back up the tree and combined with the results of other parsers
until a final result is reached.
Example
NOTE: See the full example file in doc/MkUser.hs.
Suppose we are writing a simple program that creates new user
accounts - we'll call it "mkuser". The goal will be to provide a
command line interface with the following syntax:
mkuser [--uid=INT] [--system] [--groups={GROUP...}] USERNAME
First, let's create a new record that captures the program's runtime
configuration.
data Settings = Settings
{ userId :: Maybe Int -- ^ An optional target user ID
, userSystem :: Bool -- ^ Is this a system user?
, userGroups :: [Text] -- ^ Groups the new user will be in
, userName :: Text -- ^ Username for the new user
} deriving (Show)
Let's also pretend that our program's logic lives inside a function
run :: Settings -> IO (). We pass it the settings we want, and it
runs the program accordingly. However, since this is just an example
program, we won't actually create any user accounts; instead we'll
just have the program print its settings to stdout.
run :: Settings -> IO ()
run = print
Now we need to construct a parser that reads a list of arguments and
yields a Settings. Our parser will have the type UnixParser Settings.
NOTE: This example uses the language extensions OverloadedLists
and OverloadedStrings since we need to write lots of NonEmpty list
and Text literals.
NOTE: UnixParser is just a convenient type synonym for
ParseTree UnixScheme. This tells us that we will build a ParseTree
by combining parsers from the UNIX scheme.
Positional Parameters
A "positional parameter" is a positional input that accepts the first
non-flag argument it encounters. In Mangrove, positional parameters
are usually just referred to as "parameters" since other kinds of
parameters have their own names. Consider an example program called
substring whose command line syntax is substring START END STRING.
START, END, and STRING would be parameters. If we invoke
substring 1 3 "example", we know that START is 1, END is 3,
and STRING is "example" because of the order in which they appear.
Our program will have just one parameter: a username. Here is how we
define a parser for it:
prm_name :: UnixParser Text
prm_name = parameter defaultParser
The parameter function creates a parameter parser out of a
TextParser.
TextParsers
A TextParser r is just a wrapper around a function that parses
Text into a value of type r. It also contains a "hint" string used
for displaying usage information.
Many common data types have a reasonable default TextParser
implementation. Types that are instances of the DefaultParser class
implement defaultParser :: DefaultParser a => TextParser a, letting
us automatically select the correct parser based on the required type.
In the example above, Text has a very simple DefaultParser
instance that just returns its input unchanged.
Options
An "option" is a construct representing a named input. Options begin
with a flag followed by an optional subargument string.
A "flag" is special symbol that signals the beginning of a particular
option. Per UNIX tradition there are long flags (e.g. --foo) and
short flags (e.g -f).
To prevent ambiguity, sometimes an equals sign is used to separate a
long flag from its subargument string (instead of a space). For
example, --uid=1000 is an option that begins with the --uid flag
and is followed by the subargument string 1000. Similarly, an
option's short flag can be directly concatenated with its argument,
e.g. -u 1000 can be written -u1000.
Let's define a parser for the --uid option:
opt_uid :: UnixParser Int
opt_uid = option ["--uid", "-u"]
"Specify a user ID"
$ subparameter defaultParser
The option function creates a parser for CLI options. It takes three
arguments:
- A
NonEmpty list of Flags that trigger the option, in this case
"--uid" and "-u". Flag is an instance of IsString, so we can
just write the string representation instead of LongFlag "uid"
and ShortFlag 'u'.
- A human readable description. This will be displayed when help
output is triggered.
- A subparser tree (
SubParser r) that will parse any subparameters or
suboptions. In this case, we declare a single subparameter (an
integer).
The subparameter function behaves just like parameter from
earlier, except it creates a SubParser instead of a UnixParser. We
also use defaultParser to automatically select an appropriate
TextParser for Int.
NOTE: SubParser is a type synonym for ParseTree SubScheme.
That means we build a SubParser by combining SubScheme parsers.
SubScheme provides parsers for handling subarguments to options.
You might notice that our Settings record requires a Maybe Int,
not an Int. However, since UnixParser is an instance of
Alternative, we can use optional from Control.Applicative.
optional opt_uid :: UnixParser (Maybe Int) describes an option that
is not required and might be absent (which should give us Nothing).
Switches
The --system option is simpler because because it doesn't accept any
subarguments - it is either present (True) or absent (False). This
special type of option is a "switch", and we can use the switch
function to create a parser:
opt_system :: UnixParser Bool
opt_system = switch ["--system", "-s"] "Create a system user"
-- If we defined this without 'switch' it would look like this:
-- opt_system = option ["--system", "-s"]
-- "Create a system user"
-- (pure True)
-- <|> pure False
Options with Multiple Subparameters
Let's deal with the --groups option. This option is a bit different
from the --uid option because we want the user to be able to specify
a list of groups for the new user to join. Thus, we want to create an
option that accepts one or more subarguments.
Thankfully, SubParser is also an Alternative instance. We can use
some (from Control.Applicative) to convert a SubParser r into a
SubParser [r] that will expect to parse one or more r values.
opt_groups :: UnixParser [Text]
opt_groups =
option ["--groups", "-g"]
"Specify what groups the user is part of"
$ some $ subparameter defaultParser
Mangrove recognizes that the subparser some $ subparameter defaultParser :: SubParser [Text] can consume multiple subarguments,
so it splits those subarguments apart by comma. This allows us to pass
a list of group names like so: --groups=wheel,audio,input, and the
parser will yield ["wheel","audio","input"].
By using some instead of the similar function many, we have
created a subparser that will fail if no subarguments are provided
(e.g. mkuser alice --groups).
What if the --groups option isn't present at all? We still need a
[Text] value for our Settings record. In that case, an empty list
makes sense. Just like with our --uid option, we use Alternative
to define what happens if our parser never finds applicable input.
opt_groups <|> pure [] :: UnixParser [Text]
NOTE: There is an important distinction between a parser that
never finds relevant input and a parser that fails. In an expression
like opt_groups <|> pure [], if opt_groups never finds relevant
input, the alternative provides a default value. However, if
opt_groups does find applicable input, but parsing it fails, an
error will be thrown instead.
More generally, if p and q are parsers, then p <|> q is a parser
that yields the result from whichever parser finds applicable input
first. If neither parser finds input, we first try resolving p and
then q with no input and yield the first result we get. If neither
succeeds, we throw an error.
Applicative
We are now ready to construct our Settings parser using <$> and
<*>:
parseSettings :: UnixParser Settings
parseSettings =
Settings
<$> optional opt_uid
<*> opt_system
<*> (opt_groups <|> pure [])
<*> prm_name
Now we can inspect the automatically generated usage information for
our parser in GHCi using render from Mangrove.Text:
ghci> render parseSettings
"[--uid=INT] [--system] [--groups={STRING...}] STRING"
This output indicates that our parser accepts (but does not require) a
--uid option with an integer subargument, a --system option, and a
--groups option with a list of string subarguments. Finally, it
requires a single parameter, which is a string. We'll see how to
improve those type hints later.
The last thing we need to define before running our parser is a
structure with some metadata about the program:
programInfo :: ProgramInfo
programInfo = ProgramInfo
{ programName = "mkuser" -- The name of the program
, programDesc = "Create user accounts" -- A short description of the program
}
This information is used to display nice, human-readable help and
usage information, which will is discussed in more detail in the Help
Options section.
Running the Parser
The parseArguments function will run our parser with the arguments
passed to our program by the operating system.
main :: IO ()
main = parseArguments programInfo parseSettings run
parseArguments takes three arguments: the program metadata (for help
output), a UnixParser r, and a function of type r -> IO a. When
the parser completes successfully, this function will be called with
the result. Otherwise, parseArguments will print error messages or
help information as appropriate, and then exit.
If you want to run an argument parser without using IO, or you want
to pass your own argument list, check out runHelpfulParser from
Mangrove.
Now we have a complete program we can build and run to show the
argument parser in action!
$ ghc -o mkuser MkUser.hs
[1 of 2] Compiling Main ( MkUser.hs, MkUser.o )
[2 of 2] Linking mkuser
$ ./mkuser --system --groups audio,input bilbo
Settings {userId = Nothing, userSystem = True, userGroups = ["audio","input"], userName = "bilbo"}
$ ./mkuser --badinput
unexpected --badinput
$ ./mkuser --system
expected: STRING
$ ./mkuser --uid=InvalidNumber bilbo
--uid=InvalidNumber: InvalidNumber: input does not start with a digit
Help Options
Currently, our CLI interface is missing something important: an option
for displaying help and usage information. Let's create a new
Settings parser that recognizes --help as a request for help
information.
parseSettings' :: UnixParser Settings
parseSettings' = addHelpOptions ["--help"]
"Display help and usage information"
parseSettings
main :: IO ()
main = parseArguments programInfo parseSettings' run
Now if we invoke our program with the --help option, it will display
a nice summary of how to use it:
./mkuser --help
Usage:
mkuser [--uid=INT] [--system] [--groups={STRING...}] STRING
mkuser --help
Create user accounts
--help Display help and usage information
-g --groups {STRING...} Specify what groups the user is part of
-s --system Create a system user
-u --uid INT Specify a user ID
NOTE: If an interface defines any commands (see below),
addHelpOptions will add a help option at the root of the parse tree
as well as the root of every command subtree. This is so that you can
invoke myprogram --help to get general help or myprogram somecommand --help to get help information specifically for
somecommand.
Hints
Type hints are displayed as placeholders for parameters in help and
usage information. They are a hint to the user about what kind of
information is expected by that input. For example, --uid=INT
indicates the --uid option expects an integer as a subargument.
Hints stored inside the parserHint field of a TextParser.
Our program uses the generic hints defined in the DefaultParser
instances for Int and Text. These defaults are often reasonable,
but we can also tailor hints more specifically for our use case. All
we need to do is alter the value of parserHint for the relevant
TextParser.
opt_groups :: UnixParser [Text]
opt_groups =
option ["--groups", "-g"]
"Specify what groups the user is part of"
$ some $ subparameter defaultParser {parserHint = "GROUP"}
prm_name :: UnixParser Text
prm_name = parameter defaultParser {parserHint = "USERNAME"}
Now our help output looks like this:
$ ./mkuser --help
Usage:
mkuser [--uid=INT] [--system] [--groups={GROUP...}] USERNAME
mkuser --help
Create user accounts
--help Display help and usage information
-g --groups {GROUP...} Specify what groups the user is part of
-s --system Create a system user
-u --uid INT Specify a user ID
Commands
A "command" is a special argument changes the context of a parser.
When a command is encountered, the parser begins using the parse tree
associated with that command as a new context until it completes.
Commands are usually used as a way to invoke different modes of
functionality for a single program. For example, git supports
various commands like commit or pull.
Suppose we are creating a basic version control system similar to
git. Our program will have several runtime modes for doing
operations like commit or pull. Here is how we might define a
parser that recognizes the corresponding commands (for the full code,
see doc/VersionControl.hs):
data Mode
= CommitMode CommitSettings
| PullMode PullSettings
-- ... and probably other modes too
deriving (Show)
parseMode :: UnixParser Mode
parseMode = cmd_commit <|> cmd_pull
where
cmd_commit =
command ["commit"]
"Make a new commit"
$ CommitMode <$> parseCommitSettings
cmd_pull =
command ["pull"]
"Download remote changes"
$ PullMode <$> parsePullSettings