Command Line Interface Scala Toolkit
The Backuity CLIST is a scala-only (2.11+) library for quickly building beautiful type-safe modular and reusable mutable CLIs.
- You said beautiful
- Why mutable?
- Let's start - Single Command CLI
- Attributes: Argument vs Arguments vs Options
- Parsing
- Exit code vs Exception
- Version, Help and Usage
- Multiple Commands
- Licence & Contribution
You said beautiful?
An image is worth a thousand words, here is a taste of what you'd get for free:
Why mutable?
We think that CLIs do not require an immutable approach. Immutability often comes at the expense of simplicity. If you are looking for an immutable CLI library you should take a look at projects like https://github.com/scopt/scopt.
Let's start - Single Command CLI
First let's configure our SBT build
libraryDependencies ++= Seq(
"org.backuity.clist" %% "clist-core" % "3.5.1",
"org.backuity.clist" %% "clist-macros" % "3.5.1" % "provided")
Then define a command:
import org.backuity.clist._
// or if you do not like wildcard imports:
// import org.backuity.clist.{Command, opt, args}
class Cat extends Command(description = "concatenate files and print on the standard output") {
// `opt`, `arg` and `args` are scala macros that will extract the name of the member
// to use it as the option/arguments name.
// Here for instance the member `showAll` will be turned into the option `--show-all`
var showAll = opt[Boolean](abbrev = "A", description = "equivalent to -vET")
// an abbreviated form can be added, so that this option can be triggered both by `--number-nonblank` or `-b`
var numberNonblank = opt[Boolean](abbrev = "b", description = "number nonempty output lines, overrides -n")
// default values can be provided
var maxLines = opt[Int](default = 123)
var files = args[Seq[File]](description = "files to concat")
}
And use it to parse args
:
def main(args: Array[String]) {
Cli.parse(args).withCommand(new Cat) { case cat =>
// the new Cat instance will be mutated to receive the command-line arguments
println(cat.files)
}
}
Alternatively for simple commands like this one you can simply extend CliMain
thus reducing the boiler plate even further:
import java.io.File
import org.backuity.clist._
object CatDemo extends CliMain[Unit](
name = "cat",
description = "concatenate files and print on the standard output") {
var showAll = opt[Boolean](abbrev = "A", description = "equivalent to -vET")
var numberNonblank = opt[Boolean](abbrev = "b", description = "number nonempty output lines, overrides -n")
var maxLines = opt[Int](default = 123)
var files = args[Seq[File]](description = "files to concat")
def run: Unit = {
println("files = " + files)
println("showAll = " + showAll)
println("maxLines = " + maxLines)
}
}
Attributes: Argument vs Arguments vs Option
A Command
can have 3 kinds of attributes:
opt
: an option is always optional and is provided either by--option-name=value
, or with an abbreviated form such as-x
. Declaration order does not matter.arg
: an argument receives an un-named value as in the commandcat <file>
. It might be optional. Argument declaration order matters.args
: the equivalent of a var-args. At most one can be specified and it must be declared last.
Default Option value
An option (being optional) must have a default value (as we want to avoid null
for obvious reasons).
That default value is automatically provided for Boolean
and Option
(respectively false
and None
).
Note that a boolean option can be true by default, in that case providing it will make it false:
var prettyPrint = opt[Boolean](default = true, name = "no-pretty-print")
Then on the command line: cmd --no-pretty-print
will make prettyPrint
false.
Abbreviated Option
Currently only boolean are supported.
Optional Argument
An argument can turned optional by setting the required
attribute to false
:
var target = arg[Option[String]](required = false)
Very much like options, optional arguments must provide a default value for types other than Boolean
and Option
.
Named Argument
An argument can be provided through the command line in the same fashion as options.
var target = arg[String]()
var verbose = opt[Boolean]()
Then on the command line: cmd --verbose --target=stuff
Note that when doing so its order become irrelevant (the argument can be provided after options/arguments that were declared after him).
Parsing
The parsing is done through the Read
and ReadMultiple
type-classes. User-specific instances can be provided by simply
adding them to the implicit scope.
Read
(used by opt
and arg
) parses a String into a type T
,
whereas ReadMultiple
(used by args
) takes a list of string to produce a type U
.
The following types are supported out-of-the-box:
- String
- Int, Long, Double
- BigInt, BigDecimal
- java.util.Calendar (in the
yyyy-MM-dd
format) - java.io.File
- java.net.URI
- Tuple2
- java enums
Note that on the command line there is a distinction between
cat file1 file2 "file with space"
and
cat file1 file2 file with space
Example
var maxDelay = opt[Long](default = 3000L)
var maxError = opt[Double](default = 3.24)
Or if you need to customize that parsing:
object Person extends Command {
var name = arg[Name]()
}
case class Name(firstName: String, lastName: String)
import org.backuity.clist.util.Read
implicit val nameRead = Read.reads[Name] { str =>
val Array(first,last) = str.split("\\.")
Name(first,last)
}
Exit code vs Exception
By default, upon failure, the Cli
will exit with code 1. This behavior can be customized:
Cli.parse(args).throwExceptionOnError()
: throws an exception instead of exitingCli.parse(args).exitCode(12)
: exits with a specific code
Version, Help and Usage
You can provide a version number for your program through version("1.0.0")
. This will add a version
option,
whose name can be customized with version("1.0.0", "custom-name")
.
By default a help command is added, which displays the command usage. This can be removed with noHelp()
.
The usage is printed for each parsing error but this can be disabled with noUsageOnError()
.
Finally the usage can be customized through withUsage(newCustomUsage)
.
Multiple Commands
To build a multi-command CLI simply provide the parser with more than one command:
object Run extends Command
object Show extends Command
val res = Cli.parse(args).withCommands(Run, Show)
// res will be an Option[Command]
It makes sense now to define a name for our program:
Cli.parse(args).withProgramName("my-cli").withCommands(Run, Show)
Composition: grouping Options into Traits
It is entirely possible (and encouraged) to factorize options into traits and compose Commands with them:
trait Common { this: Command =>
var verbose = opt[Boolean](abbrev = "v")
}
object Run extends Command with Common
object Show extends Command with Common
val res = Cli.parse(args).withCommands(Run, Show)
// res is also now inferred as an `Option[Common]`
You can also seal your command hierarchy to allow exhaustive pattern matching checks:
sealed trait Common { this: Command => // same as above
}
Cli.parse(args).withCommands(Run, Show) match {
case Some(Run) =>
case Some(Show) =>
case None =>
}
Adding behavior to Commands
Depending on your taste, you might want to define the behavior of your commands within them:
sealed trait Common { this: Command =>
var verbose = opt[Boolean](abbrev = "v")
def run(): Unit
}
object Run extends Command with Common {
def run(): Unit = {
println("Running...")
}
}
object Show extends Command with Common {
def run(): Unit = {
println("Showing...")
}
}
Cli.parse(args).withCommands(Run, Show).foreach(_.run())
Licence & Contribution
The code is published under the Apache 2.0 licence.
You're welcome to fork and submit PRs, and if you like the project you can up-vote the related StackOverflow answer.