From 5da646f977b747a7a04a72d741a9ac2b8fdecb15 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Thu, 13 Aug 2026 19:52:46 +0200 Subject: [PATCH] [2.x] fix: Honor -java-home in the Windows launchers (#9434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem** -java-home wasn't fully honored on Windows: sbtw rejected the single-dash form, silently ignored bad paths, and didn't propagate the JDK to the sbt/sbtn child; sbt.bat didn't fix up PATH/JAVACMD, and a project .java-version could override an explicit -java-home. Residual #963 gaps (the original javac-from-PATH bug was fixed long ago). **Solution** New SelectedJava seam in sbtw resolves the JDK once (--java-home > JAVACMD > JAVA_HOME > PATH) and applies one env overlay on every launch path; sbt.bat's handler now sets PATH/JAVACMD and a one-hop marker so .java-version defers to an explicit -java-home. JDK_HOME > JAVA_HOME (the issue's "even more desired" ordering) is intentionally **not** implemented — it only mattered when JAVA_HOME pointed at a JRE without javac, and JDK 17+ (required by sbt 2.x) no longer ships standalone JREs, so JAVA_HOME always resolves to a full JDK. --- .../src/test/scala/JavaHomeScriptTest.scala | 36 +++++ launcher-package/src/universal/bin/sbt.bat | 9 +- sbtw/src/main/scala/sbtw/ConfigLoader.scala | 28 +++- sbtw/src/main/scala/sbtw/Main.scala | 150 ++++++++++-------- sbtw/src/main/scala/sbtw/Runner.scala | 39 ++--- sbtw/src/main/scala/sbtw/SelectedJava.scala | 70 ++++++++ sbtw/src/test/scala/sbtw/ArgParserSpec.scala | 22 +++ .../test/scala/sbtw/ConfigLoaderSpec.scala | 36 +++++ .../test/scala/sbtw/SelectedJavaSpec.scala | 147 +++++++++++++++++ 9 files changed, 442 insertions(+), 95 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 5e24e488a..b1ece361d 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 @@ -511,8 +512,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 ( @@ -794,6 +798,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/ConfigLoader.scala b/sbtw/src/main/scala/sbtw/ConfigLoader.scala index 40835db35..7482cbe1b 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`). */ + 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..4a03a9e5b 100644 --- a/sbtw/src/main/scala/sbtw/Runner.scala +++ b/sbtw/src/main/scala/sbtw/Runner.scala @@ -6,25 +6,6 @@ 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 = try val pb = Process(Seq(javaCmd, "-Xms32M", "-Xmx32M", "-version")) @@ -71,7 +52,12 @@ object Runner: if opts.jvmClient then s = s :+ "--client" s - def runNativeClient(sbtBinDir: File, scriptPath: String, opts: LauncherOptions): Int = + 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, + 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 = + 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 => diff --git a/sbtw/src/main/scala/sbtw/SelectedJava.scala b/sbtw/src/main/scala/sbtw/SelectedJava.scala new file mode 100644 index 000000000..24814106e --- /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. */ +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 + +object SelectedJava: + + /** One-hop marker telling `sbt.bat`'s `.java-version` block to defer to an explicit `--java-home`. */ + 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..f807d1532 --- /dev/null +++ b/sbtw/src/test/scala/sbtw/ArgParserSpec.scala @@ -0,0 +1,22 @@ +/* + * 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 detect new alongside --java-home") { + val opts = ArgParser.parse(Array("--java-home", "C:\\jdk", "new")).get + assert(opts.sbtNew) + } +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