From adc885795a2d7ad69ec959f2eef9b15138ce9dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93lafur=20P=C3=A1ll=20Geirsson?= Date: Tue, 5 Jan 2021 14:04:29 +0100 Subject: [PATCH] Mix Scala 2 macros in MUnit Scala 3 module With this change, Scala 2.13 users will be able to depend on munit_3.0 artifacts instead of munit_2.13. I wasn't able to manually test this change because MUnit doesn't cross-build for 3.0.0-M1, which is the latest version supported by the `-Ytasty-reader` flag in 2.13.4. Once 2.13.5 is out, it will be easier ot make use of this feature. --- build.sbt | 21 +- .../munit/internal/MacroCompat.scala | 89 +++++++++ .../munit/internal/MacroCompat.scala | 89 +++++++++ website/blog/2021-01-05-macromix.md | 183 ++++++++++++++++++ 4 files changed, 368 insertions(+), 14 deletions(-) create mode 100644 website/blog/2021-01-05-macromix.md diff --git a/build.sbt b/build.sbt index c7e0665f..838a53d1 100644 --- a/build.sbt +++ b/build.sbt @@ -40,11 +40,7 @@ inThisBuild( testFrameworks := List( new TestFramework("munit.Framework") ), - useSuperShell := false, - scalacOptions ++= List( - "-target:jvm-1.8", - "-language:implicitConversions" - ) + useSuperShell := false ) ) @@ -187,15 +183,12 @@ lazy val munit = crossProject(JSPlatform, JVMPlatform, NativePlatform) } result.toList }, - libraryDependencies ++= { - CrossVersion.partialVersion(scalaVersion.value) match { - case Some((major, _)) if major != 2 => Nil - case _ => - List( - "org.scala-lang" % "scala-reflect" % scalaVersion.value - ) - } - } + libraryDependencies ++= List( + "org.scala-lang" % "scala-reflect" % { + if (isDotty.value) scala213 + else scalaVersion.value + } % Provided + ) ) .nativeConfigure(sharedNativeConfigure) .nativeSettings( diff --git a/munit/shared/src/main/scala-3.0.0-M2/munit/internal/MacroCompat.scala b/munit/shared/src/main/scala-3.0.0-M2/munit/internal/MacroCompat.scala index e9049620..d2cb203e 100644 --- a/munit/shared/src/main/scala-3.0.0-M2/munit/internal/MacroCompat.scala +++ b/munit/shared/src/main/scala-3.0.0-M2/munit/internal/MacroCompat.scala @@ -3,11 +3,23 @@ package munit.internal import munit.Clue import munit.Location import scala.quoted._ +import scala.language.experimental.macros +import scala.reflect.macros.blackbox.Context +import scala.reflect.macros.TypecheckException +import scala.reflect.macros.ParseException object MacroCompat { trait LocationMacro { inline implicit def generate: Location = ${ locationImpl() } + implicit def generate: Location = macro locationImplScala2 + } + + def locationImplScala2(c: Context): c.Tree = { + import c.universe._ + val line = Literal(Constant(c.enclosingPosition.line)) + val path = Literal(Constant(c.enclosingPosition.source.path)) + New(c.mirror.staticClass(classOf[Location].getName()), path, line) } def locationImpl()(using Quotes): Expr[Location] = { @@ -20,6 +32,7 @@ object MacroCompat { trait ClueMacro { inline implicit def generate[T](value: T): Clue[T] = ${ clueImpl('value) } + implicit def generate[T](value: T): Clue[T] = macro clueImplScala2 } def clueImpl[T: Type](value: Expr[T])(using Quotes): Expr[Clue[T]] = { @@ -29,6 +42,44 @@ object MacroCompat { '{ new Clue(${Expr(source)}, $value, ${Expr(valueType)}) } } + def clueImplScala2(c: Context)(value: c.Tree): c.Tree = { + import c.universe._ + import compat._ + val text: String = + if (value.pos != null && value.pos.isRange) { + val chars = value.pos.source.content + val start = value.pos.start + val end = value.pos.end + if ( + end > start && + start >= 0 && start < chars.length && + end >= 0 && end < chars.length + ) { + new String(chars, start, end - start) + } else { + "" + } + } else { + "" + } + def simplifyType(tpe: Type): Type = tpe match { + case TypeRef(ThisType(pre), sym, args) if pre == sym.owner => + simplifyType(TypeRef(NoPrefix, sym, args)) + case t => + // uncomment to debug: + // Printers.log(t)(Location.empty) + t.widen + } + val source = Literal(Constant(text)) + val valueType = Literal(Constant(simplifyType(value.tpe).toString())) + New( + TypeRef(NoPrefix, c.mirror.staticClass(classOf[Clue[_]].getName), List(value.tpe.widen)), + source, + value, + valueType + ) + } + trait CompileErrorMacro { inline def compileErrors(inline code: String): String = { val errors = scala.compiletime.testing.typeCheckErrors(code) @@ -42,6 +93,44 @@ object MacroCompat { s"error:${separator}${trimMessage}\n${error.lineContent}\n${indent}^" }.mkString("\n") } + def compileErrors(code: String): String = macro compileErrorsImplScala2 + } + + def compileErrorsImplScala2(c: Context)(code: c.Tree): c.Tree = { + import c.universe._ + val toParse: String = code match { + case Literal(Constant(literal: String)) => literal + case _ => + c.abort( + code.pos, + "cannot compile dynamic expressions, only constant literals.\n" + + "To fix this problem, pass in a string literal in double quotes \"...\"" + ) + } + + def formatError(message: String, pos: scala.reflect.api.Position): String = + new StringBuilder() + .append("error:") + .append(if (message.contains('\n')) "\n" else " ") + .append(message) + .append("\n") + .append(pos.lineContent) + .append("\n") + .append(" " * (pos.column - 1)) + .append("^") + .toString() + + val message: String = + try { + c.typecheck(c.parse(s"{\n$toParse\n}")) + "" + } catch { + case e: ParseException => + formatError(e.getMessage(), e.pos) + case e: TypecheckException => + formatError(e.getMessage(), e.pos) + } + Literal(Constant(message)) } } diff --git a/munit/shared/src/main/scala-3.0.0-M3/munit/internal/MacroCompat.scala b/munit/shared/src/main/scala-3.0.0-M3/munit/internal/MacroCompat.scala index 263bec9e..9a0b4388 100644 --- a/munit/shared/src/main/scala-3.0.0-M3/munit/internal/MacroCompat.scala +++ b/munit/shared/src/main/scala-3.0.0-M3/munit/internal/MacroCompat.scala @@ -3,11 +3,16 @@ package munit.internal import munit.Clue import munit.Location import scala.quoted._ +import scala.language.experimental.macros +import scala.reflect.macros.blackbox.Context +import scala.reflect.macros.TypecheckException +import scala.reflect.macros.ParseException object MacroCompat { trait LocationMacro { inline implicit def generate: Location = ${ locationImpl() } + implicit def generate: Location = macro locationImplScala2 } def locationImpl()(using Quotes): Expr[Location] = { @@ -18,8 +23,16 @@ object MacroCompat { '{ new Location(${Expr(path)}, ${Expr(startLine)}) } } + def locationImplScala2(c: Context): c.Tree = { + import c.universe._ + val line = Literal(Constant(c.enclosingPosition.line)) + val path = Literal(Constant(c.enclosingPosition.source.path)) + New(c.mirror.staticClass(classOf[Location].getName()), path, line) + } + trait ClueMacro { inline implicit def generate[T](value: T): Clue[T] = ${ clueImpl('value) } + implicit def generate[T](value: T): Clue[T] = macro clueImplScala2 } def clueImpl[T: Type](value: Expr[T])(using Quotes): Expr[Clue[T]] = { @@ -29,6 +42,44 @@ object MacroCompat { '{ new Clue(${Expr(source)}, $value, ${Expr(valueType)}) } } + def clueImplScala2(c: Context)(value: c.Tree): c.Tree = { + import c.universe._ + import compat._ + val text: String = + if (value.pos != null && value.pos.isRange) { + val chars = value.pos.source.content + val start = value.pos.start + val end = value.pos.end + if ( + end > start && + start >= 0 && start < chars.length && + end >= 0 && end < chars.length + ) { + new String(chars, start, end - start) + } else { + "" + } + } else { + "" + } + def simplifyType(tpe: Type): Type = tpe match { + case TypeRef(ThisType(pre), sym, args) if pre == sym.owner => + simplifyType(TypeRef(NoPrefix, sym, args)) + case t => + // uncomment to debug: + // Printers.log(t)(Location.empty) + t.widen + } + val source = Literal(Constant(text)) + val valueType = Literal(Constant(simplifyType(value.tpe).toString())) + New( + TypeRef(NoPrefix, c.mirror.staticClass(classOf[Clue[_]].getName), List(value.tpe.widen)), + source, + value, + valueType + ) + } + trait CompileErrorMacro { inline def compileErrors(inline code: String): String = { val errors = scala.compiletime.testing.typeCheckErrors(code) @@ -42,6 +93,44 @@ object MacroCompat { s"error:${separator}${trimMessage}\n${error.lineContent}\n${indent}^" }.mkString("\n") } + def compileErrors(code: String): String = macro compileErrorsImplScala2 + } + + def compileErrorsImplScala2(c: Context)(code: c.Tree): c.Tree = { + import c.universe._ + val toParse: String = code match { + case Literal(Constant(literal: String)) => literal + case _ => + c.abort( + code.pos, + "cannot compile dynamic expressions, only constant literals.\n" + + "To fix this problem, pass in a string literal in double quotes \"...\"" + ) + } + + def formatError(message: String, pos: scala.reflect.api.Position): String = + new StringBuilder() + .append("error:") + .append(if (message.contains('\n')) "\n" else " ") + .append(message) + .append("\n") + .append(pos.lineContent) + .append("\n") + .append(" " * (pos.column - 1)) + .append("^") + .toString() + + val message: String = + try { + c.typecheck(c.parse(s"{\n$toParse\n}")) + "" + } catch { + case e: ParseException => + formatError(e.getMessage(), e.pos) + case e: TypecheckException => + formatError(e.getMessage(), e.pos) + } + Literal(Constant(message)) } } diff --git a/website/blog/2021-01-05-macromix.md b/website/blog/2021-01-05-macromix.md new file mode 100644 index 00000000..e939a066 --- /dev/null +++ b/website/blog/2021-01-05-macromix.md @@ -0,0 +1,183 @@ +--- +author: Ólafur Páll Geirsson +title: Publish Scala 2 and Scala 3 macros together +authorURL: https://twitter.com/olafurpg +authorImageURL: https://github.com/olafurpg.png +--- + +The next release of MUnit makes use of a new compiler feature that allows you to +publish Scala 2 and Scala 3 macros together in a single artifact. The blog post +[Forward Compatibility for the Scala 3 Transition](https://www.scala-lang.org/blog/2020/11/19/scala-3-forward-compat.html) +by Jamie Thompson explains this feature in more detail. The +[Scala 3 Migration Guide](https://scalacenter.github.io/scala-3-migration-guide/docs/macros/migration-tutorial.html#mixing-macro-definitions) +contains a hands-on tutorial on how to use this feature. In this post, I want to +share a small example to motivate why I think this feature will be critical for +a smooth Scala 3 transition. + +> This blog post was written as part of a collaboration with the Scala Center. + +While it's standard practice to cross-build a Scala library between 2.12 and +2.13, you should think twice before cross-building for Scala 2.13 and Scala 3. +There is a chance you can skip 2.13 and publish only for Scala 3 instead. The +reason you may want to skip 2.13 is to prevent unexpected runtime crashes. + +To demonstrate how runtime crashes can happen, consider the following dependency +graph for a Scala 3 application. + + +
+ + +G + + + +app_3.0 + +app_3.0 + + + +a_library_3.0 + +a_library_3.0 + + + +app_3.0->a_library_3.0 + + + + + +b_library_2.13 + +b_library_2.13 + + + +app_3.0->b_library_2.13 + + + + + +munit_3.0 + +munit_3.0 + + + +a_library_3.0->munit_3.0 + + + + + +munit_2.13 + +munit_2.13 + + + +b_library_2.13->munit_2.13 + + + + + + +
+ + +The application has two direct dependencies (`a_library_3.0`, `b_library_2.13`) +and one transitive dependency on MUnit. The problem is that the transitive MUnit +dependency appears twice on the classpath: once for Scala 3 (`munit_3.0`) and +once for Scala 2.13 (`munit_2.13`). If `munit_3.0` and `munit_2.13` have binary +incompatibilities then the application may compile successfully but crash at +runtime with a `MethodNotFoundException` or `ClassNotFoundException`. + +The next release of MUnit avoids this problem by including Scala 2 macros in the +`munit_3.0` artifact. With this change, `b_library_2.13` can depend on +`munit_3.0` and the dependency graph becomes like this instead. + + +
+ + +G + + + +app_3.0 + +app_3.0 + + + +a_library_3.0 + +a_library_3.0 + + + +app_3.0->a_library_3.0 + + + + + +b_library_2.13 + +b_library_2.13 + + + +app_3.0->b_library_2.13 + + + + + +munit_3.0 + +munit_3.0 + + + +a_library_3.0->munit_3.0 + + + + + +b_library_2.13->munit_3.0 + + + + + +
+ + +This change is possible thanks to the new `-Ytasty-reader` flag in the Scala +2.13.4 compiler that enables the Scala 2 compiler to read Scala 3 libraries. + +This feature is new and has some limitations that's good to be aware of: + +- Scala 2.13.4 can only understand libraries that are published with the old + Scala 3.0.0-M1 version (latest is 3.0.0-M3 at the time of this writing). The + upcoming Scala 2.13.5 release will be able to understand newer Scala 3.x + releases. +- Some common features in Scala 2 macros don't work in Scala 3. Most notably, + you can't use quasiquotes or macro bundles. In the case of MUnit, we had to + replace `typeOf[Location]` with + `c.mirror.staticClass(classOf[Location].getName)` because `typeOf` is itself + implemented as a Scala 2 macro. +- Your Scala 3 library needs a compile-time dependecy on `scala-reflect:2.13.x`, + +Nevertheless, it's a phenomenal achievement that it's at all possible to publish +Scala 2 and Scala 3 macros together. For certain libraries, I'm optimistic this +feature will be a critical component to smoothen the Scala 3 transition.