From ccbac1cbcc08f183ce01671567712fd1266bddd7 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 8 May 2026 14:39:10 +0200 Subject: [PATCH 01/11] Add support for JSHELL in `repl` --- .../cli/commands/repl/JShellRunner.scala | 147 +++++++++ .../scala/scala/cli/commands/repl/Repl.scala | 78 ++++- .../scala/cli/commands/repl/ReplOptions.scala | 6 +- .../cli/commands/repl/SharedReplOptions.scala | 7 + .../commands/tests/JShellRunnerTests.scala | 98 ++++++ .../cli/commands/tests/ReplOptionsTests.scala | 22 ++ .../cli/integration/ReplTestDefinitions.scala | 292 +++++++++++++++++- .../scala/build/options/ReplOptions.scala | 3 + website/docs/commands/repl.md | 26 +- website/docs/reference/cli-options.md | 6 + website/docs/reference/commands.md | 2 +- .../docs/reference/scala-command/commands.md | 2 +- .../scala-command/runner-specification.md | 2 +- 13 files changed, 669 insertions(+), 22 deletions(-) create mode 100644 modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala create mode 100644 modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala 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..e551cc1ca7 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala @@ -0,0 +1,147 @@ +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) + + final case class ParsedReplArgs( + initScriptOpt: Option[String], + quitAfterInit: Boolean, + remainingArgs: Seq[String] + ) + + private def executableExt(isWindows: Boolean): String = + if (isWindows) ".exe" + else "" + + def parseReplArgs(args: Seq[String]): ParsedReplArgs = { + val b = Seq.newBuilder[String] + var initScriptOpt: Option[String] = None + var quitAfterInit = false + var idx = 0 + while (idx < args.length) { + val arg = args(idx) + if (arg == "--repl-init-script" || arg == "-repl-init-script") + if (idx + 1 < args.length) { + initScriptOpt = Some(args(idx + 1)) + idx += 1 + } + else b += arg + else if (arg.startsWith("--repl-init-script:") || arg.startsWith("-repl-init-script:")) + initScriptOpt = Some(arg.dropWhile(_ != ':').drop(1)) + else if (arg == "--repl-quit-after-init" || arg == "-repl-quit-after-init") + quitAfterInit = true + else b += arg + idx += 1 + } + ParsedReplArgs( + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, + remainingArgs = b.result() + ) + } + + 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) + 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)) + 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) { + 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) { + logger.message(s"JShell command: ${command.processCommand.mkString(" ")}") + logger.message("Dry run, not running REPL.") + Right(()) + } + else { + val process = + if (allowExecve) + 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) 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..5d58a3b889 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 @@ -60,6 +60,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { import ops.sharedRepl.* val logger = ops.shared.logger + if (jshell.contains(true) && ammonite.contains(true)) + 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 +105,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 @@ -298,6 +301,16 @@ 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 shouldUseJshell = explicitJshellOpt.getOrElse(isPureJavaProject && !shouldUseAmmonite) + val replBackend = + if (shouldUseJshell) ReplBackend.JShell + else if (shouldUseAmmonite) ReplBackend.Ammonite + else ReplBackend.Default val scalaParams: ScalaParameters = value { val distinctScalaParams = allArtifacts.flatMap(_.scalaOpt).map(_.params).distinct @@ -482,22 +495,59 @@ 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 parsedReplArgs = JShellRunner.parseReplArgs(additionalArgs) + if (parsedReplArgs.remainingArgs.nonEmpty) + logger.message( + s"Warning: JShell ignores Scala REPL options (${parsedReplArgs.remainingArgs.mkString(" ")})." + ) + 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 = parsedReplArgs.initScriptOpt, + quitAfterInit = parsedReplArgs.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 replArgs = ammoniteAdditionalArgs() ++ programArgs + maybeRunRepl(replArtifacts, replArgs) + } + + case ReplBackend.Default => + runMode match { + case RunMode.Default => + val replArtifacts = value(defaultArtifacts()) + val replArgs = additionalArgs ++ programArgs + maybeRunRepl(replArtifacts, replArgs) + } + } + } + + private enum ReplBackend { + case Default, Ammonite, JShell } + final class ConflictingReplBackendsError(message0: String) extends BuildException(message0) 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..dcd9cf3a5c 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.experimental) + @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) 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..bbbb93e45b --- /dev/null +++ b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala @@ -0,0 +1,98 @@ +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("parse repl args extracts init script and quit flag") { + val args = Seq( + "--repl-init-script", + "System.out.println(1);", + "--repl-quit-after-init", + "-Xfatal-warnings" + ) + val parsed = JShellRunner.parseReplArgs(args) + expect(parsed.initScriptOpt.contains("System.out.println(1);")) + expect(parsed.quitAfterInit) + expect(parsed.remainingArgs == Seq("-Xfatal-warnings")) + } + + 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..4767b39c55 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,26 @@ 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("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/ReplTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala index de28ad5863..ceca3dc458 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -3,12 +3,31 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect import scala.cli.integration.TestUtil.removeAnsiColors -import scala.util.Properties +import scala.util.{Properties, Try} abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { this: TestScalaVersion => protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions + protected val jshellDryRunPrefix: String = "JShell dry run:" + protected val runInJShellPrefix: String = "Running in JShell:" + + /** JShell integration tests need a JDK (resolved via JAVA_HOME, then the test JVM) with + * `bin/jshell` and spec version >= 9. + */ + protected lazy val jshellAvailable: Boolean = { + val javaHomeOpt: Option[os.Path] = + sys.env.get("JAVA_HOME").filter(_.nonEmpty).map(os.Path(_, os.pwd)) + .orElse(Option(sys.props("java.home")).filter(_.nonEmpty).map(os.Path(_, os.pwd))) + val jshellExe = if Properties.isWin then "jshell.exe" else "jshell" + val jshellExists = javaHomeOpt.exists(h => os.exists(h / "bin" / jshellExe)) + val javaSpecVersionOpt = + Try(sys.props("java.specification.version").toInt).toOption.orElse { + Try(sys.props("java.specification.version").stripPrefix("1.").toInt).toOption + } + jshellExists && javaSpecVersionOpt.exists(_ >= 9) + } + protected lazy val canRunInRepl: Boolean = (actualScalaVersion.startsWith("3.3") && actualScalaVersion.coursierVersion >= "3.3.7".coursierVersion) || @@ -71,6 +90,72 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } + /** Dry-run `repl` with `--power --jshell` (mirrors [[dryRun]] for the JShell backend). */ + 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, + "--power", + "repl", + ".", + "--jshell", + "--repl-dry-run", + cliOptions, + if useExtraOptions then extraOptions else Seq.empty + ).call(cwd = root, mergeErrIntoOut = true, check = check) + } + + private def jshellOutput(res: os.CommandResult): String = + TestUtil.removeAnsiColors(res.out.text()) + + /** Run JShell via `repl --jshell` with `--repl-init-script` + `--repl-quit-after-init`. + * + * @param replCliOptions + * forwarded after [[initScript]] (defaults to [[extraOptions]]). Use [[TestUtil.extraOptions]] + * alone when inputs compile Scala 2.x sources: Scala 2 scalac rejects `--repl-quit-after-init` + * / `--repl-init-script`, while default Scala (see launcher) accepts them for JShell mapping. + */ + 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 => Unit, + runBeforeReplAndGetExtraCliOpts: () => Seq[os.Shellable] = () => Seq.empty + ): Unit = { + testInputs.fromRoot { root => + val potentiallyExtraCliOpts = runBeforeReplAndGetExtraCliOpts() + runAfterRepl( + os.proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--jshell", + "--repl-quit-after-init", + "--repl-init-script", + initScript, + replCliOptions, + cliOptions, + potentiallyExtraCliOpts + ).call( + cwd = root, + mergeErrIntoOut = true, + env = env, + check = check + ) + ) + } + } + test(s"$dryRunPrefix default")(dryRun()) test(s"$dryRunPrefix with main scope sources") { @@ -132,6 +217,102 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr expect(output.contains("typer")) } + 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, + "--power", + "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 / "Main.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")) + } + } + if canRunInRepl then { test(s"$runInReplPrefix simple") { val expectedMessage = "1337" @@ -288,4 +469,113 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } } + + // --Xshow-phases / ScalaPy / Ammonite-specific scenarios do not apply to JShell (Java REPL). + if jshellAvailable then { + test(s"$runInJShellPrefix simple") { + val expectedMessage = "1337" + runInJShell(s"""System.out.println("$expectedMessage");""")(res => + expect(jshellOutput(res).contains(expectedMessage)) + ) + } + + test(s"$runInJShellPrefix verify JVM version respects --jvm") { + runInJShell( + initScript = """System.out.println(System.getProperty("java.specification.version"));""", + cliOptions = Seq("--jvm", "17") + )(res => + expect( + jshellOutput(res).trim().linesIterator.exists { line => + val t = line.trim + t == "17" || t.startsWith("17.") + } + ) + ) + } + + 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"))) + } + } } 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..5a962fa984 100644 --- a/website/docs/commands/repl.md +++ b/website/docs/commands/repl.md @@ -22,7 +22,31 @@ 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. + +:::caution +The JShell integration is experimental and currently requires `--power`. +You can pass it explicitly or set it globally: + + scala-cli config power true +::: + + + +```bash ignore +scala-cli --power 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..18715fae36 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` 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/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..6ea3563c86 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. From 6055eea63fc3dfebb6342f9094d515444ae4edf6 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 11 May 2026 09:23:59 +0200 Subject: [PATCH 02/11] Add support for `--repl-init-script-file` to enable testing JSHELL on Windows --- .../cli/commands/repl/JShellRunner.scala | 73 +++++++++++++++--- .../scala/scala/cli/commands/repl/Repl.scala | 74 +++++++++++++++++-- .../cli/commands/repl/SharedReplOptions.scala | 7 ++ .../cli/commands/shared/ScalacOptions.scala | 6 +- .../commands/tests/JShellRunnerTests.scala | 57 +++++++++++++- .../cli/commands/tests/ReplOptionsTests.scala | 10 +++ .../ReplAmmoniteTestDefinitions.scala | 49 ++++++++++++ .../cli/integration/ReplTestDefinitions.scala | 59 +++++++++++++-- .../scala/build/options/ReplOptions.scala | 3 +- website/docs/reference/cli-options.md | 4 + 10 files changed, 311 insertions(+), 31 deletions(-) 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 index e551cc1ca7..88e22a2272 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala @@ -21,6 +21,8 @@ object JShellRunner { final class JShellUnavailable(message0: String) extends BuildException(message0) + final class ReplInitScriptError(message0: String, cause0: Throwable = null) + extends BuildException(message0, cause = cause0) final case class ParsedReplArgs( initScriptOpt: Option[String], @@ -32,11 +34,36 @@ object JShellRunner { if (isWindows) ".exe" else "" - def parseReplArgs(args: Seq[String]): ParsedReplArgs = { - val b = Seq.newBuilder[String] - var initScriptOpt: Option[String] = None - var quitAfterInit = false - var idx = 0 + 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)) + Left(ReplInitScriptError(s"REPL init script file not found: $path")) + else if (os.isDir(path)) + 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 + )) + } + } + } + + def parseReplArgs(args: Seq[String]): Either[BuildException, ParsedReplArgs] = { + val b = Seq.newBuilder[String] + var initScriptOpt: Option[String] = None + var initScriptFileOpt: Option[String] = None + var quitAfterInit = false + var idx = 0 while (idx < args.length) { val arg = args(idx) if (arg == "--repl-init-script" || arg == "-repl-init-script") @@ -47,16 +74,42 @@ object JShellRunner { else b += arg else if (arg.startsWith("--repl-init-script:") || arg.startsWith("-repl-init-script:")) initScriptOpt = Some(arg.dropWhile(_ != ':').drop(1)) + else if (arg == "--repl-init-script-file" || arg == "-repl-init-script-file") + if (idx + 1 < args.length) { + initScriptFileOpt = Some(args(idx + 1)) + idx += 1 + } + else b += arg + else if ( + arg.startsWith("--repl-init-script-file:") || arg.startsWith("-repl-init-script-file:") + ) + initScriptFileOpt = Some(arg.dropWhile(_ != ':').drop(1)) else if (arg == "--repl-quit-after-init" || arg == "-repl-quit-after-init") quitAfterInit = true else b += arg idx += 1 } - ParsedReplArgs( - initScriptOpt = initScriptOpt, - quitAfterInit = quitAfterInit, - remainingArgs = b.result() - ) + if (initScriptOpt.nonEmpty && initScriptFileOpt.nonEmpty) + Left(ReplInitScriptError( + "--repl-init-script cannot be used together with --repl-init-script-file" + )) + else { + val resolvedInitScriptOpt = + initScriptOpt match { + case some @ Some(_) => Right(some) + case None => initScriptFileOpt match { + case Some(file) => readInitScriptFile(file).map(Some(_)) + case None => Right(None) + } + } + resolvedInitScriptOpt.map { resolvedInitScriptOpt => + ParsedReplArgs( + initScriptOpt = resolvedInitScriptOpt, + quitAfterInit = quitAfterInit, + remainingArgs = b.result() + ) + } + } } def commandFor( 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 5d58a3b889..6efa70f849 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 @@ -108,7 +108,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { useJshellOpt = jshell, useAmmoniteOpt = ammonite, ammoniteVersionOpt = ammoniteVersion.map(_.trim).filter(_.nonEmpty), - ammoniteArgs = ammoniteArg + ammoniteArgs = ammoniteArg, + replInitScriptFileOpt = replInitScriptFile.map(_.trim).filter(_.nonEmpty) ), addRunnerDependencyOpt = baseOptions.notForBloopOptions.addRunnerDependencyOpt .orElse(Some(false)) @@ -280,7 +281,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { private def maybeAdaptForWindows(args: Seq[String]): Seq[String] = if (Properties.isWin) args.map { a => - if (a.contains(" ")) "\"" + a.replace("\"", "\\\"") + "\"" + if (a.exists(c => c.isWhitespace || c == '"')) "\"" + a.replace("\"", "\\\"") + "\"" else a } else @@ -350,7 +351,14 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { pythonArgs ++ options.scalaOptions.scalacOptions.toSeq.map(_.value.value) } - def ammoniteAdditionalArgs() = { + val replInitScriptFileArgs = + options.notForBloopOptions.replOptions.replInitScriptFileOpt.toSeq.flatMap { path => + Seq("--repl-init-script-file", path) + } + + val parsedReplArgs = value(JShellRunner.parseReplArgs(additionalArgs ++ replInitScriptFileArgs)) + + def ammoniteAdditionalArgs(parsedReplArgs: JShellRunner.ParsedReplArgs) = { val pythonPredef = if (setupPython) """import me.shadaj.scalapy.py @@ -358,10 +366,16 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { |""".stripMargin else "" - val predefArgs = + val pythonPredefArgs = if (pythonPredef.isEmpty) Nil else Seq("--predef-code", pythonPredef) - predefArgs ++ options.notForBloopOptions.replOptions.ammoniteArgs + val replInitScriptPredefArgs = + parsedReplArgs.initScriptOpt.toSeq.flatMap(script => Seq("--predef-code", script)) + val replQuitAfterInitArgs = + if (parsedReplArgs.quitAfterInit) Seq("--code", "") + else Nil + pythonPredefArgs ++ replInitScriptPredefArgs ++ replQuitAfterInitArgs ++ + options.notForBloopOptions.replOptions.ammoniteArgs } // TODO Warn if some entries of artifacts.classPath were evicted in replArtifacts.replClassPath @@ -497,7 +511,6 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { replBackend match { case ReplBackend.JShell => - val parsedReplArgs = JShellRunner.parseReplArgs(additionalArgs) if (parsedReplArgs.remainingArgs.nonEmpty) logger.message( s"Warning: JShell ignores Scala REPL options (${parsedReplArgs.remainingArgs.mkString(" ")})." @@ -529,7 +542,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { runMode match { case RunMode.Default => val replArtifacts = value(ammoniteArtifacts()) - val replArgs = ammoniteAdditionalArgs() ++ programArgs + val replArgs = ammoniteAdditionalArgs(parsedReplArgs) ++ programArgs maybeRunRepl(replArtifacts, replArgs) } @@ -537,12 +550,57 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { runMode match { case RunMode.Default => val replArtifacts = value(defaultArtifacts()) - val replArgs = additionalArgs ++ programArgs + val replArgs = + value(defaultReplArgs( + additionalArgs, + options.notForBloopOptions.replOptions.replInitScriptFileOpt + )) ++ programArgs maybeRunRepl(replArtifacts, replArgs) } } } + private[commands] def defaultReplArgs( + additionalArgs: Seq[String], + replInitScriptFileOpt: Option[String] + ): Either[BuildException, Seq[String]] = { + val b = Seq.newBuilder[String] + var idx = 0 + var errorOpt: Option[BuildException] = None + while (idx < additionalArgs.length) { + val arg = additionalArgs(idx) + if (arg == "--repl-init-script-file" || arg == "-repl-init-script-file") + if (idx + 1 < additionalArgs.length) { + JShellRunner.readInitScriptFile(additionalArgs(idx + 1)) match { + case Right(initScript) => + b += "--repl-init-script" + b += initScript + case Left(e) => errorOpt = Some(e) + } + idx += 1 + } + else b += arg + else if ( + arg.startsWith("--repl-init-script-file:") || arg.startsWith("-repl-init-script-file:") + ) + JShellRunner.readInitScriptFile(arg.dropWhile(_ != ':').drop(1)) match { + case Right(initScript) => + b += "--repl-init-script" + b += initScript + case Left(e) => errorOpt = Some(e) + } + else b += arg + idx += 1 + } + for { + args <- errorOpt.toLeft(b.result()) + sharedInitScriptOpt <- replInitScriptFileOpt match { + case Some(path) => JShellRunner.readInitScriptFile(path).map(Some(_)) + case None => Right(None) + } + } yield args ++ sharedInitScriptOpt.toSeq.flatMap(script => Seq("--repl-init-script", script)) + } + private enum ReplBackend { case Default, Ammonite, JShell } 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 dcd9cf3a5c..f19379b5a2 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 @@ -48,6 +48,13 @@ final case class SharedReplOptions( @Hidden ammoniteArg: List[String] = Nil, + @Group(HelpGroup.Repl.toString) + @Tag(tags.experimental) + @Tag(tags.inShortHelp) + @ValueDescription("path") + @HelpMessage("Read the 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/main/scala/scala/cli/commands/shared/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index b06f688d14..dfede5b5bb 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala @@ -47,9 +47,9 @@ object ScalacOptions { val YScriptRunnerOption = "Yscriptrunner" private val scalacOptionsPurePrefixes = Set("V", "W", "X", "Y") private val scalacOptionsPrefixes = Set("P") ++ scalacOptionsPurePrefixes - val replExecuteScriptOptions @ Seq(replInitScript, replQuitAfterInit) = - Seq("repl-init-script", "repl-quit-after-init") - private val replAliasedOptions = Set(replInitScript) + val replExecuteScriptOptions @ Seq(replInitScript, replQuitAfterInit, replInitScriptFile) = + Seq("repl-init-script", "repl-quit-after-init", "repl-init-script-file") + private val replAliasedOptions = Set(replInitScript, replInitScriptFile) private val replNoArgAliasedOptions = Set(replQuitAfterInit) private val scalacAliasedOptions = // these options don't require being passed after -O and accept an arg Set( diff --git a/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala index bbbb93e45b..0fb8ef9651 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala @@ -3,7 +3,7 @@ package scala.cli.commands.tests import com.eed3si9n.expecty.Expecty.assert as expect import scala.build.options.BuildOptions -import scala.cli.commands.repl.JShellRunner +import scala.cli.commands.repl.{JShellRunner, Repl} class JShellRunnerTests extends munit.FunSuite { @@ -29,12 +29,65 @@ class JShellRunnerTests extends munit.FunSuite { "--repl-quit-after-init", "-Xfatal-warnings" ) - val parsed = JShellRunner.parseReplArgs(args) + val parsed = JShellRunner.parseReplArgs(args).toOption.get expect(parsed.initScriptOpt.contains("System.out.println(1);")) expect(parsed.quitAfterInit) expect(parsed.remainingArgs == Seq("-Xfatal-warnings")) } + test("parse repl args extracts init script from file") { + val initScriptFile = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") + val initScript = + """System.out.println("from file"); + |System.out.println(2); + |""".stripMargin + os.write.over(initScriptFile, initScript) + val parsed = JShellRunner + .parseReplArgs(Seq("--repl-init-script-file", initScriptFile.toString)) + .toOption + .get + expect(parsed.initScriptOpt.contains(initScript)) + expect(parsed.remainingArgs.isEmpty) + } + + test("parse repl args rejects init script string and file together") { + val initScriptFile = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") + os.write.over(initScriptFile, "System.out.println(1);") + val parsed = JShellRunner.parseReplArgs(Seq( + "--repl-init-script", + "System.out.println(1);", + "--repl-init-script-file", + initScriptFile.toString + )) + expect(parsed.isLeft) + } + + test("parse repl args rejects missing init script file") { + val missing = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") + os.remove(missing) + val parsed = JShellRunner.parseReplArgs(Seq("--repl-init-script-file", missing.toString)) + expect(parsed.isLeft) + } + + test("default REPL maps init script file option to the Scala REPL init script flag") { + val initScriptFile = os.temp(prefix = "scala-cli-repl-init-test-", suffix = ".sc") + val otherInitFile = os.temp(prefix = "scala-cli-repl-init-test-other-", suffix = ".sc") + os.write.over(initScriptFile, """println("first")""") + os.write.over(otherInitFile, """println("second")""") + val args = Repl.defaultReplArgs( + additionalArgs = + Seq("--repl-init-script-file", initScriptFile.toString, "--repl-quit-after-init"), + replInitScriptFileOpt = Some(otherInitFile.toString) + ).toOption.get + expect(args == Seq( + "--repl-init-script", + """println("first")""", + "--repl-quit-after-init", + "--repl-init-script", + """println("second")""" + )) + } + test("commandFor adds classpath java opts startup and load files") { withTempJavaHome() { javaHomeInfo => val cpRoot = os.temp.dir(prefix = "scala-cli-jshell-cp-", deleteOnExit = false) 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 4767b39c55..94020ffc6d 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala @@ -42,6 +42,16 @@ class ReplOptionsTests extends munit.FunSuite { expect(buildOptions.notForBloopOptions.replOptions.useJshellOpt.contains(true)) } + test("Propagate --repl-init-script-file to build options") { + val replOptions = ReplOptions( + sharedRepl = SharedReplOptions( + replInitScriptFile = Some("init.sc") + ) + ) + val buildOptions = Repl.buildOptions(replOptions).value + expect(buildOptions.notForBloopOptions.replOptions.replInitScriptFileOpt.contains("init.sc")) + } + test("Reject --jshell with --ammonite") { val replOptions = ReplOptions( sharedRepl = SharedReplOptions( 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..08b12eec44 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplAmmoniteTestDefinitions.scala @@ -3,6 +3,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect import scala.cli.integration.TestUtil.{normalizeArgsForWindows, removeAnsiColors} +import scala.util.Properties trait ReplAmmoniteTestDefinitions { this: ReplTestDefinitions => protected val ammonitePrefix: String = "Running in Ammonite REPL:" @@ -185,6 +186,54 @@ 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") + } + } + if !Properties.isWin then + test(s"$ammonitePrefix direct --repl-init-script string form$ammoniteMaxVersionString") { + val initScript = + """val message = "hello from ammonite string" + |""".stripMargin + TestInputs.empty.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--ammonite", + "--repl-init-script", + initScript, + "--ammonite-arg", + "-c", + "--ammonite-arg", + "println(message)", + ammoniteExtraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(res.out.trim() == "hello from ammonite string") + } + } test(s"$ammonitePrefix scalapy$ammoniteMaxVersionString")(ammoniteScalapyTest()) test(s"$ammonitePrefix with test scope sources$ammoniteMaxVersionString")(ammoniteTestScope()) 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 ceca3dc458..7bc6556d6b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -44,21 +44,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 @@ -113,12 +120,13 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr private def jshellOutput(res: os.CommandResult): String = TestUtil.removeAnsiColors(res.out.text()) - /** Run JShell via `repl --jshell` with `--repl-init-script` + `--repl-quit-after-init`. + /** Run JShell via `repl --jshell` with `--repl-init-script-file` + `--repl-quit-after-init`. * * @param replCliOptions * forwarded after [[initScript]] (defaults to [[extraOptions]]). Use [[TestUtil.extraOptions]] * alone when inputs compile Scala 2.x sources: Scala 2 scalac rejects `--repl-quit-after-init` - * / `--repl-init-script`, while default Scala (see launcher) accepts them for JShell mapping. + * / `--repl-init-script-file`, while default Scala (see launcher) accepts them for JShell + * mapping. */ protected def runInJShell( initScript: String, @@ -133,6 +141,8 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr ): 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, @@ -141,8 +151,8 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr ".", "--jshell", "--repl-quit-after-init", - "--repl-init-script", - initScript, + "--repl-init-script-file", + initScriptFile.toString, replCliOptions, cliOptions, potentiallyExtraCliOpts @@ -321,6 +331,16 @@ 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 verify Scala version from the REPL") { val opts = if actualScalaVersion.startsWith("3") && !isScala38OrNewer then Seq("--with-compiler") @@ -479,6 +499,31 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr ) } + if !Properties.isWin then + test(s"$runInJShellPrefix direct --repl-init-script string form") { + val initScript = + """System.out.println("hi from string"); + |var c = Class.forName("java.lang.String"); + |System.out.println(c.getName()); + |""".stripMargin + TestInputs.empty.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--jshell", + "--repl-quit-after-init", + "--repl-init-script", + initScript, + extraOptions + ).call(cwd = root, mergeErrIntoOut = true) + val out = jshellOutput(res) + expect(out.contains("hi from string")) + expect(out.contains("java.lang.String")) + } + } + test(s"$runInJShellPrefix verify JVM version respects --jvm") { runInJShell( initScript = """System.out.println(System.getProperty("java.specification.version"));""", 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 bce7960bfe..f6930c9b35 100644 --- a/modules/options/src/main/scala/scala/build/options/ReplOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ReplOptions.scala @@ -7,7 +7,8 @@ final case class ReplOptions( useJshellOpt: Option[Boolean] = None, useAmmoniteOpt: Option[Boolean] = None, ammoniteVersionOpt: Option[String] = None, - ammoniteArgs: Seq[String] = Nil + ammoniteArgs: Seq[String] = Nil, + replInitScriptFileOpt: Option[String] = None ) { def useJshell: Boolean = useJshellOpt.getOrElse(false) diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 18715fae36..c8bcd742c2 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1293,6 +1293,10 @@ Aliases: `-a` [Internal] Provide arguments for ammonite repl +### `--repl-init-script-file` + +Read the REPL init script from a file. Mutually exclusive with --repl-init-script. + ### `--repl-dry-run` [Internal] From 814366051480e777195fc0a32716c1ee72c39905 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 11 May 2026 10:04:20 +0200 Subject: [PATCH 03/11] Extract Jshell integration tests to a dedicated trait --- .../ReplJShellTestDefinitions.scala | 325 ++++++++++++++++++ .../cli/integration/ReplTestDefinitions.scala | 320 +---------------- .../scala/cli/integration/ReplTests212.scala | 6 +- .../scala/cli/integration/ReplTests213.scala | 6 +- .../scala/cli/integration/ReplTests3Lts.scala | 3 +- .../cli/integration/ReplTests3NextRc.scala | 2 +- .../cli/integration/ReplTestsDefault.scala | 1 + 7 files changed, 340 insertions(+), 323 deletions(-) create mode 100644 modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala 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..1463bc4c07 --- /dev/null +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -0,0 +1,325 @@ +package scala.cli.integration + +import com.eed3si9n.expecty.Expecty.expect + +import scala.util.{Properties, Try} + +trait ReplJShellTestDefinitions { this: ReplTestDefinitions => + protected val jshellDryRunPrefix: String = "JShell dry run:" + protected val runInJShellPrefix: String = "Running in JShell:" + + /** JShell integration tests need a JDK (resolved via JAVA_HOME, then the test JVM) with + * `bin/jshell` and spec version >= 9. + */ + protected lazy val jshellAvailable: Boolean = { + val javaHomeOpt: Option[os.Path] = + sys.env.get("JAVA_HOME").filter(_.nonEmpty).map(os.Path(_, os.pwd)) + .orElse(Option(sys.props("java.home")).filter(_.nonEmpty).map(os.Path(_, os.pwd))) + val jshellExe = if Properties.isWin then "jshell.exe" else "jshell" + val jshellExists = javaHomeOpt.exists(h => os.exists(h / "bin" / jshellExe)) + val javaSpecVersionOpt = + Try(sys.props("java.specification.version").toInt).toOption.orElse { + Try(sys.props("java.specification.version").stripPrefix("1.").toInt).toOption + } + jshellExists && javaSpecVersionOpt.exists(_ >= 9) + } + + /** Dry-run `repl` with `--power --jshell` (mirrors [[dryRun]] for the JShell backend). */ + 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, + "--power", + "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()) + + /** Run JShell via `repl --jshell` with `--repl-init-script-file` + `--repl-quit-after-init`. + * + * @param replCliOptions + * forwarded after [[initScript]] (defaults to [[extraOptions]]). Use [[TestUtil.extraOptions]] + * alone when inputs compile Scala 2.x sources: Scala 2 scalac rejects `--repl-quit-after-init` + * / `--repl-init-script-file`, while default Scala (see launcher) accepts them for JShell + * mapping. + */ + 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 => 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, + "--power", + "repl", + ".", + "--jshell", + "--repl-quit-after-init", + "--repl-init-script-file", + initScriptFile.toString, + replCliOptions, + cliOptions, + potentiallyExtraCliOpts + ).call( + cwd = root, + mergeErrIntoOut = true, + env = env, + check = check + ) + ) + } + } + + 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, + "--power", + "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 / "Main.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")) + } + } + + // --Xshow-phases / ScalaPy / Ammonite-specific scenarios do not apply to JShell (Java REPL). + if jshellAvailable then { + test(s"$runInJShellPrefix simple") { + val expectedMessage = "1337" + runInJShell(s"""System.out.println("$expectedMessage");""")(res => + expect(jshellOutput(res).contains(expectedMessage)) + ) + } + + if !Properties.isWin then + test(s"$runInJShellPrefix direct --repl-init-script string form") { + val initScript = + """System.out.println("hi from string"); + |var c = Class.forName("java.lang.String"); + |System.out.println(c.getName()); + |""".stripMargin + TestInputs.empty.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "repl", + ".", + "--jshell", + "--repl-quit-after-init", + "--repl-init-script", + initScript, + extraOptions + ).call(cwd = root, mergeErrIntoOut = true) + val out = jshellOutput(res) + expect(out.contains("hi from string")) + expect(out.contains("java.lang.String")) + } + } + + test(s"$runInJShellPrefix verify JVM version respects --jvm") { + runInJShell( + initScript = """System.out.println(System.getProperty("java.specification.version"));""", + cliOptions = Seq("--jvm", "17") + )(res => + expect( + jshellOutput(res).trim().linesIterator.exists { line => + val t = line.trim + t == "17" || t.startsWith("17.") + } + ) + ) + } + + 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"))) + } + } +} 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 7bc6556d6b..9c434ab9eb 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -3,31 +3,12 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect import scala.cli.integration.TestUtil.removeAnsiColors -import scala.util.{Properties, Try} +import scala.util.Properties abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { this: TestScalaVersion => protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions - protected val jshellDryRunPrefix: String = "JShell dry run:" - protected val runInJShellPrefix: String = "Running in JShell:" - - /** JShell integration tests need a JDK (resolved via JAVA_HOME, then the test JVM) with - * `bin/jshell` and spec version >= 9. - */ - protected lazy val jshellAvailable: Boolean = { - val javaHomeOpt: Option[os.Path] = - sys.env.get("JAVA_HOME").filter(_.nonEmpty).map(os.Path(_, os.pwd)) - .orElse(Option(sys.props("java.home")).filter(_.nonEmpty).map(os.Path(_, os.pwd))) - val jshellExe = if Properties.isWin then "jshell.exe" else "jshell" - val jshellExists = javaHomeOpt.exists(h => os.exists(h / "bin" / jshellExe)) - val javaSpecVersionOpt = - Try(sys.props("java.specification.version").toInt).toOption.orElse { - Try(sys.props("java.specification.version").stripPrefix("1.").toInt).toOption - } - jshellExists && javaSpecVersionOpt.exists(_ >= 9) - } - protected lazy val canRunInRepl: Boolean = (actualScalaVersion.startsWith("3.3") && actualScalaVersion.coursierVersion >= "3.3.7".coursierVersion) || @@ -97,75 +78,6 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } - /** Dry-run `repl` with `--power --jshell` (mirrors [[dryRun]] for the JShell backend). */ - 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, - "--power", - "repl", - ".", - "--jshell", - "--repl-dry-run", - cliOptions, - if useExtraOptions then extraOptions else Seq.empty - ).call(cwd = root, mergeErrIntoOut = true, check = check) - } - - private def jshellOutput(res: os.CommandResult): String = - TestUtil.removeAnsiColors(res.out.text()) - - /** Run JShell via `repl --jshell` with `--repl-init-script-file` + `--repl-quit-after-init`. - * - * @param replCliOptions - * forwarded after [[initScript]] (defaults to [[extraOptions]]). Use [[TestUtil.extraOptions]] - * alone when inputs compile Scala 2.x sources: Scala 2 scalac rejects `--repl-quit-after-init` - * / `--repl-init-script-file`, while default Scala (see launcher) accepts them for JShell - * mapping. - */ - 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 => 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, - "--power", - "repl", - ".", - "--jshell", - "--repl-quit-after-init", - "--repl-init-script-file", - initScriptFile.toString, - replCliOptions, - cliOptions, - potentiallyExtraCliOpts - ).call( - cwd = root, - mergeErrIntoOut = true, - env = env, - check = check - ) - ) - } - } - test(s"$dryRunPrefix default")(dryRun()) test(s"$dryRunPrefix with main scope sources") { @@ -227,102 +139,6 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr expect(output.contains("typer")) } - 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, - "--power", - "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 / "Main.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")) - } - } - if canRunInRepl then { test(s"$runInReplPrefix simple") { val expectedMessage = "1337" @@ -489,138 +305,4 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } } - - // --Xshow-phases / ScalaPy / Ammonite-specific scenarios do not apply to JShell (Java REPL). - if jshellAvailable then { - test(s"$runInJShellPrefix simple") { - val expectedMessage = "1337" - runInJShell(s"""System.out.println("$expectedMessage");""")(res => - expect(jshellOutput(res).contains(expectedMessage)) - ) - } - - if !Properties.isWin then - test(s"$runInJShellPrefix direct --repl-init-script string form") { - val initScript = - """System.out.println("hi from string"); - |var c = Class.forName("java.lang.String"); - |System.out.println(c.getName()); - |""".stripMargin - TestInputs.empty.fromRoot { root => - val res = os.proc( - TestUtil.cli, - "--power", - "repl", - ".", - "--jshell", - "--repl-quit-after-init", - "--repl-init-script", - initScript, - extraOptions - ).call(cwd = root, mergeErrIntoOut = true) - val out = jshellOutput(res) - expect(out.contains("hi from string")) - expect(out.contains("java.lang.String")) - } - } - - test(s"$runInJShellPrefix verify JVM version respects --jvm") { - runInJShell( - initScript = """System.out.println(System.getProperty("java.specification.version"));""", - cliOptions = Seq("--jvm", "17") - )(res => - expect( - jshellOutput(res).trim().linesIterator.exists { line => - val t = line.trim - t == "17" || t.startsWith("17.") - } - ) - ) - } - - 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"))) - } - } } 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") } From 8fef1485a71d6186cfc634337bb2fbc953c12e8d Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 11 May 2026 10:31:49 +0200 Subject: [PATCH 04/11] Test JSHELL with the supported JVMs --- .../ReplJShellTestDefinitions.scala | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index 1463bc4c07..7184a3aad6 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -93,6 +93,27 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => } } + 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) @@ -198,6 +219,23 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => ) } + 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 direct --repl-init-script string form") { val initScript = @@ -223,20 +261,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => } } - test(s"$runInJShellPrefix verify JVM version respects --jvm") { - runInJShell( - initScript = """System.out.println(System.getProperty("java.specification.version"));""", - cliOptions = Seq("--jvm", "17") - )(res => - expect( - jshellOutput(res).trim().linesIterator.exists { line => - val t = line.trim - t == "17" || t.startsWith("17.") - } - ) - ) - } - if !Properties.isWin then test(s"$runInJShellPrefix with extra JAR") { runInJShell( From 80fd2f62de3a123516da0539e187a837bafa04d2 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 12 May 2026 10:16:53 +0200 Subject: [PATCH 05/11] Refactor --- .../cli/commands/repl/JShellRunner.scala | 106 +----------- .../scala/scala/cli/commands/repl/Repl.scala | 155 +++++++++--------- .../cli/commands/repl/SharedReplOptions.scala | 2 +- .../cli/commands/shared/ScalacOptions.scala | 2 +- .../commands/tests/JShellRunnerTests.scala | 68 +------- .../cli/commands/tests/ReplOptionsTests.scala | 14 +- .../ReplAmmoniteTestDefinitions.scala | 28 +--- .../ReplJShellTestDefinitions.scala | 25 --- .../scala/build/options/ReplOptions.scala | 3 +- .../reference/scala-command/cli-options.md | 34 ++-- .../scala-command/runner-specification.md | 4 + 11 files changed, 121 insertions(+), 320 deletions(-) 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 index 88e22a2272..17c4c093a1 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/JShellRunner.scala @@ -9,7 +9,6 @@ import scala.build.options.BuildOptions import scala.util.Properties object JShellRunner { - final case class Command( jshellCommand: String, args: Seq[String], @@ -21,96 +20,8 @@ object JShellRunner { final class JShellUnavailable(message0: String) extends BuildException(message0) - final class ReplInitScriptError(message0: String, cause0: Throwable = null) - extends BuildException(message0, cause = cause0) - - final case class ParsedReplArgs( - initScriptOpt: Option[String], - quitAfterInit: Boolean, - remainingArgs: Seq[String] - ) - - private def executableExt(isWindows: Boolean): String = - if (isWindows) ".exe" - else "" - - 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)) - Left(ReplInitScriptError(s"REPL init script file not found: $path")) - else if (os.isDir(path)) - 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 - )) - } - } - } - def parseReplArgs(args: Seq[String]): Either[BuildException, ParsedReplArgs] = { - val b = Seq.newBuilder[String] - var initScriptOpt: Option[String] = None - var initScriptFileOpt: Option[String] = None - var quitAfterInit = false - var idx = 0 - while (idx < args.length) { - val arg = args(idx) - if (arg == "--repl-init-script" || arg == "-repl-init-script") - if (idx + 1 < args.length) { - initScriptOpt = Some(args(idx + 1)) - idx += 1 - } - else b += arg - else if (arg.startsWith("--repl-init-script:") || arg.startsWith("-repl-init-script:")) - initScriptOpt = Some(arg.dropWhile(_ != ':').drop(1)) - else if (arg == "--repl-init-script-file" || arg == "-repl-init-script-file") - if (idx + 1 < args.length) { - initScriptFileOpt = Some(args(idx + 1)) - idx += 1 - } - else b += arg - else if ( - arg.startsWith("--repl-init-script-file:") || arg.startsWith("-repl-init-script-file:") - ) - initScriptFileOpt = Some(arg.dropWhile(_ != ':').drop(1)) - else if (arg == "--repl-quit-after-init" || arg == "-repl-quit-after-init") - quitAfterInit = true - else b += arg - idx += 1 - } - if (initScriptOpt.nonEmpty && initScriptFileOpt.nonEmpty) - Left(ReplInitScriptError( - "--repl-init-script cannot be used together with --repl-init-script-file" - )) - else { - val resolvedInitScriptOpt = - initScriptOpt match { - case some @ Some(_) => Right(some) - case None => initScriptFileOpt match { - case Some(file) => readInitScriptFile(file).map(Some(_)) - case None => Right(None) - } - } - resolvedInitScriptOpt.map { resolvedInitScriptOpt => - ParsedReplArgs( - initScriptOpt = resolvedInitScriptOpt, - quitAfterInit = quitAfterInit, - remainingArgs = b.result() - ) - } - } - } + private def executableExt(isWindows: Boolean): String = if isWindows then ".exe" else "" def commandFor( javaHomeInfo: BuildOptions.JavaHomeInfo, @@ -122,7 +33,7 @@ object JShellRunner { currentEnv: Map[String, String], isWindows: Boolean = Properties.isWin ): Either[BuildException, Command] = - if (javaHomeInfo.version < 9) + if javaHomeInfo.version < 9 then Left( JShellUnavailable( s"JShell requires JDK >= 9, but the selected JDK is ${javaHomeInfo.version}. Consider using --jvm 17." @@ -131,7 +42,7 @@ object JShellRunner { else { val jshellPath = javaHomeInfo.javaHome / "bin" / s"jshell${executableExt(isWindows)}" val jshellCommand = jshellPath.toString - if (!os.exists(jshellPath)) + 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)." @@ -149,7 +60,7 @@ object JShellRunner { Seq("--startup", "DEFAULT", "--startup", scriptFile.toString) } val quitAfterInitArgs = - if (quitAfterInit) { + if quitAfterInit then { val exitFile = os.temp( prefix = "scala-cli-jshell-exit-", suffix = ".jsh", @@ -181,19 +92,18 @@ object JShellRunner { " Running" + System.lineSeparator() + command.displayedCommand.iterator.map(_ + System.lineSeparator()).mkString ) - if (dryRun) { + if dryRun then { logger.message(s"JShell command: ${command.processCommand.mkString(" ")}") logger.message("Dry run, not running REPL.") Right(()) } else { val process = - if (allowExecve) + if allowExecve then Runner.maybeExec("jshell", command.processCommand, logger, extraEnv = command.extraEnv) - else - Runner.run(command.processCommand, logger, extraEnv = command.extraEnv) + else Runner.run(command.processCommand, logger, extraEnv = command.extraEnv) val retCode = process.waitFor() - if (retCode == 0) Right(()) + 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 6efa70f849..fd8bb3affb 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 @@ -108,8 +109,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { useJshellOpt = jshell, useAmmoniteOpt = ammonite, ammoniteVersionOpt = ammoniteVersion.map(_.trim).filter(_.nonEmpty), - ammoniteArgs = ammoniteArg, - replInitScriptFileOpt = replInitScriptFile.map(_.trim).filter(_.nonEmpty) + ammoniteArgs = ammoniteArg ), addRunnerDependencyOpt = baseOptions.notForBloopOptions.addRunnerDependencyOpt .orElse(Some(false)) @@ -122,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 = @@ -136,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, @@ -144,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, @@ -162,6 +176,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { } def doRunReplFromBuild( builds: Seq[Build.Successful], + initScriptOpt: Option[String], + quitAfterInit: Boolean, allowExit: Boolean, runMode: RunMode.HasRepl, asJar: Boolean @@ -170,6 +186,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), @@ -199,6 +217,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, @@ -231,6 +251,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds => doRunReplFromBuild( successfulBuilds, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allowExit = false, runMode = runMode(options), asJar = options.shared.asJar @@ -258,6 +280,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds => doRunReplFromBuild( successfulBuilds, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, allowExit = true, runMode = runMode(options), asJar = options.shared.asJar @@ -287,8 +311,34 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { 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], @@ -342,23 +392,15 @@ 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 replInitScriptFileArgs = - options.notForBloopOptions.replOptions.replInitScriptFileOpt.toSeq.flatMap { path => - Seq("--repl-init-script-file", path) - } - - val parsedReplArgs = value(JShellRunner.parseReplArgs(additionalArgs ++ replInitScriptFileArgs)) + val pythonReplArgs = + if (setupPython && scalaParams.scalaVersion.startsWith("2.13.")) + Seq("-Yimports:java.lang,scala,scala.Predef,me.shadaj.scalapy") + else + Nil + val additionalArgs = + pythonReplArgs ++ options.scalaOptions.scalacOptions.toSeq.map(_.value.value) - def ammoniteAdditionalArgs(parsedReplArgs: JShellRunner.ParsedReplArgs) = { + def ammoniteAdditionalArgs() = { val pythonPredef = if (setupPython) """import me.shadaj.scalapy.py @@ -370,9 +412,9 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { if (pythonPredef.isEmpty) Nil else Seq("--predef-code", pythonPredef) val replInitScriptPredefArgs = - parsedReplArgs.initScriptOpt.toSeq.flatMap(script => Seq("--predef-code", script)) + initScriptOpt.toSeq.flatMap(script => Seq("--predef-code", script)) val replQuitAfterInitArgs = - if (parsedReplArgs.quitAfterInit) Seq("--code", "") + if (quitAfterInit) Seq("--code", "") else Nil pythonPredefArgs ++ replInitScriptPredefArgs ++ replQuitAfterInitArgs ++ options.notForBloopOptions.replOptions.ammoniteArgs @@ -511,10 +553,6 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { replBackend match { case ReplBackend.JShell => - if (parsedReplArgs.remainingArgs.nonEmpty) - logger.message( - s"Warning: JShell ignores Scala REPL options (${parsedReplArgs.remainingArgs.mkString(" ")})." - ) val javaHomeInfo = options.javaHome().value val jshellCommand0 = value( JShellRunner.commandFor( @@ -522,8 +560,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { javaOpts = scalapyJavaOpts ++ options.javaOptions.javaOpts.toSeq.map(_.value.value), classPath = mainJarsOrClassDirs ++ allArtifacts.flatMap(_.classPath).distinct, programArgs = programArgs, - initScriptOpt = parsedReplArgs.initScriptOpt, - quitAfterInit = parsedReplArgs.quitAfterInit, + initScriptOpt = initScriptOpt, + quitAfterInit = quitAfterInit, currentEnv = sys.env ) ) @@ -542,63 +580,22 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { runMode match { case RunMode.Default => val replArtifacts = value(ammoniteArtifacts()) - val replArgs = ammoniteAdditionalArgs(parsedReplArgs) ++ programArgs - maybeRunRepl(replArtifacts, replArgs) + val ammoniteArgs = ammoniteAdditionalArgs() ++ programArgs + maybeRunRepl(replArtifacts, ammoniteArgs) } case ReplBackend.Default => runMode match { case RunMode.Default => - val replArtifacts = value(defaultArtifacts()) - val replArgs = - value(defaultReplArgs( - additionalArgs, - options.notForBloopOptions.replOptions.replInitScriptFileOpt - )) ++ programArgs - maybeRunRepl(replArtifacts, replArgs) - } - } - } - - private[commands] def defaultReplArgs( - additionalArgs: Seq[String], - replInitScriptFileOpt: Option[String] - ): Either[BuildException, Seq[String]] = { - val b = Seq.newBuilder[String] - var idx = 0 - var errorOpt: Option[BuildException] = None - while (idx < additionalArgs.length) { - val arg = additionalArgs(idx) - if (arg == "--repl-init-script-file" || arg == "-repl-init-script-file") - if (idx + 1 < additionalArgs.length) { - JShellRunner.readInitScriptFile(additionalArgs(idx + 1)) match { - case Right(initScript) => - b += "--repl-init-script" - b += initScript - case Left(e) => errorOpt = Some(e) - } - idx += 1 - } - else b += arg - else if ( - arg.startsWith("--repl-init-script-file:") || arg.startsWith("-repl-init-script-file:") - ) - JShellRunner.readInitScriptFile(arg.dropWhile(_ != ':').drop(1)) match { - case Right(initScript) => - b += "--repl-init-script" - b += initScript - case Left(e) => errorOpt = Some(e) + val replArtifacts = value(defaultArtifacts()) + val initScriptArgs = + initScriptOpt.toSeq.flatMap(script => + Seq(s"--${ScalacOptions.replInitScript}", script) + ) + val defaultArgs = additionalArgs ++ initScriptArgs ++ programArgs + maybeRunRepl(replArtifacts, defaultArgs) } - else b += arg - idx += 1 } - for { - args <- errorOpt.toLeft(b.result()) - sharedInitScriptOpt <- replInitScriptFileOpt match { - case Some(path) => JShellRunner.readInitScriptFile(path).map(Some(_)) - case None => Right(None) - } - } yield args ++ sharedInitScriptOpt.toSeq.flatMap(script => Seq("--repl-init-script", script)) } private enum ReplBackend { @@ -606,6 +603,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { } 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/SharedReplOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala index f19379b5a2..2c2a4464ff 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 @@ -49,7 +49,7 @@ final case class SharedReplOptions( ammoniteArg: List[String] = Nil, @Group(HelpGroup.Repl.toString) - @Tag(tags.experimental) + @Tag(tags.implementation) @Tag(tags.inShortHelp) @ValueDescription("path") @HelpMessage("Read the REPL init script from a file. Mutually exclusive with --repl-init-script.") diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index dfede5b5bb..3c8e7e21fb 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala @@ -49,7 +49,7 @@ object ScalacOptions { private val scalacOptionsPrefixes = Set("P") ++ scalacOptionsPurePrefixes val replExecuteScriptOptions @ Seq(replInitScript, replQuitAfterInit, replInitScriptFile) = Seq("repl-init-script", "repl-quit-after-init", "repl-init-script-file") - private val replAliasedOptions = Set(replInitScript, replInitScriptFile) + private val replAliasedOptions = Set(replInitScript) private val replNoArgAliasedOptions = Set(replQuitAfterInit) private val scalacAliasedOptions = // these options don't require being passed after -O and accept an arg Set( diff --git a/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala index 0fb8ef9651..d15c3b268f 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/JShellRunnerTests.scala @@ -3,7 +3,7 @@ package scala.cli.commands.tests import com.eed3si9n.expecty.Expecty.assert as expect import scala.build.options.BuildOptions -import scala.cli.commands.repl.{JShellRunner, Repl} +import scala.cli.commands.repl.JShellRunner class JShellRunnerTests extends munit.FunSuite { @@ -22,72 +22,6 @@ class JShellRunnerTests extends munit.FunSuite { f(info) } - test("parse repl args extracts init script and quit flag") { - val args = Seq( - "--repl-init-script", - "System.out.println(1);", - "--repl-quit-after-init", - "-Xfatal-warnings" - ) - val parsed = JShellRunner.parseReplArgs(args).toOption.get - expect(parsed.initScriptOpt.contains("System.out.println(1);")) - expect(parsed.quitAfterInit) - expect(parsed.remainingArgs == Seq("-Xfatal-warnings")) - } - - test("parse repl args extracts init script from file") { - val initScriptFile = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") - val initScript = - """System.out.println("from file"); - |System.out.println(2); - |""".stripMargin - os.write.over(initScriptFile, initScript) - val parsed = JShellRunner - .parseReplArgs(Seq("--repl-init-script-file", initScriptFile.toString)) - .toOption - .get - expect(parsed.initScriptOpt.contains(initScript)) - expect(parsed.remainingArgs.isEmpty) - } - - test("parse repl args rejects init script string and file together") { - val initScriptFile = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") - os.write.over(initScriptFile, "System.out.println(1);") - val parsed = JShellRunner.parseReplArgs(Seq( - "--repl-init-script", - "System.out.println(1);", - "--repl-init-script-file", - initScriptFile.toString - )) - expect(parsed.isLeft) - } - - test("parse repl args rejects missing init script file") { - val missing = os.temp(prefix = "scala-cli-jshell-init-test-", suffix = ".jsh") - os.remove(missing) - val parsed = JShellRunner.parseReplArgs(Seq("--repl-init-script-file", missing.toString)) - expect(parsed.isLeft) - } - - test("default REPL maps init script file option to the Scala REPL init script flag") { - val initScriptFile = os.temp(prefix = "scala-cli-repl-init-test-", suffix = ".sc") - val otherInitFile = os.temp(prefix = "scala-cli-repl-init-test-other-", suffix = ".sc") - os.write.over(initScriptFile, """println("first")""") - os.write.over(otherInitFile, """println("second")""") - val args = Repl.defaultReplArgs( - additionalArgs = - Seq("--repl-init-script-file", initScriptFile.toString, "--repl-quit-after-init"), - replInitScriptFileOpt = Some(otherInitFile.toString) - ).toOption.get - expect(args == Seq( - "--repl-init-script", - """println("first")""", - "--repl-quit-after-init", - "--repl-init-script", - """println("second")""" - )) - } - test("commandFor adds classpath java opts startup and load files") { withTempJavaHome() { javaHomeInfo => val cpRoot = os.temp.dir(prefix = "scala-cli-jshell-cp-", deleteOnExit = false) 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 94020ffc6d..37ef88d4a7 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/ReplOptionsTests.scala @@ -42,14 +42,12 @@ class ReplOptionsTests extends munit.FunSuite { expect(buildOptions.notForBloopOptions.replOptions.useJshellOpt.contains(true)) } - test("Propagate --repl-init-script-file to build options") { - val replOptions = ReplOptions( - sharedRepl = SharedReplOptions( - replInitScriptFile = Some("init.sc") - ) - ) - val buildOptions = Repl.buildOptions(replOptions).value - expect(buildOptions.notForBloopOptions.replOptions.replInitScriptFileOpt.contains("init.sc")) + 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") { 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 08b12eec44..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,8 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.cli.integration.TestUtil.{normalizeArgsForWindows, removeAnsiColors} -import scala.util.Properties +import scala.cli.integration.TestUtil.normalizeArgsForWindows trait ReplAmmoniteTestDefinitions { this: ReplTestDefinitions => protected val ammonitePrefix: String = "Running in Ammonite REPL:" @@ -211,35 +210,12 @@ trait ReplAmmoniteTestDefinitions { this: ReplTestDefinitions => expect(res.out.trim() == "hello from ammonite file") } } - if !Properties.isWin then - test(s"$ammonitePrefix direct --repl-init-script string form$ammoniteMaxVersionString") { - val initScript = - """val message = "hello from ammonite string" - |""".stripMargin - TestInputs.empty.fromRoot { root => - val res = os.proc( - TestUtil.cli, - "--power", - "repl", - ".", - "--ammonite", - "--repl-init-script", - initScript, - "--ammonite-arg", - "-c", - "--ammonite-arg", - "println(message)", - ammoniteExtraOptions - ).call(cwd = root, stderr = os.Pipe) - expect(res.out.trim() == "hello from ammonite string") - } - } 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 index 7184a3aad6..dcdd79c147 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -236,31 +236,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => runInJShellOnJvm(javaVersion, extraInitScript = versionSpecific)(_ => ()) } - if !Properties.isWin then - test(s"$runInJShellPrefix direct --repl-init-script string form") { - val initScript = - """System.out.println("hi from string"); - |var c = Class.forName("java.lang.String"); - |System.out.println(c.getName()); - |""".stripMargin - TestInputs.empty.fromRoot { root => - val res = os.proc( - TestUtil.cli, - "--power", - "repl", - ".", - "--jshell", - "--repl-quit-after-init", - "--repl-init-script", - initScript, - extraOptions - ).call(cwd = root, mergeErrIntoOut = true) - val out = jshellOutput(res) - expect(out.contains("hi from string")) - expect(out.contains("java.lang.String")) - } - } - if !Properties.isWin then test(s"$runInJShellPrefix with extra JAR") { runInJShell( 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 f6930c9b35..bce7960bfe 100644 --- a/modules/options/src/main/scala/scala/build/options/ReplOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ReplOptions.scala @@ -7,8 +7,7 @@ final case class ReplOptions( useJshellOpt: Option[Boolean] = None, useAmmoniteOpt: Option[Boolean] = None, ammoniteVersionOpt: Option[String] = None, - ammoniteArgs: Seq[String] = Nil, - replInitScriptFileOpt: Option[String] = None + ammoniteArgs: Seq[String] = Nil ) { def useJshell: Boolean = useJshellOpt.getOrElse(false) diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 383d963db2..f1fd9eaa6f 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -728,6 +728,26 @@ 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) + + + +### `--repl-init-script-file` + +`IMPLEMENTATION specific` per Scala Runner specification + +Read the 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 +1636,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/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 6ea3563c86..d364ecd226 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -2106,6 +2106,10 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--repl-init-script-file** + +Read the REPL init script from a file. Mutually exclusive with --repl-init-script. + **--repl-dry-run** Don't actually run the REPL, just fetch it From 1002c98c45aecc74f2800150d727973b64a28673 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 18 May 2026 13:59:51 +0200 Subject: [PATCH 06/11] Refactor some more --- .../main/scala/scala/cli/commands/shared/ScalacOptions.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index 3c8e7e21fb..b06f688d14 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala @@ -47,8 +47,8 @@ object ScalacOptions { val YScriptRunnerOption = "Yscriptrunner" private val scalacOptionsPurePrefixes = Set("V", "W", "X", "Y") private val scalacOptionsPrefixes = Set("P") ++ scalacOptionsPurePrefixes - val replExecuteScriptOptions @ Seq(replInitScript, replQuitAfterInit, replInitScriptFile) = - Seq("repl-init-script", "repl-quit-after-init", "repl-init-script-file") + val replExecuteScriptOptions @ Seq(replInitScript, replQuitAfterInit) = + Seq("repl-init-script", "repl-quit-after-init") private val replAliasedOptions = Set(replInitScript) private val replNoArgAliasedOptions = Set(replQuitAfterInit) private val scalacAliasedOptions = // these options don't require being passed after -O and accept an arg From b0c46b8d471325e7add7a44a9aa1cccfa6af5083 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 18 May 2026 14:00:54 +0200 Subject: [PATCH 07/11] Make JSHELL non-experimental --- .../scala/cli/commands/repl/SharedReplOptions.scala | 2 +- .../cli/integration/ReplJShellTestDefinitions.scala | 7 ++----- website/docs/commands/repl.md | 9 +-------- website/docs/reference/scala-command/cli-options.md | 8 ++++++++ .../docs/reference/scala-command/runner-specification.md | 6 ++++++ 5 files changed, 18 insertions(+), 14 deletions(-) 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 2c2a4464ff..f14c114788 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 @@ -16,7 +16,7 @@ final case class SharedReplOptions( compileCross: CrossOptions = CrossOptions(), @Group(HelpGroup.Repl.toString) - @Tag(tags.experimental) + @Tag(tags.implementation) @Tag(tags.inShortHelp) @HelpMessage("Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9.") @Name("jsh") diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index dcdd79c147..04de1371e8 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -24,7 +24,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => jshellExists && javaSpecVersionOpt.exists(_ >= 9) } - /** Dry-run `repl` with `--power --jshell` (mirrors [[dryRun]] for the JShell backend). */ + /** Dry-run `repl` with `--jshell` (mirrors [[dryRun]] for the JShell backend). */ protected def dryRunJshell( testInputs: TestInputs = TestInputs.empty, cliOptions: Seq[String] = Seq.empty, @@ -34,7 +34,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => testInputs.fromRoot { root => os.proc( TestUtil.cli, - "--power", "repl", ".", "--jshell", @@ -73,7 +72,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => runAfterRepl( os.proc( TestUtil.cli, - "--power", "repl", ".", "--jshell", @@ -156,7 +154,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => val jshellRes = os .proc( TestUtil.cli, - "--power", "repl", ".", "--jshell", @@ -183,7 +180,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => | public static void main(String[] args) {} |} |""".stripMargin, - os.rel / "Main.test.java" -> "public class MainTest {}" + os.rel / "MainTest.test.java" -> "public class MainTest {}" ), cliOptions = Seq("--test") ) diff --git a/website/docs/commands/repl.md b/website/docs/commands/repl.md index 5a962fa984..0ba32af21f 100644 --- a/website/docs/commands/repl.md +++ b/website/docs/commands/repl.md @@ -28,17 +28,10 @@ Scala CLI uses the Scala REPL by default, except for pure-Java projects where it You can force JShell as the REPL backend with `--jshell` (`--jsh`), including in mixed Scala/Java or pure Scala projects. -:::caution -The JShell integration is experimental and currently requires `--power`. -You can pass it explicitly or set it globally: - - scala-cli config power true -::: - ```bash ignore -scala-cli --power repl --jshell +scala-cli repl --jshell ``` ```text diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index f1fd9eaa6f..597746f6dd 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -736,6 +736,14 @@ Available in commands: +### `--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 diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index d364ecd226..d4900bfa05 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -2106,6 +2106,12 @@ 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 from a file. Mutually exclusive with --repl-init-script. From 733d899083495c651dbf907c9e69734fd6513b0e Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 18 May 2026 14:23:07 +0200 Subject: [PATCH 08/11] Refactor some more --- .../scala/scala/cli/commands/repl/Repl.scala | 10 +- .../cli/commands/repl/SharedReplOptions.scala | 2 +- .../ReplJShellTestDefinitions.scala | 224 ++++++++---------- .../cli/integration/ReplTestDefinitions.scala | 18 ++ website/docs/reference/cli-options.md | 2 +- .../reference/scala-command/cli-options.md | 2 +- .../scala-command/runner-specification.md | 2 +- 7 files changed, 125 insertions(+), 135 deletions(-) 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 fd8bb3affb..fc51dcea98 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 @@ -61,8 +61,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { import ops.sharedRepl.* val logger = ops.shared.logger - if (jshell.contains(true) && ammonite.contains(true)) - throw new ConflictingReplBackendsError("--jshell cannot be used together with --ammonite") + 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) @@ -589,9 +589,9 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { case RunMode.Default => val replArtifacts = value(defaultArtifacts()) val initScriptArgs = - initScriptOpt.toSeq.flatMap(script => - Seq(s"--${ScalacOptions.replInitScript}", script) - ) + initScriptOpt.toSeq.map { script => + s"--${ScalacOptions.replInitScript}:$script" + } val defaultArgs = additionalArgs ++ initScriptArgs ++ programArgs maybeRunRepl(replArtifacts, defaultArgs) } 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 f14c114788..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 @@ -52,7 +52,7 @@ final case class SharedReplOptions( @Tag(tags.implementation) @Tag(tags.inShortHelp) @ValueDescription("path") - @HelpMessage("Read the REPL init script from a file. Mutually exclusive with --repl-init-script.") + @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) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index 04de1371e8..58d3511f61 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -2,29 +2,12 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.util.{Properties, Try} +import scala.util.Properties trait ReplJShellTestDefinitions { this: ReplTestDefinitions => protected val jshellDryRunPrefix: String = "JShell dry run:" protected val runInJShellPrefix: String = "Running in JShell:" - /** JShell integration tests need a JDK (resolved via JAVA_HOME, then the test JVM) with - * `bin/jshell` and spec version >= 9. - */ - protected lazy val jshellAvailable: Boolean = { - val javaHomeOpt: Option[os.Path] = - sys.env.get("JAVA_HOME").filter(_.nonEmpty).map(os.Path(_, os.pwd)) - .orElse(Option(sys.props("java.home")).filter(_.nonEmpty).map(os.Path(_, os.pwd))) - val jshellExe = if Properties.isWin then "jshell.exe" else "jshell" - val jshellExists = javaHomeOpt.exists(h => os.exists(h / "bin" / jshellExe)) - val javaSpecVersionOpt = - Try(sys.props("java.specification.version").toInt).toOption.orElse { - Try(sys.props("java.specification.version").stripPrefix("1.").toInt).toOption - } - jshellExists && javaSpecVersionOpt.exists(_ >= 9) - } - - /** Dry-run `repl` with `--jshell` (mirrors [[dryRun]] for the JShell backend). */ protected def dryRunJshell( testInputs: TestInputs = TestInputs.empty, cliOptions: Seq[String] = Seq.empty, @@ -46,14 +29,6 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => protected def jshellOutput(res: os.CommandResult): String = TestUtil.removeAnsiColors(res.out.text()) - /** Run JShell via `repl --jshell` with `--repl-init-script-file` + `--repl-quit-after-init`. - * - * @param replCliOptions - * forwarded after [[initScript]] (defaults to [[extraOptions]]). Use [[TestUtil.extraOptions]] - * alone when inputs compile Scala 2.x sources: Scala 2 scalac rejects `--repl-quit-after-init` - * / `--repl-init-script-file`, while default Scala (see launcher) accepts them for JShell - * mapping. - */ protected def runInJShell( initScript: String, testInputs: TestInputs = TestInputs.empty, @@ -207,115 +182,112 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => } } - // --Xshow-phases / ScalaPy / Ammonite-specific scenarios do not apply to JShell (Java REPL). - if jshellAvailable then { - test(s"$runInJShellPrefix simple") { - val expectedMessage = "1337" - runInJShell(s"""System.out.println("$expectedMessage");""")(res => - expect(jshellOutput(res).contains(expectedMessage)) - ) - } + 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)(_ => ()) + 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());""" } - - 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"))) + runInJShellOnJvm(javaVersion, extraInitScript = versionSpecific)(_ => ()) } - test(s"$runInJShellPrefix mixed Java/Scala project, JShell sees both") { + if !Properties.isWin then + test(s"$runInJShellPrefix with extra JAR") { 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")) - } + """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 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)); + 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, - replCliOptions = TestUtil.extraOptions, - testInputs = TestInputs( - os.rel / "Smth.scala" -> - """package demo - | - |object Smth { - | def smth: String = "haha" - |} - |""".stripMargin - ) - )(res => expect(jshellOutput(res).contains("haha"))) + 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"))) + } } 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 9c434ab9eb..bd2b426ef0 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -157,6 +157,24 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr )(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/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index c8bcd742c2..8c5a9adbbb 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1295,7 +1295,7 @@ Provide arguments for ammonite repl ### `--repl-init-script-file` -Read the REPL init script from a file. Mutually exclusive with --repl-init-script. +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. ### `--repl-dry-run` diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 597746f6dd..6d8dc3e7fc 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -748,7 +748,7 @@ Use JShell as the REPL (default for pure-Java projects). Requires JDK >= 9. `IMPLEMENTATION specific` per Scala Runner specification -Read the REPL init script from a file. Mutually exclusive with --repl-init-script. +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. ### `--repl-dry-run` diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index d4900bfa05..bfc1f34a08 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -2114,7 +2114,7 @@ Aliases: `--jsh` **--repl-init-script-file** -Read the REPL init script from a file. Mutually exclusive with --repl-init-script. +Read the REPL init script (--repl-init-script) from a file. Mutually exclusive with --repl-init-script. **--repl-dry-run** From c4a6480e9ac68231741dc05506f729f5f0372c6a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 18 May 2026 16:40:32 +0200 Subject: [PATCH 09/11] Allow to run pure Java projects in Scala REPL --- .../scala/scala/cli/commands/repl/Repl.scala | 14 ++++++-- .../ReplJShellTestDefinitions.scala | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) 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 fc51dcea98..8ac6a1ffbe 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 @@ -357,11 +357,18 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { successfulBuilds.nonEmpty && successfulBuilds.exists(_.sources.hasJava) && !successfulBuilds.exists(_.sources.hasScala) - val shouldUseJshell = explicitJshellOpt.getOrElse(isPureJavaProject && !shouldUseAmmonite) - val replBackend = + val pureJavaInDefaultRepl = + isPureJavaProject && explicitJshellOpt.contains(false) && !shouldUseAmmonite + val shouldUseJshell = + explicitJshellOpt.getOrElse(isPureJavaProject && !shouldUseAmmonite) + val replBackend = if (shouldUseJshell) ReplBackend.JShell else if (shouldUseAmmonite) 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 @@ -478,7 +485,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) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index 58d3511f61..1c32620458 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -290,4 +290,40 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => ) )(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")) + } + } } From 1cff2a55c2eb17b08f2acb7c07c31eef13415d6e Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 19 May 2026 09:34:06 +0200 Subject: [PATCH 10/11] Enable usage of Scala dependencies in pure Java projects --- .../ReplJShellTestDefinitions.scala | 92 ++++++++++++++++--- .../main/scala/scala/build/Artifacts.scala | 19 +++- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index 1c32620458..4c04a11278 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -37,7 +37,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => check: Boolean = true, env: Map[String, String] = Map.empty )( - runAfterRepl: os.CommandResult => Unit, + runAfterRepl: (os.CommandResult, os.Path) => Unit, runBeforeReplAndGetExtraCliOpts: () => Seq[os.Shellable] = () => Seq.empty ): Unit = { testInputs.fromRoot { root => @@ -61,7 +61,8 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => mergeErrIntoOut = true, env = env, check = check - ) + ), + root ) } } @@ -80,10 +81,11 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => | System.out.println("$sentinel"); |} |""".stripMargin - runInJShell(initScript = initScript, cliOptions = Seq("--jvm", javaVersion.toString)) { res => - val out = jshellOutput(res) - expect(out.contains(sentinel)) - check(res) + runInJShell(initScript = initScript, cliOptions = Seq("--jvm", javaVersion.toString)) { + (res, _) => + val out = jshellOutput(res) + expect(out.contains(sentinel)) + check(res) } } @@ -184,9 +186,9 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => test(s"$runInJShellPrefix simple") { val expectedMessage = "1337" - runInJShell(s"""System.out.println("$expectedMessage");""")(res => + runInJShell(s"""System.out.println("$expectedMessage");""") { (res, _) => expect(jshellOutput(res).contains(expectedMessage)) - ) + } } for javaVersion <- Constants.allJavaVersions.filter(_ >= 11) do @@ -221,7 +223,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => .trim Seq("--jar", jar) }, - runAfterRepl = res => + runAfterRepl = (res, _) => expect(jshellOutput(res).contains("org.slf4j.Logger")) ) } @@ -237,7 +239,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => |} |""".stripMargin ) - )(res => expect(jshellOutput(res).contains("hi-java"))) + )((res, _) => expect(jshellOutput(res).contains("hi-java"))) } test(s"$runInJShellPrefix mixed Java/Scala project, JShell sees both") { @@ -264,7 +266,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => |} |""".stripMargin ) - ) { res => + ) { (res, _) => val out = jshellOutput(res) expect(out.contains("hi-java")) expect(out.contains("hi-scala")) @@ -288,7 +290,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => |} |""".stripMargin ) - )(res => expect(jshellOutput(res).contains("haha"))) + )((res, _) => expect(jshellOutput(res).contains("haha"))) } if !Properties.isWin && canRunInRepl then @@ -326,4 +328,70 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => 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 = """var __pwd = os.package$.MODULE$.pwd(); + |System.out.println("PWD-MARKER:" + __pwd.toString()); + |""".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/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 ) From 9ba208e2c09ece151542c22409c5f87974c8471e Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 19 May 2026 10:32:44 +0200 Subject: [PATCH 11/11] Refactor some more --- .../scala/scala/cli/commands/repl/Repl.scala | 53 +++++++++---------- .../ReplJShellTestDefinitions.scala | 3 +- 2 files changed, 26 insertions(+), 30 deletions(-) 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 8ac6a1ffbe..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 @@ -168,9 +168,7 @@ 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(()) => } } @@ -205,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 @@ -227,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, @@ -303,9 +301,10 @@ 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.exists(c => c.isWhitespace || c == '"')) "\"" + a.replace("\"", "\\\"") + "\"" + if a.exists(c => c.isWhitespace || c == '"') + then "\"" + a.replace("\"", "\\\"") + "\"" else a } else @@ -362,8 +361,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { val shouldUseJshell = explicitJshellOpt.getOrElse(isPureJavaProject && !shouldUseAmmonite) val replBackend = - if (shouldUseJshell) ReplBackend.JShell - else if (shouldUseAmmonite) ReplBackend.Ammonite + if shouldUseJshell then ReplBackend.JShell + else if shouldUseAmmonite then ReplBackend.Ammonite else ReplBackend.Default if pureJavaInDefaultRepl then logger.message( @@ -380,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 @@ -400,28 +399,29 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { (Nil, Map.empty[String, String]) val pythonReplArgs = - if (setupPython && scalaParams.scalaVersion.startsWith("2.13.")) - Seq("-Yimports:java.lang,scala,scala.Predef,me.shadaj.scalapy") - else - Nil + 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 pythonPredefArgs = - if (pythonPredef.isEmpty) Nil + if pythonPredef.isEmpty + then Nil else Seq("--predef-code", pythonPredef) val replInitScriptPredefArgs = initScriptOpt.toSeq.flatMap(script => Seq("--predef-code", script)) val replQuitAfterInitArgs = - if (quitAfterInit) Seq("--code", "") + if quitAfterInit + then Seq("--code", "") else Nil pythonPredefArgs ++ replInitScriptPredefArgs ++ replQuitAfterInitArgs ++ options.notForBloopOptions.replOptions.ammoniteArgs @@ -454,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." @@ -509,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))) } } @@ -524,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 ) @@ -543,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) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala index 4c04a11278..27a44791a8 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplJShellTestDefinitions.scala @@ -367,8 +367,7 @@ trait ReplJShellTestDefinitions { this: ReplTestDefinitions => ) ) ) - osPwdInitScript = """var __pwd = os.package$.MODULE$.pwd(); - |System.out.println("PWD-MARKER:" + __pwd.toString()); + osPwdInitScript = """System.out.println("PWD-MARKER:" + os.package$.MODULE$.pwd()); |""".stripMargin directiveExtension = if kind == "pure Java" then ".java" else ".scala" inputsWithDirective =