cuzfrog / simple-cmd

Command-line argument parser for scala application.

GitHub

Scmd - Simple yet powerful command-line arguments parser for scala.

Join the chat at https://gitter.im/simple-cmd/Lobby Build Status Maven Central

This project is under improvement. It needs your help to push it to maturity.

Built against scala versions: 2.11.11, 2.12.3

Nomenclature

  • Argument arg - general phrase of all below.
  • Command cmd - a predefined phrase that must be fully matched on the command-line to tell the app what to do.
  • Parameter param - argument without a name on the command-line, which directly matches the value. This is equivalent to "argument" in many other libraries.
  • Option opt - (optional) argument that has a name with hyphens precede: -f, --source-file
  • Properties prop(s) - an argument with a flag as its name, and values of key/value pairs: -Dkey=value
  • PriorArg prior - an argument with alias that has priority to be matched. -help, --help

Motivation:

If you ask google "scala command line arguments parser github", google gives you a whole page of answers. Some libraries said: "Life is too short to parse command-line arguments". I think it might be "Life is too short to swing from one command-line argument parser to another". I tried many of these parsers, some are complicated with hard-to-read README, some kind of lack features that fit into some cases, some do not generate usage info...

Scmd brings these features all-in-one:

Fetures example
Boolean option folding -xyz
Option Value folding -fValue
Option Value evaluation -f=Value
Trailing option cp -r SRC DST equivalent to cp SRC DST -r
Mutual exclusion --start ❘ --stop
Mutual dependency [-a -b] or [-a [-b]]
Validation cp SRC DST SRC must exist/etc.
Typed argument cp SRC DST SRC is File or Path
Argument optionality SRC [DST]
Variable argument SRC... DST
Properties -Dkey=value, -Dkey:value
Sub commands and argument hierarchy openstack nova list service
Optional commands command [<command1> <command2>]
Routing no manually writing if .. else or match case to route command
Contextual help preciser help info
Typo correction git comit triggers: did you mean: 'commit'
Usage info generation see pictures below

Goals I'm trying to achieve:

  • Simplicity - Clients would be able to use it with little effort. More complex functionality should be possible and addable.
  • Versatility - It has to be powerful enough to tackle those tricky things.
  • Beauty - It should provide fluent coding style; It should be able to generate formatted usage console info.
  • Strictness - As a library, it should be well structured, documented and self-encapsulated.

openstack-help cp-help

Minimal example:

First, define the arguments in def-class:

import java.io.File
import Scmd._
@ScmdDef
private class CpDef(args: Seq[String]) extends ScmdDefStub[CpDef]{ //app name 'cp' is inferred from CpDef
    val SRC = paramDefVariable[File]().mandatory
    val DEST = paramDef[File]().mandatory
    val recursive = optDef[Boolean](abbr = "R")
}

This definition matches cmd-line argument combinations below:

$cp file1 file2 ... dest       //backtracking: dest always matched.
$cp file1 dest -R              
$cp -recursive file1 dest      //option can be put at anywhere.
...

Then use them:

object CpApp extends App{
    val conf = (new CpDef(args)).parse
    import scmdValueConverter._
    conf.SRC.value // Seq[File]
    conf.DEST.value // File
    conf.recursive.value // Boolean
}

Document:

Project setup:

Scmd depends on scalameta at compile time.

val macroAnnotationSettings = Seq(
  addCompilerPlugin("org.scalameta" % "paradise" % "3.0.0-M10" cross CrossVersion.full),
  scalacOptions += "-Xplugin-require:macroparadise",
  scalacOptions in(Compile, console) ~= (_ filterNot (_ contains "paradise")),
  libraryDependencies += "org.scalameta" %% "scalameta" % "1.8.0" % Provided
)
val yourProject = project
  .settings(macroAnnotationSettings)
  .settings(
    libraryDependencies ++= Seq(
      "com.github.cuzfrog" %% "scmd" % "0.1.2"
    )
  )

For simplicity, Scmd is not divided into multiple projects. If one seriously demands smaller runtime jar size, please fire an issue or manually exclude package com.github.cuzfrog.scmd.macros.

Define arguments:

  1. name is from val's name.
val nova = cmdDef(description = "nova command entry") //the command's name is nova
val Nova = cmdDef() //another cmd  (cmd name is case-sensitive)
val remotePort = optDef[Int]() //matches `--remote-port` and `--remotePort`
//val RemotePort = optDef[Int]()  //won't compile, name conflicts are checked at compile time.

The description cannot be omitted.

  1. param/opt/props are typed:
val port = optDef[Int](abbr = "p", description = "manually specify tcp port to use") //type is Int

See Supported types. Put custom evidence into def-class to support more types.

  1. mandatory argument:
val srcFile = paramDef[Path](description = "file to send.").mandatory
  1. argument with default value:
val name = optDef[String]().withDefault("default name")

Mandatory argument cannot have default value. Boolean args have default value of false, which could be set to true manually. Boolean args cannot(should not) be mandatory.

  1. variable argument(repetition):
val SRC = paramDefVariable[File]().mandatory
val num = optDefVariable[Long](abbr = "N")

-N=1 --num 2 --num=3 -N4 -N 5 is legal, num will have the value of Seq(1,2,3,4,5)

  1. properties(argument with flag):
val properties = propDef[Int](flag = "D",description = "this is a property arg.")

-Dkey1=1 -Dkey2=3 gives properties the value Seq("key1"->1,"key2"->3) Props are global argument, that means they can be matched from anywhere on the command-line arguments.

  1. prior argument:
val help = priorDef(alias = Seq("-help", "--help")) //fully matches `-help` and `--help`

Prior args are scoped to cmd: git --help prints the help info for git, git tag --help prints for tag Prior args are picked immediately. cp --help SRC DST prints the help info, SRC and DST are ignored.

Built up arguments structure.

Argument definition is of the shape of a tree, params/opts are scoped to their cmds.

  1. Inferred from declaration. The definition order matters in @ScmdDef annotated Class.
val sharedParam = paramDef[String](description = "this should be shared by cmds below.")
val topLevelOpt = optDef[Int]()
val nova = cmdDef(description = "nova command entry")
val param1 = paramDef[File]()
val opt1 = optDef[Int]()
val neutron = cmdDef(description = "neutron command entry")
val opt2 = optDef[Int]()

This will build the structure:

openstack
    +-topLevelOpt
    +-nova
          +-sharedParam
          +-param1
          +-opt1
    +-neutron
          +-sharedParam
          +-opt2
  1. Using tree building DSL:
import scmdTreeDefDSL._
argTreeDef( //app entry
  verbose, //opt
  nova(
    list(service ?, project)//cmds under list are optional.
    //equivalent to list(service ?, project ?)
  ),
  neutron(
    alive | dead, // --alive | --dead, mutual exclusion
    list(service, project) ?
    //cmd list is optional. but once list is entered, one of its sub-cmds is required.
  ),
  cinder(
    list(service, project), //every level of cmds is required.
  )
)

This will build the structure:

openstack
    +-verbose
    +-nova
          +-list
              +-service
              +-project
    +-neutron
          +-alive
          +-dead
          +-list
              +-service
              +-project
    +-cinder
          +-list
              +-service
              +-project

Notice, service, project and list are reused in the DSL.

Tree's legality is checked at compile time by macros.

Validation.

This refers to argument basic(low-level) validation. Mutual limitation is defined above, and its validation is implicit.

@ScmdValid
class CatValidation(argDef: CatDef) {
  validation(argDef.files) { files =>
    files.foreach { f =>
      if (!f.toFile.exists()) throw new IllegalArgumentException(s"$f not exists.")
    }
  }
}
val conf = (new CatDef(args)).withValidation(new CatValidation(_)).parsed

Arguments will be checked by validation statements when they are evaluated(parsed from cmd-line args).

Use parsed values.

Scmd provides 2 styles of getting evaluated arguments:

  1. Implicit conversion:
import scmdValueImplicitConversion._
val src:Seq[File] = conf.SRC 
val dst:File = conf.DST //mandatory arg will be converted into value directly.
val port:Option[Int] = conf.remotePort //optional arg will be converted into an Option

If an arg has default value, it will fall back to default value when not specified by user.

  1. Converter(Recommended):
import scmdValueConverter._
val src:Seq[File] = conf.SRC.value 
val dst:File = conf.DST.value
val port:Option[Int] = conf.remotePort.value

Routing.

Manual routing: if(conf.cmd.met){...} else {...}

Scmd provides a DSL similar to that of akka-http to route through commands:

def buildRoute(argDef: ArgDef): ArgRoute = {
    import scmdRouteDSL._
    import argDef._
    import scmdValueConverter._
    app.onConditions(
        boolOpt.expectTrue,
        properties.expectByKey("key1")(_.forall(_ > 6))
    ).run {
        println(intOpt.value)
        //do something
    } ~
    app.run {
        //fall back behavior.
    }
}
(new ArgDef(args)).runWithRoute(buildRoute)

~ links routes together that if the first route's conditions are not met, then the second route is tried, until the statements inside run are called, the route continues to try through. Once a run is done, the whole route ends. If one does not want the whole route to end after one of the run finishes, use runThrough instead.

Customization.

  1. Customize argument exception handling.
implicit val customHandler: ScmdExceptionHandler[ScmdException] =
    new ScmdExceptionHandler[ScmdException] {
      override def handle(e: ScmdException): Nothing = e match {
        case ex: ArgParseException => //...handle
        case ex: ArgValidationException => //...handle
      }
    }

Put evidence above inside def-class or suitable scope.

Misc.

  1. limitations:
  • Reusing arg-def in tree-building-DSL: arg cannot duplicate through lineage. Duplication through lineage makes it possibly ambiguous for an argument's scope. This makes features, like trailing option, very hard to implement.
  1. built-in priors: help and version are built-in prior arguments. When they are matched against top cmd(app itself), usage info and version info will be printed respectively. The alias of them will be matched only, i.e. -help, --help

define them in route against top cmd will override the default behavior.

import scmdRouteDSL._
app.runOnPrior(help){
  //different behavior
}.run{...}
  1. ScmdDefStub[T] ScmdDefStub[T] contains some abstract public methods for IDE to recognize the api, which are stripped off during macro expansion. That means extends ScmdDefStub[T] could be omitted without errors.

About

Thanks:

This project is inspired by mow.cli. Ansi formatting is modified from backuity/clist.

Developer:

See: Internal explanation.

Contribution is welcomed.

Author:

Cause Chung (cuzfrog@139.com)/(cuzfrog@gmail.com)