diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 3fa7899067..daaea1d932 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -401,14 +401,14 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { destPath case PackageType.GraalVMNativeImage => - NativeImage.buildNativeImage( + value(NativeImage.buildNativeImage( build, value(mainClass), destPath, build.inputs.nativeImageWorkDir, extraArgs, logger - ) + )) destPath case nativePackagerType: PackageType.NativePackagerType => diff --git a/modules/cli/src/main/scala/scala/cli/commands/util/JvmUtils.scala b/modules/cli/src/main/scala/scala/cli/commands/util/JvmUtils.scala index b8bcdd312e..b47221a6cf 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/util/JvmUtils.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/util/JvmUtils.scala @@ -1,6 +1,8 @@ package scala.cli.commands package util +import coursier.jvm.JvmIndexEntry + import java.io.File import scala.build.EitherCps.{either, value} @@ -13,6 +15,7 @@ import scala.build.{Os, Position, Positioned, options as bo} import scala.cli.commands.shared.{CoursierOptions, SharedJvmOptions, SharedOptions} import scala.concurrent.ExecutionContextExecutorService import scala.util.control.NonFatal +import scala.util.matching.Regex import scala.util.{Failure, Properties, Success, Try} object JvmUtils { @@ -118,4 +121,35 @@ object JvmUtils { javaCmd <- getJavaCmdVersionOrHigher(javaVersion, options) } yield javaCmd } + + /** Returns the available JVMs for the given version and name. At least one of the two must be + * specified (otherwise, an empty sequence is returned). + */ + def getMatchingJvms( + buildOptions: bo.BuildOptions, + versionOpt: Option[String], + nameOpt: Option[String] = None + ): Seq[JvmIndexEntry] = + for { + jvmCache <- buildOptions.javaHomeManager.cache.toSeq + jvmId <- + nameOpt.fold(versionOpt.toSeq)(n => Seq(s"$n${versionOpt.fold("")(_.prepended('@'))}")) + entry <- jvmCache.entries(jvmId) + .unsafeRun()(buildOptions.finalCache.ec) + .toSeq + .flatten + } yield entry + + /** Returns the IDs of the available JVMs that match the specified Id regex. */ + def getJvmsById( + buildOptions: bo.BuildOptions, + regex: Regex + ): Seq[String] = for { + jvmCache <- buildOptions.javaHomeManager.cache.toSeq + jvmIndexTask <- jvmCache.index.toSeq + jvmIndex = jvmIndexTask.unsafeRun()(buildOptions.finalCache.ec) + index <- jvmIndex.available().toSeq + (id, /*versionIndex*/ _) <- index + if regex.matches(id) + } yield id } diff --git a/modules/cli/src/main/scala/scala/cli/errors/GraalVMNativeImageError.scala b/modules/cli/src/main/scala/scala/cli/errors/GraalVMNativeImageError.scala index f2dcdf65b2..87dc8945f8 100644 --- a/modules/cli/src/main/scala/scala/cli/errors/GraalVMNativeImageError.scala +++ b/modules/cli/src/main/scala/scala/cli/errors/GraalVMNativeImageError.scala @@ -2,5 +2,5 @@ package scala.cli.errors import scala.build.errors.BuildException -final class GraalVMNativeImageError() - extends BuildException(s"Error building native image with GraalVM") +final class GraalVMNativeImageError(msg: String = "Error building native image with GraalVM") + extends BuildException(msg) diff --git a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala index e431b0ec75..3fb62b43ae 100644 --- a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala +++ b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala @@ -3,11 +3,17 @@ package scala.cli.packaging import java.io.File import scala.annotation.tailrec +import scala.build.EitherCps.{either, value} +import scala.build.errors.BuildException import scala.build.internal.{ManifestJar, Runner} +import scala.build.options.BuildOptions +import scala.build.options.BuildOptions.JavaHomeInfo import scala.build.{Build, Logger, Positioned} +import scala.cli.commands.util.JvmUtils import scala.cli.errors.GraalVMNativeImageError import scala.cli.graal.{BytecodeProcessor, TempCache} import scala.cli.internal.CachedBinary +import scala.concurrent.ExecutionContext import scala.util.Properties object NativeImage { @@ -35,6 +41,94 @@ object NativeImage { nativeImage } + /** Check whether the JVM used for compilation is compatible with the chosen GraalVM */ + private def getCompatibleGraalVmId( + build: Build.Successful, + logger: Logger + ): Either[GraalVMNativeImageError, String] = { + def defaultError(details: String) = + new GraalVMNativeImageError("Couldn't fetch a correct GraalVM JVM: " + details) + + val options = build.options + val cacheEc = options.finalCache.ec + val Positioned(buildJvmSource, buildJavaHome) = options.javaHome() + val buildJvmVersion = buildJavaHome.version + + val forcedGraalJvmVersion = + build.options.notForBloopOptions.packageOptions.nativeImageOptions.graalvmJavaVersion + + if (forcedGraalJvmVersion.exists(_ < buildJvmVersion)) + Left(GraalVMNativeImageError( + s"""Cannot build a native image with a JVM older than the one used for compilation. + |Specified Versions: + | - compilation JVM: $buildJvmVersion, taken from ${buildJvmSource.mkString(", ")} + | - graalVM JVM specified: ${forcedGraalJvmVersion.get} + |""".stripMargin + )) + else { + val availableGraalVMJavaVersions = JvmUtils.getJvmsById(options, "graalvm-java\\d\\d?".r) + .map(_.stripPrefix("graalvm-java").toInt) + .sorted + + val maybeFinalJavaVersion: Either[GraalVMNativeImageError, Int] = forcedGraalJvmVersion + .orElse { + def isLtsJava(version: Int): Boolean = Seq(8, 11, 17, 21).contains(version) + availableGraalVMJavaVersions.filter(_ >= buildJvmVersion) match + case Nil => None + case versions if versions.forall(v => !isLtsJava(v)) => + logger.diagnostic("Using a GraalVM for non-LTS Java version") + Some(versions.min) + // Prefer LTS Java versions + case versions => versions.find(isLtsJava) + } + .toRight(defaultError( + s"""The JVM version used for compiling is too high to find a compatible GraalVM JVM. + | - compilation JVM: $buildJvmVersion, taken from ${buildJvmSource.mkString(", ")} + |""".stripMargin + )) + + val deducedOrSpecified = forcedGraalJvmVersion.fold("deduced")(_ => "specified") + + val graalVmJvmId = for { + finalJavaVersion <- maybeFinalJavaVersion + cache <- build.options.javaHomeManager.cache + .toRight(defaultError("No JVM cache found")) + + graalVmIndexEntries <- cache.entries(s"graalvm-java$finalJavaVersion") + .map(_.left.map(defaultError)) + .unsafeRun()(cacheEc) + + _ <- if graalVmIndexEntries.isEmpty then + Left(defaultError( + s"""A release of GraalVM compatible with the $deducedOrSpecified JVM version could not be found. + | - GraalVM JVM $deducedOrSpecified: $finalJavaVersion + | - compilation JVM: $buildJvmVersion, taken from ${buildJvmSource.mkString(", ")} + |Use '--graalvm-java-version' to force a different JVM version. + |""".stripMargin + )) + else Right(()) + + forcedGraalVersion = + build.options.notForBloopOptions.packageOptions.nativeImageOptions.graalvmVersion + finalIndexEntry <- + forcedGraalVersion.fold(Right(graalVmIndexEntries.maxBy(_.version))) { forcedV => + graalVmIndexEntries.find(_.version == forcedV) + .toRight(defaultError( + s"""No GraalVM found with version ${forcedGraalVersion.getOrElse("")} + |Available versions for the $deducedOrSpecified JVM $finalJavaVersion: + | - ${graalVmIndexEntries.map(_.version).mkString("\n - ")} + |Use '--graalvm-version' to force a different GraalVM version. + |Use '--graalvm-java-version' to force a different JVM version. + |Or Use '--graalvm-jvm-id' to specify both, e.g. '--graalvm-jvm-id graalvm-java17:22.0.0'. + |""".stripMargin + )) + } + } yield finalIndexEntry.id + + graalVmJvmId + } + } + private def vcVersions = Seq("2022", "2019", "2017") private def vcEditions = Seq("Enterprise", "Community", "BuildTools") lazy val vcvarsCandidates = Option(System.getenv("VCVARSALL")) ++ { @@ -172,14 +266,19 @@ object NativeImage { nativeImageWorkDir: os.Path, extraOptions: Seq[String], logger: Logger - ): Unit = { - + ): Either[BuildException, Unit] = either { os.makeDir.all(nativeImageWorkDir) - val jvmId = build.options.notForBloopOptions.packageOptions.nativeImageOptions.jvmId + val graalVmId = + build.options.notForBloopOptions.packageOptions.nativeImageOptions.graalvmJvmId.getOrElse { + value(getCompatibleGraalVmId(build, logger)) + } + + logger.message(s"Using GraalVM JVM: $graalVmId") + val options = build.options.copy( javaOptions = build.options.javaOptions.copy( - jvmIdOpt = Some(Positioned.none(jvmId)) + jvmIdOpt = Some(Positioned.none(graalVmId)) ) ) @@ -240,6 +339,8 @@ object NativeImage { mainClass ) ++ nativeImageArgs + pprint.err.log(args) + maybeWithShorterGraalvmHome(javaHome.javaHome, logger) { graalVMHome => val nativeImageCommand = ensureHasNativeImageCommand(graalVMHome, logger) @@ -269,7 +370,7 @@ object NativeImage { ) } else - throw new GraalVMNativeImageError + throw new GraalVMNativeImageError() } } finally util.Try(toClean.foreach(os.remove.all)) diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 4e7d05bd0d..bbf7364bb3 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -867,6 +867,19 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio } } + def packageProc(extraArgs: os.Shellable*) = + os.proc( + TestUtil.cli, + "--power", + "package", + extraOptions, + ".", + extraArgs, + "--native-image", + "--", + "--no-fallback" + ) + test("native image") { val message = "Hello from native-image" val dest = "hello" @@ -882,18 +895,7 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio |""".stripMargin ) inputs.fromRoot { root => - os.proc( - TestUtil.cli, - "--power", - "package", - extraOptions, - ".", - "--native-image", - "-o", - dest, - "--", - "--no-fallback" - ).call( + packageProc("-o", dest).call( cwd = root, stdin = os.Inherit, stdout = os.Inherit @@ -909,6 +911,120 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio } } + for { + jvmVersion <- Seq(8, 11, 17, 21) + if !Properties.isWin || jvmVersion != 21 // our Windows GitHub runner can't use graalVmJvm 21 + } + test(s"native image with JVM $jvmVersion") { + val message = "Hello from native-image" + val dest = "hello" + val actualDest = + if (Properties.isWin) "hello.exe" + else "hello" + val inputs = TestInputs( + os.rel / "Hello.scala" -> + s"""object Hello { + | def main(args: Array[String]): Unit = + | println("$message") + |} + |""".stripMargin + ) + + val extraEnv: Map[String, String] = if (jvmVersion == 21) + Map("USE_NATIVE_IMAGE_JAVA_PLATFORM_MODULE_SYSTEM" -> "true") + else Map.empty + + inputs.fromRoot { root => + packageProc("--jvm", jvmVersion, "-o", dest).call( + cwd = root, + stdin = os.Inherit, + stdout = os.Inherit, + env = extraEnv + ) + + expect(os.isFile(root / actualDest)) + + val res = os.proc(root / actualDest).call(cwd = root) + val output = res.out.trim() + expect(output == message) + } + } + + test(s"native image with --jvm 21 and --graalvm-java-version 17") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + s"""object Hello { + | def main(args: Array[String]): Unit = + | println("nope") + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val res = packageProc("--jvm", 21, "--graalvm-java-version", 17) + .call( + cwd = root, + stderr = os.Pipe, + check = false + ) + + expect(res.exitCode == 1) + + val errOutput = res.err.text().trim() + .linesIterator + .dropWhile(!_.contains("error")) + .mkString(System.lineSeparator()) + .replace("Custom(/usr/libexec/java_home -v)", "CommandLine(--jvm)") + + assertNoDiff( + errOutput, + """[error] Cannot build a native image with a JVM older than the one used for compilation. + |Specified Versions: + | - compilation JVM: 21, taken from CommandLine(--jvm) + | - graalVM JVM specified: 17 + |""".stripMargin + ) + } + } + + test(s"native image with --graalvm-vm-version 99") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + s"""object Hello { + | def main(args: Array[String]): Unit = + | println("nope") + |} + |""".stripMargin + ) + + inputs.fromRoot { root => + val res = packageProc("--jvm", 17, "--graalvm-version", 99) + .call( + cwd = root, + stderr = os.Pipe, + check = false + ) + + expect(res.exitCode == 1) + + val errOutput = res.err.text().trim() + .linesIterator + .dropWhile(!_.contains("error")) + .filterNot(_.startsWith(" - ")) + .mkString(System.lineSeparator()) + assertNoDiff( + errOutput, + """[error] Couldn't fetch a correct GraalVM JVM: No GraalVM found with version 99 + |Available versions for the deduced JVM 17: + |Use '--graalvm-version' to force a different GraalVM version. + |Use '--graalvm-java-version' to force a different JVM version. + |Or Use '--graalvm-jvm-id' to specify both, e.g. '--graalvm-jvm-id graalvm-java17:22.0.0'. + |""".stripMargin, + clue = res.err.text().trim() + ) + } + } + test("correctly list main classes") { val (scalaFile1, scalaFile2, scriptName) = ("ScalaMainClass1", "ScalaMainClass2", "ScalaScript") val scriptsDir = "scripts" diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala index 753bd17355..dedd80dca3 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala @@ -379,7 +379,7 @@ trait RunScalacCompatTestDefinitions { _: RunTestDefinitions => (1 until 4).foreach { scalaPatchVersion => val scala212VersionString = s"2.12.$scalaPatchVersion" val res = - os.proc(TestUtil.cli, "run", ".", "-S", scala212VersionString, TestUtil.extraOptions) + os.proc(TestUtil.cli, "run", ".", "-S", scala212VersionString, TestUtil.extraOptions, "-v", "-v", "-v") .call(cwd = root) expect(res.out.trim() == scala212VersionString) } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ScriptWrapperTests.scala b/modules/integration/src/test/scala/scala/cli/integration/ScriptWrapperTests.scala index 33f76868d8..1d9d43637c 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ScriptWrapperTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ScriptWrapperTests.scala @@ -6,7 +6,7 @@ import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration class ScriptWrapperTests extends ScalaCliSuite { - def expectAppWrapper(wrapperName: String, path: os.Path) = { + def expectAppWrapper(wrapperName: String, path: os.Path): Unit = { val generatedFileContent = os.read(path) assert( generatedFileContent.contains(s"object $wrapperName extends App {"), @@ -19,7 +19,7 @@ class ScriptWrapperTests extends ScalaCliSuite { ) } - def expectObjectWrapper(wrapperName: String, path: os.Path) = { + def expectObjectWrapper(wrapperName: String, path: os.Path): Unit = { val generatedFileContent = os.read(path) assert( generatedFileContent.contains(s"object $wrapperName {"), @@ -32,7 +32,7 @@ class ScriptWrapperTests extends ScalaCliSuite { ) } - def expectClassWrapper(wrapperName: String, path: os.Path) = { + def expectClassWrapper(wrapperName: String, path: os.Path): Unit = { val generatedFileContent = os.read(path) assert( generatedFileContent.contains(s"final class $wrapperName$$_"), @@ -164,9 +164,8 @@ class ScriptWrapperTests extends ScalaCliSuite { for { useDirectives <- Seq(true, false) - (directive, options) <- Seq( - (s"//> using scala ${Constants.scala213}", Seq("--scala", Constants.scala213)) - ) + directive = s"//> using scala ${Constants.scala213}" + options = Seq("--scala", Constants.scala213) } { val inputs = TestInputs( os.rel / "script1.sc" -> diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala index 6df0a98aa5..8da996d295 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala @@ -96,8 +96,7 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr val successfulScalaCheckFromCatsNativeInputs: TestInputs = TestInputs( os.rel / "MyTests.test.scala" -> - """//> using scala "2.13.8" - |//> using platform "native" + """//> using platform "native" |//> using dep "org.typelevel::cats-kernel-laws::2.8.0" | |import org.scalacheck._ diff --git a/modules/options/src/main/scala/scala/build/options/JavaOptions.scala b/modules/options/src/main/scala/scala/build/options/JavaOptions.scala index 594a3055bc..1a817699d6 100644 --- a/modules/options/src/main/scala/scala/build/options/JavaOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/JavaOptions.scala @@ -134,7 +134,7 @@ final case class JavaOptions( catch { case NonFatal(e) => throw new Exception(e) } - Positioned(Position.Custom("OsLibc.defaultJvm"), os.Path(path)) + Positioned(Position.Custom("default JVM"), os.Path(path)) } }