Skip to content

Instantly share code, notes, and snippets.

@Jentsch
Last active August 21, 2022 01:26
Show Gist options
  • Save Jentsch/2aca30bdd38037b72bb978d9153238b9 to your computer and use it in GitHub Desktop.
Save Jentsch/2aca30bdd38037b72bb978d9153238b9 to your computer and use it in GitHub Desktop.
Expressions in string interpolation

Expressions in string interpolation

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)

Getting the expression

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.

String interpolation

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.

Summary

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)

renameing

Final notes

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].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment