From 13cf82fb278609d97e10fbf5d2c84a29a75137dc Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Sat, 11 Jul 2026 20:46:09 +0200 Subject: [PATCH] [2.x] fix: Honor -java-home in the Windows launchers --- .../src/test/scala/JavaHomeScriptTest.scala | 36 +++++ launcher-package/src/universal/bin/sbt.bat | 9 +- sbtw/src/main/scala/sbtw/ArgParser.scala | 129 +++++++++------ sbtw/src/main/scala/sbtw/ConfigLoader.scala | 28 +++- sbtw/src/main/scala/sbtw/Main.scala | 150 ++++++++++-------- sbtw/src/main/scala/sbtw/Runner.scala | 49 +++--- sbtw/src/main/scala/sbtw/SelectedJava.scala | 70 ++++++++ sbtw/src/test/scala/sbtw/ArgParserSpec.scala | 85 ++++++++++ .../test/scala/sbtw/ConfigLoaderSpec.scala | 36 +++++ .../test/scala/sbtw/SelectedJavaSpec.scala | 147 +++++++++++++++++ 10 files changed, 591 insertions(+), 148 deletions(-) create mode 100644 launcher-package/integration-test/src/test/scala/JavaHomeScriptTest.scala create mode 100644 sbtw/src/main/scala/sbtw/SelectedJava.scala create mode 100644 sbtw/src/test/scala/sbtw/ArgParserSpec.scala create mode 100644 sbtw/src/test/scala/sbtw/ConfigLoaderSpec.scala create mode 100644 sbtw/src/test/scala/sbtw/SelectedJavaSpec.scala diff --git a/launcher-package/integration-test/src/test/scala/JavaHomeScriptTest.scala b/launcher-package/integration-test/src/test/scala/JavaHomeScriptTest.scala new file mode 100644 index 000000000..147978116 --- /dev/null +++ b/launcher-package/integration-test/src/test/scala/JavaHomeScriptTest.scala @@ -0,0 +1,36 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package example.test + +import java.io.File + +/** + * First-hop propagation guard: an explicit `-java-home` must be the JDK the Windows launcher runs + * (echoed on the verbose command line). Not the full sbt/sbt#963 regression — the + * `.java-version`-override case needs two JDK paths + the native client, so it is verified manually + * on Windows. + */ +object JavaHomeScriptTest extends verify.BasicTestSuite with ShellScriptUtil: + private val jdkHome = + new File(sys.env.getOrElse("JAVA_HOME", System.getProperty("java.home"))).getAbsolutePath + + testOutput("sbt -java-home selects the JDK for the launcher")( + "-java-home", + jdkHome, + "compile", + "-v" + ): (out: List[String]) => + if !isWindows then cancel("`-java-home` bin/java.exe selection is Windows-specific") + else + val javaExe = new File(new File(jdkHome, "bin"), "java.exe").getAbsolutePath + assert( + out.exists(_.contains(javaExe)), + s"launcher should run $javaExe; command echo was: ${out.mkString(" | ")}" + ) +end JavaHomeScriptTest diff --git a/launcher-package/src/universal/bin/sbt.bat b/launcher-package/src/universal/bin/sbt.bat index 682f5bb78..7e6e90089 100755 --- a/launcher-package/src/universal/bin/sbt.bat +++ b/launcher-package/src/universal/bin/sbt.bat @@ -87,7 +87,8 @@ if exist "!SBT_CONFIG!" ( ) rem poor man's jenv (which is not available on Windows) -if defined JAVA_HOMES ( +rem explicit -java-home wins over the project .java-version +if not defined SBT_EXPLICIT_JAVA_HOME if defined JAVA_HOMES ( if exist .java-version for /F %%A in (.java-version) do ( set JAVA_HOME=%JAVA_HOMES%\%%A set JDK_HOME=%JAVA_HOMES%\%%A @@ -499,8 +500,11 @@ if defined _java_home_arg ( if not "%~1" == "" ( if exist "%~1\bin\java.exe" ( set "_JAVACMD=%~1\bin\java.exe" + set "JAVACMD=%~1\bin\java.exe" set "JAVA_HOME=%~1" set "JDK_HOME=%~1" + set "SBT_EXPLICIT_JAVA_HOME=1" + set "PATH=%~1\bin;!PATH!" shift goto args_loop ) else ( @@ -782,6 +786,9 @@ if defined sbt_args_verbose ( echo. ) +rem one-hop marker: drop it before the server JVM +set "SBT_EXPLICIT_JAVA_HOME=" + "!_JAVACMD!" !_JAVA_OPTS! !_SBT_OPTS! %JAVA_TOOL_OPTIONS% %JDK_JAVA_OPTIONS% -cp "!sbt_jar!" xsbt.boot.Boot %* goto :eof diff --git a/sbtw/src/main/scala/sbtw/ArgParser.scala b/sbtw/src/main/scala/sbtw/ArgParser.scala index 8af66646d..9affac270 100644 --- a/sbtw/src/main/scala/sbtw/ArgParser.scala +++ b/sbtw/src/main/scala/sbtw/ArgParser.scala @@ -4,56 +4,89 @@ import scopt.OParser object ArgParser: - def parse(args: Array[String]): Option[LauncherOptions] = + private val parser = val b = OParser.builder[LauncherOptions] - val parser = - import b.* - OParser.sequence( - programName("sbtw"), - head("sbtw", "Windows launcher for sbt"), - opt[Unit]('h', "help").action((_, c) => c.copy(help = true)), - opt[Unit]('v', "verbose").action((_, c) => c.copy(verbose = true)), - opt[Unit]('d', "debug").action((_, c) => c.copy(debug = true)), - opt[Unit]('V', "version").action((_, c) => c.copy(version = true)), - opt[Unit]("numeric-version").action((_, c) => c.copy(numericVersion = true)), - opt[Unit]("script-version").action((_, c) => c.copy(scriptVersion = true)), - opt[Unit]("shutdownall").action((_, c) => c.copy(shutdownAll = true)), - opt[Unit]("allow-empty").action((_, c) => c.copy(allowEmpty = true)), - opt[Unit]("sbt-create").action((_, c) => c.copy(allowEmpty = true)), - opt[Unit]("client").action((_, c) => c.copy(client = true)), - opt[Unit]("server").action((_, c) => c.copy(server = true)), - opt[Unit]("jvm-client").action((_, c) => c.copy(jvmClient = true)), - opt[Unit]("no-server").action((_, c) => c.copy(noServer = true)), - opt[Unit]("no-colors").action((_, c) => c.copy(noColors = true)), - opt[Unit]("no-global").action((_, c) => c.copy(noGlobal = true)), - opt[Unit]("no-share").action((_, c) => c.copy(noShare = true)), - opt[Unit]("no-hide-jdk-warnings").action((_, c) => c.copy(noHideJdkWarnings = true)), - opt[Unit]("debug-inc").action((_, c) => c.copy(debugInc = true)), - opt[Unit]("timings").action((_, c) => c.copy(timings = true)), - opt[Unit]("traces").action((_, c) => c.copy(traces = true)), - opt[Unit]("batch").action((_, c) => c.copy(batch = true)), - opt[String]("sbt-dir").action((x, c) => c.copy(sbtDir = Some(x))), - opt[String]("sbt-boot").action((x, c) => c.copy(sbtBoot = Some(x))), - opt[String]("sbt-cache").action((x, c) => c.copy(sbtCache = Some(x))), - opt[String]("sbt-jar").action((x, c) => c.copy(sbtJar = Some(x))), - opt[String]("sbt-version").action((x, c) => c.copy(sbtVersion = Some(x))), - opt[String]("ivy").action((x, c) => c.copy(ivy = Some(x))), - opt[Int]("mem").action((x, c) => c.copy(mem = Some(x))), - opt[String]("supershell").action((x, c) => c.copy(supershell = Some(x))), - opt[String]("color").action((x, c) => c.copy(color = Some(x))), - opt[String]("autostart").action((x, c) => c.copy(autostart = Some(x))), - opt[Int]("jvm-debug").action((x, c) => c.copy(jvmDebug = Some(x))), - opt[String]("java-home").action((x, c) => c.copy(javaHome = Some(x))), - opt[String]("experimental_execution_log").action((x, c) => - c.copy(experimentalExecutionLog = Some(x)) - ), - arg[String]("") - .unbounded() - .optional() - .action((x, c) => c.copy(residual = c.residual :+ x)), - ) + import b.* + OParser.sequence( + programName("sbtw"), + head("sbtw", "Windows launcher for sbt"), + opt[Unit]('h', "help").action((_, c) => c.copy(help = true)), + opt[Unit]('v', "verbose").action((_, c) => c.copy(verbose = true)), + opt[Unit]('d', "debug").action((_, c) => c.copy(debug = true)), + opt[Unit]('V', "version").action((_, c) => c.copy(version = true)), + opt[Unit]("numeric-version").action((_, c) => c.copy(numericVersion = true)), + opt[Unit]("script-version").action((_, c) => c.copy(scriptVersion = true)), + opt[Unit]("shutdownall").action((_, c) => c.copy(shutdownAll = true)), + opt[Unit]("allow-empty").action((_, c) => c.copy(allowEmpty = true)), + opt[Unit]("sbt-create").action((_, c) => c.copy(allowEmpty = true)), + opt[Unit]("client").action((_, c) => c.copy(client = true)), + opt[Unit]("server").action((_, c) => c.copy(server = true)), + opt[Unit]("jvm-client").action((_, c) => c.copy(jvmClient = true)), + opt[Unit]("no-server").action((_, c) => c.copy(noServer = true)), + opt[Unit]("no-colors").action((_, c) => c.copy(noColors = true)), + opt[Unit]("no-global").action((_, c) => c.copy(noGlobal = true)), + opt[Unit]("no-share").action((_, c) => c.copy(noShare = true)), + opt[Unit]("no-hide-jdk-warnings").action((_, c) => c.copy(noHideJdkWarnings = true)), + opt[Unit]("debug-inc").action((_, c) => c.copy(debugInc = true)), + opt[Unit]("timings").action((_, c) => c.copy(timings = true)), + opt[Unit]("traces").action((_, c) => c.copy(traces = true)), + opt[Unit]("batch").action((_, c) => c.copy(batch = true)), + opt[String]("sbt-dir").action((x, c) => c.copy(sbtDir = Some(x))), + opt[String]("sbt-boot").action((x, c) => c.copy(sbtBoot = Some(x))), + opt[String]("sbt-cache").action((x, c) => c.copy(sbtCache = Some(x))), + opt[String]("sbt-jar").action((x, c) => c.copy(sbtJar = Some(x))), + opt[String]("sbt-version").action((x, c) => c.copy(sbtVersion = Some(x))), + opt[String]("ivy").action((x, c) => c.copy(ivy = Some(x))), + opt[Int]("mem").action((x, c) => c.copy(mem = Some(x))), + opt[String]("supershell").action((x, c) => c.copy(supershell = Some(x))), + opt[String]("color").action((x, c) => c.copy(color = Some(x))), + opt[String]("autostart").action((x, c) => c.copy(autostart = Some(x))), + opt[Int]("jvm-debug").action((x, c) => c.copy(jvmDebug = Some(x))), + opt[String]("java-home").action((x, c) => c.copy(javaHome = Some(x))), + opt[String]("experimental_execution_log").action((x, c) => + c.copy(experimentalExecutionLog = Some(x)) + ), + arg[String]("") + .unbounded() + .optional() + .action((x, c) => c.copy(residual = c.residual :+ x)), + ) + + /** Long-option names, derived from the parser (`fullName` is `--name` for options) — one source of truth. */ + private[sbtw] val longNames: Set[String] = + parser.toList + .map(_.fullName) + .collect { + case n if n.startsWith("--") => n.drop(2) + } + .toSet + + /** Long options that take a value, probed from the parser so it stays derived. */ + private val valueTakingNames: Set[String] = + longNames.filter(n => OParser.runParser(parser, Seq(s"--$n"), LauncherOptions())._1.isEmpty) + + /** Rewrites single-dash `-name` to `--name`, stopping at `--` and skipping option value slots. */ + private[sbtw] def normalizeArgs(args: Seq[String]): Seq[String] = + val (options, passthrough) = args.span(_ != "--") + val out = List.newBuilder[String] + var expectValue = false + options.foreach: arg => + if expectValue then + out += arg + expectValue = false + else + val normalized = alias(arg) + out += normalized + expectValue = normalized.startsWith("--") && valueTakingNames(normalized.drop(2)) + out.result() ++ passthrough + + private def alias(arg: String): String = + if arg.startsWith("-") && !arg.startsWith("--") && longNames(arg.drop(1)) then "-" + arg + else arg + + def parse(args: Array[String]): Option[LauncherOptions] = OParser - .parse(parser, args, LauncherOptions()) + .parse(parser, normalizeArgs(args.toSeq), LauncherOptions()) .map: opts => val sbtNew = opts.residual.contains("new") || opts.residual.contains("init") val isScript = opts.residual.exists(_.startsWith("-Dsbt.main.class=sbt.ScriptMain")) diff --git a/sbtw/src/main/scala/sbtw/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala index 40835db35..298bf4e44 100644 --- a/sbtw/src/main/scala/sbtw/ConfigLoader.scala +++ b/sbtw/src/main/scala/sbtw/ConfigLoader.scala @@ -21,13 +21,31 @@ object ConfigLoader: val fromConfig = new File(sbtHome, "conf/sbtopts") val fromEtc = new File("/etc/sbt/sbtopts") val fromSbtConfig = new File(sbtHome, "conf/sbtconfig.txt") - val fromEnv = sys.env.get("SBT_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) - val fromProjectLines = loadLines(fromProject).map(stripJ) - val fromConfigLines = loadLines(fromConfig) - val fromEtcLines = loadLines(fromEtc) - val fromSbtConfigLines = loadLines(fromSbtConfig) + val fromEnv = sys.env.get("SBT_OPTS").toSeq.flatMap(tokenize) + val fromProjectLines = loadLines(fromProject).flatMap(tokenize).map(stripJ) + val fromConfigLines = loadLines(fromConfig).flatMap(tokenize) + val fromEtcLines = loadLines(fromEtc).flatMap(tokenize) + val fromSbtConfigLines = loadLines(fromSbtConfig).flatMap(tokenize) (fromEtcLines ++ fromConfigLines ++ fromSbtConfigLines ++ fromEnv ++ fromProjectLines) + /** Splits an sbtopts line on unquoted whitespace, honoring `'`/`"` (like the bash `parseLineIntoWords`). */ + private[sbtw] def tokenize(line: String): Seq[String] = + val out = scala.collection.mutable.ListBuffer.empty[String] + val word = new StringBuilder + var inDouble = false + var inSingle = false + line.foreach: c => + if inDouble then if c == '"' then inDouble = false else word += c + else if inSingle then if c == '\'' then inSingle = false else word += c + else + c match + case '"' => inDouble = true + case '\'' => inSingle = true + case w if w.isWhitespace => if word.nonEmpty then { out += word.toString; word.clear() } + case other => word += other + if word.nonEmpty then out += word.toString + out.toList + def loadJvmOpts(cwd: File): Seq[String] = val fromProject = new File(cwd, ".jvmopts") val fromEnv = sys.env.get("JAVA_OPTS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala index 133d0a3ec..8b6b25bac 100644 --- a/sbtw/src/main/scala/sbtw/Main.scala +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -16,8 +16,7 @@ object Main: ) val sbtBinDir = new File(sbtHome, "bin") - val fileSbtOpts = ConfigLoader.loadSbtOpts(cwd, sbtHome) - val fileArgs = fileSbtOpts.flatMap(_.split("\\s+").filter(_.nonEmpty)) + val fileArgs = ConfigLoader.loadSbtOpts(cwd, sbtHome) val allArgs = fileArgs ++ args ArgParser.parse(allArgs.toArray) match @@ -31,8 +30,9 @@ object Main: else if opts.version || opts.numericVersion || opts.scriptVersion then handleVersionCommands(cwd, sbtHome, sbtBinDir, opts) else if opts.shutdownAll then - val javaCmd = Runner.findJavaCmd(opts.javaHome) - Runner.shutdownAll(javaCmd) + SelectedJava.resolve(opts.javaHome) match + case Left(error) => reportJavaHomeError(error) + case Right(selected) => Runner.shutdownAll(selected) else if !opts.allowEmpty && !opts.sbtNew && !ConfigLoader.isSbtProjectDir(cwd) then System.err.println( "[error] Neither build.sbt nor a 'project' directory in the current directory: " + cwd @@ -40,61 +40,80 @@ object Main: System.err.println("[error] run 'sbt new', touch build.sbt, or run 'sbt --allow-empty'.") 1 else - val buildPropsVersion = ConfigLoader.sbtVersionFromBuildProperties(cwd) + SelectedJava.resolve(opts.javaHome) match + case Left(error) => reportJavaHomeError(error) + case Right(selected) => launch(cwd, sbtBinDir, opts, selected) - val javaCmd = Runner.findJavaCmd(opts.javaHome) - val javaVer = Runner.javaVersion(javaCmd) - val minJdk = Runner.minimumJdkVersion(buildPropsVersion) - if javaVer > 0 && javaVer < minJdk then - if minJdk >= 17 then - System.err.println( - "[error] sbt 2.x requires JDK 17 or above, but you have JDK " + javaVer - ) - else System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer) - 1 + private def reportJavaHomeError(error: String): Int = + System.err.println(error) + 1 + + private def launch( + cwd: File, + sbtBinDir: File, + opts: LauncherOptions, + selected: SelectedJava + ): Int = + val buildPropsVersion = ConfigLoader.sbtVersionFromBuildProperties(cwd) + val javaVer = Runner.javaVersion(selected.javaCmd) + val minJdk = Runner.minimumJdkVersion(buildPropsVersion) + if javaVer > 0 && javaVer < minJdk then + if minJdk >= 17 then + System.err.println( + "[error] sbt 2.x requires JDK 17 or above, but you have JDK " + javaVer + ) + else System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer) + 1 + else + val bspMode = opts.residual.exists(a => a == "bsp" || a == "-bsp" || a == "--bsp") + val clientOpt = opts.client || sys.env.get("SBT_NATIVE_CLIENT").contains("true") + val useNativeClient = + if bspMode then false + else shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion) + + if useNativeClient then + val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat" + Runner.runNativeClient(sbtBinDir, scriptPath, opts, selected) else - val bspMode = opts.residual.exists(a => a == "bsp" || a == "-bsp" || a == "--bsp") - val clientOpt = opts.client || sys.env.get("SBT_NATIVE_CLIENT").contains("true") - val useNativeClient = - if bspMode then false - else shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion) - - if useNativeClient then - val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat" - Runner.runNativeClient(sbtBinDir, scriptPath, opts) + val sbtJar = opts.sbtJar + .filter(p => new File(p).isFile) + .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) + if !new File(sbtJar).isFile then + System.err.println("[error] Launcher jar not found: " + sbtJar) + 1 else - val sbtJar = opts.sbtJar - .filter(p => new File(p).isFile) - .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) - if !new File(sbtJar).isFile then - System.err.println("[error] Launcher jar not found: " + sbtJar) - 1 - else - var javaOpts = ConfigLoader.loadJvmOpts(cwd) - if javaOpts.isEmpty then javaOpts = ConfigLoader.defaultJavaOpts - var sbtOpts = Runner.buildSbtOpts(opts) + var javaOpts = ConfigLoader.loadJvmOpts(cwd) + if javaOpts.isEmpty then javaOpts = ConfigLoader.defaultJavaOpts + var sbtOpts = Runner.buildSbtOpts(opts) - val (residualJava, bootArgs) = Runner.splitResidual(opts.residual) - javaOpts = javaOpts ++ residualJava + val (residualJava, bootArgs) = Runner.splitResidual(opts.residual) + javaOpts = javaOpts ++ residualJava - val (finalJava, finalSbt) = if opts.mem.isDefined then - val evictedJava = Memory.evictMemoryOpts(javaOpts) - val evictedSbt = Memory.evictMemoryOpts(sbtOpts) - val memOpts = Memory.addMemory(opts.mem.get, javaVer) - (evictedJava ++ memOpts, evictedSbt) - else Memory.addDefaultMemory(javaOpts, sbtOpts, javaVer, LauncherOptions.defaultMemMb) - sbtOpts = finalSbt + val (finalJava, finalSbt) = if opts.mem.isDefined then + val evictedJava = Memory.evictMemoryOpts(javaOpts) + val evictedSbt = Memory.evictMemoryOpts(sbtOpts) + val memOpts = Memory.addMemory(opts.mem.get, javaVer) + (evictedJava ++ memOpts, evictedSbt) + else Memory.addDefaultMemory(javaOpts, sbtOpts, javaVer, LauncherOptions.defaultMemMb) + sbtOpts = finalSbt - if !opts.noHideJdkWarnings && javaVer >= 25 then - sbtOpts = sbtOpts ++ Seq( - "--sun-misc-unsafe-memory-access=allow", - "--enable-native-access=ALL-UNNAMED" - ) - val javaOptsWithDebug = opts.jvmDebug.fold(finalJava)(port => - finalJava :+ s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$port" + if !opts.noHideJdkWarnings && javaVer >= 25 then + sbtOpts = sbtOpts ++ Seq( + "--sun-misc-unsafe-memory-access=allow", + "--enable-native-access=ALL-UNNAMED" ) + val javaOptsWithDebug = opts.jvmDebug.fold(finalJava)(port => + finalJava :+ s"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$port" + ) - Runner.runJvm(javaCmd, javaOptsWithDebug, sbtOpts, sbtJar, bootArgs, opts.verbose) + Runner.runJvm( + selected, + javaOptsWithDebug, + sbtOpts, + sbtJar, + bootArgs, + opts.verbose + ) private def shouldRunNativeClient( opts: LauncherOptions, @@ -130,19 +149,22 @@ object Main: ) 0 else if opts.numericVersion then - val javaCmd = Runner.findJavaCmd(opts.javaHome) - val sbtJar = opts.sbtJar - .filter(p => new File(p).isFile) - .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) - if !new File(sbtJar).isFile then - System.err.println("[error] Launcher jar not found for version check") - 1 - else - try - val out = Process(Seq(javaCmd, "-jar", sbtJar, "sbtVersion")).!! - println(out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("")) - 0 - catch { case _: Exception => 1 } + SelectedJava.resolve(opts.javaHome) match + case Left(error) => reportJavaHomeError(error) + case Right(selected) => + val sbtJar = opts.sbtJar + .filter(p => new File(p).isFile) + .getOrElse(new File(sbtBinDir, "sbt-launch.jar").getAbsolutePath) + if !new File(sbtJar).isFile then + System.err.println("[error] Launcher jar not found for version check") + 1 + else + try + val cmd = Seq(selected.javaCmd, "-jar", sbtJar, "sbtVersion") + val out = Process(cmd, None, selected.envOverlay*).!! + println(out.linesIterator.toSeq.lastOption.map(_.trim).getOrElse("")) + 0 + catch { case _: Exception => 1 } else 0 private def projectSbtVersion(cwd: File): Option[String] = diff --git a/sbtw/src/main/scala/sbtw/Runner.scala b/sbtw/src/main/scala/sbtw/Runner.scala index d694cfd1a..55e221a90 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -6,26 +6,7 @@ import scala.sys.process.* object Runner: - def findJavaCmd(javaHome: Option[String]): String = - val cmd = javaHome match - case Some(h) => - val exe = new File(h, "bin/java.exe") - if exe.isFile then exe.getAbsolutePath - else - sys.env - .get("JAVACMD") - .orElse( - sys.env.get("JAVA_HOME").map(h0 => new File(h0, "bin/java.exe").getAbsolutePath) - ) - .getOrElse("java") - case None => - sys.env - .get("JAVACMD") - .orElse(sys.env.get("JAVA_HOME").map(h => new File(h, "bin/java.exe").getAbsolutePath)) - .getOrElse("java") - cmd.replace("\"", "") - - def javaVersion(javaCmd: String): Int = + private[sbtw] def javaVersion(javaCmd: String): Int = try val pb = Process(Seq(javaCmd, "-Xms32M", "-Xmx32M", "-version")) val out = pb.!! @@ -39,11 +20,11 @@ object Runner: catch { case _: Exception => 0 } /** Returns the minimum JDK version required for the given sbt version. */ - def minimumJdkVersion(sbtVersion: Option[String]): Int = + private[sbtw] def minimumJdkVersion(sbtVersion: Option[String]): Int = val isSbt2 = sbtVersion.exists(v => v.takeWhile(_.isDigit).toIntOption.exists(_ >= 2)) if isSbt2 then 17 else 8 - def buildSbtOpts(opts: LauncherOptions): Seq[String] = + private[sbtw] def buildSbtOpts(opts: LauncherOptions): Seq[String] = var s: Seq[String] = Nil if opts.debug then s = s :+ "-debug" if opts.debugInc then s = s :+ "-Dxsbt.inc.debug=true" @@ -71,7 +52,12 @@ object Runner: if opts.jvmClient then s = s :+ "--client" s - def runNativeClient(sbtBinDir: File, scriptPath: String, opts: LauncherOptions): Int = + private[sbtw] def runNativeClient( + sbtBinDir: File, + scriptPath: String, + opts: LauncherOptions, + selected: SelectedJava + ): Int = val sbtn = new File(sbtBinDir, "sbtn-x86_64-pc-win32.exe") if !sbtn.isFile then System.err.println("[error] sbtn-x86_64-pc-win32.exe not found in " + sbtBinDir) @@ -84,11 +70,12 @@ object Runner: if opts.verbose then System.err.println("# running native client") cmd.foreach(a => System.err.println(a)) - val proc = Process(cmd, None, "SBT_SCRIPT" -> scriptPath) + val extraEnv = ("SBT_SCRIPT" -> scriptPath) +: selected.handoffEnv + val proc = Process(cmd, None, extraEnv*) proc.! - def runJvm( - javaCmd: String, + private[sbtw] def runJvm( + selected: SelectedJava, javaOpts: Seq[String], sbtOpts: Seq[String], sbtJar: String, @@ -99,21 +86,23 @@ object Runner: sys.env.get("JAVA_TOOL_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) val jdkOpts = sys.env.get("JDK_JAVA_OPTIONS").toSeq.flatMap(_.split("\\s+").filter(_.nonEmpty)) val fullJavaOpts = javaOpts ++ sbtOpts ++ toolOpts ++ jdkOpts - val cmd = Seq(javaCmd) ++ fullJavaOpts ++ Seq("-cp", sbtJar, "xsbt.boot.Boot") ++ bootArgs + val cmd = + Seq(selected.javaCmd) ++ fullJavaOpts ++ Seq("-cp", sbtJar, "xsbt.boot.Boot") ++ bootArgs if verbose then System.err.println("# Executing command line:") cmd.foreach(a => System.err.println(if a.contains(" ") then s""""$a"""" else a)) val jpb = new JProcessBuilder(cmd*) jpb.inheritIO() + selected.envOverlay.foreach((k, v) => jpb.environment().put(k, v)) val p = jpb.start() try p.waitFor() p.exitValue() finally if p.isAlive then p.destroy() - def shutdownAll(javaCmd: String): Int = + private[sbtw] def shutdownAll(selected: SelectedJava): Int = try - val jpsOut = Process(Seq("jps", "-lv")).!! + val jpsOut = Process(Seq("jps", "-lv"), None, selected.envOverlay*).!! val pids = jpsOut.linesIterator .filter(_.contains("xsbt.boot.Boot")) .flatMap: line => @@ -127,7 +116,7 @@ object Runner: 0 catch { case _: Exception => 1 } - def splitResidual(residual: Seq[String]): (Seq[String], Seq[String]) = + private[sbtw] def splitResidual(residual: Seq[String]): (Seq[String], Seq[String]) = var javaOpts: Seq[String] = Nil var bootArgs: Seq[String] = Nil var i = 0 diff --git a/sbtw/src/main/scala/sbtw/SelectedJava.scala b/sbtw/src/main/scala/sbtw/SelectedJava.scala new file mode 100644 index 000000000..c60263ed2 --- /dev/null +++ b/sbtw/src/main/scala/sbtw/SelectedJava.scala @@ -0,0 +1,70 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbtw + +import java.io.File + +/** The JDK chosen to launch sbt: `javaCmd` to run, plus `envOverlay` so indirect launches use it too. */ +private[sbtw] final case class SelectedJava( + javaCmd: String, + envOverlay: Seq[(String, String)], + explicit: Boolean = false +): + /** Overlay for the native-client handoff, plus the one-hop explicit marker. */ + def handoffEnv: Seq[(String, String)] = + if explicit then envOverlay :+ (SelectedJava.explicitMarker -> "1") else envOverlay +end SelectedJava + +private[sbtw] object SelectedJava: + + /** One-hop marker telling `sbt.bat`'s `.java-version` block to defer to an explicit `--java-home`. */ + private[sbtw] val explicitMarker = "SBT_EXPLICIT_JAVA_HOME" + + /** Resolves `--java-home` > `JAVACMD` > `JAVA_HOME` > `PATH`; an invalid `--java-home` is an error. */ + def resolve( + javaHome: Option[String], + env: Map[String, String] = sys.env + ): Either[String, SelectedJava] = + javaHome match + case Some(home) => + val exe = new File(home, "bin/java.exe") + if exe.isFile then Right(fromHome(home, env, explicit = true)) + else Left(s"""[error] Directory "$home" for JAVA_HOME is not valid""") + case None => + getIgnoreCase(env, "JAVACMD").map(stripQuotes) match + case Some(cmd) => Right(SelectedJava(cmd, Nil)) + case None => + getIgnoreCase(env, "JAVA_HOME") match + case Some(home) => Right(fromHome(home, env, explicit = false)) + case None => Right(SelectedJava("java", Nil)) + + private def fromHome(home: String, env: Map[String, String], explicit: Boolean): SelectedJava = + val absoluteHome = new File(home).getAbsolutePath + val cmd = new File(absoluteHome, "bin/java.exe").getAbsolutePath + val bin = absoluteHome + File.separator + "bin" + val path = getIgnoreCase(env, "PATH") match + case Some(old) => bin + File.pathSeparator + old + case None => bin + val overlay = Seq( + keyFor(env, "JAVACMD") -> cmd, + keyFor(env, "JAVA_HOME") -> absoluteHome, + keyFor(env, "JDK_HOME") -> absoluteHome, + keyFor(env, "PATH") -> path + ) + SelectedJava(cmd, overlay, explicit) + + private def getIgnoreCase(env: Map[String, String], key: String): Option[String] = + env.collectFirst { case (k, v) if k.equalsIgnoreCase(key) => v } + + /** Existing env key matching `name` case-insensitively, so overlays replace rather than duplicate. */ + private def keyFor(env: Map[String, String], name: String): String = + env.keys.find(_.equalsIgnoreCase(name)).getOrElse(name) + + private def stripQuotes(s: String): String = s.replace("\"", "") +end SelectedJava diff --git a/sbtw/src/test/scala/sbtw/ArgParserSpec.scala b/sbtw/src/test/scala/sbtw/ArgParserSpec.scala new file mode 100644 index 000000000..86305d593 --- /dev/null +++ b/sbtw/src/test/scala/sbtw/ArgParserSpec.scala @@ -0,0 +1,85 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbtw + +object ArgParserSpec extends verify.BasicTestSuite: + test("parse should accept --java-home") { + val opts = ArgParser.parse(Array("--java-home", "C:\\jdk", "compile")).get + assert(opts.javaHome == Some("C:\\jdk")) + assert(opts.residual == Seq("compile")) + } + + test("parse should accept single-dash -java-home") { + val opts = ArgParser.parse(Array("-java-home", "C:\\jdk", "compile")).get + assert(opts.javaHome == Some("C:\\jdk")) + assert(opts.residual == Seq("compile")) + } + + test("parse should accept single-dash -sbt-version") { + val opts = ArgParser.parse(Array("-sbt-version", "1.10.7")).get + assert(opts.sbtVersion == Some("1.10.7")) + } + + test("parse should accept single-dash -mem") { + val opts = ArgParser.parse(Array("-mem", "2048")).get + assert(opts.mem == Some(2048)) + } + + test("parse should accept single-dash -no-colors") { + val opts = ArgParser.parse(Array("-no-colors")).get + assert(opts.noColors) + } + + test("parse should accept single-dash -jvm-debug") { + val opts = ArgParser.parse(Array("-jvm-debug", "5005")).get + assert(opts.jvmDebug == Some(5005)) + } + + test("parse should detect new alongside -java-home") { + val opts = ArgParser.parse(Array("-java-home", "C:\\jdk", "new")).get + assert(opts.sbtNew) + } + + test("longNames should be derived from the parser grammar") { + assert(ArgParser.longNames.contains("java-home")) + assert(ArgParser.longNames.contains("client")) + assert(ArgParser.longNames.contains("sbt-version")) + assert(!ArgParser.longNames.contains("h")) + assert(!ArgParser.longNames.contains("")) + assert(!ArgParser.longNames.contains("")) + } + + test("normalizeArgs should rewrite single-dash long options to double-dash") { + assert(ArgParser.normalizeArgs(Seq("-java-home")) == Seq("--java-home")) + assert(ArgParser.normalizeArgs(Seq("-sbt-version")) == Seq("--sbt-version")) + assert(ArgParser.normalizeArgs(Seq("-timings")) == Seq("--timings")) + } + + test("normalizeArgs should leave other tokens untouched") { + val args = Seq("-Dfoo=bar", "-Xmx1G", "-J-Xss2m", "-v", "--java-home", "compile") + assert(ArgParser.normalizeArgs(args) == args) + } + + test("normalizeArgs should not rewrite past a -- terminator") { + assert(ArgParser.normalizeArgs(Seq("--", "-server")) == Seq("--", "-server")) + assert( + ArgParser.normalizeArgs(Seq("-java-home", "X", "--", "-server")) == + Seq("--java-home", "X", "--", "-server") + ) + } + + test("normalizeArgs should not rewrite a value-taking option's value") { + assert(ArgParser.normalizeArgs(Seq("--sbt-dir", "-server")) == Seq("--sbt-dir", "-server")) + assert(ArgParser.normalizeArgs(Seq("-sbt-dir", "-server")) == Seq("--sbt-dir", "-server")) + } + + test("normalizeArgs should still rewrite the token after a value-less flag") { + assert(ArgParser.normalizeArgs(Seq("-no-colors", "-server")) == Seq("--no-colors", "--server")) + } +end ArgParserSpec diff --git a/sbtw/src/test/scala/sbtw/ConfigLoaderSpec.scala b/sbtw/src/test/scala/sbtw/ConfigLoaderSpec.scala new file mode 100644 index 000000000..3035db9f6 --- /dev/null +++ b/sbtw/src/test/scala/sbtw/ConfigLoaderSpec.scala @@ -0,0 +1,36 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbtw + +object ConfigLoaderSpec extends verify.BasicTestSuite: + test("tokenize keeps a double-quoted path together") { + assert( + ConfigLoader.tokenize("""-java-home "C:\Program Files\jdk"""") == Seq( + "-java-home", + "C:\\Program Files\\jdk" + ) + ) + } + + test("tokenize keeps a single-quoted path together") { + assert(ConfigLoader.tokenize("-sbt-dir '/Users/a dog'") == Seq("-sbt-dir", "/Users/a dog")) + } + + test("tokenize splits on unquoted whitespace") { + assert(ConfigLoader.tokenize("-mem 2048") == Seq("-mem", "2048")) + } + + test("tokenize returns a single token for a bare flag") { + assert(ConfigLoader.tokenize("-java-home") == Seq("-java-home")) + } + + test("tokenize returns nothing for blank input") { + assert(ConfigLoader.tokenize(" ") == Seq.empty) + } +end ConfigLoaderSpec diff --git a/sbtw/src/test/scala/sbtw/SelectedJavaSpec.scala b/sbtw/src/test/scala/sbtw/SelectedJavaSpec.scala new file mode 100644 index 000000000..3c33b69c0 --- /dev/null +++ b/sbtw/src/test/scala/sbtw/SelectedJavaSpec.scala @@ -0,0 +1,147 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbtw + +import java.io.File +import java.nio.file.Files + +object SelectedJavaSpec extends verify.BasicTestSuite: + + private def jdkHome(): File = + val home = Files.createTempDirectory("sbtw-jdk").toFile + val exe = new File(home, "bin/java.exe") + exe.getParentFile.mkdirs() + exe.createNewFile() + home + + test("resolve should use a valid --java-home") { + val home = jdkHome() + val selected = SelectedJava.resolve(Some(home.getPath), Map.empty).toOption.get + assert(selected.javaCmd == new File(home, "bin/java.exe").getAbsolutePath) + } + + test("resolve should reject an invalid --java-home instead of falling back") { + val result = + SelectedJava.resolve(Some("no-such-dir"), Map("JAVACMD" -> "C:\\jdk\\bin\\java.exe")) + assert(result.isLeft) + assert(result.swap.exists(_.contains("for JAVA_HOME is not valid"))) + } + + test("--java-home overlay makes JAVACMD authoritative over an inherited one") { + val home = jdkHome() + val exe = new File(home, "bin/java.exe").getAbsolutePath + val overlay = + SelectedJava + .resolve(Some(home.getPath), Map("JAVACMD" -> "C:\\old\\bin\\java.exe")) + .toOption + .get + .envOverlay + .toMap + assert(overlay.get("JAVACMD") == Some(exe)) + assert(overlay.get("JAVA_HOME") == Some(home.getPath)) + assert(overlay.get("JDK_HOME") == Some(home.getPath)) + val expectedPath = home.getPath + File.separator + "bin" + File.pathSeparator + "C:\\old" + assert(overlay.get("PATH") == Some(home.getPath + File.separator + "bin")) + assert( + SelectedJava + .resolve(Some(home.getPath), Map("PATH" -> "C:\\old")) + .toOption + .get + .envOverlay + .toMap + .get("PATH") == Some(expectedPath) + ) + } + + test("resolve should prefer JAVACMD over JAVA_HOME with no overlay") { + val env = Map("JAVACMD" -> "C:\\some\\java.exe", "JAVA_HOME" -> "C:\\jdk") + val selected = SelectedJava.resolve(None, env).toOption.get + assert(selected.javaCmd == "C:\\some\\java.exe") + assert(selected.envOverlay.isEmpty) + } + + test("resolve should strip quotes from JAVACMD") { + val selected = + SelectedJava.resolve(None, Map("JAVACMD" -> "\"C:\\some\\java.exe\"")).toOption.get + assert(selected.javaCmd == "C:\\some\\java.exe") + } + + test("a JDK derived from JAVA_HOME still gets a full overlay (PATH prepend)") { + val home = jdkHome().getPath + val selected = + SelectedJava.resolve(None, Map("JAVA_HOME" -> home, "PATH" -> "/old")).toOption.get + assert(selected.javaCmd == new File(home, "bin/java.exe").getAbsolutePath) + val overlay = selected.envOverlay.toMap + assert(overlay.get("JAVA_HOME") == Some(home)) + assert(overlay.get("PATH") == Some(home + File.separator + "bin" + File.pathSeparator + "/old")) + } + + test("a relative home is exported as an absolute path") { + val overlay = + SelectedJava.resolve(None, Map("JAVA_HOME" -> "rel-jdk")).toOption.get.envOverlay.toMap + assert(new File(overlay("JAVA_HOME")).isAbsolute) + assert(new File(overlay("JDK_HOME")).isAbsolute) + } + + test("resolve should read JAVACMD case-insensitively") { + val selected = SelectedJava.resolve(None, Map("JavaCmd" -> "C:\\x\\java.exe")).toOption.get + assert(selected.javaCmd == "C:\\x\\java.exe") + assert(selected.envOverlay.isEmpty) + } + + test("resolve should read JAVA_HOME case-insensitively") { + val home = jdkHome().getPath + val selected = SelectedJava.resolve(None, Map("Java_Home" -> home)).toOption.get + assert(selected.javaCmd == new File(home, "bin/java.exe").getAbsolutePath) + } + + test("resolve should fall back to bare java with no overlay") { + val selected = SelectedJava.resolve(None, Map.empty).toOption.get + assert(selected.javaCmd == "java") + assert(selected.envOverlay.isEmpty) + } + + test("overlay should reuse the existing PATH key case") { + val overlay = SelectedJava + .resolve(Some(jdkHome().getPath), Map("Path" -> "C:\\old")) + .toOption + .get + .envOverlay + .toMap + assert(overlay.contains("Path")) + assert(!overlay.contains("PATH")) + } + + test("an explicit --java-home carries the provenance marker only on the handoff") { + val selected = SelectedJava.resolve(Some(jdkHome().getPath), Map.empty).toOption.get + assert(!selected.envOverlay.toMap.contains(SelectedJava.explicitMarker)) + assert(selected.handoffEnv.toMap.get(SelectedJava.explicitMarker) == Some("1")) + } + + test("a JAVA_HOME-derived selection never carries the provenance marker") { + val selected = SelectedJava.resolve(None, Map("JAVA_HOME" -> jdkHome().getPath)).toOption.get + assert(!selected.envOverlay.toMap.contains(SelectedJava.explicitMarker)) + assert(!selected.handoffEnv.toMap.contains(SelectedJava.explicitMarker)) + } + + test("overlay should replace any-cased alias rather than duplicate it") { + val home = jdkHome().getPath + val overlay = + SelectedJava + .resolve(Some(home), Map("javacmd" -> "old", "JavaCmd2" -> "x")) + .toOption + .get + .envOverlay + .toMap + assert(overlay.contains("javacmd")) + assert(!overlay.contains("JAVACMD")) + assert(overlay.get("javacmd") == Some(new File(home, "bin/java.exe").getAbsolutePath)) + assert(overlay.contains("JAVA_HOME")) + } +end SelectedJavaSpec