Skip to content

Instantly share code, notes, and snippets.

@ryanmiville
Created March 31, 2023 15:24
Show Gist options
  • Save ryanmiville/a1aa78fc1934c33f6847fb568c9b74c7 to your computer and use it in GitHub Desktop.
Save ryanmiville/a1aa78fc1934c33f6847fb568c9b74c7 to your computer and use it in GitHub Desktop.
//> using scala "2.12"
//> using dep "com.chuusai::shapeless:2.3.3"
//> using dep "org.scalameta::munit:0.7.29"
/**
* Parser is a type class for parsing command line arguments in the format
* '--key value' into a case class. The arguments can be in any order, and
* extra fields that are not in your case class will be ignored. The field
* name in your case class must match the argument exactly (except for the
* leading --)
* i.e. myField matches --myField, and my_field matches --my_field
*
* Parser will cast the value of an argument to the associated type if it is
* supported. If it is not supported, you will get a compile time error.
*
* Supported types:
* String
* Int
* Double
* Boolean
* Option of any supported type
*
* If an argument of type Option is not present, it will default to None.
* Adding support for more primitive types is trivial, and only requires the implementer
* to supply a function for parsing a String into your type. Look at the
* existing implementations for examples
*
* Parser is intended to allow for fast failure, and therefore can throw Exceptions
*
* Possible Exceptions:
* MissingArgumentError - an argument is missing and the correlating type is not an Option
* InvalidTypeError - the argument was found, but cannot be converted to the correct type
*
* Usage:
* case class Args(i: Int, s: String, o: Option[Boolean])
*
* def main(args: Array[String]): Unit = {
* val parsed = Parser[Args].parse(args)
* }
*/
trait Parser[A] {
def parse(args: Array[String]): A = parse(args.toList)
def parse(args: List[String]): A
}
object Parser {
import shapeless.labelled.{FieldType, field}
import shapeless.{::, HList, HNil, LabelledGeneric, Lazy, Witness}
sealed trait ParserError
case class MissingArgumentError(message: String) extends Exception(message) with ParserError
case class InvalidTypeError(message: String) extends Exception(message) with ParserError
def apply[A](implicit p: Lazy[Parser[A]]): Parser[A] = p.value
private def create[A](fn: List[String] => A): Parser[A] = new Parser[A] {
override def parse(args: List[String]): A = fn(args)
}
private def argParser[K <: Symbol, A](fn: String => A)(implicit witness: Witness.Aux[K]): Parser[FieldType[K, A]] = {
val name = witness.value.name
create { args =>
try {
val arg = args.dropWhile(_ != s"--$name").tail.head
field[K](fn(arg))
} catch {
case _: UnsupportedOperationException => throw MissingArgumentError(s"missing argument: --$name")
case _: NumberFormatException => throw InvalidTypeError(s"invalid type for argument: --$name")
case _: IllegalArgumentException => throw InvalidTypeError(s"invalid type for argument: --$name")
}
}
}
implicit def genericParser[A, R <: HList](
implicit generic: LabelledGeneric.Aux[A, R],
parser: Lazy[Parser[R]]
): Parser[A] =
create(args => generic.from(parser.value.parse(args)))
implicit def hlistParser[K <: Symbol, H, T <: HList](
implicit hParser: Lazy[Parser[FieldType[K, H]]],
tParser: Parser[T]
): Parser[FieldType[K, H] :: T] =
create(args => hParser.value.parse(args) :: tParser.parse(args))
implicit def optionParser[K <: Symbol, A](
implicit witness: Witness.Aux[K],
parser: Lazy[Parser[FieldType[K, A]]]
): Parser[FieldType[K, Option[A]]] = {
val name = witness.value.name
create { args =>
val arg = args.find(_ == s"--$name").map(_ => parser.value.parse(args).asInstanceOf[A])
field[K](arg)
}
}
implicit def stringParser[K <: Symbol](implicit witness: Witness.Aux[K]): Parser[FieldType[K, String]] =
argParser(s => s)
implicit def intParser[K <: Symbol](implicit witness: Witness.Aux[K]): Parser[FieldType[K, Int]] =
argParser(_.toInt)
implicit def doubleParser[K <: Symbol](implicit witness: Witness.Aux[K]): Parser[FieldType[K, Double]] =
argParser(_.toDouble)
implicit def booleanParser[K <: Symbol](implicit witness: Witness.Aux[K]): Parser[FieldType[K, Boolean]] =
argParser(_.toBoolean)
implicit def hnilParser: Parser[HNil] = create(_ => HNil)
}
class ParserSuite extends munit.FunSuite {
case class Args(str: String, i: Int, d: Double, b: Boolean, opt: Option[Int])
test ("parse arguments in the format '--key value' into a case class") {
val args = Array("--str", "test", "--i", "1", "--opt", "3", "--b", "true", "--d", "2.0")
val expected = Args("test", 1, 2.0, true, Some(3))
assertEquals(Parser[Args].parse(args), expected)
}
test("default missing optional fields to None") {
val args = Array("--str", "test", "--i", "1", "--b", "true", "--d", "2.0")
val expected = Args("test", 1, 2.0, true, None)
assertEquals(Parser[Args].parse(args), expected)
}
test("ignore extra arguments") {
val args = Array("--extra", "arg", "--str", "test", "--i", "1", "--opt", "3", "--b", "true", "--d", "2.0")
val expected = Args("test", 1, 2.0, true, Some(3))
assertEquals(Parser[Args].parse(args), expected)
}
test("exception when a required field is missing") {
val args = Array("test", "--i", "1", "--b", "true", "--d", "2.0")
val caught = intercept[Parser.MissingArgumentError] {
Parser[Args].parse(args)
}
assertEquals(caught.message, "missing argument: --str")
}
test("exception when an Int argument cannot be parsed") {
val args = Array("--str", "hello", "test", "--i", "one", "--b", "true", "--d", "2.0")
val caught = intercept[Parser.InvalidTypeError] {
Parser[Args].parse(args)
}
assertEquals(caught.message, "invalid type for argument: --i")
}
test("exception when a Double argument cannot be parsed") {
val args = Array("--str", "hello", "test", "--i", "1", "--b", "true", "--d", "two")
val caught = intercept[Parser.InvalidTypeError] {
Parser[Args].parse(args)
}
assertEquals(caught.message, "invalid type for argument: --d")
}
test("exception when a Boolean argument cannot be parsed") {
val args = Array("--str", "hello", "test", "--i", "1", "--b", "0", "--d", "2.2")
val caught = intercept[Parser.InvalidTypeError] {
Parser[Args].parse(args)
}
assertEquals(caught.message, "invalid type for argument: --b")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment