0

The dsinfo library lets you access the names of values from the context of where a function is written using Scala 2 macros. The example they give is that if you have something like

val name = myFunction(x, y)

myFunction will actually be passed the name of its val in addition to the other arguments, i.e., myFunction("name", x, y).

This is very useful for DSLs where you'd like named values for error reporting or other kinds of encoding. The only other option seems to explicitly pass the name as a String, which can lead to unintentional mismatches.

Is this possible with Scala 3 macros, and if so, how do you "climb up" the tree at the macro's use location to find its id?

Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
Todd O'Bryan
  • 2,234
  • 17
  • 30

1 Answers1

1

In Scala 3 there is no c.macroApplication. Only Position.ofMacroExpansion instead of a tree. But we can analyze Symbol.spliceOwner.maybeOwner. I presume that scalacOptions += "-Yretain-trees" is switched on.

import scala.annotation.experimental
import scala.quoted.*

object Macro {
  inline def makeCallWithName[T](inline methodName: String): T = 
    ${makeCallWithNameImpl[T]('methodName)}

  @experimental
  def makeCallWithNameImpl[T](methodName: Expr[String])(using Quotes, Type[T]): Expr[T] = {
    import quotes.reflect.*
    println(Position.ofMacroExpansion.sourceCode)//Some(twoargs(1, "one"))

    val methodNameStr = methodName.valueOrAbort
    val strs = methodNameStr.split('.')
    val moduleName = strs.init.mkString(".")
    val moduleSymbol = Symbol.requiredModule(moduleName)

    val shortMethodName = strs.last
    val ident = Ident(TermRef(moduleSymbol.termRef, shortMethodName))

    val (ownerName, ownerRhs) = Symbol.spliceOwner.maybeOwner.tree match {
      case ValDef(name, tpt, Some(rhs)) => (name, rhs)
      case DefDef(name, paramss, tpt, Some(rhs)) => (name, rhs)
      case t => report.errorAndAbort(s"can't find RHS of ${t.show}")
    }

    val treeAccumulator = new TreeAccumulator[Option[Tree]] {
      override def foldTree(acc: Option[Tree], tree: Tree)(owner: Symbol): Option[Tree] = tree match {
        case Apply(fun, args) if fun.symbol.fullName == "App$.twoargs" =>
          Some(Apply(ident, Literal(StringConstant(ownerName)) :: args))
        case _ => foldOverTree(acc, tree)(owner)
      }
    }
    treeAccumulator.foldTree(None, ownerRhs)(ownerRhs.symbol)
      .getOrElse(report.errorAndAbort(s"can't find twoargs in RHS: ${ownerRhs.show}"))
      .asExprOf[T]
  }
}

Usage:

package mypackage
case class TwoArgs(name : String, i : Int, s : String)
import mypackage.TwoArgs

object App {
  inline def twoargs(i: Int, s: String) = 
    Macro.makeCallWithName[TwoArgs]("mypackage.TwoArgs.apply")

  def x() = twoargs(1, "one") // TwoArgs("x", 1, "one")

  def aMethod() = {
    val y = twoargs(2, "two") // TwoArgs("y", 2, "two")
  }

  val z = Some(twoargs(3, "three")) // Some(TwoArgs("z", 3, "three"))
}

dsinfo also handles the name twoargs at call site (as template $macro) but I didn't implement this. I guess the name (if necessary) can be obtained from Position.ofMacroExpansion.sourceCode.


Update. Here is implementation handling name of inline method (e.g. twoargs) using Scalameta + Semanticdb besides Scala 3 macros.

import mypackage.TwoArgs

object App {
  inline def twoargs(i: Int, s: String) =
    Macro.makeCallWithName[TwoArgs]("mypackage.TwoArgs.apply")

  inline def twoargs1(i: Int, s: String) =
    Macro.makeCallWithName[TwoArgs]("mypackage.TwoArgs.apply")

  def x() = twoargs(1, "one") // TwoArgs("x", 1, "one")

  def aMethod() = {
    val y = twoargs(2, "two") // TwoArgs("y", 2, "two")
  }

  val z = Some(twoargs1(3, "three")) // Some(TwoArgs("z", 3, "three"))
}
package mypackage

case class TwoArgs(name : String, i : Int, s : String)
import scala.annotation.experimental
import scala.quoted.*

object Macro {
  inline def makeCallWithName[T](inline methodName: String): T =
    ${makeCallWithNameImpl[T]('methodName)}

  @experimental
  def makeCallWithNameImpl[T](methodName: Expr[String])(using Quotes, Type[T]): Expr[T] = {
    import quotes.reflect.*

    val position = Position.ofMacroExpansion
    val scalaFile = position.sourceFile.getJPath.getOrElse(
      report.errorAndAbort(s"maybe virtual file, can't find path to position $position")
    )
    val inlineMethodSymbol =
      new SemanticdbInspector(scalaFile)
        .getInlineMethodSymbol(position.start, position.end)
        .getOrElse(report.errorAndAbort(s"can't find Scalameta symbol at position (${position.startLine},${position.startColumn})..(${position.endLine},${position.endColumn})=$position"))

    val methodNameStr = methodName.valueOrAbort
    val strs = methodNameStr.split('.')
    val moduleName = strs.init.mkString(".")
    val moduleSymbol = Symbol.requiredModule(moduleName)

    val shortMethodName = strs.last
    val ident = Ident(TermRef(moduleSymbol.termRef, shortMethodName))

    val owner = Symbol.spliceOwner.maybeOwner

    val macroApplication: Option[Tree] = {
      val (ownerName, ownerRhs) = owner.tree match {
        case ValDef(name, tpt, Some(rhs)) => (name, rhs)
        case DefDef(name, paramss, tpt, Some(rhs)) => (name, rhs)
        case t => report.errorAndAbort(s"can't find RHS of ${t.show}")
      }

      val treeAccumulator = new TreeAccumulator[Option[Tree]] {
        override def foldTree(acc: Option[Tree], tree: Tree)(owner: Symbol): Option[Tree] = tree match {
          case Apply(fun, args) if tree.pos == position /* fun.symbol.fullName == inlineMethodSymbol */ =>
            Some(Apply(ident, Literal(StringConstant(ownerName)) :: args))
          case _ => foldOverTree(acc, tree)(owner)
        }
      }
      treeAccumulator.foldTree(None, ownerRhs)(ownerRhs.symbol)
    }

    val res = macroApplication
      .getOrElse(report.errorAndAbort(s"can't find application of $inlineMethodSymbol in RHS of $owner"))
    report.info(res.show)
    res.asExprOf[T]
  }
}
import java.nio.file.{Path, Paths}
import scala.io
import scala.io.BufferedSource
import scala.meta.*
import scala.meta.interactive.InteractiveSemanticdb
import scala.meta.internal.semanticdb.{ClassSignature, Locator, Range, SymbolInformation, SymbolOccurrence, TextDocument, TypeRef}

class SemanticdbInspector(val scalaFile: Path) {
  val scalaFileStr = scalaFile.toString

  var textDocuments: Seq[TextDocument] = Seq()
  Locator(
    Paths.get(scalaFileStr + ".semanticdb")
  )((path, textDocs) => {
    textDocuments ++= textDocs.documents
  })

  val bufferedSource: BufferedSource = io.Source.fromFile(scalaFileStr)
  val source = try bufferedSource.mkString finally bufferedSource.close()

  extension (tree: Tree) {
    def occurence: Option[SymbolOccurrence] = {
      val treeRange = Range(tree.pos.startLine, tree.pos.startColumn, tree.pos.endLine, tree.pos.endColumn)
      textDocuments.flatMap(_.occurrences)
        .find(_.range.exists(occurrenceRange => treeRange == occurrenceRange))
    }

    def info: Option[SymbolInformation] = occurence.flatMap(_.symbol.info)
  }

  extension (symbol: String) {
    def info: Option[SymbolInformation] = textDocuments.flatMap(_.symbols).find(_.symbol == symbol)
  }

  def getInlineMethodSymbol(startOffset: Int, endOffset: Int): Option[String] = {
    def translateScalametaToMacro3(symbol: String): String =
      symbol
        .stripPrefix("_empty_/")
        .stripSuffix("().")
        .replace(".", "$.")
        .replace("/", ".")

    dialects.Scala3(source).parse[Source].get.collect {
      case t@Term.Apply(fun, args) if t.pos.start == startOffset && t.pos.end == endOffset =>
        fun.info.map(_.symbol)
    }.headOption.flatten.map(translateScalametaToMacro3)
  }
}
lazy val scala3V = "3.1.3"
lazy val scala2V = "2.13.8"
lazy val scalametaV = "4.5.13"

lazy val root = project
  .in(file("."))
  .settings(
    name := "scala3demo",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3V,
    libraryDependencies ++= Seq(
      "org.scalameta" %% "scalameta" % scalametaV cross CrossVersion.for3Use2_13,
      "org.scalameta" % s"semanticdb-scalac_$scala2V" % scalametaV,
    ),
    scalacOptions ++= Seq(
      "-Yretain-trees",
    ),
    semanticdbEnabled := true,
  )

By the way, Semantidb can't be replaced by Tasty here because when a macro in App is being expanded, the file App.scala.semantidb already exists (it's generated early, at frontend phase of compilation) but App.tasty hasn't yet (it appears when App has been compiled i.e. after expansion of the macro, at pickler phase).

.scala.semanticdb file will appear even if .scala file doesn't compile (e.g. if there is an error in macro expansion) but .tasty file won't.

scala.meta parent of parent of Defn.Object

Is it possible to using macro to modify the generated code of structural-typing instance invocation?

Scala conditional compilation

Macro annotation to override toString of Scala function

How to merge multiple imports in scala?

How to get the type of a variable with scalameta if the decltpe is empty?


See also https://github.com/lampepfl/dotty-macro-examples/tree/main/accessEnclosingParameters


Simplified version:

import scala.quoted.*

inline def makeCallWithName[T](inline methodName: String): T =
  ${makeCallWithNameImpl[T]('methodName)}

def makeCallWithNameImpl[T](methodName: Expr[String])(using Quotes, Type[T]): Expr[T] = {
  import quotes.reflect.*

  val position = Position.ofMacroExpansion

  val methodNameStr = methodName.valueOrAbort
  val strs = methodNameStr.split('.')
  val moduleName = strs.init.mkString(".")
  val moduleSymbol = Symbol.requiredModule(moduleName)
  val shortMethodName = strs.last
  val ident = Ident(TermRef(moduleSymbol.termRef, shortMethodName))

  val owner0 = Symbol.spliceOwner.maybeOwner

  val ownerName = owner0.tree match {
    case ValDef(name, _, _) => name
    case DefDef(name, _, _, _) => name
    case t => report.errorAndAbort(s"unexpected tree shape: ${t.show}")
  }

  val owner = if owner0.isLocalDummy then owner0.maybeOwner else owner0
  
  val macroApplication: Option[Tree] = {
    val treeAccumulator = new TreeAccumulator[Option[Tree]] {
      override def foldTree(acc: Option[Tree], tree: Tree)(owner: Symbol): Option[Tree] = tree match {
        case _ if tree.pos == position => Some(tree)
        case _ => foldOverTree(acc, tree)(owner)
      }
    }
    treeAccumulator.foldTree(None, owner.tree)(owner)
  }

  val res = macroApplication.getOrElse(
    report.errorAndAbort("can't find macro application")
  ) match {
    case Apply(_, args) => Apply(ident, Literal(StringConstant(ownerName)) :: args)
    case t => report.errorAndAbort(s"unexpected shape of macro application: ${t.show}")
  }
  report.info(res.show)
  res.asExprOf[T]
}
Dmytro Mitin
  • 48,194
  • 3
  • 28
  • 66
  • Hey, @dmytro-mitin ! Thanks for this. I finally got around to trying it out, and it works great for methods, but I wanted to use it to call a constructor. I found this https://users.scala-lang.org/t/scala-3-macro-create-instance-of-a-type/8016, but I'm not sure if I can just replace the `case Apply` or if I need to create a separate implementation for `makeNewWithName` or something similar. I'm currently working around by creating a `make` method in the companion object that just calls the constructor. – Todd O'Bryan May 30 '23 at 15:50