From b1db6ba44d2a199acb8c2250b8cc1735878569e2 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:49:10 -0800 Subject: [PATCH] [2.x] feat: support passing JVM arguments via -- delimiter in run commands (#8868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary - Adds support for passing JVM arguments inline to `run`, `runMain`, `bgRun`, `bgRunMain`, and `fgRun`/`fgRunMain` using `--` as a delimiter - Syntax: `run -- ` (e.g., `run -Xmx2G -Dapp.mode=debug -- arg1 arg2`) - Fully backward compatible — without `--`, all arguments are treated as app args as before - When `fork` is `false`, a warning is logged that JVM arguments will be ignored --- main/src/main/scala/sbt/Defaults.scala | 10 ++- .../src/main/scala/sbt/internal/RunUtil.scala | 90 ++++++++++++++----- .../test/scala/sbt/internal/RunUtilTest.scala | 51 +++++++++++ .../src/sbt-test/run/fork-jvm-args/build.sbt | 2 + .../fork-jvm-args/src/main/scala/Main.scala | 8 ++ sbt-app/src/sbt-test/run/fork-jvm-args/test | 5 ++ 6 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 sbt-app/src/sbt-test/run/fork-jvm-args/build.sbt create mode 100644 sbt-app/src/sbt-test/run/fork-jvm-args/src/main/scala/Main.scala create mode 100644 sbt-app/src/sbt-test/run/fork-jvm-args/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index e9548b93f..7b7802af5 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1953,11 +1953,15 @@ object Defaults extends BuildCommon { val parser = loadForParser(discoveredMainClasses)((s, names) => runMainParser(s, names getOrElse Nil)) Def.inputTask { - val (mainClass, args) = parser.parsed + val (mainClass, allArgs) = parser.parsed + val (jvmArgs, appArgs) = RunUtil.splitArgs(allArgs) val cp = classpath.value + val fo = (run / forkOptions).value + val log = streams.value.log given FileConverter = fileConverter.value - scalaRun.value - .run(mainClass, cp.files, args, streams.value.log) + val (modifiedRun, _) = RunUtil.applyJvmArgs(scalaRun.value, jvmArgs, fo, log) + modifiedRun + .run(mainClass, cp.files, appArgs, log) .get } } diff --git a/main/src/main/scala/sbt/internal/RunUtil.scala b/main/src/main/scala/sbt/internal/RunUtil.scala index 536978467..e3c1bc712 100644 --- a/main/src/main/scala/sbt/internal/RunUtil.scala +++ b/main/src/main/scala/sbt/internal/RunUtil.scala @@ -12,9 +12,43 @@ import sbt.internal.worker.{ ClientJobParams, FilePath, JvmRunInfo, RunInfo } import sbt.io.IO import sbt.protocol.Serialization import sbt.util.CacheImplicits.given +import sbt.util.Logger import xsbti.FileConverter object RunUtil: + /** + * Split arguments at the first `--` delimiter. + * Tokens before `--` are JVM args; tokens after are app args. + * If no `--` is present, all tokens are app args (backward compatible). + */ + private[sbt] def splitArgs(args: Seq[String]): (Seq[String], Seq[String]) = + val idx = args.indexOf("--") + if idx < 0 then (Nil, args) + else (args.take(idx), args.drop(idx + 1)) + + /** + * Apply CLI JVM args to the ScalaRun and ForkOptions. + * For ForkRun: creates new ForkRun with augmented runJVMOptions. + * For Run (non-fork): warns and returns unchanged. + */ + private[sbt] def applyJvmArgs( + scalaRun: ScalaRun, + jvmArgs: Seq[String], + fo: ForkOptions, + log: Logger, + ): (ScalaRun, ForkOptions) = + if jvmArgs.isEmpty then (scalaRun, fo) + else + scalaRun match + case _: ForkRun => + val newFo = fo.withRunJVMOptions(fo.runJVMOptions ++ jvmArgs) + (new ForkRun(newFo), newFo) + case _ => + log.warn( + s"JVM options (${jvmArgs.mkString(" ")}) will be ignored, fork is set to false" + ) + (scalaRun, fo) + private def setWindowTitle(title: String): Unit = if System.console() != null && System.getenv("TERM") != null then scala.Console.print(s"\u001b]0;$title\u0007") @@ -38,11 +72,14 @@ object RunUtil: ): Initialize[InputTask[Unit]] = val parser = Def.spaceDelimited() Def.inputTask { - val in = parser.parsed + val (jvmArgs, appArgs) = splitArgs(parser.parsed) val mainClass = getMainClass(mainClassTask.value) val cp = classpath.value + val fo = (run / forkOptions).value + val log = streams.value.log given FileConverter = fileConverter.value - scalaRun.value.run(mainClass, cp.files, in, streams.value.log).get + val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) + modifiedRun.run(mainClass, cp.files, appArgs, log).get } def configTasks(c: ScopeAxis[ConfigKey]): Seq[Setting[?]] = Seq( @@ -136,9 +173,12 @@ object RunUtil: val conv = fileConverter.value given FileConverter = conv val service = bgJobService.value - val (mainClass, args) = parser.parsed + val (mainClass, allArgs) = parser.parsed + val (jvmArgs, appArgs) = splitArgs(allArgs) val hashClasspath = (bgRunMain / bgHashClasspath).value val fo = (run / forkOptions).value + val log = streams.value.log + val (modifiedRun, modifiedFo) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) val state = Keys.state.value val windowTitle = mkWindowTitle("runMain", organization.value, name.value, version.value) if clientRun.value && state.isNetworkCommand then @@ -149,7 +189,8 @@ object RunUtil: workingDir, conv, ) - val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle)) + val info = + mkRunInfo(appArgs.toVector, mainClass, cp, modifiedFo, conv, Some(windowTitle)) val result = ClientJobParams( runInfo = info ) @@ -168,15 +209,15 @@ object RunUtil: hashClasspath, conv, ) - scalaRun.value match + modifiedRun match case r: Run => val loader = r.newLoader(cp.files) ( Some(loader), - wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get) + wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get) ) case sr => - (None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get)) + (None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get)) service.waitForTry(handle).get () } @@ -192,11 +233,13 @@ object RunUtil: Def.inputTask { val conv = fileConverter.value given FileConverter = conv - val args = parser.parsed + val (jvmArgs, appArgs) = splitArgs(parser.parsed) val service = bgJobService.value val mainClass = getMainClass(mainClassTask.value) val hashClasspath = (bgRun / bgHashClasspath).value val fo = (run / forkOptions).value + val log = streams.value.log + val (modifiedRun, modifiedFo) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) val state = Keys.state.value val windowTitle = mkWindowTitle("run", organization.value, name.value, version.value) if clientRun.value && state.isNetworkCommand then @@ -207,7 +250,7 @@ object RunUtil: workingDir, conv, ) - val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle)) + val info = mkRunInfo(appArgs.toVector, mainClass, cp, modifiedFo, conv, Some(windowTitle)) val result = ClientJobParams( runInfo = info ) @@ -226,15 +269,15 @@ object RunUtil: hashClasspath, conv ) - scalaRun.value match + modifiedRun match case r: Run => val loader = r.newLoader(cp.files) ( Some(loader), - wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get) + wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get) ) case sr => - (None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get)) + (None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get)) service.waitForTry(handle).get () } @@ -250,8 +293,12 @@ object RunUtil: ) Def.inputTask { val service = bgJobService.value - val (mainClass, args) = parser.parsed + val (mainClass, allArgs) = parser.parsed + val (jvmArgs, appArgs) = splitArgs(allArgs) val hashClasspath = (bgRunMain / bgHashClasspath).value + val fo = (run / forkOptions).value + val log = streams.value.log + val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) val wrapper = termWrapper(canonicalInput.value, echoInput.value) val converter = fileConverter.value setWindowTitle(mkWindowTitle("bgRunMain", organization.value, name.value, version.value)) @@ -268,15 +315,15 @@ object RunUtil: ) else classpath.value given FileConverter = fileConverter.value - scalaRun.value match + modifiedRun match case r: Run => val loader = r.newLoader(cp.files) ( Some(loader), - wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get) + wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get) ) case sr => - (None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get)) + (None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get)) } } @@ -289,10 +336,13 @@ object RunUtil: ): Initialize[InputTask[JobHandle]] = val parser = Def.spaceDelimited() Def.inputTask { - val args = parser.parsed + val (jvmArgs, appArgs) = splitArgs(parser.parsed) val service = bgJobService.value val mainClass = getMainClass(mainClassTask.value) val hashClasspath = (bgRun / bgHashClasspath).value + val fo = (run / forkOptions).value + val log = streams.value.log + val (modifiedRun, _) = applyJvmArgs(scalaRun.value, jvmArgs, fo, log) val wrapper = termWrapper(canonicalInput.value, echoInput.value) val converter = fileConverter.value setWindowTitle(mkWindowTitle("bgRun", organization.value, name.value, version.value)) @@ -309,15 +359,15 @@ object RunUtil: ) else classpath.value given FileConverter = converter - scalaRun.value match + modifiedRun match case r: Run => val loader = r.newLoader(cp.files) ( Some(loader), - wrapper(() => r.runWithLoader(loader, cp.files, mainClass, args, logger).get) + wrapper(() => r.runWithLoader(loader, cp.files, mainClass, appArgs, logger).get) ) case sr => - (None, wrapper(() => sr.run(mainClass, cp.files, args, logger).get)) + (None, wrapper(() => sr.run(mainClass, cp.files, appArgs, logger).get)) } } end RunUtil diff --git a/main/src/test/scala/sbt/internal/RunUtilTest.scala b/main/src/test/scala/sbt/internal/RunUtilTest.scala index 5309c1410..671ab34fe 100644 --- a/main/src/test/scala/sbt/internal/RunUtilTest.scala +++ b/main/src/test/scala/sbt/internal/RunUtilTest.scala @@ -16,6 +16,13 @@ object RunUtilTest extends Properties: override def tests: List[Test] = List( property("mkWindowTitle formats title correctly", testMkWindowTitle), property("mkWindowTitle handles empty strings", testMkWindowTitleEmpty), + property("splitArgs with no -- treats all as app args", testSplitArgsNoDash), + property("splitArgs splits at first --", testSplitArgsWithDash), + property("splitArgs with only -- gives empty jvm and app args", testSplitArgsOnlyDash), + property("splitArgs with -- at start gives empty jvm args", testSplitArgsDashAtStart), + property("splitArgs with -- at end gives empty app args", testSplitArgsDashAtEnd), + property("splitArgs with multiple -- splits at first only", testSplitArgsMultipleDash), + property("splitArgs with empty input", testSplitArgsEmpty), ) def testMkWindowTitle: Property = @@ -34,4 +41,48 @@ object RunUtilTest extends Properties: yield val result = RunUtil.mkWindowTitle("run", "", "", "") Result.assert(result == "sbt run: % % ") + + def testSplitArgsNoDash: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("arg1", "arg2")) + Result.assert(jvm.isEmpty).and(Result.assert(app == Seq("arg1", "arg2"))) + + def testSplitArgsWithDash: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "-ea", "--", "arg1", "arg2")) + Result.assert(jvm == Seq("-Xmx2G", "-ea")).and(Result.assert(app == Seq("arg1", "arg2"))) + + def testSplitArgsOnlyDash: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("--")) + Result.assert(jvm.isEmpty).and(Result.assert(app.isEmpty)) + + def testSplitArgsDashAtStart: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("--", "arg1")) + Result.assert(jvm.isEmpty).and(Result.assert(app == Seq("arg1"))) + + def testSplitArgsDashAtEnd: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "--")) + Result.assert(jvm == Seq("-Xmx2G")).and(Result.assert(app.isEmpty)) + + def testSplitArgsMultipleDash: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq("-Xmx2G", "--", "arg1", "--", "arg2")) + Result + .assert(jvm == Seq("-Xmx2G")) + .and(Result.assert(app == Seq("arg1", "--", "arg2"))) + + def testSplitArgsEmpty: Property = + for _ <- Gen.constant(()).forAll + yield + val (jvm, app) = RunUtil.splitArgs(Seq.empty) + Result.assert(jvm.isEmpty).and(Result.assert(app.isEmpty)) end RunUtilTest diff --git a/sbt-app/src/sbt-test/run/fork-jvm-args/build.sbt b/sbt-app/src/sbt-test/run/fork-jvm-args/build.sbt new file mode 100644 index 000000000..e2f1b3b5c --- /dev/null +++ b/sbt-app/src/sbt-test/run/fork-jvm-args/build.sbt @@ -0,0 +1,2 @@ +scalaVersion := "3.6.4" +run / fork := true diff --git a/sbt-app/src/sbt-test/run/fork-jvm-args/src/main/scala/Main.scala b/sbt-app/src/sbt-test/run/fork-jvm-args/src/main/scala/Main.scala new file mode 100644 index 000000000..d9e42a9a2 --- /dev/null +++ b/sbt-app/src/sbt-test/run/fork-jvm-args/src/main/scala/Main.scala @@ -0,0 +1,8 @@ +object Main { + def main(args: Array[String]): Unit = { + val prop = System.getProperty("test.prop") + assert(prop == "hello", s"Expected system property test.prop=hello but got '$prop'") + assert(args.toList == List("arg1", "arg2"), s"Expected args [arg1, arg2] but got ${args.toList}") + println("OK") + } +} diff --git a/sbt-app/src/sbt-test/run/fork-jvm-args/test b/sbt-app/src/sbt-test/run/fork-jvm-args/test new file mode 100644 index 000000000..07bc0b399 --- /dev/null +++ b/sbt-app/src/sbt-test/run/fork-jvm-args/test @@ -0,0 +1,5 @@ +# Verify that JVM args can be passed via the -- delimiter +> run -Dtest.prop=hello -- arg1 arg2 + +# Verify runMain also supports JVM args via -- +> runMain Main -Dtest.prop=hello -- arg1 arg2