During the fives episode of the scalaprofies podcast the two Speaker where questioning for a special string interpolation that shows the definition of parts instead of evaluated result of the expressions. Invalid expressions should be still rejected by the compiler and refactoring tools should also affect the expressions within the string interpolation. Here we will implement such a functionality and see the tip of the of the iceberg of macros and string interpolation.
Example:
scala> def f(x: Int) = x * 2
scala> e"Please show ${f(2)}"
res: String = Please show f(2)
It obviously that we need macros to get the definition of a term. But since we don't generate any new type black box macros are OK for us. (The difference between blackbox and whitebox macros are explained in the scala documentation by Eugen Burmako) To enable them we need to import macros in general and blackbox macros:
import scala.language.experimental.macros
import scala.reflect.macros.blackbox
Based upon the initial example from in the scalac.io blog about Scala macros by Jakub Kozłowski we define:
def exprStringImpl(c: blackbox.Context)(any: c.Expr[Any]): c.Expr[String] = {
import c.universe._
val exprDef = any.toString
c.Expr(q""" $exprDef """)
}
def exprString(any: Any): String = macro exprStringImpl
Note that not only the number and types of the parameter list must be equal but also the names.
Those scala c.Expr
things are path dependent types, but we don't need to worry about them to much now and can pretend they normal types. If you want to read more about them, see a good example from Daniel Westheide.
Now we get:
scala> def f(x: Int) = x * 2
f: (x: Int)Int
scala> exprString(f(1))
res1: String = Expr[Nothing](f(1))
That looks already very close what we what, but there are some cases which aren't that close:
scala> def g = f(_)
g: Int => Int
scala> exprString(g(2)) // desugared call of apply
res2: String = Expr[Nothing](g.apply(2))
scala> exprString(f(1 + 1)) // compiler simplifies 1 + 1
res3: String = Expr[Nothing](f(2))
scala> exprString(1.toDouble + 1.0) // desugared infix operators
res4: String = Expr[Nothing](1.toDouble.+(1.0))
So recovering the original text from the AST (Abstract Syntax Tree) would be a fruitless attempt, because we lost to much information. Based upon this Gist by Pedro Furlanetto we can extract the original source code fragment. This requires adding scalacOptions += "-Yrangepos"
to our build.sbt
otherwise the result would be just an empty string.
def exprStringImpl(c: blackbox.Context)(any: c.Expr[Any]): c.Expr[String] = {
import c.universe._
val pos = any.tree.pos
val exprDef = new String(pos.source.content.drop(pos.start).take(pos.end - pos.start))
c.Expr(q""" $exprDef """)
}
Now we receive the concrete string of the expression and could even use it within a normal string interpolation:
scala> exprString(g(1))
res5: String = g(1)
scala> exprString(f(1 + 1))
res6: String = f(1 + 1)
scala> s" ${exprString(1 + 1)} = ${1 + 1} "
res7: String = " 1 + 1 = 2 "
But would like to use it directly without invoking exprString
manually.
The only way to get the compiler to inserting our method is to enforce a type mismatch and marking our method as an implicit conversion that fix this mismatch. It's good practice to reduce the scope of an implicit conversion by tightening the types as far as possible, so we declare a type specify for our exprString
that just wraps a String
.
case class ExprString(expr: String)
To create a new string interpolation, we need to provide a new method to the class StringContext
, also via implicit conversion.
implicit class ExprStringContext(sc: StringContext) {
def e(parts: ExprString*) =
sc.s(parts.map(_.expr): _*)
}
Now we get a type mismatch:
scala> e" ${f(1)} "
<console>:19: error: type mismatch;
found : Int
required: ExprString
e" ${f(1)} "
^
To resolve this mismatch we modify our macro from above slightly and mark the entry method as implicit conversion:
def exprStringImpl(c: blackbox.Context)(any: c.Expr[Any]): c.Expr[ExprString] = {
import c.universe._
val pos = any.tree.pos
val exprDef = new String(pos.source.content.drop(pos.start).take(pos.end - pos.start))
c.Expr(q""" ExprString($exprDef) """)
}
implicit def exprString(any: Any): ExprString = macro exprStringImpl
And done we are.
With everything above combined we can now write expressions inside string interpolation:
scala> e"Please show ${f(2)}"
<console>:19: error: type mismatch;
found : Int
required: ExprString
e"Please show ${f(2)}"
^
And if we use IntelliJ to rename used functions it will also update references inside the string interpolation. (Eclipse should do the same)
As we use the plain textual representation we lose all information about imports and the implicit context of the expression that's maybe needed to evaluate the expression. To got such information we would have to investigate the AST in any: c.Expr[Any]
.