Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/build/src/main/scala/scala/build/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@ object Build {
val classesDir0 = classesRootDir(inputs.workspace, inputs.projectName)
val (crossSources: CrossSources, inputs0) = value(allInputs(inputs, options, logger))
val buildOptions = crossSources.sharedOptions(options)
// Resolve JVM and emit Scala/JVM compatibility warnings before compilation.
if buildOptions.platform.value == Platform.JVM then
buildOptions.checkAndResolveJavaHome(logger)
if !buildOptions.suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) &&
buildOptions.scalaParams.exists(_.exists(_.scalaVersion == "2.12.4") &&
!buildOptions.useBuildServer.contains(false))
Expand Down
2 changes: 1 addition & 1 deletion modules/build/src/main/scala/scala/build/bsp/BspImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ final class BspImpl(
)
case Right(preBuildProject) =>
lazy val projectJavaHome = preBuildProject.mainScope.buildOptions
.javaHome()
.checkAndResolveJavaHome(reloadableOptions.logger)
.value

val finalBloopSession =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import scala.build.Ops.*
import scala.build.errors.{
InvalidBinaryScalaVersionError,
NoValidScalaVersionFoundError,
ScalaJvmIncompatibleError,
ScalaVersionError,
UnsupportedScalaVersionError
}
Expand Down Expand Up @@ -451,6 +452,19 @@ class BuildOptionsTests extends TestUtil.ScalaCliBuildSuite {
expect(semanticDbVersion == "4.8.4")
}

test("explicit too-old JVM for Scala 3.8+ fails with a clear error") {
val scalaVersion =
if defaultScalaVersion.startsWith("3.8") then defaultScalaVersion
else "3.8.3"
val options = BuildOptions(
scalaOptions = ScalaOptions(scalaVersion = Some(MaybeScalaVersion(scalaVersion))),
javaOptions = JavaOptions(jvmIdOpt = Some(Positioned.none("11")))
)
val ex = intercept[Exception](options.checkAndResolveJavaHome(TestLogger())).getCause
.asInstanceOf[ScalaJvmIncompatibleError]
expect(ex.getMessage.contains("requires at least Java 17"))
}

test("skip setting release option when -release or -java-output-version is set by user") {
val javaOutputVersionOpt =
s"-java-output-version:${scala.build.internal.Constants.scala38MinJavaVersion}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package scala.build.tests

import com.eed3si9n.expecty.Expecty.assert as expect

import scala.build.internal.ScalaJdkCompat

class ScalaJdkCompatTests extends munit.FunSuite {

test("normalizeScalaVersion strips suffixes") {
expect(ScalaJdkCompat.normalizeScalaVersion("3.7.4-RC1") == "3.7.4")
expect(ScalaJdkCompat.normalizeScalaVersion("3.8.3-nightly-20250101") == "3.8.3")
expect(ScalaJdkCompat.normalizeScalaVersion("2.13.18-bin-abcd") == "2.13.18")
expect(ScalaJdkCompat.normalizeScalaVersion("3.3.7") == "3.3.7")
}

test("Scala 3.8+ requires JDK 17") {
val compat = ScalaJdkCompat.forScalaVersion("3.8.3").get
expect(compat.minJdk == 17)
expect(compat.maxRecommendedJdk == 26)
expect(ScalaJdkCompat.forScalaVersion("3.8.0-RC2").get.minJdk == 17)
}

test("Scala 3.7.4 supports JDK 8-25") {
val compat = ScalaJdkCompat.forScalaVersion("3.7.4").get
expect(compat.minJdk == 8)
expect(compat.maxRecommendedJdk == 25)
expect(ScalaJdkCompat.forScalaVersion("3.7.4-RC1").get == compat)
}

test("Scala 3.7.0 supports JDK 8-21") {
val compat = ScalaJdkCompat.forScalaVersion("3.7.0").get
expect(compat.maxRecommendedJdk == 21)
}

test("Scala 3.3 LTS patch-dependent max JDK") {
expect(ScalaJdkCompat.forScalaVersion("3.3.0").get.maxRecommendedJdk == 17)
expect(ScalaJdkCompat.forScalaVersion("3.3.1").get.maxRecommendedJdk == 21)
expect(ScalaJdkCompat.forScalaVersion("3.3.7").get.maxRecommendedJdk == 25)
expect(ScalaJdkCompat.forScalaVersion("3.3.8").get.maxRecommendedJdk == 26)
}

test("Scala 3.4-3.6 supports JDK 8-21") {
expect(ScalaJdkCompat.forScalaVersion("3.4.0").get.maxRecommendedJdk == 21)
expect(ScalaJdkCompat.forScalaVersion("3.6.4").get.maxRecommendedJdk == 21)
}

test("Scala 2.13 patch-dependent max JDK") {
expect(ScalaJdkCompat.forScalaVersion("2.13.5").get.maxRecommendedJdk == 11)
expect(ScalaJdkCompat.forScalaVersion("2.13.10").get.maxRecommendedJdk == 17)
expect(ScalaJdkCompat.forScalaVersion("2.13.17").get.maxRecommendedJdk == 25)
expect(ScalaJdkCompat.forScalaVersion("2.13.18").get.maxRecommendedJdk == 26)
}

test("Scala 2.12 patch-dependent max JDK") {
expect(ScalaJdkCompat.forScalaVersion("2.12.3").get.maxRecommendedJdk == 8)
expect(ScalaJdkCompat.forScalaVersion("2.12.18").get.maxRecommendedJdk == 21)
expect(ScalaJdkCompat.forScalaVersion("2.12.21").get.maxRecommendedJdk == 26)
}

test("unknown Scala version returns None") {
expect(ScalaJdkCompat.forScalaVersion("4.0.0").isEmpty)
expect(ScalaJdkCompat.forScalaVersion("not-a-version").isEmpty)
}
}
10 changes: 7 additions & 3 deletions modules/cli/src/main/scala/scala/cli/commands/run/Run.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import scala.build.EitherCps.{either, value}
import scala.build.Ops.*
import scala.build.errors.{BuildException, CompositeBuildException}
import scala.build.input.*
import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig}
import scala.build.internal.{Constants, Runner, ScalaJdkCompat, ScalaJsLinkerConfig}
import scala.build.internals.ConsoleUtils.ScalaCliConsole
import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix
import scala.build.internals.EnvVar
Expand Down Expand Up @@ -81,9 +81,13 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
jvmIdOpt = baseOptions.javaOptions.jvmIdOpt.orElse {
runMode(options) match {
case _: RunMode.Spark | RunMode.HadoopJar =>
val sparkOrHadoopDefaultJvm = "8"
val javaMin = Constants.mainJavaVersions.min
val scalaMin = baseOptions.scalaParams.toOption.flatten
.flatMap(sp => ScalaJdkCompat.forScalaVersion(sp.scalaVersion).map(_.minJdk))
.getOrElse(javaMin)
val sparkOrHadoopDefaultJvm = math.max(javaMin, scalaMin).toString
logger.message(
s"Defaulting the JVM to $sparkOrHadoopDefaultJvm for Spark/Hadoop runs."
s"Defaulting the JVM to $sparkOrHadoopDefaultJvm for Spark/Hadoop runs."
)
Some(Positioned.none(sparkOrHadoopDefaultJvm))
case RunMode.Default => None
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package scala.build.errors

import scala.build.Position

final class ScalaJvmIncompatibleError(
scalaVersion: String,
jvmVersion: Int,
minJdk: Int,
jvmOrigin: String,
override val positions: Seq[Position] = Nil
) extends BuildException(
s"""Scala $scalaVersion requires at least Java $minJdk, but $jvmOrigin is Java $jvmVersion.
|Pass `--jvm $minJdk` or higher, or use `//> using jvm $minJdk`.""".stripMargin
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package scala.build.internal

/** Scala version ↔ JDK compatibility ranges.
*
* Sourced from https://docs.scala-lang.org/overviews/jdk-compatibility/overview.html
*
* Non-stable versions (RC, nightly, custom suffixes) are normalised by stripping everything from
* the first `-` onward (e.g. `3.7.4-RC1` → `3.7.4`).
*
* @param maxRecommendedJdk
* highest JDK version Scala is tested with for this line; warn when a newer JDK is used. For
* Scala 3.8+, this tracks the latest released JDK and should be bumped when new JDKs ship.
*/
final case class ScalaJdkCompat(minJdk: Int, maxRecommendedJdk: Int)

object ScalaJdkCompat {

def normalizeScalaVersion(scalaVersion: String): String =
val dash = scalaVersion.indexOf('-')
if dash < 0 then scalaVersion
else scalaVersion.substring(0, dash)

def forScalaVersion(scalaVersion: String): Option[ScalaJdkCompat] =
parseVersion(normalizeScalaVersion(scalaVersion)).flatMap(compatFor.tupled)

private def parseVersion(version: String): Option[(Int, Int, Int)] =
val parts = version.split('.')
if parts.length < 2 then None
else
for
major <- parts(0).toIntOption
minor <- parts(1).toIntOption
patch = if parts.length >= 3 then parts(2).takeWhile(_.isDigit).toIntOption.getOrElse(0)
else 0
yield (major, minor, patch)

private def patchTable(table: Seq[(Int, Int)])(patch: Int): Int =
table.reverseIterator.collectFirst { case (threshold, jdk) if patch >= threshold => jdk }
.getOrElse(table.head._2)

private val table_2_12 = Seq(0 -> 8, 4 -> 11, 15 -> 17, 18 -> 21, 21 -> 26)
private val table_2_13 = Seq(0 -> 11, 6 -> 17, 11 -> 21, 17 -> 25, 18 -> 26)
private val table_3_3 = Seq(0 -> 17, 1 -> 21, 6 -> 25, 8 -> 26)

private def compatFor(major: Int, minor: Int, patch: Int): Option[ScalaJdkCompat] =
(major, minor) match
case (2, 12) => Some(ScalaJdkCompat(8, patchTable(table_2_12)(patch)))
case (2, 13) => Some(ScalaJdkCompat(8, patchTable(table_2_13)(patch)))
case (3, m) if m >= 8 => Some(ScalaJdkCompat(17, 26))
case (3, 7) => Some(ScalaJdkCompat(8, if patch >= 1 then 25 else 21))
case (3, m) if m >= 4 => Some(ScalaJdkCompat(8, 21))
case (3, 3) => Some(ScalaJdkCompat(8, patchTable(table_3_3)(patch)))
case (3, _) => Some(ScalaJdkCompat(8, 17))
case _ => None
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions =>
}
}

for javaVersion <- Constants.allJavaVersions.filter(_ >= 11) do
private def minJdkForCurrentScala: Int =
if actualScalaVersion.startsWith("3.8") || actualScalaVersion.startsWith("3.9")
then Constants.scala38MinJavaVersion
else 11

for javaVersion <- Constants.allJavaVersions.filter(_ >= minJdkForCurrentScala) do
test(s"$runInJShellPrefix simple on JDK $javaVersion") {
val versionSpecific = javaVersion match {
case v if v >= 23 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,81 @@ trait RunJdkTestDefinitions { this: RunTestDefinitions =>
}
}
}

if (isScala38OrNewer)
for (oldJvm <- Seq(11, 8).filter(Constants.allJavaVersions.contains)) {
test(
s"auto-falls back from JAVA_HOME $oldJvm when Scala $actualScalaVersion requires a newer JDK"
) {
TestUtil.retryOnCi() {
TestInputs(
os.rel / "check_java_version.sc" ->
"""println(System.getProperty("java.version"))""".stripMargin
).fromRoot { root =>
val javaHome =
os.Path(
os.proc(TestUtil.cs, "java-home", "--jvm", oldJvm).call().out.trim(),
os.pwd
)
val res = os
.proc(TestUtil.cli, "run", ".", extraOptions)
.call(cwd = root, env = Map("JAVA_HOME" -> javaHome.toString), stderr = os.Pipe)
val reportedVersion = res.out.trim()
expect(
reportedVersion.startsWith("17") ||
reportedVersion.startsWith("21") ||
reportedVersion.startsWith("23") ||
reportedVersion.startsWith("24") ||
reportedVersion.startsWith("25") ||
reportedVersion.startsWith("26")
)
expect(
res.err.text().contains(s"requires at least Java ${Constants.scala38MinJavaVersion}")
)
}
}
}

test(s"errors on explicit --jvm $oldJvm when Scala $actualScalaVersion requires a newer JDK") {
TestUtil.retryOnCi() {
TestInputs(
os.rel / "hello.sc" -> """println("ok")"""
).fromRoot { root =>
val res = os
.proc(TestUtil.cli, "run", "hello.sc", extraOptions, "--jvm", oldJvm)
.call(cwd = root, check = false, stderr = os.Pipe)
expect(res.exitCode != 0)
expect(
res.err.text().contains(s"requires at least Java ${Constants.scala38MinJavaVersion}")
)
}
}
}
}

{
val newJavaVersion = Constants.allJavaVersions.max
if newJavaVersion > 17 && actualScalaVersion == Constants.defaultScala then
test(s"warns when JVM $newJavaVersion is newer than Scala 3.0.2 supports") {
TestUtil.retryOnCi() {
TestInputs(
os.rel / "hello.sc" -> """println("ok")"""
).fromRoot { root =>
val res = os
.proc(
TestUtil.cli,
"run",
"hello.sc",
TestUtil.extraOptions,
"--jvm",
newJavaVersion,
"-S",
"3.0.2"
)
.call(cwd = root, check = false, stderr = os.Pipe)
expect(res.err.text().contains("only tested up to JDK 17"))
}
}
}
}
}
Loading
Loading