[2.x] fix: Fix consoleProject for Scala 3

**Problem**
`consoleProject` does not work with Scala 3 because the compiler bridge
does not implement REPL binding injection (scala/scala3#5069).
The bindings `currentState`, `extracted`, and `cpHelpers` are never
injected into the REPL session, causing `Not found` errors.

**Solution**
Work around the missing binding support by storing the three runtime
objects in a static holder (`ConsoleProjectBindings`) before launching
the REPL, and generating `val` definitions via `initialCommands` that
read from the holder. The original `bindings` are still passed to
`Console` to preserve Scala 2 backward compatibility.

Fixes sbt/sbt#7722
This commit is contained in:
eureka928 2026-04-12 08:57:24 +02:00
parent 05cd00e135
commit 673789111b
No known key found for this signature in database
GPG Key ID: B0C6F02BD1A12D8E
6 changed files with 190 additions and 24 deletions

View File

@ -790,6 +790,28 @@ object Defaults extends BuildCommon with DefExtra {
ScalaArtifacts.Organization,
appConfiguration.value.provider.scalaProvider.version
),
// consoleProject compiles the *build definition*, which targets sbt's own
// Scala version (the launcher's scalaProvider.version), not the project's.
// The default `scalaCompilerBridgeBin` resolves via the `update` report,
// which uses the project's scalaVersion; resolve the pre-built Scala 3
// bridge directly for sbt's Scala version instead. See sbt/sbt#7722.
consoleProject / scalaCompilerBridgeBin := Def.uncached {
val sv = appConfiguration.value.provider.scalaProvider.version
val st = state.value
val g = BuildPaths.getGlobalBase(st)
val zincDir = BuildPaths.getZincDirectory(st, g)
val conv = fileConverter.value
Compiler
.scala3ConsoleProjectBridgeJar(
scalaVersion = sv,
scalaOrg = ScalaArtifacts.Organization,
dr = (LocalRootProject / dependencyResolution).value,
retrieveDir = zincDir,
log = streams.value.log,
)
.toVector
.map(jar => conv.toVirtualFile(jar.toPath()): HashedVirtualFileRef)
},
classpathOptions := ClasspathOptionsUtil.noboot(scalaVersion.value),
console / classpathOptions := ClasspathOptionsUtil.replNoboot(scalaVersion.value),
)
@ -811,22 +833,35 @@ object Defaults extends BuildCommon with DefExtra {
),
consoleProject := ConsoleProject.consoleProjectTask.value,
consoleProject / scalaInstance := {
val topLoader = classOf[org.jline.terminal.Terminal].getClassLoader
// Use the classloader that has `ConsoleProjectBindings` as the top so
// that code generated by `initialCommands` in the REPL sees the same
// class instance that sbt has set fields on. See sbt/sbt#7722.
val topLoader = classOf[ConsoleProjectBindings.type].getClassLoader
val scalaProvider = appConfiguration.value.provider.scalaProvider
val allJars = scalaProvider.jars
val libraryJars = allJars.filter { jar =>
val launcherJars = scalaProvider.jars
val libraryJars = launcherJars.filter { jar =>
jar.getName == "scala-library.jar" || jar.getName.startsWith("scala3-library_3")
}
val compilerJar = allJars.filter { jar =>
jar.getName == "scala-compiler.jar" || jar.getName.startsWith("scala3-compiler_3")
}
ScalaInstance(scalaProvider.version, scalaProvider.launcher)
// Scala 3.8+ extracted ReplDriver from scala3-compiler_3 into scala3-repl_3,
// which the sbt launcher does not ship. Resolve it explicitly so that
// consoleProject can start the Scala 3 REPL. See sbt/sbt#7722.
val st = state.value
val g = BuildPaths.getGlobalBase(st)
val zincDir = BuildPaths.getZincDirectory(st, g)
val replToolJars = Compiler.scala3ReplToolJars(
scalaVersion = scalaProvider.version,
scalaOrg = ScalaArtifacts.Organization,
dr = (LocalRootProject / dependencyResolution).value,
retrieveDir = zincDir,
log = streams.value.log,
)
val allJars = (launcherJars.toSeq ++ replToolJars).distinct
Compiler.makeScalaInstance(
scalaProvider.version,
libraryJars,
allJars.toSeq,
Seq.empty,
state.value,
allJars,
replToolJars,
st,
topLoader,
)
},

View File

@ -27,6 +27,8 @@ import sbt.librarymanagement.{
Configuration,
Configurations,
ConfigurationReport,
CrossVersion,
DependencyResolution,
ModuleID,
ScalaArtifacts,
SemanticSelector,
@ -110,6 +112,73 @@ object Compiler:
case _ => ScalaInstance(sv, scalaProvider)
}
/**
* Resolves extra REPL tool jars required for Scala 3.8+.
*
* In Scala 3.8+, `dotty.tools.repl.ReplDriver` was extracted from
* `scala3-compiler_3` into a new artifact `scala3-repl_3`. The sbt launcher's
* Scala provider only includes `scala3-compiler_3`, so we need to resolve
* `scala3-repl_3` explicitly for `consoleProject` to work.
*
* Returns an empty sequence for Scala versions < 3.8.
*
* @see https://github.com/sbt/sbt/issues/7722
* @see https://github.com/scala/scala3/pull/24243
*/
private[sbt] def scala3ReplToolJars(
scalaVersion: String,
scalaOrg: String,
dr: DependencyResolution,
retrieveDir: File,
log: Logger
): Seq[File] =
if !ScalaArtifacts.isScala3_8Plus(scalaVersion) then Nil
else
// Scala 3 artifacts use the `_3` suffix, not `_3.8`. Bake the suffix into
// the artifact name and disable cross-version resolution, because the
// caller's scalaModuleInfo may be None or reflect a different project
// Scala version than the one we actually need.
val replModule = ModuleID(scalaOrg, s"${ScalaArtifacts.Scala3ReplID}_3", scalaVersion)
.withCrossVersion(CrossVersion.disabled)
dr.retrieve(replModule, scalaModuleInfo = None, retrieveDir, log) match
case Right(resolved) => resolved.toSeq
case Left(unresolved) =>
log.warn(
s"Could not resolve $replModule for consoleProject; REPL may fail to start: ${unresolved.resolveException.getMessage}"
)
Nil
/**
* Resolves the pre-built Scala 3 compiler bridge jar for `consoleProject`.
*
* The default `scalaCompilerBridgeBin` uses the project's Scala version, but
* `consoleProject` compiles the build definition with sbt's own Scala
* version. Resolve the pre-built bridge directly for that version to keep
* the bridge consistent with `consoleProject / scalaInstance`.
*
* The bridge is a Java-compiled jar (cross-version disabled).
*
* @see https://github.com/sbt/sbt/issues/7722
*/
private[sbt] def scala3ConsoleProjectBridgeJar(
scalaVersion: String,
scalaOrg: String,
dr: DependencyResolution,
retrieveDir: File,
log: Logger
): Option[File] =
if !ScalaArtifacts.isScala3(scalaVersion) then None
else
val bridgeModule = ModuleID(scalaOrg, "scala3-sbt-bridge", scalaVersion)
.withCrossVersion(CrossVersion.disabled)
dr.retrieve(bridgeModule, scalaModuleInfo = None, retrieveDir, log) match
case Right(resolved) => resolved.find(_.getName.startsWith("scala3-sbt-bridge"))
case Left(unresolved) =>
log.warn(
s"Could not resolve $bridgeModule for consoleProject: ${unresolved.resolveException.getMessage}"
)
None
def scalaInstanceConfigFromHome(dir: File): Def.Initialize[Task[ScalaInstanceConfig]] =
Def.task {
val dummy = ScalaInstance(dir)(Keys.state.value.classLoaderCache.apply)

View File

@ -53,7 +53,9 @@ object ConsoleProject:
): Unit = {
val extracted = Project.extract(state)
val cpImports = new Imports(extracted, state)
// Bindings are blocked by https://github.com/scala/scala3/issues/5069
// Bindings are ignored by Scala 3 bridge: https://github.com/scala/scala3/issues/5069
// Workaround: vals are injected via initialCommands from ConsoleProjectBindings holder.
// bindings are still passed to Console for Scala 2 backward compatibility.
val bindings =
("currentState" -> state) :: ("extracted" -> extracted) :: ("cpHelpers" -> cpImports) :: Nil
val unit = extracted.currentUnit
@ -86,20 +88,29 @@ object ConsoleProject:
classLoaderCache = state.get(BasicKeys.classLoaderCache),
log = log
)
val imports = BuildUtil.getImports(unit.unit) ++ BuildUtil.importAll(bindings.map(_._1))
val importString = imports.mkString("", ";\n", ";\n\n")
val initCommands = importString + extra
ConsoleProjectBindings.set(state, extracted, cpImports)
val baseImports = BuildUtil.getImports(unit.unit)
val bindingDefs = Seq(
"val currentState = _root_.sbt.internal.ConsoleProjectBindings.state",
"val extracted = _root_.sbt.internal.ConsoleProjectBindings.extracted",
"val cpHelpers = _root_.sbt.internal.ConsoleProjectBindings.cpHelpers",
)
val bindingImports = BuildUtil.importAll(bindings.map(_._1))
val allLines = baseImports ++ bindingDefs ++ bindingImports
val initCommands = allLines.mkString("", ";\n", ";\n\n") + extra
val loader = ClasspathUtil.makeLoader(unit.classpath, si, tempDir)
val terminal = Terminal.get
// TODO - Hook up dsl classpath correctly...
(new Console(compiler))(
unit.classpath.map(_.toFile),
options,
initCommands,
cleanupCommands,
terminal
)(Some(loader), bindings).get
()
try
(new Console(compiler))(
unit.classpath.map(_.toFile),
options,
initCommands,
cleanupCommands,
terminal
)(Some(loader), bindings).get
()
finally ConsoleProjectBindings.clear()
}
/** Conveniences for consoleProject that shouldn't normally be used for builds. */

View File

@ -0,0 +1,43 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
import scala.compiletime.uninitialized
/**
* Static holder for consoleProject bindings.
*
* Scala 3 compiler bridge does not implement REPL binding injection
* (https://github.com/scala/scala3/issues/5069), so we store the runtime
* objects here and generate `val` definitions in `initialCommands` that
* read from this holder.
*
* Must NOT be `private[sbt]` because the Scala 3 REPL compiles
* `initialCommands` outside the `sbt` package.
*/
object ConsoleProjectBindings:
@volatile private var _state: State = uninitialized
@volatile private var _extracted: Extracted = uninitialized
@volatile private var _cpHelpers: ConsoleProject.Imports = uninitialized
def set(state: State, extracted: Extracted, cpHelpers: ConsoleProject.Imports): Unit =
_state = state
_extracted = extracted
_cpHelpers = cpHelpers
def clear(): Unit =
_state = null
_extracted = null
_cpHelpers = null
def state: State = _state
def extracted: Extracted = _extracted
def cpHelpers: ConsoleProject.Imports = _cpHelpers
end ConsoleProjectBindings

View File

@ -1,3 +1,11 @@
ThisBuild / scalaVersion := "3.0.0-M2"
ThisBuild / scalaVersion := "3.7.4"
lazy val root = project.in(file("."))
lazy val root = project.in(file(".")).settings(
// Verify that consoleProject bindings are accessible in the Scala 3 REPL.
// These assertions run as initialCommands; if any binding is null, the REPL fails.
consoleProject / initialCommands :=
"""assert(currentState != null, "currentState binding missing")
|assert(extracted != null, "extracted binding missing")
|assert(cpHelpers != null, "cpHelpers binding missing")
|""".stripMargin,
)