From 673789111be3aaf3a13279ce7e81b97e37fedafc Mon Sep 17 00:00:00 2001 From: eureka928 Date: Sun, 12 Apr 2026 08:57:24 +0200 Subject: [PATCH] [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 --- main/src/main/scala/sbt/Defaults.scala | 55 ++++++++++++--- .../main/scala/sbt/internal/Compiler.scala | 69 +++++++++++++++++++ .../scala/sbt/internal/ConsoleProject.scala | 35 ++++++---- .../sbt/internal/ConsoleProjectBindings.scala | 43 ++++++++++++ .../project/scala3-console-project/build.sbt | 12 +++- .../scala3-console-project/{pending => test} | 0 6 files changed, 190 insertions(+), 24 deletions(-) create mode 100644 main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala rename sbt-app/src/sbt-test/project/scala3-console-project/{pending => test} (100%) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 90b9a5655..7ec2d417b 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -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, ) }, diff --git a/main/src/main/scala/sbt/internal/Compiler.scala b/main/src/main/scala/sbt/internal/Compiler.scala index 57e9c1604..879a4bf44 100644 --- a/main/src/main/scala/sbt/internal/Compiler.scala +++ b/main/src/main/scala/sbt/internal/Compiler.scala @@ -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) diff --git a/main/src/main/scala/sbt/internal/ConsoleProject.scala b/main/src/main/scala/sbt/internal/ConsoleProject.scala index 35fd97906..ae6b1703e 100644 --- a/main/src/main/scala/sbt/internal/ConsoleProject.scala +++ b/main/src/main/scala/sbt/internal/ConsoleProject.scala @@ -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. */ diff --git a/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala b/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala new file mode 100644 index 000000000..15a2b9caf --- /dev/null +++ b/main/src/main/scala/sbt/internal/ConsoleProjectBindings.scala @@ -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 diff --git a/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt b/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt index 208b8c684..9befa34ba 100644 --- a/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt +++ b/sbt-app/src/sbt-test/project/scala3-console-project/build.sbt @@ -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, +) diff --git a/sbt-app/src/sbt-test/project/scala3-console-project/pending b/sbt-app/src/sbt-test/project/scala3-console-project/test similarity index 100% rename from sbt-app/src/sbt-test/project/scala3-console-project/pending rename to sbt-app/src/sbt-test/project/scala3-console-project/test