Skip to content

Macro-based automatic derivation results differ based on how you construct a typeclass instance #16835

Open
@arainko

Description

@arainko

Compiler version

3.2.1, 3.2.2, 3.3.0-RC2, 3.3.1-RC1-bin-20230204-a356581-NIGHTLY

Minimized code

import scala.quoted.*
import scala.deriving.Mirror

// derivation code is a slightly modified version of: https://github.com/lampepfl/dotty-macro-examples/blob/main/macroTypeClassDerivation/src/macro.scala
object Derivation {

  // Typeclass instance gets constructed as part of a macro
  inline given deriveFullyConstrucedByMacro[A](using Mirror.ProductOf[A]): Show[A] = Derivation.deriveShow[A]

  // Typeclass instance is built inside as part of a method, only the 'show' impl is filled in by a macro
  inline given derivePartiallyConstructedByMacro[A](using Mirror.ProductOf[A]): Show[A] =
    new {
      def show(value: A): String = Derivation.show(value)
    }

  inline def show[T](value: T): String = ${ showValue('value) }

  inline def deriveShow[T]: Show[T] = ${ deriveCaseClassShow[T] }

  private def deriveCaseClassShow[T](using quotes: Quotes, tpe: Type[T]): Expr[Show[T]] = {
    import quotes.reflect.*
    // Getting the case fields of the case class
    val fields: List[Symbol] = TypeTree.of[T].symbol.caseFields

    '{
      new Show[T] {
        override def show(t: T): String =
          ${ showValue('t) }
      }
    }
  }

  def showValue[T: Type](value: Expr[T])(using Quotes): Expr[String] = {
    import quotes.reflect.*

    val fields: List[Symbol] = TypeTree.of[T].symbol.caseFields

    val vTerm: Term = value.asTerm
    val valuesExprs: List[Expr[String]] = fields.map(showField(vTerm, _))
    val exprOfList: Expr[List[String]] = Expr.ofList(valuesExprs)
    '{ "{ " + $exprOfList.mkString(", ") + " }" }
  }

  /** Create a quoted String representation of a given field of the case class */
  private def showField(using Quotes)(caseClassTerm: quotes.reflect.Term, field: quotes.reflect.Symbol): Expr[String] = {
    import quotes.reflect.*

    val fieldValDef: ValDef = field.tree.asInstanceOf[ValDef]
    val fieldTpe: TypeRepr = fieldValDef.tpt.tpe
    val fieldName: String = fieldValDef.name

    val tcl: Term = lookupShowFor(fieldTpe) // Show[$fieldTpe]
    val fieldValue: Term = Select(caseClassTerm, field) // v.field
    val strRepr: Expr[String] = applyShow(tcl, fieldValue).asExprOf[String]
    '{ ${ Expr(fieldName) } + ": " + $strRepr } // summon[Show[$fieldTpe]].show(v.field)
  }

  /** Look up the Show[$t] typeclass for a given type t */
  private def lookupShowFor(using Quotes)(t: quotes.reflect.TypeRepr): quotes.reflect.Term = {
    import quotes.reflect.*
    t.asType match {
      case '[tpe] =>
        Implicits.search(TypeRepr.of[Show[tpe]]) match {
          case res: ImplicitSearchSuccess   => res.tree
          case failure: DivergingImplicit   => report.errorAndAbort(s"Diverving: ${failure.explanation}")
          case failure: NoMatchingImplicits => report.errorAndAbort(s"NoMatching: ${failure.explanation}")
          case failure: AmbiguousImplicits  => report.errorAndAbort(s"Ambiguous: ${failure.explanation}")
          case failure: ImplicitSearchFailure =>
            report.errorAndAbort(s"catch all: ${failure.explanation}")
        }
    }
  }

  /** Composes the tree: $tcl.show($arg) */
  private def applyShow(using Quotes)(tcl: quotes.reflect.Term, arg: quotes.reflect.Term): quotes.reflect.Term = {
    import quotes.reflect.*
    Apply(Select.unique(tcl, "show"), arg :: Nil)
  }
}
import scala.deriving.*

trait Show[A] {
  def show(value: A): String
}

object Show {
  given identity: Show[String] = a => a

  given int: Show[Int] = _.toString()

  given list[A](using A: Show[A]): Show[List[A]] = _.map(A.show).toString()
}

object usage {
  final case class Person(name: String, age: Int, otherNames: List[String], p2: Person2)

  final case class Person2(name: String, age: Int, otherNames: List[String])

  locally {
    import Derivation.deriveFullyConstrucedByMacro
    // works for case classes without other nested case classes inside
    summon[Show[Person2]]

    // also derives instances with nested case classes
    summon[Show[Person]]
  }

  locally {
    import Derivation.derivePartiallyConstructedByMacro

    // works for case classes without other nested case classes inside
    summon[Show[Person2]]

    // fails for case classes with other nested case classes inside,
    // note how that error is not a `NonMatching', `Diverging` or `Ambiguous` implicit search error but something else
    /*
    catch all: given instance deriveWithConstructionOutsideMacro in object Derivation does not match type io.github.arainko.ducktape.issue_repros.Show[Person2]
    */
    summon[Show[Person]]
  }
}

Output

catch all: given instance derivePartiallyConstructedByMacro in object Derivation does not match type io.github.arainko.ducktape.issue_repros.Show[
  io.github.arainko.ducktape.issue_repros.usage.Person2
]

Expectation

These two versions of automatic derivation should be equivalent or a less cryptic error message

Workarounds

If we alter the definition of Derivation.lookupShowFor to include a splice of summonInline[Show[tpe]] instead of aborting, the derivation will succeed in all of the cases:

/** Look up the Show[$t] typeclass for a given type t */
  private def lookupShowFor(using Quotes)(t: quotes.reflect.TypeRepr): quotes.reflect.Term = {
    import quotes.reflect.*
    t.asType match {
      case '[tpe] =>
        Implicits.search(TypeRepr.of[Show[tpe]]) match {
          case res: ImplicitSearchSuccess   => res.tree
          case failure: DivergingImplicit   => report.errorAndAbort(s"Diverving: ${failure.explanation}")
          case failure: NoMatchingImplicits => report.errorAndAbort(s"NoMatching: ${failure.explanation}")
          case failure: AmbiguousImplicits  => report.errorAndAbort(s"Ambiguous: ${failure.explanation}")
          case failure: ImplicitSearchFailure =>
            '{ compiletime.summonInline[Show[tpe]] }.asTerm
        }
    }
  }

But that severely limits what you can do with an AST like that moving forward (you can't really inspect the trees that
get summoned, from my testing only after the whole tree is spliced summonInlines get replaced with actual trees that can be inspected) plus I'd kind of expect that Implicits.search will behave just like summonInline

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions