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
An image is worth a thousand words, here is a taste of what you'd get for free:
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.
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)
}
}
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.
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.
Currently only boolean are supported.
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
.
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).
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
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)
}
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
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)
.
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)
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 =>
}
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())
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.