An applicative-style CLI option parsing lib with accumulative errors and type-based command parsing.
Let's say we have a CLI program called p, and we want it to accept a
flag that determines whether its output will be colored or not.
We want users to express this intention by writing p --color.
Somewhere in our code we have a Config type that represents options
passed in:
type Config r = {color :: Boolean | r}To parse this, we use the flag combinator:
parseConfig :: Optlicative (Config ())
parseConfig = {color: _} <$> flag "color" NothingIf we want to extend Config to include an --output option that takes a filename argument, that's easy too:
type Config' r = Config (output :: String | r)
parseConfig' :: Optlicative (Config' ())
parseConfig' = {color: _, output: _}
<$> flag "color" Nothing
<*> string "output" NothingSuddenly we think of several more boolean flags we want to support:
type Config'' r = Config' (humanReadable :: Boolean, metricUnits :: Boolean | r)
parseConfig'' :: Optlicative (Config'' ())
parseConfig'' = {color: _, output: _, humanReadable: _, metricUnits: _}
<$> flag "color" Nothing
<*> string "output" Nothing
<*> flag "human-readable" Nothing
<*> flag "metric-units" NothingBut now if we want users to use every one of these flags, we require them to write something like p --color --human-readable --metric-units --output "./output.txt". That's way too long!
If we want to allow single-hyphen, single-character options we just change a few Nothing's:
parseConfig2 :: Optlicative (Config'' ())
parseConfig2 = {color: _, humanReadable: _, metricUnits: _, output: _}
<$> flag "color" (Just 'c')
<*> flag "human-readable" (Just 'H')
<*> flag "metric-units" (Just 'm')
<*> string "output" NothingNow our users can write p -cHm --output "./output.txt". Much better!
By default, if the --output option is missing the following error will be
generated: "Missing option: Option 'output' is required."
Our flags won't produce any error messages, since if a user doesn't supply a flag we assume they want it to be false.
But we can also change the error message, for example by changing our --output parser to string "output" (Just "I need to know where to place my output!")
Error messages are accumulated via the semigroup-based V applicative functor,
meaning that if the user gives input that causes multiple errors, each one can be
shown.
What if we want to provide a default output directory, and don't want to require
the user to always supply it? We can use optional, withDefault or withDefaultM:
parseConfig4 :: Optlicative (Config'' ())
parseConfig4 = {color: _, _, humanReadable: _, metricUnits: _, output: _}
<$> flag "color" (Just 'c')
<*> flag "human-readable" (Just 'H')
<*> flag "metric-units" (Just 'm')
<*> withDefault "./output.txt" (string "output" Nothing)Note that none of these three combinators will fail.
If we have a way of reading values from a String (specifically a function f of
type String -> F a) then we can use optF f to read such a value. Any errors
in the F monad get turned into OptErrors in the Optlicative functor.
Example:
readTupleString :: String -> F (Tuple Int Int)
optTuple :: Optlicative (Tuple Int Int)
optTuple = optF readTupleString "point" (Just "Points must be in the form '(x,y)'")Then the option --point (3,5) won't error if and only if
readTupleString "(3,5)" does not error.
Again, assuming we have a function read :: String -> F a for some type a,
we can use manyF read :: Int -> String -> Maybe ErrorMsg -> Optlicative (List a).
In this case, Int represents the number of arguments expected (none of which
may start with a hyphen character).
optlicate :: Constraints => Record optrow -> Preferences a -> Effect {cmd :: Maybe String, value :: Value a}Preferences is a record:
{ errorOnUnrecognizedOpts :: Boolean
, usage :: Maybe String
, globalOpts :: Optlicative a
}A defaultPreferences :: Preferences Void is available.
The errorOnUnrecognizedOpts field indicates whether an error should be generated
if a user passes in an option that isn't recognized by the parser.
The usage field will print a given message in case of any error.
globalOpts is for options which don't match a given command; for more on commands
see the next section.
The value field has type Value a, which is a type synonym for
V (List OptError) a. This means you'll need to use unV from
Data.Validation.Semigroup, handling any possible errors, in order to have access
to the value of type a.
Let's take a closer look at the "Constraints" part of the optlicate type signature.
The actual signature starts like this:
optlicate :: forall optrow a e. Commando optrow => Record optrow -> Preferences a -> ...The important part is the Commando typeclass constraint. It applies only to
a certain class of rows -- similar to homogenous rows, but a bit more generalized
than what usually comes to mind. Let's look at an example:
type MyConfig =
( command :: Opt Config
( more :: Opt Config ()
)
, second :: Opt Config ()
)
Note that this type has not only breadth but also depth. The Opt type is a
datatype around Optlicative but with extra type information in the second argument:
this is what allows us to nest commands, treating every possible command (and
associated options) as a tree-like structure (a record), where each node (field)
represents a pair of a command entered, and the options for that command.
For example, if the user had run p command --help, the parser would then recognize this, and match the Optlicative Config associated with the command command and
run it against the --help flag.
Any command, if it exists, will be placed into the cmd field of the result --
if the program is used like p command more, then cmd = Just "more".
Let's look at the first argument to optlicate. In our example case, we'd need a
value of type Record MyConfig. If we can construct a value for just one field,
we can construct them all. And those values are built using Opt, as suggested
by the definition of MyConfig:
data Opt (a :: Type) (row :: # Type) = Opt (Optlicative a) (Record row)Opts are pairs of Optlicatives and a record, which allows us to continue
chaining new Optlicatives. With this in mind, we can construct what we want:
myConfig :: Record MyConfig
myConfig =
{ command: Opt commandOptlicative
{ more: Opt moreOptlicative {}
}
, second: Opt secondOptlicative {}
}We can also use endOpt to get rid of those empty records if we wish:
more: endOpt moreOptlicative.
See the test/ folder.
- "Unsupported command" errors: when a command is given but does not match anything
- passthrough options (as in
program --program-opt -- --passthrough-opt) - use of single characters for options instead of just flags
- other things I haven't thought of
- Using bower:
> bower install purescript-optlicative
- Or,
Add it to a package set!