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 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@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

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

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.