zinking
zinking

Reputation: 5685

how to parse generic case class fields using scala option parser?

I have a case class includes around 20 fields , all of which are primitive types.

case class A( f1: String, f2: Int .....)

and I have to parse these fields all from command line (unfortunately). I can , but I really don't want to write this 20 times

opt[String]("f1") required() valueName "<f1>" action { (x, c) =>
    c.copy(f1 = x)
  } text "f1 is required"
//...repeat 20 times

I can obtain the field name and filed type through reflection, but I have no idea how to stuck those information to this call within a for loop

I can connect this with shapeless but I'm still not familiar with that and can this be done without shapeless ?

==

scala option parser => scopt

Upvotes: 5

Views: 1319

Answers (2)

thibr
thibr

Reputation: 617

Here is a version implemented with runtime reflection only. While it's less elegant than a macro based solution, it only requires the scala-reflect.jar:

libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value

Code:

import scala.collection.mutable
import scala.reflect.runtime.universe._

def genericParser[T: TypeTag](programName: String): OptionParser[T] = new OptionParser[T](programName) {
  val StringTpe: Type = typeOf[String]

  val fields: List[MethodSymbol] = typeOf[T].decls.sorted.collect {
    case m: MethodSymbol if m.isCaseAccessor ⇒ m
  }

  val values = mutable.Map.empty[TermName, Any]

  /**
    * Returns an instance of a [[scopt.Read]] corresponding to the provided type
    */
  def typeToRead(tpe: Type): Read[Any] = (tpe match {
    case definitions.IntTpe ⇒ implicitly[Read[Int]]
    case StringTpe          ⇒ implicitly[Read[String]]
      // Add more types if necessary...
  }) map identity[Any]

  for (f ← fields) {
    // kind of dynamic implicit resolution
    implicit val read: Read[Any] = typeToRead(f.returnType)
    opt[Any](f.name.toString) required() valueName s"<${f.name}>" foreach { value ⇒
      values(f.name) = value
    } text s"${f.name} is required"
  }

  override def parse(args: Seq[String], init: T): Option[T] = {
    super.parse(args, init) map { _ ⇒
      val classMirror = typeTag[T].mirror.reflectClass(typeOf[T].typeSymbol.asClass)
      val constructor = typeOf[T].decl(termNames.CONSTRUCTOR).asMethod
      val constructorMirror = classMirror.reflectConstructor(constructor)
      val constructorArgs = constructor.paramLists.flatten.map(symbol ⇒ values(symbol.asTerm.name))

      constructorMirror(constructorArgs: _*).asInstanceOf[T]
    }
  }
}

Example usage:

case class A(f1: String, f2: Int)

println(genericParser[A]("main").parse(args, A("", -1)))

A few things to take in consideration:

  • The parameters are stored in a mutable Map when they are parsed. The case class conversion in performed in a final step using the class constructor (copy method is not involved).
  • As a consequence, the initial value passed in the parse method is not used at all (but it should not matter since all arguments are required).
  • You must tweak the code to support different types of arguments, acccording to your need (the types of your case class values). I only added String and Int (See the Add more types if necessary... comment).

Upvotes: 2

Stephen Carman
Stephen Carman

Reputation: 997

I just noticed you wanted no libraries like shapeless. If it's any consolation this is a library that will replace scala reflect macros eventually, so it's about as close as pure scala you'll get without reinventing the wheel.

I think I may have something that might help with this. It's a kind of heavy solution but I think it will do what you are asking.

This uses the fantastic scalameta (http://www.scalameta.org) library in order to create a static annotation. You will annotate your case class and this inline macro will then generate the appropriate scopt parser for your command line args.

Your build.sbt is going to need the macro paradise plugin as well as the scalameta library. You can add these to your project with.

addCompilerPlugin("org.scalameta" % "paradise" % paradise cross CrossVersion.full)
libraryDependencies ++= Seq(
    "org.scalameta" %% "scalameta" % meta % Provided,
)

Once you have added those deps to your build you will have to create a separate project for you macros.

A complete SBT project definition would look like

lazy val macros = project
  .in(file("macros"))
  .settings(
    addCompilerPlugin("org.scalameta" % "paradise" % paradise cross CrossVersion.full),
    libraryDependencies ++= Seq(
      "org.scalameta" %% "scalameta" % "1.8.0" % Provided,
    )
   )

If the module itself is named "macros", then create a class and here is the static annotation.

import scala.annotation.{StaticAnnotation, compileTimeOnly}
import scala.meta._

@compileTimeOnly("@Opts not expanded")
class Opts extends StaticAnnotation {
  inline def apply(defn: Any): Any = meta {
    defn match {
      case q"..$mods class $tname[..$tparams] ..$ctorMods (...$paramss) extends $template" =>
        val opttpe = Type.Name(tname.value)
        val optName = Lit.String(tname.value)
        val opts = paramss.flatten.map {
          case param"..${_} $name: ${tpeopt: Option[Type]} = $expropt" =>
            val tpe = Type.Name(tpeopt.get.toString())
            val litName = Lit.String(name.toString())
            val errMsg = Lit.String(s"${litName.value} is required.")
            val tname = Term.Name(name.toString())
            val targ = Term.Arg.Named(tname, q"x")
            q"""
                opt[$tpe]($litName)
                  .required()
                  .action((x, c) => c.copy($targ))
                  .text($errMsg)
            """
        }
        val stats = template.stats.getOrElse(Nil) :+ q"def options: OptionParser[$opttpe] = new OptionParser[$opttpe]($optName){ ..$opts }"
        q"""..$mods class $tname[..$tparams] ..$ctorMods (...$paramss) {
            import scopt._
            ..$stats
        }"""
    }
  }
}

After that you will make your main module depend on your macros module. Then you can annotate your case classes like so...

@Opts
case class Options(name: String, job: String, age: Int, netWorth: Double, job_title: String)

This will then at compile time expand your case class to include the scopt definitions. Here is what a generated class looks like from above.

case class Options(name: String, job: String, age: Int, netWorth: Double, job_title: String) {
  import scopt._

  def options: OptionParser[Options] = new OptionParser[Options]("Options") {
    opt[String]("name").required().action((x, c) => c.copy(name = x)).text("name is required.")
    opt[String]("job").required().action((x, c) => c.copy(job = x)).text("job is required.")
    opt[Int]("age").required().action((x, c) => c.copy(age = x)).text("age is required.")
    opt[Double]("netWorth").required().action((x, c) => c.copy(netWorth = x)).text("netWorth is required.")
    opt[String]("job_title").required().action((x, c) => c.copy(job_title = x)).text("job_title is required.")
  }
}

This should save you a ton of boiler plate, and for anyone with more knowledge of inline macros please feel free to tell me how I could write this better, since I am not an expert on this.

You can find the appropriate tutorial and documentation about this at http://scalameta.org/tutorial/#Macroannotations I'm also happy to answer any questions you might have about this approach!

Upvotes: 3

Related Questions