Using Decline
Welcome to decline
!
Here, we’ll run through all of decline
’s major features and look at how they fit together.
decline
is packaged under com.monovore.decline
, so let’s pull that in:
import com.monovore.decline._
Basic Options
‘Normal’ options take a single argument, with a specific type.
(It’s important that you specify the type here;
the compiler usually can’t infer it!)
This lets you parse options like the -n50
in tail -n50
.
val lines = Opts.option[Int]("lines", short = "n", metavar = "count", help = "Set a number of lines.")
// lines: Opts[Int] = Opts(--lines <count>)
Flags are similar, but take no arguments.
This is often used for ‘boolean’ flags,
like the --quiet
in tail --quiet
.
val quiet = Opts.flag("quiet", help = "Don't print any metadata to the console.")
// quiet: Opts[Unit] = Opts(--quiet)
Positional arguments aren’t marked off by hyphens at all,
but they do take a type parameter.
This handles arguments like the file.txt
in tail file.txt
.
import java.nio.file.Path
val file = Opts.argument[Path](metavar = "file")
// file: Opts[Path] = Opts(<file>)
Each of these option types has a plural form,
which are useful when you want users to pass the same kind of option multiple times.
Instead of just returning a value A
,
repeated options and positional arguments will return a NonEmptyList[A]
,
with all the values that were passed on the command line;
repeated flags will return the number of times that flag was passed.
val settings = Opts.options[String]("setting", help = "...")
// settings: Opts[cats.data.NonEmptyList[String]] = Opts(--setting <string> [--setting <string>]...)
val verbose = Opts.flags("verbose", help = "Print extra metadata to the console.")
// verbose: Opts[Int] = Opts(--verbose [--verbose]...)
val files = Opts.arguments[String]("file")
// files: Opts[cats.data.NonEmptyList[String]] = Opts(<file>...)
You can also read a value directly from an environment variable.
val port = Opts.env[Int]("PORT", help = "The port to run on.")
// port: Opts[Int] = Opts()
Default Values
All of the above options are required: if they’re missing, the parser will complain.
We can allow missing values with the withDefault
method:
val linesOrDefault = lines.withDefault(10)
// linesOrDefault: Opts[Int] = Opts([--lines <count>])
That returns a new Opts[Int]
instance…
but this one can always return a value,
whether or not --lines
is passed on the command line.
There’s a few more handy combinators for some particularly common cases:
val optionalFile = file.orNone
// optionalFile: Opts[Option[Path]] = Opts([<file>])
val fileList = files.orEmpty
// fileList: Opts[List[String]] = Opts([<file>...])
val quietOrNot = quiet.orFalse
// quietOrNot: Opts[Boolean] = Opts([--quiet])
Transforming and Validating
Like many other Scala types, Opts
can be mapped over.
lines.map { _.toString }
// res0: Opts[String] = Opts(--lines <count>)
validate
is much like filter –
the parser will fail if the parsed value doesn’t match the given function –
but it comes with a spot for a better error message.
mapValidated
lets you validate and transform at once, since that’s sometimes useful.
import cats.data.Validated
val validated = lines.validate("Must be positive!") { _ > 0 }
// validated: Opts[Int] = Opts(--lines <count>)
val both = lines.mapValidated { n =>
if (n > 0) Validated.valid(n.toString)
else Validated.invalidNel("Must be positive!")
}
// both: Opts[String] = Opts(--lines <count>)
Combining Options
You can combine multiple Opts
instances
using cats
’ applicative syntax:
import cats.implicits._
val tailOptions = (linesOrDefault, fileList).mapN { (n, files) =>
println(s"LOG: Printing the last $n lines from each file in $files!")
}
// tailOptions: Opts[Unit] = Opts([--lines <count>] [<file>...])
tupled
is a useful operation when you want
to compose into a larger Opts
that yields a tuple:
import cats.implicits._
val tailOptionsTuple = (linesOrDefault, fileList).tupled
// tailOptionsTuple: Opts[(Int, List[String])] = Opts([--lines <count>] [<file>...])
Other options are mutually exclusive:
you might want to pass --verbose
to make a command noisier,
or --quiet
to make it quieter,
but it doesn’t make sense to do both at once!
val verbosity = verbose orElse quiet.map { _ => -1 } withDefault 0
// verbosity: Opts[Int] = Opts([--verbose [--verbose]... | --quiet])
When you combine Opts
instances with orElse
like this,
the parser will choose the first alternative
that matches the given command-line arguments.
Commands and Subcommands
A Command
bundles up an Opts
instance with some extra metadata,
like a command name and description.
val tailCommand = Command(
name = "tail",
header = "Print the last few lines of one or more files."
) {
tailOptions
}
// tailCommand: Command[Unit] = com.monovore.decline.Command@1d606a7a
To embed the command as part of a larger application, you can wrap it up as a subcommand.
val tailSubcommand = Opts.subcommand(tailCommand)
// tailSubcommand: Opts[Unit] = Opts(tail)
… or, equivalently and more concisely…
val tailSubcommand2 = Opts.subcommand("tail", help = "Print the few lines of one or more files.") {
tailOptions
}
// tailSubcommand2: Opts[Unit] = Opts(tail)
A subcommand is an instance of Opts
…
and can be transformed, nested, and combined just like any other option type.
(If you’re supporting multiple subcommands,
the orElse
method is particularly useful:
tailSubcommand orElse otherSubcommand orElse ...
.)
Parsing Arguments
Command
s aren’t just useful for defining subcommands –
they’re also used to parse an array of command-line arguments directly.
Calling parse
returns either the parsed value, if the arguments were good,
or a help text if something went wrong.
tailCommand.parse(Seq("-n50", "foo.txt", "bar.txt"))
// LOG: Printing the last 50 lines from each file in List(foo.txt, bar.txt)!
// res1: Either[Help, Unit] = Right(())
tailCommand.parse(Seq("--mystery-option"))
// res2: Either[Help, Unit] = Left(Unexpected option: --mystery-option
//
// Usage: tail [--lines <count>] [<file>...]
//
// Print the last few lines of one or more files.
//
// Options and flags:
// --help
// Display this help text.
// --lines <count>, -n <count>
// Set a number of lines.)
If your parser reads environment variables, you’ll want to pass in the environment as well.
tailCommand.parse(Seq("foo.txt"), sys.env)
// LOG: Printing the last 10 lines from each file in List(foo.txt)!
// res3: Either[Help, Unit] = Right(())
A main method that uses decline
for argument parsing would look something like:
def main(args: Array[String]) = tailCommand.parse(args, sys.env) match {
case Left(help) if help.errors.isEmpty =>
// help was requested by the user, i.e.: `--help`
println(help)
sys.exit(0)
case Left(help) =>
// user needs help due to bad/missing arguments
System.err.println(help)
sys.exit(1)
case Right(parsedValue) =>
// Your program goes here!
}
This handles arguments and environment variables correctly, and reports any bad arguments clearly to the user.
Using CommandApp
If you have a Command[Unit]
,
extending CommandApp
will wire up that main method for you.
object Tail extends CommandApp(tailCommand)
The resulting application can be called like any other Java app.
Instead of defining a separate command, it’s often easier to just define everything inline:
object TailApp extends CommandApp(
name = "tail",
header = "Print the last few lines of one or more files.",
main = (linesOrDefault, fileList).mapN { (n, files) =>
println(s"LOG: Printing the last $n lines from each file in $files!")
}
)
That’s it!
If you made it this far,
you might be interested in more supported argument types,
cats-effect
integration or Scala.js support.