Howto model named parameters in method invocations with Scala macros?

scala, scala-macros

Solution

Here's an implementation that's also a little more generic:

import scala.language.experimental.macros

object WithIdExample {
  import scala.reflect.macros.Context

  def withId[T, I](entity: T, id: I): T = macro withIdImpl[T, I]

  def withIdImpl[T: c.WeakTypeTag, I: c.WeakTypeTag](c: Context)(
    entity: c.Expr[T], id: c.Expr[I]
  ): c.Expr[T] = {
    import c.universe._

    val tree = reify(entity.splice).tree
    val copy = entity.actualType.member(newTermName("copy"))

    val params = copy match {
      case s: MethodSymbol if (s.paramss.nonEmpty) => s.paramss.head
      case _ => c.abort(c.enclosingPosition, "No eligible copy method!")
    }

    c.Expr[T](Apply(
      Select(tree, copy),
      params.map {
        case p if p.name.decoded == "id" => reify(id.splice).tree
        case p => Select(tree, p.name)
      }
    ))
  }
}

It'll work on any case class with a member named `id`, no matter what its type is:

scala> case class Bar(arg0: String, id: Option[Int])
defined class Bar

scala> case class Foo(x: Double, y: String, id: Int)
defined class Foo

scala> WithIdExample.withId(Bar("bar", None), Some(2))
res0: Bar = Bar(bar,Some(2))

scala> WithIdExample.withId(Foo(0.0, "foo", 1), 2)
res1: Foo = Foo(0.0,foo,2)

If the case class doesn't have an `id` member, `withId` will compile—it just won't do anything. If you want a compile error in that case, you can add an extra condition to the match on `copy`.

Edit: As Eugene Burmako just pointed out on Twitter, you can write this a little more naturally using `AssignOrNamedArg` at the end:

c.Expr[T](Apply(
  Select(tree, copy),
  AssignOrNamedArg(Ident("id"), reify(id.splice).tree) :: Nil
))

This version won't compile if the case class doesn't have an `id` member, but that's more likely to be the desired behavior anyway.

Problem

There are use cases where it is useful to create a copy of an object which is an instance of a case class of a set of case classes, which have a specific value in common. For example let's consider the following case classes: ``` case class Foo(id: Option[Int]) case class Bar(arg0: String, id: Option[Int]) case class Baz(arg0: Int, id: Option[Int], arg2: String) ``` Then `copy` can be called on each of these case class instances: ``` val newId = Some(1) Foo(None).copy(id = newId) Bar("bar", None).copy(id = newId) Baz(42, None, "baz").copy(id = newId) ``` As described here and here there is no simple way to abstract this like this: ``` type Copyable[T] = { def copy(id: Option[Int]): T } // THIS DOES *NOT* WORK FOR CASE CLASSES def withId[T <: Copyable[T]](obj: T, newId: Option[Int]): T = obj.copy(id = newId) ``` So I created a scala macro, which does this job (almost): ``` import scala.reflect.macros.Context object Entity { import scala.language.experimental.macros import scala.reflect.macros.Context def withId[T](entity: T, id: Option[Int]): T = macro withIdImpl[T] def withIdImpl[T: c.WeakTypeTag](c: Context)(entity: c.Expr[T], id: c.Expr[Option[Int]]): c.Expr[T] = { import c.universe._ val currentType = entity.actualType // reflection helpers def equals(that: Name, name: String) = that.encoded == name || that.decoded == name def hasName(name: String)(implicit method: MethodSymbol) = equals(method.name, name) def hasReturnType(`type`: Type)(implicit method: MethodSymbol) = method.typeSignature match { case MethodType(_, returnType) => `type` == returnType } def hasParameter(name: String, `type`: Type)(implicit method: MethodSymbol) = method.typeSignature match { case MethodType(params, _) => params.exists { param => equals(param.name, name) && param.typeSignature == `type` } } // finding method entity.copy(id: Option[Int]) currentType.members.find { symbol => symbol.isMethod && { implicit val method = symbol.asMethod hasName("copy") && hasReturnType(currentType) && hasParameter("id", typeOf[Option[Int]]) } } match { case Some(symbol) => { val method = symbol.asMethod val param = reify(( c.Expr[String](Literal(Constant("id"))).splice, id.splice)).tree c.Expr( Apply( Select( reify(entity.splice).tree, newTermName("copy")), List( /*id.tree*/ ))) } case None => c.abort(c.enclosingPosition, currentType + " needs method 'copy(..., id: Option[Int], ...): " + currentType + "'") } } } ``` The last argument of `Apply` (see bottom of above code block) is a List of parameters (here: parameters of method 'copy'). How can the given `id` of type `c.Expr[Option[Int]]` be passed as named parameter to the copy method with the help of the new macro API? In particular the following macro expression ``` c.Expr( Apply( Select( reify(entity.splice).tree, newTermName("copy")), List(/*?id?*/))) ``` should result in ``` entity.copy(id = id) ``` so that the following holds ``` case class Test(s: String, id: Option[Int] = None) // has to be compiled by its own object Test extends App { assert( Entity.withId(Test("scala rulz"), Some(1)) == Test("scala rulz", Some(1))) } ``` The missing part is denoted by the placeholder `/*?id?*/`.

Original source

Related problems