Scala 3 Migration Guide

Cross-Building a Macro Library

Language

Macro libraries must be re-implemented from the ground-up.

Before starting you should be familiar with the Scala 3 migration as described in the Porting an sbt Project tutorial. The purpose of the current tutorial is to cross-build an existing Scala 2.13 macro library so that it becomes available in both Scala 3 and Scala 2.13.

An alternative solution called Mixing Macros is explained in the next tutorial. You are encouraged to read both solutions to choose the technique that is best suited for your needs.

Introduction

In order to exemplify this tutorial, we will consider the minimal macro library defined below.

// build.sbt
lazy val example = project
  .in(file("example"))
  .settings(
    scalaVersion := "2.13.11",
    libraryDependencies ++= Seq(
      "org.scala-lang" % "scala-reflect" % scalaVersion.value
    )
  )
// example/src/main/scala/location/Location.scala
package location

import scala.reflect.macros.blackbox.Context
import scala.language.experimental.macros

case class Location(path: String, line: Int)

object Macros {
  def location: Location = macro locationImpl

  private def locationImpl(c: Context): c.Tree =  {
    import c.universe._
    val location = typeOf[Location]
    val line = Literal(Constant(c.enclosingPosition.line))
    val path = Literal(Constant(c.enclosingPosition.source.path))
    q"new $location($path, $line)"
  }
}

You should recognize some similarities with your library: one or more macro methods, in our case the location method, are implemented by consuming a macro Context and returning a Tree from this context.

We can make this library available for Scala 3 users by using the Cross Building technique provided by sbt.

The main idea is to build the artifact twice and to publish two releases:

  • example_2.13 for Scala 2.13 users
  • example_3 for Scala 3 users

Cross-building Architecture

1. Set cross-building up

You can add Scala 3 to the list of crossScalaVersions of your project:

crossScalaVersions := Seq("2.13.11", "3.3.1")

The scala-reflect dependency won’t be useful in Scala 3. Remove it conditionally with something like:

// build.sbt
libraryDependencies ++= {
  CrossVersion.partialVersion(scalaVersion.value) match {
    case Some((2, 13)) => Seq(
      "org.scala-lang" % "scala-reflect" % scalaVersion.value
    )
    case _ => Seq.empty
  }
}

After reloading sbt, you can switch to the Scala 3 context by running ++3.3.1. At any point you can go back to the Scala 2.13 context by running ++2.13.11.

2. Rearrange the code in version-specific source directories

If you try to compile with Scala 3 you should see some errors of the same kind as:

sbt:example> ++3.3.1
sbt:example> example / compile
[error] -- Error: /example/src/main/scala/location/Location.scala:15:35 
[error] 15 |    val location = typeOf[Location]
[error]    |                                   ^
[error]    |                              No TypeTag available for location.Location
[error] -- Error: /example/src/main/scala/location/Location.scala:18:4 
[error] 18 |    q"new $location($path, $line)"
[error]    |    ^
[error]    |Scala 2 macro cannot be used in Dotty. See https://dotty.epfl.ch/docs/reference/dropped-features/macros.html
[error]    |To turn this error into a warning, pass -Xignore-scala2-macros to the compiler

To provide a Scala 3 alternative while preserving the Scala 2 implementation, we are going to rearrange the code in version-specific source directories. All the code that cannot be compiled by the Scala 3 compiler goes to the src/main/scala-2 folder.

Scala version-specific source directories is an sbt feature that is available by default. Learn more about it in the sbt documentation.

In our example, the Location class stays in the src/main/scala folder but the Macros object is moved to the src/main/scala-2 folder:

// example/src/main/scala/location/Location.scala
package location

case class Location(path: String, line: Int)
// example/src/main/scala-2/location/Macros.scala
package location

import scala.reflect.macros.blackbox.Context
import scala.language.experimental.macros

object Macros {
  def location: Location = macro locationImpl

  private def locationImpl(c: Context): c.Tree =  {
    import c.universe._
    val location = typeOf[Location]
    val line = Literal(Constant(c.enclosingPosition.line))
    val path = Literal(Constant(c.enclosingPosition.source.path))
    q"new $location($path, $line)"
  }
}

Now we can initialize each of our Scala 3 macro definitions in the src/main/scala-3 folder. They must have the exact same signature than their Scala 2.13 counterparts.

// example/src/main/scala-3/location/Macros.scala
package location

object Macros:
  def location: Location = ???

3. Implement the Scala 3 macro

There is no magic formula to port a Scala 2 macro into Scala 3. One needs to learn about the new Metaprogramming features.

We eventually come up with this implementation:

// example/src/main/scala-3/location/Macros.scala
package location

import scala.quoted.{Quotes, Expr}

object Macros:
  inline def location: Location = ${locationImpl}

  private def locationImpl(using quotes: Quotes): Expr[Location] =
    import quotes.reflect.Position
    val pos = Position.ofMacroExpansion
    val file = Expr(pos.sourceFile.jpath.toString)
    val line = Expr(pos.startLine + 1)
    '{new Location($file, $line)}

4. Cross-validate the macro

Adding some tests is important to check that the macro method works the same in both Scala versions.

In our example, we add a single test.

// example/src/test/scala/location/MacrosSpec.scala
package location

class MacrosSpec extends munit.FunSuite {
  test("location") {
    assertEquals(Macros.location.line, 5)
  }
}

You should now be able to run the tests in both versions.

sbt:example> ++2.13.11
sbt:example> example / test
location.MacrosSpec:
  + location
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success]
sbt:example> ++3.3.1
sbt:example> example / test
location.MacrosSpec:
  + location
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success]

Final overview

Your macro project should now contain the following source files:

  • src/main/scala/*.scala: Cross-compatible classes
  • src/main/scala-2/*.scala: The Scala 2 implementation of the macro methods
  • src/main/scala-3/*.scala: The Scala 3 implementation of the macro methods
  • src/test/scala/*.scala: Common tests

Cross-building Architecture

You are now ready to publish your library by creating two releases:

  • example_2.13 for Scala 2.13 users
  • example_3 for Scala 3 users

Contributors to this page: