Created
March 31, 2023 15:24
-
-
Save ryanmiville/a1aa78fc1934c33f6847fb568c9b74c7 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//> 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