Using Decline

Welcome! 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, 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 catsapplicative 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@42918b9a

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

Commands 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.)

For a top level command like this, you’ll need to make sure that the command name matches whatever you expect your users to type in to run your program; decline has no way to enforce it.

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 might look something like:

def main(args: Array[String]) = tailCommand.parse(args, sys.env) match {
  case Left(help) =>
    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 and Scala Native support.