jfkelley / argyle   1.0.0

GitHub

Scala command-line arguments parser

Scala versions: 2.12

Argyle

Command-line argument parsing for Scala

Example:

import com.joefkelley.argyle._
import scala.util.{Try, Success, Failure}

object Example extends App {

  val a = (
    required[String]("--name", "-n") and
    optional[String]("--occupation") and
    required[Int]("--age") and
    repeated[String]("--hobby") and
    requiredOneOf("--single" -> Single, "--married" -> Married) and
    optional[Int]("--fingers").default(10) and
    flag("--debug") and
    optionalBranch[Pet](
      "--pet-rock" -> required[String]("--rock-name").to[Rock],
      "--pet-dog"  -> (required[String]("--dog-breed") and required[String]("--dog-name")).to[Dog]
    ) and
    (requiredOneOf("--male" -> Male, "--female" -> Female) xor required[String]("--gender").to[OtherGender])
  ).to[PersonConfig]

  a.parse(args) match {
    case Success(person) => println(person)
    case Failure(e) => throw e
  }

  case class PersonConfig(
      name: String,
      occupation: Option[String],
      age: Int,
      hobbies: List[String],
      maritalStatus: MaritalStatus,
      nFingers: Int,
      likesToDebug: Boolean,
      pet: Option[Pet],
      gender: Gender)

  sealed trait MaritalStatus
  object Single extends MaritalStatus
  object Married extends MaritalStatus

  trait Pet
  case class Dog(breed: String, name: String) extends Pet
  case class Rock(name: String) extends Pet

  sealed trait Gender
  object Male extends Gender
  object Female extends Gender
  case class OtherGender(str: String) extends Gender

}

SBT Dependency

libraryDependencies += "com.joefkelley" %% "argyle" % "1.0.0"

Background

Argyle is a simple tool for parsing command-line arguments. Its goals are:

  • Absolute minimum boilerplate
  • Type-safe
  • Flexible / Extensible
  • Minimal dependencies
  • Pure functional

It does not currently have the ability to print help/usage information. The hope is that the syntax is so concise and readable that the code is documentation enough. Including this feature would be a crutch that would allow compromising on the first goal ;)

Usage

Three steps:

  1. Define a case class containing all of the options you want to configure
  2. Construct a com.joefkelley.argyle.Arg[<config class>] by "and"-ing together more granular Args representing individual fields
  3. Call .parse(args) on that, returning a Try[<config class>]

Here's a simple example that requires two command-line arguments: "--name" and "--age":

case class Person(name: String, age: Int)
val nameArg = required[String]("--name")
val ageArg = required[Int]("--age")
val personArg = (nameArg and ageArg).to[Person]
val result: Try[Person] = personArg.parse(args)

Breaking this down line-by-line:

case class Person(name: String, age: Int)

This class will eventually contain all of the configuration options we want from the command line

val nameArg = required[String]("--name")

Creates an Arg[String] that will match arguments passed in the form "--name Joe" or similar. Note that because the arg is required, parsing will fail if it is not present.

val ageArg = required[Int]("--age")

The specified argument type will be the type of the resulting object that is eventually returned by the parser. In this case it is Int, so this creates an Arg[Int]. The signature of the required method is:

def required[A : Reader](keys: String*): Arg[A]

Note that the type requires an implicit typeclass com.joefkelley.argyle.Reader[A]. There are built-in values for all primitive types, Files, ISO-8601 Dates and Times, Lists (comma-separated), and Eithers. If you wish to use some different parsing logic, the Reader trait has a very simple interface that you can implement.

val personArg = (nameArg and ageArg).to[Person]

The and method here combines an Arg[String] and an Arg[Int] into an Arg[String :: Int :: HNil], an arg parser for a shapeless hlist. But there is no need to deal with shapeless directly. The .to[Person] call converts this to an Arg[Person]. Note that this is still type-safe; the types must match the fields of the Person class or it would not compile.

val result: Try[Person] = personArg.parse(args)

Finally, we call .parse(args), which will return a Success[Person] iff both "--name" and "--age" are supplied, the argument for "--age" is an integer, and there are no other unused arguments. Otherwise, it will return a Failure containing an appropriate error message. By default, the args are expected in the form "--name Joe --age 100", but equals-separated format can also be used by calling .parse(args, com.joefkelley.argyle.EqualsSeparated), for example "--name=Joe --age=100".

Full API Details

The com.joefkelley.argyle package object contains several methods for creating Args. They are:

def required[A : Reader](keys: String*): Arg[A]

Requires that one of the given keys must be present exactly once, and will fail otherwise.

def optional[A : Reader](keys: String*): Arg[Option[A]]

Requires that one of the given keys will be present at most once. Will result in a None if not present, a Some[A] if present, and fail if present more than once.

def repeated[A : Reader](keys: String*): Arg[List[A]]

Matches any one of the keys any number of times, resulting in a List[A]. For example, repeated[Int]("-n") would successfully parse "-n 1 -n 5 -n 10", resulting in List(1, 5, 10).

def repeatedAtLeastOnce[A : Reader](keys: String*): Arg[List[A]]

Same as above, but fails if not present at least once. Always results in a list with at least one element.

def requiredOneOf[A](kvs: (String, A)*): Arg[A]

A parser that matches exactly one of the keys in the key-value pairs, resulting in its corresponding value. Note that no value should be passed in the command-line arguments. For example, requiredOneOf("-a" -> 1, "-b" -> 2) would match just "-a", resulting in a value of 1. Fails if none of the keys are present.

def optionalOneOf[A](kvs: (String, A)*): Arg[Option[A]]

Same as above, except returns a None instead of failing if none are present, and returns a Some[A] if one is.

def flag(keys: String*): Arg[Boolean]

If any of the provided keys are present, results in true, otherwise false.

def requiredFree[A : Reader]: Arg[A]

Matches any argument. For example, requiredFree[String] and required[Int]("--n") would match "--n 5 foo". Fails if no extra args are present.

def optionalFree[A : Reader]: Arg[Option[A]]

Same as above, except returns a None instead of failing if none are present, and returns a Some[A] if one is.

def repeatedFree[A : Reader]: Arg[List[A]]

Matches any number of arguments not matched by "keyed" arguments.

def repeatedAtLeastOnceFree[A : Reader]: Arg[List[A]]

Same as above, except fails if no arguments are present.

Note that the order of free arguments is important; they are matched greedily in order, but with back-tracking if neccessary. For example, repeatedFree[String] and requiredFree[String] will match all arguments except the last for the repeatedFree, and just the last for the requiredFree. If the order were reversed, the requiredFree would match the first argument, and the repeatedFree would match the rest.

Something like repeatedFree[String] and requiredFree[Int] should also successfully match "foo 5 bar", since back-tracking will eventually find that the requiredFree[Int] must match the "5", and the repeatedFree[String] will match everything else.

def requiredBranch[A](kvs: (String, Arg[A])*): Arg[A]

Allows branching behavior based on the presence of keys in the key-value pairs. "Activates" one of the passed args based on which key is present, and parses using that arg. For example: requiredBranch("-a" -> required[String]("--foo"), "-b" -> requiredFree[String]) would parse either "-a --foo hello" or "-b hello". Arbitrarily-complex args can be used, including nested branches or anything else. Note that the "branching" argument must be present in the command line arguments before the arguments parsed within that branch (i.e. "--foo hello -a" would not work).

def optionalBranch[A](kvs: (String, Arg[A])*): Arg[Option[A]]

Same as above, but does not fail if none of the branching keys are present.

def constant[A](a: A): Arg[A]

Does not consume or require any command-line arguments, just returns the value back. Useful for configuration options that can't be changed, or for simple cases of branching arguments.

The com.joefkelley.argyle.Arg[A] class itself contains methods for combining and modifying args. They are:

def flatMap[B](f: A => Try[B]): Arg[B]

If parsing succeeds, applies f to the result, and succeeds if the result is a success, otherwise fails.

def map[B](f: A => B): Arg[B] = flatMap(a => Success(f(a)))

If parsing succeeds, applies f to the result and returns the output.

def as[B](implicit f: A => B): Arg[B] = map(f)

Syntactic sugar for map for cases when an implicit conversion A => B is possible.

def xor[B >: A](arg2: Arg[B]): Arg[B]

Returns a new Arg that succeeds if either this, or the passed in arg is present and succeeds. Notably fails if they are both present. For example, required[Int]("-n").xor(required[Int]("-m")) would return 1 for both "-n 1" and "-m 1", but would fail for "-n 1 -m 1".

def or[B >: A](arg2: Arg[B], f: (B, B) => B): Arg[B]

Same as xor, except does not fail if both are present. Instead, calls f with the output from both. (In the order f(thisOutput, arg2Output)).

def default[B](b: B)(implicit ev: A <:< Option[B]): Arg[B]

For arguments that result in an Option[A], such as those from optional[A](...), promotes it from an Arg[Option[A]] to an Arg[A] by providing a default result value for when the argument is not present.

and

The signature for the and method is a bit more complex because it involves a small amount of shapeless magic. If a is an Arg[A], and b is an Arg[B], and A is not an HList, then a and b returns an Arg[A :: B :: HNil]. However, if A is an HList, then the resulting Arg has B appended to the end of A. If this was not the case, then a and b and c would return an Arg[(A :: B :: HNil) :: C :: HNil], which is subtly different than the expected Arg[A :: B :: C :: HNil] that is actually returned.

to

The arg.to[MyClass] is useful to avoid dealing with shapeless HLists directly. Can be used if the type of arg is an HList and the output class's fields match exactly the types of the HList.

The Arg class is meant to be extensible

Custom parsing logic can added by implementing the Arg trait's two abstract methods:

sealed abstract class VisitResult[+A]
case class VisitError(msg: String) extends VisitResult[Nothing]
case class VisitConsume[+A](next: Arg[A], remaining: List[String]) extends VisitResult[A]
object VisitNoop extends VisitResult[Nothing]

trait Arg[+A] {

  def visit(xs: NonEmptyList[String], mode: ArgMode): Seq[VisitResult[A]]

  def complete: Try[A]

}

The visit method will be called multiple times, passing in smaller and smaller subsets of the command-line arguments. Most often, the returned Seq should contain a single element. If the head of the passed list is not relevant to the Arg, it should return VisitNoop. If the head is relevant, but incorrect or unexpected in some way, it should return VisitError. If the head is relevant and correct, A VisitConsume should be returned containing a new Arg to use for parsing instead of this from here on out, and the remaining unmatched/unconsumed part of the arguments. If you do not wish to use a purely-functional style, you may instead mutate the Arg and return this. If it is unclear which to return, depending on the rest of the arguments, and you wish to allow back-tracking to find the correct option, return a Seq with multiple elements. The back-tracking procedure has the possibility of becoming exponential, so putting more likely outcomes first can vastly improve runtime.

The branching arg class is a good simple example implementation:

class BranchArg[A](branch: Map[String, Arg[A]]) extends Arg[Option[A]] {
  override def visit(xs: NonEmptyList[String], mode: ArgMode): Seq[VisitResult[Option[A]]] = xs match {
    case NonEmptyList(x, rest) => branch.get(x) match {
      case Some(arg) => Seq(VisitConsume(arg.map(a => Some(a)), rest))
      case None => Seq(VisitNoop)
    }
  }
  override def complete: Try[Option[A]] = Success(None)
}

And the repeated free arg class is a good example of returning multiple VisitResults for back-tracking:

case class RepeatedFreeArg[A](
    parse: String => Try[A], collected: List[String] = Nil) extends Arg[List[A]] {
  override def visit(xs: NonEmptyList[String], mode: ArgMode): Seq[VisitResult[List[A]]] = xs match {
    case NonEmptyList(head, rest) => Seq(VisitNoop, VisitConsume(this.copy(collected = collected :+ head), rest))
  }
  override def complete: Try[List[A]] = Utils.sequence(collected.map(parse))
}

An instance of the Reader typeclass is required for parsing individual argument values from strings to specific types

This is the one section of code that is somewhat less type-safe (it's "stringly-typed"), so it is isolated and very simple. Default instances are provided for: String, Boolean, Byte, Short, Int, Long, Float, Double, Char, java.time.Duration, scala.concurrent.duration.Duration, File, Path, LocalDate, LocalTime, LocalDateTime (all ISO 8601), List[A : Reader] (comma-separated), and Either[A : Reader, B : Reader].

If you wish to parse to some other class, this is also extensible. The trait to implement is:

trait Reader[A] {
  def apply(s: String): Try[A]
}

Built-in instances are, unsurprisingly, very simple examples to follow:

implicit val IntParser = new Reader[Int] { def apply(s: String) = Try(s.toInt) }