search term:

ifdef macro in Scala

Update 2023-10-15: There’s now a better 0.2.0 that I implemented via pre-typer processing.

Rust has an interesting feature called cfg attribute, which is aware of the build configuration at the language level. This lets us write unit test in the same source as the library code like this:

...

#[cfg(test)]
mod tests {
    #[test]
    fn some_test() {
        ...
    }
}

I implemented an experimental @ifdef macro that does something similar.

import com.eed3si9n.ifdef.ifdef

class A {
  def foo: Int = 42
}

@ifdef("test")
class ATest extends munit.FunSuite {
  test("hello") {
    val actual = new A().foo
    val expected = 43
    assertEquals(actual, expected)
  }
}

prior discussions

Back in 2019, Stefan Zeiger wrote up scala-dev#640 and a SIP draft, but it sort of got killed in immediate feedback from the community before it could take off. Some of the criticisms were that it might break IDEs, or it might increase the cognitive load.

It’s possible that those feedback would still apply. But personally, if we limit to just writing test in the same source, it feels like a doable idea.

prior works: enableIf.scala

Update: Back in 2016 Atry (Yang Bo) has implemented ThoughtWorksInc/enableIf.scala. Thanks Yoshida-san for the ping.

import com.thoughtworks.enableIf

@enableIf(scala.util.Properties.versionNumberString.startsWith("2.10."))
implicit class FlatMapForTailRec[A](underlying: TailRec[A]) {
  final def flatMap[B](f: A => TailRec[B]): TailRec[B] = {
    tailcall(f(underlying.result))
  }
}

This seems to cover cross build usages, and more.

details

macro annotation

In short @ifdef(...) is a macro annotation that annotates class definitions. During the Compile compilation, it will try to blank out the class definition, and during the Test compilation, it will keep the class.

object IfDefMacro {
  private final val macroSetting = "com.eed3si9n.ifdef.declare:"
  def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = {
    import c.universe._
    ....
    val arg = extractAnnotationArg(c.macroApplication)
    annottees.map(_.tree) match {
      case (decl: ClassDef) :: Nil =>
        if (keys(arg)) c.Expr(decl)
        else {
          val className = extractClassName(decl)
          c.Expr(q"""
            private class $className
          """)
        }
      case _ => c.abort(c.enclosingPosition, "invalid annottee")
    }
  }
}

passing information to the macro

To drive the logic, we need to somehow pass information to the running macro during compilation. We can do this using -Xmacro-settings scalac options, so I wrote an sbt plugin that will pass test during Test compilation:

Test / ifDefDeclations += "test",
Test / scalacOptions ++= {
  val sv = scalaVersion.value
  val decls = (Test / ifDefDeclations).value
  toMacroSettings(sv, decls.toList)
},

....

  def toMacroSettings(sv: String, decls: List[String]): List[String] = {
    if (sv.startsWith("2."))
      decls.flatMap { decl =>
        List("-Xmacro-settings", s"$macroSetting$decl")
      }
    else
      decls.flatMap { decl =>
        List(s"-Xmacro-settings:$macroSetting$decl")
      }
  }

and we can pick this up at the macro side as follows:

val keys = (c.settings.collect {
  case x if x.startsWith(macroSetting) => x.drop(macroSetting.size)
}).toSet

and in Scala 3:

val keys = (CompilationInfo.XmacroSettings.collect:
  case x if x.startsWith(macroSetting) => x.drop(macroSetting.size)
).toSet

demo (Scala 2.13)

Here’s the demo of the initial snippet that I showed.

sbt:ifdef root> app/compile
[info] compiling 1 Scala source to ifdef/app/target/scala-2.13/classes ...
[success] Total time: 2 s
sbt:ifdef root> app/test
[info] compiling 1 Scala source to ifdef/app/target/scala-2.13/test-classes ...
ATest:
==> X ATest.hello  0.059s munit.ComparisonFailException: /Users/eed3si9n/work/ifdef/app/app.scala:14
13:    val expected = 43
14:    assertEquals(actual, expected)
15:  }
values are not the same
=> Obtained
42
=> Diff (- obtained, + expected)
-42
+43
    at munit.FunSuite.assertEquals(FunSuite.scala:11)
    at ATest.$anonfun$new$1(app.scala:14)
[error] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error]   ATest
[error] (app / Test / test) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 2 s

Next, we want to verify that ATest.class is blanked out:

$ javap -cp app/target/scala-2.13/classes ATest
Compiled from "app.scala"
public class ATest {
  public ATest();
}

It seems like private didn’t actually work here.

demo (Scala 3)

I’ve also tried to implement equivalent macro annotation in Scala 3. Macro annotation in general is still experimental, but it’s good to excercise it while it’s active. First, the macro seems to run after the typer, so you need to add the test dependencies as Provided, which is not great.

libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Provided,

There’s also something weird with the generated bytecode:

$ javap -cp app/target/scala-3.4.0-RC1-bin-20231010-7dc9798-NIGHTLY/classes/ example.ATest
Compiled from "app.scala"
public class example.ATest extends munit.FunSuite {
  public example.ATest();
}

Note the extends munit.FunSuite on the Compile configuration’s ATest. Not something I expected, and I’m guessing it’s a bug, or I did something wrong.

Update: I’ve filed dotty#18677. Jamie replied:

I think in this case the tree doesn’t matter because the class symbol never changed

Nicolas agreed:

@bishabosha is correct.

Nevertheless, in this case, there might be an argument to be made to allow the addition of parents in the same way we can add new methods to that class. Such an addition to the spec would need to be examined in detail. This is probably not something that would be available in the first iteration of macro annotation but added later as an extension to the spec.

discussion

Set aside some of the details, I think being able to write test in the same source is good.

Given the current limitation of leaving ATest classes in the Compile JAR etc, I’d caution against using it for public libraries, but this might be acceptable for in-house usages.

I haven’t tried, but this general mechanism probably could be extended to support cross building on the same source as well, which I think was more controvertial usage. By supporting only class to be annotated with @ifdef, hopefully we can keep it not too messy.

setup

Put this in project/plugins.sbt:

addSbtPlugin("com.eed3si9n.ifdef" % "sbt-ifdef" % "0.1.0")

Source is available at https://github.com/eed3si9n/ifdef

Update 2023-10-15: There’s now a better 0.2.0 that I implemented via pre-typer processing. Please try that instead.

summary