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 Migrating to Scala 3.3. This page shows how 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 Mixing Scala 2.13 and Scala 3 Macros. You are encouraged to read both solutions to choose the technique that is best suited for your needs.

Introduction

As an example, consider the minimal macro library defined below.

// build.sbt
lazy val example = project
  .in(file("example"))
  .settings(
    scalaVersion := "2.13.18",
    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.18", "3.3.8")

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.8. At any point you can go back to the Scala 2.13 context by running ++2.13.18.

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.8
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://nightly.scala-lang.org/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:
  inline 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.path.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.18
sbt:example> example / test
location.MacrosSpec:
  + location
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success]
sbt:example> ++3.3.8
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: