Skip to content

Commit 8a99f9f

Browse files
authored
Add expression compiler (scala#22597)
This PR migrates the expression compiler from [scalacenter/scala-debug-adapter](https://github.com/scalacenter/scala-debug-adapter) to the scala3 repository. This change was agreed upon a few months ago during a core meeting to simplify the release process. By doing so, we eliminate the need to release parts of [scalacenter/scala-debug-adapter](https://github.com/scalacenter/scala-debug-adapter) during each Scala 3 release. The `ExpressionCompiler` and `ExpressionCompilerBridge` are now parts of the main compiler module. This integration streamlines usage for external tools like sbt, Metals, Bloop, IJ Scala plugin. They can now rely on the compiler JAR directly without needing to download additional artifacts. ## The expression compiler The expression compiler extends the main compiler with 3 phases: **InsertExpression**: - Just after parsing - Parses and inserts the expression in the original source tree. - Inserts the expression class, with a placeholder `def evaluate: Any` method. **ExtractExpression**: - After `Typer`, the pickler phases, `ExtensionMethods` and `ElimByName`. - Extracts the expression before `LambdaLift` to prevent interference: the expression itself can change how lambdas are lifted. Notably it can contains lambda that needs to be lifted to the expression class. - Replaces references to local variables and inaccessible members with magic calls to reflectEval, annotated with ReflectEvalStrategy attachments. **ResolveReflectEval**: - At the end of the transform phases - Transforms `reflectEval` calls into actual reflective calls based on `ReflectEvalStrategy` metadata. ## Usage of the expression compiler The expression compiler powers the debug console in Metals and the IJ Scala plugin, enabling evaluation of arbitrary Scala expressions at runtime (even macros). The expression compiler produce class files that can be loaded the running Scala program, to compute the evaluation output. The `ExpressionCompilerBridge` can be invoked by reflection with the following parameters: - `outputDir: Path`: Directory where compiled classes are written - `classPath: String`: Classpath used during compilation - `options: Array[String]`: Compiler options - `sourceFile: Path`: Source file where the expression is evaluated - `config: ExpressionCompilerConfig`: - `packageName`: Package name of the current evaluation frame - `outputClassName: String`: Name of the main generated class, to be loaded by the debuggee - `breakpointLine: Int`: Line number where the expression is evaluated - `expression: String`: Expression to compile - `localVariables: Set[String]`: Set of visible local variables (used for checking evaluation of captured variables) - `errorReporter: Consumer[String]`: Callback for reporting errors The `ExpressionCompilerConfig` is designed as a factory class to allow binary-compatible evolution across versions, enabling cross-compatibility between different debugger and compiler versions. ## The debug tests A significant part of this PR is the migration of [ScalaEvaluationTests](https://github.com/scalacenter/scala-debug-adapter/blob/main/modules/tests/src/test/scala/ch/epfl/scala/debugadapter/ScalaEvaluationTests.scala), notably the integration with the vulpix test infrastructure: - In `dotty.tools.vulpix.RunnerOrchestration`: - added the `debugMode` to start runners with JVM debugging options - added the `debugMain` method to connect a debugger, start a main method and execute the debug steps - Added `dotty.tools.debug.Debugger`: a lightweight implementation of a JVM debugger - Added `dotty.tools.debug.ExpressionEvaluator`: to compile and evaluate expressions inside `dotty.tools.debug.Debugger` - Added `dotty.tools.debug.DebugStepAssert`: to describe a debug scenario as a series of steps (`break`, `step`, `next` and `eval`) - Added `dotty.tools.debug.DebugTests` test class: - it reads all Scala files and folders from `tests/debug` - for each Scala file, it parses its associated `.check` file as a series of `DebugStepAssert` - for each Scala file, it compiles it, runs it with `debugMain`, and executes the debug steps I migrated all relevant tests from [ScalaEvaluationTests](https://github.com/scalacenter/scala-debug-adapter/blob/main/modules/tests/src/test/scala/ch/epfl/scala/debugadapter/ScalaEvaluationTests.scala) to `tests/debug` folder. Thanks to Vulpix parallel execution, the 47 debug tests now complete in approximately 30-40 seconds.
2 parents cce70da + b75cd4d commit 8a99f9f

File tree

125 files changed

+3575
-473
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

125 files changed

+3575
-473
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ testlogs/
6262

6363
# Put local stuff here
6464
local/
65-
compiler/test/debug/Gen.jar
6665

6766
/bin/.cp
6867

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package dotty.tools.debug
2+
3+
import dotty.tools.dotc.Compiler
4+
import dotty.tools.dotc.core.Contexts.Context
5+
import dotty.tools.dotc.core.Phases.Phase
6+
import dotty.tools.dotc.transform.ElimByName
7+
8+
/**
9+
* The expression compiler powers the debug console in Metals and the IJ Scala plugin,
10+
* enabling evaluation of arbitrary Scala expressions at runtime (even macros).
11+
* It produces class files that can be loaded by the running Scala program,
12+
* to compute the evaluation output.
13+
*
14+
* To do so, it extends the Compiler with 3 phases:
15+
* - InsertExpression: parses and inserts the expression in the original source tree
16+
* - ExtractExpression: extract the typed expression and places it in the new expression class
17+
* - ResolveReflectEval: resolves local variables or inacessible members using reflection calls
18+
*/
19+
class ExpressionCompiler(config: ExpressionCompilerConfig) extends Compiler:
20+
21+
override protected def frontendPhases: List[List[Phase]] =
22+
val parser :: others = super.frontendPhases: @unchecked
23+
parser :: List(InsertExpression(config)) :: others
24+
25+
override protected def transformPhases: List[List[Phase]] =
26+
val store = ExpressionStore()
27+
// the ExtractExpression phase should be after ElimByName and ExtensionMethods, and before LambdaLift
28+
val transformPhases = super.transformPhases
29+
val index = transformPhases.indexWhere(_.exists(_.phaseName == ElimByName.name))
30+
val (before, after) = transformPhases.splitAt(index + 1)
31+
(before :+ List(ExtractExpression(config, store))) ++ (after :+ List(ResolveReflectEval(config, store)))
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package dotty.tools.debug
2+
3+
import java.nio.file.Path
4+
import java.util.function.Consumer
5+
import java.{util => ju}
6+
import scala.jdk.CollectionConverters.*
7+
import scala.util.control.NonFatal
8+
import dotty.tools.dotc.reporting.StoreReporter
9+
import dotty.tools.dotc.core.Contexts.Context
10+
import dotty.tools.dotc.Driver
11+
12+
class ExpressionCompilerBridge:
13+
def run(
14+
outputDir: Path,
15+
classPath: String,
16+
options: Array[String],
17+
sourceFile: Path,
18+
config: ExpressionCompilerConfig
19+
): Boolean =
20+
val args = Array(
21+
"-d",
22+
outputDir.toString,
23+
"-classpath",
24+
classPath,
25+
"-Yskip:pureStats"
26+
// Debugging: Print the tree after phases of the debugger
27+
// "-Vprint:insert-expression,resolve-reflect-eval",
28+
) ++ options :+ sourceFile.toString
29+
val driver = new Driver:
30+
protected override def newCompiler(using Context): ExpressionCompiler = ExpressionCompiler(config)
31+
val reporter = ExpressionReporter(error => config.errorReporter.accept(error))
32+
try
33+
driver.process(args, reporter)
34+
!reporter.hasErrors
35+
catch
36+
case NonFatal(cause) =>
37+
cause.printStackTrace()
38+
throw cause
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package dotty.tools.debug
2+
3+
import dotty.tools.dotc.ast.tpd.*
4+
import dotty.tools.dotc.core.Symbols.*
5+
import dotty.tools.dotc.core.Types.*
6+
import dotty.tools.dotc.core.Names.*
7+
import dotty.tools.dotc.core.Flags.*
8+
import dotty.tools.dotc.core.Contexts.*
9+
import dotty.tools.dotc.core.SymUtils
10+
11+
import java.{util => ju}
12+
import ju.function.Consumer
13+
14+
class ExpressionCompilerConfig private[debug] (
15+
packageName: String,
16+
outputClassName: String,
17+
private[debug] val breakpointLine: Int,
18+
private[debug] val expression: String,
19+
private[debug] val localVariables: ju.Set[String],
20+
private[debug] val errorReporter: Consumer[String],
21+
private[debug] val testMode: Boolean
22+
):
23+
def this() = this(
24+
packageName = "",
25+
outputClassName = "",
26+
breakpointLine = -1,
27+
expression = "",
28+
localVariables = ju.Collections.emptySet,
29+
errorReporter = _ => (),
30+
testMode = false,
31+
)
32+
33+
def withPackageName(packageName: String): ExpressionCompilerConfig = copy(packageName = packageName)
34+
def withOutputClassName(outputClassName: String): ExpressionCompilerConfig = copy(outputClassName = outputClassName)
35+
def withBreakpointLine(breakpointLine: Int): ExpressionCompilerConfig = copy(breakpointLine = breakpointLine)
36+
def withExpression(expression: String): ExpressionCompilerConfig = copy(expression = expression)
37+
def withLocalVariables(localVariables: ju.Set[String]): ExpressionCompilerConfig = copy(localVariables = localVariables)
38+
def withErrorReporter(errorReporter: Consumer[String]): ExpressionCompilerConfig = copy(errorReporter = errorReporter)
39+
40+
private[debug] val expressionTermName: TermName = termName(outputClassName.toLowerCase.toString)
41+
private[debug] val expressionClassName: TypeName = typeName(outputClassName)
42+
43+
private[debug] def expressionClass(using Context): ClassSymbol =
44+
if packageName.isEmpty then requiredClass(outputClassName)
45+
else requiredClass(s"$packageName.$outputClassName")
46+
47+
private[debug] def evaluateMethod(using Context): Symbol =
48+
expressionClass.info.decl(termName("evaluate")).symbol
49+
50+
private def copy(
51+
packageName: String = packageName,
52+
outputClassName: String = outputClassName,
53+
breakpointLine: Int = breakpointLine,
54+
expression: String = expression,
55+
localVariables: ju.Set[String] = localVariables,
56+
errorReporter: Consumer[String] = errorReporter,
57+
) = new ExpressionCompilerConfig(
58+
packageName,
59+
outputClassName,
60+
breakpointLine,
61+
expression,
62+
localVariables,
63+
errorReporter,
64+
testMode
65+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package dotty.tools.debug
2+
3+
import dotty.tools.dotc.core.Contexts.*
4+
import dotty.tools.dotc.reporting.AbstractReporter
5+
import dotty.tools.dotc.reporting.Diagnostic
6+
7+
private class ExpressionReporter(reportError: String => Unit) extends AbstractReporter:
8+
override def doReport(dia: Diagnostic)(using Context): Unit =
9+
// Debugging: println(messageAndPos(dia))
10+
dia match
11+
case error: Diagnostic.Error =>
12+
val newPos = error.pos.source.positionInUltimateSource(error.pos)
13+
val errorWithNewPos = new Diagnostic.Error(error.msg, newPos)
14+
reportError(stripColor(messageAndPos(errorWithNewPos)))
15+
case _ =>
16+
// TODO report the warnings in the expression
17+
()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dotty.tools.debug
2+
3+
import dotty.tools.dotc.ast.tpd.*
4+
import dotty.tools.dotc.core.Symbols.*
5+
import dotty.tools.dotc.core.Types.*
6+
import dotty.tools.dotc.core.Names.*
7+
import dotty.tools.dotc.core.Flags.*
8+
import dotty.tools.dotc.core.Contexts.*
9+
import dotty.tools.dotc.core.SymUtils
10+
11+
private class ExpressionStore:
12+
var symbol: TermSymbol | Null = null
13+
// To resolve captured variables, we store:
14+
// - All classes in the chain of owners of the expression
15+
// - The first local method enclosing the expression
16+
var classOwners: Seq[ClassSymbol] = Seq.empty
17+
var capturingMethod: Option[TermSymbol] = None
18+
19+
def store(exprSym: Symbol)(using Context): Unit =
20+
symbol = exprSym.asTerm
21+
classOwners = exprSym.ownersIterator.collect { case cls: ClassSymbol => cls }.toSeq
22+
capturingMethod = exprSym.ownersIterator
23+
.find(sym => (sym.isClass || sym.is(Method)) && sym.enclosure.is(Method)) // the first local class or method
24+
.collect { case sym if sym.is(Method) => sym.asTerm } // if it is a method

0 commit comments

Comments
 (0)