diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala new file mode 100644 index 0000000000..17c4c093a1 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala @@ -0,0 +1,110 @@ +package scala.cli.commands.repl + +import java.io.File + +import scala.build.Logger +import scala.build.errors.BuildException +import scala.build.internal.Runner +import scala.build.options.BuildOptions +import scala.util.Properties + +object JShellRunner { + final case class Command( + jshellCommand: String, + args: Seq[String], + extraEnv: Map[String, String] + ) { + def processCommand: Seq[String] = Seq(jshellCommand) ++ args + def displayedCommand: Seq[String] = Runner.envCommand(extraEnv) ++ processCommand + } + + final class JShellUnavailable(message0: String) + extends BuildException(message0) + + private def executableExt(isWindows: Boolean): String = if isWindows then ".exe" else "" + + def commandFor( + javaHomeInfo: BuildOptions.JavaHomeInfo, + javaOpts: Seq[String], + classPath: Seq[os.Path], + programArgs: Seq[String], + initScriptOpt: Option[String], + quitAfterInit: Boolean, + currentEnv: Map[String, String], + isWindows: Boolean = Properties.isWin + ): Either[BuildException, Command] = + if javaHomeInfo.version < 9 then + Left( + JShellUnavailable( + s"JShell requires JDK >= 9, but the selected JDK is ${javaHomeInfo.version}. Consider using --jvm 17." + ) + ) + else { + val jshellPath = javaHomeInfo.javaHome / "bin" / s"jshell${executableExt(isWindows)}" + val jshellCommand = jshellPath.toString + if !os.exists(jshellPath) then + Left( + JShellUnavailable( + s"JShell executable not found at $jshellCommand. Ensure the selected JVM is a full JDK (for example with --jvm 17)." + ) + ) + else { + val classPathArg = classPath.map(_.toString).distinct.mkString(File.pathSeparator) + val startupArgs = initScriptOpt.toSeq.flatMap { script => + val scriptFile = os.temp( + prefix = "scala-cli-jshell-init-", + suffix = ".jsh", + deleteOnExit = false + ) + os.write.over(scriptFile, script + System.lineSeparator()) + Seq("--startup", "DEFAULT", "--startup", scriptFile.toString) + } + val quitAfterInitArgs = + if quitAfterInit then { + val exitFile = os.temp( + prefix = "scala-cli-jshell-exit-", + suffix = ".jsh", + deleteOnExit = false + ) + os.write.over(exitFile, "/exit" + System.lineSeparator()) + Seq(exitFile.toString) + } + else Nil + val vmArgs = javaOpts.map(opt => s"-J$opt") + val args = Seq("--class-path", classPathArg) ++ + vmArgs ++ + startupArgs ++ + programArgs ++ + quitAfterInitArgs + val extraEnv = javaHomeInfo.envUpdates(currentEnv) + Right(Command(jshellCommand, args, extraEnv)) + } + } + + def run( + command: Command, + logger: Logger, + allowExecve: Boolean, + dryRun: Boolean + ): Either[BuildException, Unit] = { + logger.log( + s"Running ${command.displayedCommand.mkString(" ")}", + " Running" + System.lineSeparator() + + command.displayedCommand.iterator.map(_ + System.lineSeparator()).mkString + ) + if dryRun then { + logger.message(s"JShell command: ${command.processCommand.mkString(" ")}") + logger.message("Dry run, not running REPL.") + Right(()) + } + else { + val process = + if allowExecve then + Runner.maybeExec("jshell", command.processCommand, logger, extraEnv = command.extraEnv) + else Runner.run(command.processCommand, logger, extraEnv = command.extraEnv) + val retCode = process.waitFor() + if retCode == 0 then Right(()) + else Left(new Repl.ReplError(retCode)) + } + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala index d0dff113e9..40bfe24c17 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala @@ -18,13 +18,14 @@ import scala.build.errors.{ } import scala.build.input.Inputs import scala.build.internal.{Constants, Runner} -import scala.build.options.ScalacOpt.noDashPrefixes +import scala.build.options.ScalacOpt.{filterScalacOptionKeys, noDashPrefixes} import scala.build.options.{BuildOptions, JavaOpt, MaybeScalaVersion, ScalaVersionUtil, Scope} import scala.cli.CurrentParams import scala.cli.commands.run.Run.{createPythonInstance, orPythonDetectionError, pythonPathEnv} import scala.cli.commands.run.RunMode import scala.cli.commands.shared.{HelpCommandGroup, HelpGroup, ScalacOptions, SharedOptions} import scala.cli.commands.util.BuildCommandHelpers +import scala.cli.commands.util.ScalacOptionsUtil.* import scala.cli.commands.{ScalaCommand, WatchUtil} import scala.cli.config.Keys import scala.cli.packaging.Library @@ -60,6 +61,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { import ops.sharedRepl.* val logger = ops.shared.logger + if jshell.contains(true) && ammonite.contains(true) + then throw new ConflictingReplBackendsError("--jshell cannot be used together with --ammonite") val ammoniteVersionOpt = ammoniteVersion.map(_.trim).filter(_.nonEmpty) val baseOptions = shared.buildOptions(watchOptions = watch).orExit(logger) @@ -103,6 +106,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { ), notForBloopOptions = baseOptions.notForBloopOptions.copy( replOptions = baseOptions.notForBloopOptions.replOptions.copy( + useJshellOpt = jshell, useAmmoniteOpt = ammonite, ammoniteVersionOpt = ammoniteVersion.map(_.trim).filter(_.nonEmpty), ammoniteArgs = ammoniteArg @@ -118,7 +122,17 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { override def runCommand(options: ReplOptions, args: RemainingArgs, logger: Logger): Unit = { val initialBuildOptions = buildOptionsOrExit(options) - def default = Inputs.default().getOrElse { + val initScriptOpt = + options.sharedRepl.replInitScriptFile + .map(_.trim) + .filter(_.nonEmpty) + .map(path => readInitScriptFile(path).orExit(logger)) + val quitAfterInit = + options.shared.scalac.scalacOption + .toScalacOptShadowingSeq + .filterScalacOptionKeys(_.noDashPrefixes == ScalacOptions.replQuitAfterInit) + .keys.nonEmpty + def default = Inputs.default().getOrElse { Inputs.empty(Os.pwd, options.shared.markdown.enableMarkdown) } val inputs = @@ -132,6 +146,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { def doRunRepl( buildOptions: BuildOptions, + initScriptOpt: Option[String], + quitAfterInit: Boolean, allArtifacts: Seq[Artifacts], mainJarsOrClassDirs: Seq[os.Path], allowExit: Boolean, @@ -140,6 +156,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { ): Unit = { val res = runRepl( options = buildOptions, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, programArgs = programArgs, allArtifacts = allArtifacts, mainJarsOrClassDirs = mainJarsOrClassDirs, @@ -150,14 +168,14 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds = successfulBuilds ) res match { - case Left(ex) => - if (allowExit) logger.exit(ex) - else logger.log(ex) + case Left(ex) => if allowExit then logger.exit(ex) else logger.log(ex) case Right(()) => } } def doRunReplFromBuild( builds: Seq[Build.Successful], + initScriptOpt: Option[String], + quitAfterInit: Boolean, allowExit: Boolean, runMode: RunMode.HasRepl, asJar: Boolean @@ -166,6 +184,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { // build options should be the same for both scopes // combining them may cause for ammonite args to be duplicated, so we're using the main scope's opts buildOptions = builds.head.options, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allArtifacts = builds.map(_.artifacts), mainJarsOrClassDirs = if asJar then Seq(Library.libraryJar(builds)) else builds.map(_.output), @@ -183,7 +203,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { ) val shouldBuildTestScope = options.shared.scope.test.getOrElse(false) - if (inputs.isEmpty) { + if inputs.isEmpty then { val allArtifacts = Seq(initialBuildOptions.artifacts(logger, Scope.Main).orExit(logger)) ++ (if shouldBuildTestScope @@ -195,6 +215,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { def runThing() = lock.synchronized { doRunRepl( buildOptions = initialBuildOptions, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allArtifacts = allArtifacts, mainJarsOrClassDirs = Seq.empty, allowExit = !options.sharedRepl.watch.watchMode, @@ -203,13 +225,13 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { ) } runThing() - if (options.sharedRepl.watch.watchMode) { + if options.sharedRepl.watch.watchMode then { // nothing to watch, just wait for Ctrl+C WatchUtil.printWatchMessage() WatchUtil.waitForCtrlC(() => runThing()) } } - else if (options.sharedRepl.watch.watchMode) { + else if options.sharedRepl.watch.watchMode then { val watcher = Build.watch( inputs, initialBuildOptions, @@ -227,6 +249,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds => doRunReplFromBuild( successfulBuilds, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allowExit = false, runMode = runMode(options), asJar = options.shared.asJar @@ -254,6 +278,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds => doRunReplFromBuild( successfulBuilds, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allowExit = true, runMode = runMode(options), asJar = options.shared.asJar @@ -275,16 +301,43 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { } private def maybeAdaptForWindows(args: Seq[String]): Seq[String] = - if (Properties.isWin) + if Properties.isWin then args.map { a => - if (a.contains(" ")) "\"" + a.replace("\"", "\\\"") + "\"" + if a.exists(c => c.isWhitespace || c == '"') + then "\"" + a.replace("\"", "\\\"") + "\"" else a } else args + private[commands] def readInitScriptFile(file: String): Either[BuildException, String] = { + val pathEither: Either[BuildException, os.Path] = + try Right(os.Path(file, os.pwd)) + catch { + case e: IllegalArgumentException => + Left(ReplInitScriptError(s"Invalid REPL init script file path: $file", e)) + } + pathEither.flatMap { path => + if !os.exists(path) then + Left(ReplInitScriptError(s"REPL init script file not found: $path")) + else if os.isDir(path) then + Left(ReplInitScriptError(s"REPL init script file is a directory: $path")) + else + try Right(os.read(path)) + catch { + case e: Exception => + Left(ReplInitScriptError( + s"Error reading REPL init script file $path: ${e.getMessage}", + e + )) + } + } + } + private def runRepl( options: BuildOptions, + initScriptOpt: Option[String], + quitAfterInit: Boolean, programArgs: Seq[String], allArtifacts: Seq[Artifacts], mainJarsOrClassDirs: Seq[os.Path], @@ -298,6 +351,23 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { val cache = options.internal.cache.getOrElse(FileCache()) val shouldUseAmmonite = options.notForBloopOptions.replOptions.useAmmonite + val explicitJshellOpt = options.notForBloopOptions.replOptions.useJshellOpt + val isPureJavaProject = + successfulBuilds.nonEmpty && + successfulBuilds.exists(_.sources.hasJava) && + !successfulBuilds.exists(_.sources.hasScala) + val pureJavaInDefaultRepl = + isPureJavaProject && explicitJshellOpt.contains(false) && !shouldUseAmmonite + val shouldUseJshell = + explicitJshellOpt.getOrElse(isPureJavaProject && !shouldUseAmmonite) + val replBackend = + if shouldUseJshell then ReplBackend.JShell + else if shouldUseAmmonite then ReplBackend.Ammonite + else ReplBackend.Default + if pureJavaInDefaultRepl then + logger.message( + "Detected a pure-Java project, but --jshell=false was passed; using the default Scala REPL instead of JShell." + ) val scalaParams: ScalaParameters = value { val distinctScalaParams = allArtifacts.flatMap(_.scalaOpt).map(_.params).distinct @@ -309,7 +379,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { } val (scalapyJavaOpts, scalapyExtraEnv) = - if (setupPython) { + if setupPython then { val props = value { val python = value(createPythonInstance().orPythonDetectionError) val propsOrError = python.scalapyProperties @@ -328,27 +398,33 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { else (Nil, Map.empty[String, String]) - def additionalArgs = { - val pythonArgs = - if (setupPython && scalaParams.scalaVersion.startsWith("2.13.")) - Seq("-Yimports:java.lang,scala,scala.Predef,me.shadaj.scalapy") - else - Nil - pythonArgs ++ options.scalaOptions.scalacOptions.toSeq.map(_.value.value) - } + val pythonReplArgs = + if setupPython && scalaParams.scalaVersion.startsWith("2.13.") + then Seq("-Yimports:java.lang,scala,scala.Predef,me.shadaj.scalapy") + else Nil + val additionalArgs = + pythonReplArgs ++ options.scalaOptions.scalacOptions.toSeq.map(_.value.value) def ammoniteAdditionalArgs() = { val pythonPredef = - if (setupPython) + if setupPython then """import me.shadaj.scalapy.py |import me.shadaj.scalapy.py.PyQuote |""".stripMargin else "" - val predefArgs = - if (pythonPredef.isEmpty) Nil + val pythonPredefArgs = + if pythonPredef.isEmpty + then Nil else Seq("--predef-code", pythonPredef) - predefArgs ++ options.notForBloopOptions.replOptions.ammoniteArgs + val replInitScriptPredefArgs = + initScriptOpt.toSeq.flatMap(script => Seq("--predef-code", script)) + val replQuitAfterInitArgs = + if quitAfterInit + then Seq("--code", "") + else Nil + pythonPredefArgs ++ replInitScriptPredefArgs ++ replQuitAfterInitArgs ++ + options.notForBloopOptions.replOptions.ammoniteArgs } // TODO Warn if some entries of artifacts.classPath were evicted in replArtifacts.replClassPath @@ -378,13 +454,11 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { .toVector .sorted } - finally - if (zf != null) - zf.close() + finally if zf != null then zf.close() } val warnRootClasses = rootClasses.nonEmpty && options.notForBloopOptions.replOptions.useAmmoniteOpt.contains(true) - if (warnRootClasses) + if warnRootClasses then logger.message( s"Warning: found classes defined in the root package (${rootClasses.mkString(", ")})." + " These will not be accessible from the REPL." @@ -409,7 +483,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { if dryRun then logger.message("Dry run, not running REPL.") else { val depClassPathArgs: Seq[String] = - if replArtifacts.depsClassPath.nonEmpty && !isAmmonite then + if !isAmmonite && (mainJarsOrClassDirs.nonEmpty || replArtifacts.depsClassPath.nonEmpty) + then Seq( "-classpath", (mainJarsOrClassDirs ++ replArtifacts.depsClassPath) @@ -432,8 +507,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { allowExecve = allowExit, extraEnv = scalapyExtraEnv ++ extraEnv ).waitFor() - if (retCode != 0) - value(Left(new ReplError(retCode))) + if retCode != 0 then value(Left(new ReplError(retCode))) } } @@ -447,8 +521,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { cache = cache, repositories = value(options.finalRepositories), addScalapy = - if setupPython then - Some(options.notForBloopOptions.scalaPyVersion.getOrElse(Constants.scalaPyVersion)) + if setupPython + then Some(options.notForBloopOptions.scalaPyVersion.getOrElse(Constants.scalaPyVersion)) else None, javaVersion = options.javaHome().value.version ) @@ -466,8 +540,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { logger = logger, cache = cache, addScalapy = - if (setupPython) - Some(options.notForBloopOptions.scalaPyVersion.getOrElse(Constants.scalaPyVersion)) + if setupPython + then Some(options.notForBloopOptions.scalaPyVersion.getOrElse(Constants.scalaPyVersion)) else None ).left.map { case FetchingDependenciesError(e: ResolutionError.CantDownloadModule, positions) @@ -482,22 +556,60 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { case other => other } - if (shouldUseAmmonite) - runMode match { - case RunMode.Default => - val replArtifacts = value(ammoniteArtifacts()) - val replArgs = ammoniteAdditionalArgs() ++ programArgs - maybeRunRepl(replArtifacts, replArgs) - } - else - runMode match { - case RunMode.Default => - val replArtifacts = value(defaultArtifacts()) - val replArgs = additionalArgs ++ programArgs - maybeRunRepl(replArtifacts, replArgs) - } + replBackend match { + case ReplBackend.JShell => + val javaHomeInfo = options.javaHome().value + val jshellCommand0 = value( + JShellRunner.commandFor( + javaHomeInfo = javaHomeInfo, + javaOpts = scalapyJavaOpts ++ options.javaOptions.javaOpts.toSeq.map(_.value.value), + classPath = mainJarsOrClassDirs ++ allArtifacts.flatMap(_.classPath).distinct, + programArgs = programArgs, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, + currentEnv = sys.env + ) + ) + val jshellCommand = + jshellCommand0.copy(extraEnv = jshellCommand0.extraEnv ++ scalapyExtraEnv) + value( + JShellRunner.run( + jshellCommand, + logger, + allowExecve = allowExit, + dryRun = dryRun + ) + ) + + case ReplBackend.Ammonite => + runMode match { + case RunMode.Default => + val replArtifacts = value(ammoniteArtifacts()) + val ammoniteArgs = ammoniteAdditionalArgs() ++ programArgs + maybeRunRepl(replArtifacts, ammoniteArgs) + } + + case ReplBackend.Default => + runMode match { + case RunMode.Default => + val replArtifacts = value(defaultArtifacts()) + val initScriptArgs = + initScriptOpt.toSeq.map { script => + s"--${ScalacOptions.replInitScript}:$script" + } + val defaultArgs = additionalArgs ++ initScriptArgs ++ programArgs + maybeRunRepl(replArtifacts, defaultArgs) + } + } + } + + private enum ReplBackend { + case Default, Ammonite, JShell } + final class ConflictingReplBackendsError(message0: String) extends BuildException(message0) + final class ReplInitScriptError(message0: String, cause0: Throwable = null) + extends BuildException(message0, cause = cause0) final class ReplError(retCode: Int) extends BuildException(s"Failed to run REPL (exit code: $retCode)") } diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/ReplOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/ReplOptions.scala index e8271be837..68579af6c5 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/ReplOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/ReplOptions.scala @@ -19,9 +19,9 @@ object ReplOptions { implicit lazy val parser: Parser[ReplOptions] = Parser.derive implicit lazy val help: Help[ReplOptions] = Help.derive val cmdName = "repl" - private val helpHeader = "Fire-up a Scala REPL." - val helpMessage: String = HelpMessages.shortHelpMessage(cmdName, helpHeader) - val detailedHelpMessage: String = + private val helpHeader = "Fire-up a REPL (Scala REPL by default, JShell for pure-Java projects)." + val helpMessage: String = HelpMessages.shortHelpMessage(cmdName, helpHeader) + val detailedHelpMessage: String = s"""$helpHeader | |The entire $fullRunnerName project's classpath is loaded to the repl. diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala index 451eea975e..63cfbd7a26 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala @@ -15,6 +15,13 @@ final case class SharedReplOptions( @Recurse compileCross: CrossOptions = CrossOptions(), + @Group(HelpGroup.Repl.toString) + @Tag(tags.implementation) + @Tag(tags.inShortHelp) + @HelpMessage("Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9.") + @Name("jsh") + jshell: Option[Boolean] = None, + @Group(HelpGroup.Repl.toString) @Tag(tags.restricted) @Tag(tags.inShortHelp) @@ -41,6 +48,13 @@ final case class SharedReplOptions( @Hidden ammoniteArg: List[String] = Nil, + @Group(HelpGroup.Repl.toString) + @Tag(tags.implementation) + @Tag(tags.inShortHelp) + @ValueDescription("path") + @HelpMessage("Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script.") + replInitScriptFile: Option[String] = None, + @Group(HelpGroup.Repl.toString) @Hidden @Tag(tags.implementation) diff --git a/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala new file mode 100644 index 0000000000..d15c3b268f --- /dev/null +++ b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala @@ -0,0 +1,85 @@ +package scala.cli.commands.tests + +import com.eed3si9n.expecty.Expecty.assert as expect + +import scala.build.options.BuildOptions +import scala.cli.commands.repl.JShellRunner + +class JShellRunnerTests extends munit.FunSuite { + + private def withTempJavaHome[T](isWindows: Boolean = + false)(f: BuildOptions.JavaHomeInfo => T): T = { + val ext = if (isWindows) ".exe" else "" + val javaHome = os.temp.dir(prefix = "scala-cli-jshell-test-jdk-", deleteOnExit = false) + os.makeDir.all(javaHome / "bin") + os.write.over(javaHome / "bin" / s"java$ext", "") + os.write.over(javaHome / "bin" / s"jshell$ext", "") + val info = BuildOptions.JavaHomeInfo( + javaHome = javaHome, + javaCommand = (javaHome / "bin" / s"java$ext").toString, + version = 17 + ) + f(info) + } + + test("commandFor adds classpath java opts startup and load files") { + withTempJavaHome() { javaHomeInfo => + val cpRoot = os.temp.dir(prefix = "scala-cli-jshell-cp-", deleteOnExit = false) + val cp = Seq(cpRoot / "cp1", cpRoot / "cp2") + val res = JShellRunner.commandFor( + javaHomeInfo = javaHomeInfo, + javaOpts = Seq("-Xmx1g", "-Dfoo=bar"), + classPath = cp, + programArgs = Seq("load.jsh"), + initScriptOpt = Some("""System.out.println("hello");"""), + quitAfterInit = true, + currentEnv = Map("PATH" -> "/usr/bin") + ) + assert(res.isRight) + val command = res.toOption.get + expect(command.jshellCommand.endsWith("/bin/jshell")) + expect(command.args.contains("--class-path")) + expect(command.args.contains("-J-Xmx1g")) + expect(command.args.contains("-J-Dfoo=bar")) + expect(command.args.contains("load.jsh")) + expect(command.args.contains("--startup")) + expect(command.args.last.endsWith(".jsh")) + expect(command.extraEnv.contains("JAVA_HOME")) + expect(command.extraEnv.contains("PATH")) + } + } + + test("commandFor uses .exe on Windows") { + withTempJavaHome(isWindows = true) { javaHomeInfo => + val res = JShellRunner.commandFor( + javaHomeInfo = javaHomeInfo, + javaOpts = Nil, + classPath = Nil, + programArgs = Nil, + initScriptOpt = None, + quitAfterInit = false, + currentEnv = Map.empty, + isWindows = true + ) + assert(res.isRight) + expect(res.toOption.get.jshellCommand.endsWith("""\bin\jshell.exe""") || + res.toOption.get.jshellCommand + .endsWith("/bin/jshell.exe")) + } + } + + test("commandFor fails for JDK 8") { + withTempJavaHome() { javaHomeInfo => + val res = JShellRunner.commandFor( + javaHomeInfo = javaHomeInfo.copy(version = 8), + javaOpts = Nil, + classPath = Nil, + programArgs = Nil, + initScriptOpt = None, + quitAfterInit = false, + currentEnv = Map.empty + ) + assert(res.isLeft) + } + } +} diff --git a/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala b/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala index db4e4d17ba..37ef88d4a7 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala @@ -31,4 +31,34 @@ class ReplOptionsTests extends munit.FunSuite { val buildOptions = Repl.buildOptions0(replOptions, maxVersion, maxLtsVersion) expect(buildOptions.scalaOptions.scalaVersion.flatMap(_.versionOpt).contains(maxVersion)) } + + test("Propagate --jshell to build options") { + val replOptions = ReplOptions( + sharedRepl = SharedReplOptions( + jshell = Some(true) + ) + ) + val buildOptions = Repl.buildOptions(replOptions).value + expect(buildOptions.notForBloopOptions.replOptions.useJshellOpt.contains(true)) + } + + test("Read --repl-init-script-file contents") { + val initScriptFile = os.temp(prefix = "scala-cli-repl-options-init-", suffix = ".sc") + val initScript = """println("from shared repl options")""" + os.write.over(initScriptFile, initScript) + val resolved = Repl.readInitScriptFile(initScriptFile.toString).toOption.get + expect(resolved == initScript) + } + + test("Reject --jshell with --ammonite") { + val replOptions = ReplOptions( + sharedRepl = SharedReplOptions( + jshell = Some(true), + ammonite = Some(true) + ) + ) + intercept[Repl.ConflictingReplBackendsError] { + Repl.buildOptions(replOptions) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala index 724fda8116..a65c12f9ca 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala @@ -2,7 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.cli.integration.TestUtil.{normalizeArgsForWindows, removeAnsiColors} +import scala.cli.integration.TestUtil.normalizeArgsForWindows trait ReplAmmoniteTestDefinitions { this: ReplTestDefinitions => protected val ammonitePrefix: String = "Running in Ammonite REPL:" @@ -185,12 +185,37 @@ trait ReplAmmoniteTestDefinitions { this: ReplTestDefinitions => else s" with Scala $actualMaxAmmoniteScalaVersion (the latest supported version)" test(s"$ammonitePrefix simple $ammoniteMaxVersionString")(ammoniteTest()) + test(s"$ammonitePrefix init script file with quotes and newlines$ammoniteMaxVersionString") { + TestInputs.empty.fromRoot { root => + val initScriptFile = root / ".scala-cli-ammonite-init.sc" + os.write.over( + initScriptFile, + """val message = "hello from ammonite file" + |""".stripMargin + ) + val res = os.proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--ammonite", + "--repl-init-script-file", + initScriptFile.toString, + "--ammonite-arg", + "-c", + "--ammonite-arg", + "println(message)", + ammoniteExtraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(res.out.trim() == "hello from ammonite file") + } + } test(s"$ammonitePrefix scalapy$ammoniteMaxVersionString")(ammoniteScalapyTest()) test(s"$ammonitePrefix with test scope sources$ammoniteMaxVersionString")(ammoniteTestScope()) test(s"$ammonitePrefix ammonite version in help$ammoniteMaxVersionString") { runInAmmoniteRepl(cliOptions = Seq("--help")) { res => - val lines = removeAnsiColors(res.out.trim()).linesIterator.toVector + val lines = TestUtil.removeAnsiColors(res.out.trim()).linesIterator.toVector val ammVersionHelp = lines.find(_.contains("--ammonite-ver")).getOrElse("") expect(ammVersionHelp.contains(s"(${Constants.ammoniteVersion} by default)")) } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala new file mode 100644 index 0000000000..27a44791a8 --- /dev/null +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -0,0 +1,396 @@ +package scala.cli.integration + +import com.eed3si9n.expecty.Expecty.expect + +import scala.util.Properties + +trait ReplJShellTestDefinitions { this: ReplTestDefinitions => + protected val jshellDryRunPrefix: String = "JShell dry run:" + protected val runInJShellPrefix: String = "Running in JShell:" + + protected def dryRunJshell( + testInputs: TestInputs = TestInputs.empty, + cliOptions: Seq[String] = Seq.empty, + useExtraOptions: Boolean = true, + check: Boolean = true + ): os.CommandResult = + testInputs.fromRoot { root => + os.proc( + TestUtil.cli, + "repl", + ".", + "--jshell", + "--repl-dry-run", + cliOptions, + if useExtraOptions then extraOptions else Seq.empty + ).call(cwd = root, mergeErrIntoOut = true, check = check) + } + + protected def jshellOutput(res: os.CommandResult): String = + TestUtil.removeAnsiColors(res.out.text()) + + protected def runInJShell( + initScript: String, + testInputs: TestInputs = TestInputs.empty, + cliOptions: Seq[String] = Seq.empty, + replCliOptions: Seq[String] = extraOptions, + check: Boolean = true, + env: Map[String, String] = Map.empty + )( + runAfterRepl: (os.CommandResult, os.Path) => Unit, + runBeforeReplAndGetExtraCliOpts: () => Seq[os.Shellable] = () => Seq.empty + ): Unit = { + testInputs.fromRoot { root => + val potentiallyExtraCliOpts = runBeforeReplAndGetExtraCliOpts() + val initScriptFile = root / ".scala-cli-jshell-init.jsh" + os.write.over(initScriptFile, initScript) + runAfterRepl( + os.proc( + TestUtil.cli, + "repl", + ".", + "--jshell", + "--repl-quit-after-init", + "--repl-init-script-file", + initScriptFile.toString, + replCliOptions, + cliOptions, + potentiallyExtraCliOpts + ).call( + cwd = root, + mergeErrIntoOut = true, + env = env, + check = check + ), + root + ) + } + } + + protected def runInJShellOnJvm(javaVersion: Int, extraInitScript: String = "")( + check: os.CommandResult => Unit + ): Unit = { + val sentinel = s"java-feature=$javaVersion" + val initScript = + s"""int __feature = Runtime.version().feature(); + |if (__feature != $javaVersion) { + | throw new RuntimeException("Unexpected JDK feature: " + __feature); + |} + |else { + |$extraInitScript + | System.out.println("$sentinel"); + |} + |""".stripMargin + runInJShell(initScript = initScript, cliOptions = Seq("--jvm", javaVersion.toString)) { + (res, _) => + val out = jshellOutput(res) + expect(out.contains(sentinel)) + check(res) + } + } + + test(s"$jshellDryRunPrefix default") { + val res = dryRunJshell(TestInputs.empty) + expect(res.exitCode == 0) + expect(res.out.text().toLowerCase.contains("jshell")) + } + + test(s"$jshellDryRunPrefix pure Java defaults to JShell") { + TestInputs( + os.rel / "Main.java" -> + """public class Main { + | public static void main(String[] args) {} + |} + |""".stripMargin + ).fromRoot { root => + val res = os + .proc(TestUtil.cli, "repl", ".", "--repl-dry-run", extraOptions) + .call(cwd = root, mergeErrIntoOut = true) + expect(res.out.text().toLowerCase.contains("jshell")) + } + } + + test(s"$jshellDryRunPrefix mixed Scala/Java defaults to Scala REPL, --jshell switches backend") { + val inputs = TestInputs( + os.rel / "Main.java" -> + """public class Main { + | public static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "Main.scala" -> + """object MainScala { + | def value = 1 + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val defaultRes = os + .proc(TestUtil.cli, "repl", ".", "--repl-dry-run", extraOptions) + .call(cwd = root, mergeErrIntoOut = true) + expect(!defaultRes.out.text().toLowerCase.contains("jshell")) + val jshellRes = os + .proc( + TestUtil.cli, + "repl", + ".", + "--jshell", + "--repl-dry-run", + extraOptions + ) + .call(cwd = root, mergeErrIntoOut = true) + expect(jshellRes.out.text().toLowerCase.contains("jshell")) + } + } + + test(s"$jshellDryRunPrefix calling repl with a directory with no scala artifacts") { + val res = + dryRunJshell(TestInputs(os.rel / "Testing.java" -> "public class Testing {}")) + expect(res.exitCode == 0) + expect(res.out.text().toLowerCase.contains("jshell")) + } + + test(s"$jshellDryRunPrefix --jshell with --test scope") { + val res = dryRunJshell( + TestInputs( + os.rel / "Main.java" -> + """public class Main { + | public static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "MainTest.test.java" -> "public class MainTest {}" + ), + cliOptions = Seq("--test") + ) + expect(res.exitCode == 0) + expect(res.out.text().toLowerCase.contains("jshell")) + } + + test("JShell rejects --ammonite combination") { + TestInputs.empty.fromRoot { root => + val res = os + .proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--jshell", + "--ammonite", + "--repl-dry-run", + extraOptions + ) + .call(cwd = root, mergeErrIntoOut = true, check = false) + expect(res.exitCode != 0) + expect(res.out.text().contains("--jshell cannot be used together with --ammonite")) + } + } + + test(s"$runInJShellPrefix simple") { + val expectedMessage = "1337" + runInJShell(s"""System.out.println("$expectedMessage");""") { (res, _) => + expect(jshellOutput(res).contains(expectedMessage)) + } + } + + for javaVersion <- Constants.allJavaVersions.filter(_ >= 11) do + test(s"$runInJShellPrefix simple on JDK $javaVersion") { + val versionSpecific = javaVersion match { + case v if v >= 23 => + """System.out.println(javax.print.attribute.standard.OutputBin.LEFT);""" + case v if v >= 21 => + """System.out.println(Thread.ofVirtual().unstarted(() -> {}).getClass().getName());""" + case v if v >= 17 => + """System.out.println(java.util.HexFormat.of().toHexDigits(255));""" + case v if v >= 16 => + """System.out.println(java.util.stream.Stream.of(1, 2, 3).toList());""" + case _ => + """System.out.println(java.util.Optional.of(1).isEmpty());""" + } + runInJShellOnJvm(javaVersion, extraInitScript = versionSpecific)(_ => ()) + } + + if !Properties.isWin then + test(s"$runInJShellPrefix with extra JAR") { + runInJShell( + initScript = + """System.out.println(Class.forName("org.slf4j.Logger").getName());""" + )( + runBeforeReplAndGetExtraCliOpts = () => { + val jar = + os.proc(TestUtil.cs, "fetch", "--intransitive", "org.slf4j:slf4j-api:2.0.13") + .call() + .out + .text() + .trim + Seq("--jar", jar) + }, + runAfterRepl = (res, _) => + expect(jshellOutput(res).contains("org.slf4j.Logger")) + ) + } + + test(s"$runInJShellPrefix pure Java project") { + runInJShell( + initScript = """System.out.println(demo.Demo.greet());""", + testInputs = TestInputs( + os.rel / "Demo.java" -> + """package demo; + |public class Demo { + | public static String greet() { return "hi-java"; } + |} + |""".stripMargin + ) + )((res, _) => expect(jshellOutput(res).contains("hi-java"))) + } + + test(s"$runInJShellPrefix mixed Java/Scala project, JShell sees both") { + runInJShell( + initScript = + """System.out.println(demo.JavaPart.hello()); + |var c = Class.forName("demo.ScalaPart$"); + |var inst = c.getField("MODULE$").get(null); + |System.out.println(c.getMethod("hello").invoke(inst)); + |""".stripMargin, + replCliOptions = TestUtil.extraOptions, + testInputs = TestInputs( + os.rel / "demo" / "JavaPart.java" -> + """package demo; + |public class JavaPart { + | public static String hello() { return "hi-java"; } + |} + |""".stripMargin, + os.rel / "demo" / "ScalaPart.scala" -> + """package demo + | + |object ScalaPart { + | def hello: String = "hi-scala" + |} + |""".stripMargin + ) + ) { (res, _) => + val out = jshellOutput(res) + expect(out.contains("hi-java")) + expect(out.contains("hi-scala")) + } + } + + test(s"$runInJShellPrefix Scala project exposed to JShell via reflection") { + runInJShell( + initScript = + """var c = Class.forName("demo.Smth$"); + |var inst = c.getField("MODULE$").get(null); + |System.out.println(c.getMethod("smth").invoke(inst)); + |""".stripMargin, + replCliOptions = TestUtil.extraOptions, + testInputs = TestInputs( + os.rel / "Smth.scala" -> + """package demo + | + |object Smth { + | def smth: String = "haha" + |} + |""".stripMargin + ) + )((res, _) => expect(jshellOutput(res).contains("haha"))) + } + + if !Properties.isWin && canRunInRepl then + test(s"$runInReplPrefix Running in default Scala REPL: pure Java sources with --jshell=false") { + val inputs = TestInputs( + os.rel / "demo" / "Demo.java" -> + """package demo; + |public class Demo { + | public static String greet() { return "hi-default"; } + |} + |""".stripMargin + ) + // Dry-run: JShell prints "JShell command: …"; the Scala REPL backend does not. + inputs.fromRoot { root => + val dryOut = os + .proc(TestUtil.cli, "repl", ".", "--jshell=false", "--repl-dry-run", extraOptions) + .call(cwd = root, mergeErrIntoOut = true) + .out + .text() + expect(!dryOut.contains("JShell command:")) + expect(dryOut.contains("using the default Scala REPL instead of JShell")) + } + // Run with a Scala-only init script (string interpolation would be rejected by JShell). + runInRepl( + codeToRunInRepl = + """import demo.Demo + |val greeting = Demo.greet() + |println(s"scala-says:$greeting") + |""".stripMargin, + testInputs = inputs, + cliOptions = Seq("--jshell=false"), + shouldPipeStdErr = true + ) { res => + val combined = res.out.text() + res.err.text() + expect(combined.contains("scala-says:hi-default")) + } + } + + for { + (kind, inputs: TestInputs) <- Seq( + ( + "pure Java", + TestInputs( + os.rel / "Demo.java" -> + """public class Demo { + | public static String hi() { return "hi-java"; } + |} + |""".stripMargin + ) + ), + ( + "pure Scala", + TestInputs( + os.rel / "Greet.scala" -> + """object Greet { + | def hi: String = "hi-scala" + |} + |""".stripMargin + ) + ), + ( + "mixed Java/Scala", + TestInputs( + os.rel / "Demo.java" -> + """public class Demo { + | public static String hi() { return "hi-java"; } + |} + |""".stripMargin, + os.rel / "Greet.scala" -> + """object Greet { + | def hi: String = "hi-scala" + |} + |""".stripMargin + ) + ) + ) + osPwdInitScript = """System.out.println("PWD-MARKER:" + os.package$.MODULE$.pwd()); + |""".stripMargin + directiveExtension = if kind == "pure Java" then ".java" else ".scala" + inputsWithDirective = + inputs.add((os.rel / s"build$directiveExtension", "//> using dep com.lihaoyi::os-lib:0.9.1")) + if actualScalaVersion.startsWith("3") + } { + test(s"$runInJShellPrefix $kind with dependency via directive") { + runInJShell( + initScript = osPwdInitScript, + testInputs = inputsWithDirective + ) { (res, root) => + expect(jshellOutput(res).contains(s"PWD-MARKER:${root.toString}")) + } + } + + test(s"$runInJShellPrefix $kind with Scala Toolkit") { + runInJShell( + initScript = osPwdInitScript, + testInputs = inputs, + cliOptions = Seq("--toolkit", "default") + ) { (res, root) => + expect(jshellOutput(res).contains(s"PWD-MARKER:${root.toString}")) + } + } + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala index de28ad5863..bd2b426ef0 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -25,21 +25,28 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr shouldPipeStdErr: Boolean = false, check: Boolean = true, skipScalaVersionArgs: Boolean = false, - env: Map[String, String] = Map.empty + env: Map[String, String] = Map.empty, + initScriptFromFile: Boolean = false )( runAfterRepl: os.CommandResult => Unit, runBeforeReplAndGetExtraCliOpts: () => Seq[os.Shellable] = () => Seq.empty ): Unit = { testInputs.fromRoot { root => val potentiallyExtraCliOpts = runBeforeReplAndGetExtraCliOpts() + val initScriptArgs = + if initScriptFromFile then { + val initScriptFile = root / ".scala-cli-repl-init.sc" + os.write.over(initScriptFile, codeToRunInRepl) + Seq("--repl-init-script-file", initScriptFile.toString) + } + else Seq("--repl-init-script", codeToRunInRepl) runAfterRepl( os.proc( TestUtil.cli, "repl", ".", "--repl-quit-after-init", - "--repl-init-script", - codeToRunInRepl, + initScriptArgs, if skipScalaVersionArgs then TestUtil.extraOptions else extraOptions, cliOptions, potentiallyExtraCliOpts @@ -140,6 +147,34 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr ) } + test(s"$runInReplPrefix init script file with quotes and newlines") { + runInRepl( + codeToRunInRepl = + """val message = "hello from file" + |println(message) + |""".stripMargin, + initScriptFromFile = true + )(r => expect(r.out.trim().linesIterator.exists(_.trim == "hello from file"))) + } + + test( + s"$runInReplPrefix init script file preserves line numbers and does not trigger argfile parsing" + ) { + runInRepl( + codeToRunInRepl = """@nonExistentAnnotation""", + initScriptFromFile = true, + check = false, + shouldPipeStdErr = true + ) { r => + val combined = r.out.text() + r.err.text() + expect(!combined.contains("Argument file")) + expect(!combined.contains("missing argument for option -repl-init-script")) + val firstErrorLine = combined.linesIterator + .find(l => l.contains("error") && l.contains(":")) + expect(firstErrorLine.forall(!_.contains(":2:"))) + } + } + test(s"$runInReplPrefix verify Scala version from the REPL") { val opts = if actualScalaVersion.startsWith("3") && !isScala38OrNewer then Seq("--with-compiler") diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTests212.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTests212.scala index a13b0d9a90..b78186d506 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTests212.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTests212.scala @@ -1,3 +1,7 @@ package scala.cli.integration -class ReplTests212 extends ReplTestDefinitions with ReplAmmoniteTestDefinitions with Test212 +class ReplTests212 + extends ReplTestDefinitions + with ReplAmmoniteTestDefinitions + with ReplJShellTestDefinitions + with Test212 diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTests213.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTests213.scala index 60ba10b487..4ab129c99b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTests213.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTests213.scala @@ -1,6 +1,10 @@ package scala.cli.integration -class ReplTests213 extends ReplTestDefinitions with ReplAmmoniteTestDefinitions with Test213 { +class ReplTests213 + extends ReplTestDefinitions + with ReplAmmoniteTestDefinitions + with ReplJShellTestDefinitions + with Test213 { for { withExplicitScala2SnapshotRepo <- Seq(true, false) nightlyVersion = "2.13.nightly" diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTests3Lts.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTests3Lts.scala index fe89e979a8..436bd2a58d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTests3Lts.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTests3Lts.scala @@ -4,7 +4,8 @@ import com.eed3si9n.expecty.Expecty.expect class ReplTests3Lts extends ReplTestDefinitions with Test3Lts with ReplAmmoniteTestDefinitions - with ReplAmmoniteTests3StableDefinitions { + with ReplAmmoniteTests3StableDefinitions + with ReplJShellTestDefinitions { import Constants.scala3LtsPrefix if canRunInRepl then for { ltsNightlyTag <- List("3.lts.nightly", "lts.nightly") } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTests3NextRc.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTests3NextRc.scala index b07d515498..f49097c01f 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTests3NextRc.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTests3NextRc.scala @@ -1,3 +1,3 @@ package scala.cli.integration -class ReplTests3NextRc extends ReplTestDefinitions with Test3NextRc +class ReplTests3NextRc extends ReplTestDefinitions with ReplJShellTestDefinitions with Test3NextRc diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala index 0c8961bdfb..0c12554eb4 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala @@ -5,6 +5,7 @@ import com.eed3si9n.expecty.Expecty.expect class ReplTestsDefault extends ReplTestDefinitions with ReplAmmoniteTestDefinitions with ReplAmmoniteTests3StableDefinitions + with ReplJShellTestDefinitions with TestDefault { if canRunInRepl then for { nightlyTag <- List("3.nightly", "nightly") } diff --git a/modules/options/src/main/scala/scala/build/Artifacts.scala b/modules/options/src/main/scala/scala/build/Artifacts.scala index c3cfa4c8ea..2e3d402242 100644 --- a/modules/options/src/main/scala/scala/build/Artifacts.scala +++ b/modules/options/src/main/scala/scala/build/Artifacts.scala @@ -143,6 +143,17 @@ object Artifacts { ): Either[BuildException, Artifacts] = either { val dependencies = defaultDependencies ++ extraDependencies + val scalaParamsForDepResolution = + scalaArtifactsParamsOpt.map(_.params).orElse(Some { + // default Scala params for dependency resolution + // used in projects with no Scala configuration (i.e. pure Java projects) + ScalaParameters( + Constants.defaultScalaVersion, + ScalaVersion.binary(Constants.defaultScalaVersion), + None + ) + }) + val scalaVersion = (for { scalaArtifactsParams <- scalaArtifactsParamsOpt scalaParams = scalaArtifactsParams.params @@ -453,7 +464,7 @@ object Artifacts { fetchAnyDependenciesWithResult( allUpdatedDependencies, allExtraRepositories, - scalaArtifactsParamsOpt.map(_.params), + scalaParamsForDepResolution, logger, cache.withMessage(updatedDependenciesMessage), classifiersOpt = Some(Set("_") ++ (if (fetchSources) Set("sources") else Set.empty)), @@ -464,7 +475,7 @@ object Artifacts { val updatedDependencies0 = value { coursierDeps( updatedDependencies, - scalaArtifactsParamsOpt.map(_.params), + scalaParamsForDepResolution, maybeRecoverOnError ) } @@ -541,7 +552,7 @@ object Artifacts { dep"$runnerOrganization::$runnerModuleName:$runnerVersion0,intransitive" )), extraRepositories ++ maybeSnapshotRepo, - scalaArtifactsParamsOpt.map(_.params), + scalaParamsForDepResolution, logger, cache.withMessage("Downloading runner dependency") ).map(_.map(_._2)) @@ -562,7 +573,7 @@ object Artifacts { artifacts( Seq(posDep), allExtraRepositories, - scalaArtifactsParamsOpt.map(_.params), + scalaParamsForDepResolution, logger, cache0 ) diff --git a/modules/options/src/main/scala/scala/build/options/ReplOptions.scala b/modules/options/src/main/scala/scala/build/options/ReplOptions.scala index a7be0a0d8d..bce7960bfe 100644 --- a/modules/options/src/main/scala/scala/build/options/ReplOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ReplOptions.scala @@ -4,10 +4,13 @@ import scala.build.Logger import scala.build.internal.Constants final case class ReplOptions( + useJshellOpt: Option[Boolean] = None, useAmmoniteOpt: Option[Boolean] = None, ammoniteVersionOpt: Option[String] = None, ammoniteArgs: Seq[String] = Nil ) { + def useJshell: Boolean = + useJshellOpt.getOrElse(false) def useAmmonite: Boolean = useAmmoniteOpt.getOrElse(false) def ammoniteVersion(scalaVersion: String, logger: Logger): String = diff --git a/website/docs/commands/repl.md b/website/docs/commands/repl.md index 921c73dfcf..0ba32af21f 100644 --- a/website/docs/commands/repl.md +++ b/website/docs/commands/repl.md @@ -22,7 +22,24 @@ scala> :exit -Scala CLI by default uses the normal Scala REPL. +Scala CLI uses the Scala REPL by default, except for pure-Java projects where it defaults to JShell. + +## JShell backend (experimental) + +You can force JShell as the REPL backend with `--jshell` (`--jsh`), including in mixed Scala/Java or pure Scala projects. + + + +```bash ignore +scala-cli repl --jshell +``` + +```text +| Welcome to JShell ... +jshell> +``` + + If you prefer to use the [Ammonite REPL](https://ammonite.io/#Ammonite-REPL), specify `--amm` to launch it rather than the default REPL: diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 45f360181f..8c5a9adbbb 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1262,6 +1262,12 @@ Available in commands: +### `--jshell` + +Aliases: `--jsh` + +Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9. + ### [deprecated] `--ammonite` Aliases: `-A`, `--amm` @@ -1287,6 +1293,10 @@ Aliases: `-a` [Internal] Provide arguments for ammonite repl +### `--repl-init-script-file` + +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. + ### `--repl-dry-run` [Internal] diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index afb2b8c70b..9aaeff82ed 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -198,7 +198,7 @@ Accepts option groups: [global suppress warning](./cli-options.md#global-suppres Aliases: `console` -Fire-up a Scala REPL. +Fire-up a REPL (Scala REPL by default, JShell for pure-Java projects). The entire Scala CLI project's classpath is loaded to the repl. diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 383d963db2..6d8dc3e7fc 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -728,6 +728,34 @@ Available in commands: *This section was automatically generated and may be empty if no options were available.* +## Repl options + +Available in commands: + +[`repl` , `console`](./commands.md#repl) + + + +### `--jshell` + +Aliases: `--jsh` + +`IMPLEMENTATION specific` per Scala Runner specification + +Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9. + +### `--repl-init-script-file` + +`IMPLEMENTATION specific` per Scala Runner specification + +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. + +### `--repl-dry-run` + +`IMPLEMENTATION specific` per Scala Runner specification + +Don't actually run the REPL, just fetch it + ## Run options Available in commands: @@ -1616,20 +1644,6 @@ Print the update to `env` variable Binary directory -### Repl options - -Available in commands: - -[`repl` , `console`](./commands.md#repl) - - - -### `--repl-dry-run` - -`IMPLEMENTATION specific` per Scala Runner specification - -Don't actually run the REPL, just fetch it - ### Semantic db options Available in commands: diff --git a/website/docs/reference/scala-command/commands.md b/website/docs/reference/scala-command/commands.md index 2f06bff46e..8b7c42a9eb 100644 --- a/website/docs/reference/scala-command/commands.md +++ b/website/docs/reference/scala-command/commands.md @@ -96,7 +96,7 @@ Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [c Aliases: `console` -Fire-up a Scala REPL. +Fire-up a REPL (Scala REPL by default, JShell for pure-Java projects). The entire Scala CLI project's classpath is loaded to the repl. diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 165ebb3467..bfc1f34a08 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -1484,7 +1484,7 @@ Aliases: `--deprecated-test-alias` Aliases: `console` -Fire-up a Scala REPL. +Fire-up a REPL (Scala REPL by default, JShell for pure-Java projects). The entire Scala CLI project's classpath is loaded to the repl. @@ -2106,6 +2106,16 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--jshell** + +Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9. + +Aliases: `--jsh` + +**--repl-init-script-file** + +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. + **--repl-dry-run** Don't actually run the REPL, just fetch it