Skip to content

Commit 827b9cb

Browse files
committed
Auto-detect min/max supported JVM for a given Scala version
1 parent b6aef3f commit 827b9cb

10 files changed

Lines changed: 329 additions & 12 deletions

File tree

modules/build/src/main/scala/scala/build/Build.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,9 @@ object Build {
629629
val classesDir0 = classesRootDir(inputs.workspace, inputs.projectName)
630630
val (crossSources: CrossSources, inputs0) = value(allInputs(inputs, options, logger))
631631
val buildOptions = crossSources.sharedOptions(options)
632+
// Resolve JVM and emit Scala/JVM compatibility warnings before compilation.
633+
if buildOptions.platform.value == Platform.JVM then
634+
buildOptions.checkAndResolveJavaHome(logger)
632635
if !buildOptions.suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) &&
633636
buildOptions.scalaParams.exists(_.exists(_.scalaVersion == "2.12.4") &&
634637
!buildOptions.useBuildServer.contains(false))

modules/build/src/main/scala/scala/build/bsp/BspImpl.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ final class BspImpl(
578578
)
579579
case Right(preBuildProject) =>
580580
lazy val projectJavaHome = preBuildProject.mainScope.buildOptions
581-
.javaHome()
581+
.checkAndResolveJavaHome(reloadableOptions.logger)
582582
.value
583583

584584
val finalBloopSession =

modules/build/src/test/scala/scala/build/tests/BuildOptionsTests.scala

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import scala.build.Ops.*
1111
import scala.build.errors.{
1212
InvalidBinaryScalaVersionError,
1313
NoValidScalaVersionFoundError,
14+
ScalaJvmIncompatibleError,
1415
ScalaVersionError,
1516
UnsupportedScalaVersionError
1617
}
@@ -451,6 +452,19 @@ class BuildOptionsTests extends TestUtil.ScalaCliBuildSuite {
451452
expect(semanticDbVersion == "4.8.4")
452453
}
453454

455+
test("explicit too-old JVM for Scala 3.8+ fails with a clear error") {
456+
val scalaVersion =
457+
if defaultScalaVersion.startsWith("3.8") then defaultScalaVersion
458+
else "3.8.3"
459+
val options = BuildOptions(
460+
scalaOptions = ScalaOptions(scalaVersion = Some(MaybeScalaVersion(scalaVersion))),
461+
javaOptions = JavaOptions(jvmIdOpt = Some(Positioned.none("11")))
462+
)
463+
val ex = intercept[Exception](options.checkAndResolveJavaHome(TestLogger())).getCause
464+
.asInstanceOf[ScalaJvmIncompatibleError]
465+
expect(ex.getMessage.contains("requires at least Java 17"))
466+
}
467+
454468
test("skip setting release option when -release or -java-output-version is set by user") {
455469
val javaOutputVersionOpt =
456470
s"-java-output-version:${scala.build.internal.Constants.scala38MinJavaVersion}"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package scala.build.tests
2+
3+
import com.eed3si9n.expecty.Expecty.assert as expect
4+
5+
import scala.build.internal.ScalaJdkCompat
6+
7+
class ScalaJdkCompatTests extends munit.FunSuite {
8+
9+
test("normalizeScalaVersion strips suffixes") {
10+
expect(ScalaJdkCompat.normalizeScalaVersion("3.7.4-RC1") == "3.7.4")
11+
expect(ScalaJdkCompat.normalizeScalaVersion("3.8.3-nightly-20250101") == "3.8.3")
12+
expect(ScalaJdkCompat.normalizeScalaVersion("2.13.18-bin-abcd") == "2.13.18")
13+
expect(ScalaJdkCompat.normalizeScalaVersion("3.3.7") == "3.3.7")
14+
}
15+
16+
test("Scala 3.8+ requires JDK 17") {
17+
val compat = ScalaJdkCompat.forScalaVersion("3.8.3").get
18+
expect(compat.minJdk == 17)
19+
expect(compat.maxRecommendedJdk == 26)
20+
expect(ScalaJdkCompat.forScalaVersion("3.8.0-RC2").get.minJdk == 17)
21+
}
22+
23+
test("Scala 3.7.4 supports JDK 8-25") {
24+
val compat = ScalaJdkCompat.forScalaVersion("3.7.4").get
25+
expect(compat.minJdk == 8)
26+
expect(compat.maxRecommendedJdk == 25)
27+
expect(ScalaJdkCompat.forScalaVersion("3.7.4-RC1").get == compat)
28+
}
29+
30+
test("Scala 3.7.0 supports JDK 8-21") {
31+
val compat = ScalaJdkCompat.forScalaVersion("3.7.0").get
32+
expect(compat.maxRecommendedJdk == 21)
33+
}
34+
35+
test("Scala 3.3 LTS patch-dependent max JDK") {
36+
expect(ScalaJdkCompat.forScalaVersion("3.3.0").get.maxRecommendedJdk == 17)
37+
expect(ScalaJdkCompat.forScalaVersion("3.3.1").get.maxRecommendedJdk == 21)
38+
expect(ScalaJdkCompat.forScalaVersion("3.3.7").get.maxRecommendedJdk == 25)
39+
expect(ScalaJdkCompat.forScalaVersion("3.3.8").get.maxRecommendedJdk == 26)
40+
}
41+
42+
test("Scala 3.4-3.6 supports JDK 8-21") {
43+
expect(ScalaJdkCompat.forScalaVersion("3.4.0").get.maxRecommendedJdk == 21)
44+
expect(ScalaJdkCompat.forScalaVersion("3.6.4").get.maxRecommendedJdk == 21)
45+
}
46+
47+
test("Scala 2.13 patch-dependent max JDK") {
48+
expect(ScalaJdkCompat.forScalaVersion("2.13.5").get.maxRecommendedJdk == 11)
49+
expect(ScalaJdkCompat.forScalaVersion("2.13.10").get.maxRecommendedJdk == 17)
50+
expect(ScalaJdkCompat.forScalaVersion("2.13.17").get.maxRecommendedJdk == 25)
51+
expect(ScalaJdkCompat.forScalaVersion("2.13.18").get.maxRecommendedJdk == 26)
52+
}
53+
54+
test("Scala 2.12 patch-dependent max JDK") {
55+
expect(ScalaJdkCompat.forScalaVersion("2.12.3").get.maxRecommendedJdk == 8)
56+
expect(ScalaJdkCompat.forScalaVersion("2.12.18").get.maxRecommendedJdk == 21)
57+
expect(ScalaJdkCompat.forScalaVersion("2.12.21").get.maxRecommendedJdk == 26)
58+
}
59+
60+
test("unknown Scala version returns None") {
61+
expect(ScalaJdkCompat.forScalaVersion("4.0.0").isEmpty)
62+
expect(ScalaJdkCompat.forScalaVersion("not-a-version").isEmpty)
63+
}
64+
}

modules/cli/src/main/scala/scala/cli/commands/run/Run.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import scala.build.EitherCps.{either, value}
1414
import scala.build.Ops.*
1515
import scala.build.errors.{BuildException, CompositeBuildException}
1616
import scala.build.input.*
17-
import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig}
17+
import scala.build.internal.{Constants, Runner, ScalaJdkCompat, ScalaJsLinkerConfig}
1818
import scala.build.internals.ConsoleUtils.ScalaCliConsole
1919
import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix
2020
import scala.build.internals.EnvVar
@@ -81,9 +81,13 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
8181
jvmIdOpt = baseOptions.javaOptions.jvmIdOpt.orElse {
8282
runMode(options) match {
8383
case _: RunMode.Spark | RunMode.HadoopJar =>
84-
val sparkOrHadoopDefaultJvm = "8"
84+
val javaMin = Constants.mainJavaVersions.min
85+
val scalaMin = baseOptions.scalaParams.toOption.flatten
86+
.flatMap(sp => ScalaJdkCompat.forScalaVersion(sp.scalaVersion).map(_.minJdk))
87+
.getOrElse(javaMin)
88+
val sparkOrHadoopDefaultJvm = math.max(javaMin, scalaMin).toString
8589
logger.message(
86-
s"Defaulting the JVM to $sparkOrHadoopDefaultJvm for Spark/Hadoop runs."
90+
s"Defaulting the JVM to $sparkOrHadoopDefaultJvm for Spark/Hadoop runs."
8791
)
8892
Some(Positioned.none(sparkOrHadoopDefaultJvm))
8993
case RunMode.Default => None
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package scala.build.errors
2+
3+
import scala.build.Position
4+
5+
final class ScalaJvmIncompatibleError(
6+
scalaVersion: String,
7+
jvmVersion: Int,
8+
minJdk: Int,
9+
jvmOrigin: String,
10+
override val positions: Seq[Position] = Nil
11+
) extends BuildException(
12+
s"""Scala $scalaVersion requires at least Java $minJdk, but $jvmOrigin is Java $jvmVersion.
13+
|Pass `--jvm $minJdk` or higher, or use `//> using jvm $minJdk`.""".stripMargin
14+
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package scala.build.internal
2+
3+
/** Scala version ↔ JDK compatibility ranges.
4+
*
5+
* Sourced from https://docs.scala-lang.org/overviews/jdk-compatibility/overview.html
6+
*
7+
* Non-stable versions (RC, nightly, custom suffixes) are normalised by stripping everything from
8+
* the first `-` onward (e.g. `3.7.4-RC1` → `3.7.4`).
9+
*
10+
* @param maxRecommendedJdk
11+
* highest JDK version Scala is tested with for this line; warn when a newer JDK is used. For
12+
* Scala 3.8+, this tracks the latest released JDK and should be bumped when new JDKs ship.
13+
*/
14+
final case class ScalaJdkCompat(minJdk: Int, maxRecommendedJdk: Int)
15+
16+
object ScalaJdkCompat {
17+
18+
def normalizeScalaVersion(scalaVersion: String): String =
19+
val dash = scalaVersion.indexOf('-')
20+
if dash < 0 then scalaVersion
21+
else scalaVersion.substring(0, dash)
22+
23+
def forScalaVersion(scalaVersion: String): Option[ScalaJdkCompat] =
24+
parseVersion(normalizeScalaVersion(scalaVersion)).flatMap(compatFor.tupled)
25+
26+
private def parseVersion(version: String): Option[(Int, Int, Int)] =
27+
val parts = version.split('.')
28+
if parts.length < 2 then None
29+
else
30+
for
31+
major <- parts(0).toIntOption
32+
minor <- parts(1).toIntOption
33+
patch = if parts.length >= 3 then parts(2).takeWhile(_.isDigit).toIntOption.getOrElse(0)
34+
else 0
35+
yield (major, minor, patch)
36+
37+
private def patchTable(table: Seq[(Int, Int)])(patch: Int): Int =
38+
table.reverseIterator.collectFirst { case (threshold, jdk) if patch >= threshold => jdk }
39+
.getOrElse(table.head._2)
40+
41+
private val table_2_12 = Seq(0 -> 8, 4 -> 11, 15 -> 17, 18 -> 21, 21 -> 26)
42+
private val table_2_13 = Seq(0 -> 11, 6 -> 17, 11 -> 21, 17 -> 25, 18 -> 26)
43+
private val table_3_3 = Seq(0 -> 17, 1 -> 21, 6 -> 25, 8 -> 26)
44+
45+
private def compatFor(major: Int, minor: Int, patch: Int): Option[ScalaJdkCompat] =
46+
(major, minor) match
47+
case (2, 12) => Some(ScalaJdkCompat(8, patchTable(table_2_12)(patch)))
48+
case (2, 13) => Some(ScalaJdkCompat(8, patchTable(table_2_13)(patch)))
49+
case (3, m) if m >= 8 => Some(ScalaJdkCompat(17, 26))
50+
case (3, 7) => Some(ScalaJdkCompat(8, if patch >= 1 then 25 else 21))
51+
case (3, m) if m >= 4 => Some(ScalaJdkCompat(8, 21))
52+
case (3, 3) => Some(ScalaJdkCompat(8, patchTable(table_3_3)(patch)))
53+
case (3, _) => Some(ScalaJdkCompat(8, 17))
54+
case _ => None
55+
}

modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,12 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions =>
172172
}
173173
}
174174

175-
for javaVersion <- Constants.allJavaVersions.filter(_ >= 11) do
175+
private def minJdkForCurrentScala: Int =
176+
if actualScalaVersion.startsWith("3.8") || actualScalaVersion.startsWith("3.9")
177+
then Constants.scala38MinJavaVersion
178+
else 11
179+
180+
for javaVersion <- Constants.allJavaVersions.filter(_ >= minJdkForCurrentScala) do
176181
test(s"$runInJShellPrefix simple on JDK $javaVersion") {
177182
val versionSpecific = javaVersion match {
178183
case v if v >= 23 =>

modules/integration/src/test/scala/scala/cli/integration/RunJdkTestDefinitions.scala

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,81 @@ trait RunJdkTestDefinitions { this: RunTestDefinitions =>
151151
}
152152
}
153153
}
154+
155+
if (isScala38OrNewer)
156+
for (oldJvm <- Seq(11, 8).filter(Constants.allJavaVersions.contains)) {
157+
test(
158+
s"auto-falls back from JAVA_HOME $oldJvm when Scala $actualScalaVersion requires a newer JDK"
159+
) {
160+
TestUtil.retryOnCi() {
161+
TestInputs(
162+
os.rel / "check_java_version.sc" ->
163+
"""println(System.getProperty("java.version"))""".stripMargin
164+
).fromRoot { root =>
165+
val javaHome =
166+
os.Path(
167+
os.proc(TestUtil.cs, "java-home", "--jvm", oldJvm).call().out.trim(),
168+
os.pwd
169+
)
170+
val res = os
171+
.proc(TestUtil.cli, "run", ".", extraOptions)
172+
.call(cwd = root, env = Map("JAVA_HOME" -> javaHome.toString), stderr = os.Pipe)
173+
val reportedVersion = res.out.trim()
174+
expect(
175+
reportedVersion.startsWith("17") ||
176+
reportedVersion.startsWith("21") ||
177+
reportedVersion.startsWith("23") ||
178+
reportedVersion.startsWith("24") ||
179+
reportedVersion.startsWith("25") ||
180+
reportedVersion.startsWith("26")
181+
)
182+
expect(
183+
res.err.text().contains(s"requires at least Java ${Constants.scala38MinJavaVersion}")
184+
)
185+
}
186+
}
187+
}
188+
189+
test(s"errors on explicit --jvm $oldJvm when Scala $actualScalaVersion requires a newer JDK") {
190+
TestUtil.retryOnCi() {
191+
TestInputs(
192+
os.rel / "hello.sc" -> """println("ok")"""
193+
).fromRoot { root =>
194+
val res = os
195+
.proc(TestUtil.cli, "run", "hello.sc", extraOptions, "--jvm", oldJvm)
196+
.call(cwd = root, check = false, stderr = os.Pipe)
197+
expect(res.exitCode != 0)
198+
expect(
199+
res.err.text().contains(s"requires at least Java ${Constants.scala38MinJavaVersion}")
200+
)
201+
}
202+
}
203+
}
204+
}
205+
206+
{
207+
val newJavaVersion = Constants.allJavaVersions.max
208+
if newJavaVersion > 17 && actualScalaVersion == Constants.defaultScala then
209+
test(s"warns when JVM $newJavaVersion is newer than Scala 3.0.2 supports") {
210+
TestUtil.retryOnCi() {
211+
TestInputs(
212+
os.rel / "hello.sc" -> """println("ok")"""
213+
).fromRoot { root =>
214+
val res = os
215+
.proc(
216+
TestUtil.cli,
217+
"run",
218+
"hello.sc",
219+
TestUtil.extraOptions,
220+
"--jvm",
221+
newJavaVersion,
222+
"-S",
223+
"3.0.2"
224+
)
225+
.call(cwd = root, check = false, stderr = os.Pipe)
226+
expect(res.err.text().contains("only tested up to JDK 17"))
227+
}
228+
}
229+
}
230+
}
154231
}

0 commit comments

Comments
 (0)