From be305eb3a547e59cd98996e4b34ed24a1b35550e Mon Sep 17 00:00:00 2001 From: BitToby <218712309+bittoby@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:57:53 +0200 Subject: [PATCH] fix: Use sbt script in BSP config instead of hardcoded Java path (#8920) **Problem** sbt bspConfig writes the absolute path of the current Java binary into .bsp/sbt.json. When the user switches Java versions (via sdkman, cs java, etc.) or removes that JDK, the IDE fails to start the sbt BSP server because the hardcoded path is stale or gone. **Solution** When an sbt launcher script is available (via `sbt.script` system property or PATH lookup), generate: "argv": ["/path/to/sbt", "bsp"] --- .../src/test/scala/BspConfigTest.scala | 10 +++- launcher-package/src/universal/bin/sbt.bat | 5 ++ .../sbt/internal/client/NetworkClient.scala | 2 +- main/src/main/scala/sbt/Main.scala | 2 +- .../internal/bsp/BuildServerConnection.scala | 60 ++++++++++--------- .../bsp/BuildServerConnectionSpec.scala | 15 +++++ sbt | 1 + sbtw/src/main/scala/sbtw/Main.scala | 5 +- 8 files changed, 68 insertions(+), 32 deletions(-) diff --git a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala index 50b5bad0a..da6d8369d 100644 --- a/launcher-package/integration-test/src/test/scala/BspConfigTest.scala +++ b/launcher-package/integration-test/src/test/scala/BspConfigTest.scala @@ -49,8 +49,14 @@ object BspConfigTest extends BasicTestSuite: // Verify argv structure assert(argv.nonEmpty, "argv should not be empty") - assert(argv.head.contains("java"), s"argv should start with java command, got: ${argv.head}") - assert(argv.contains("-bsp"), s"argv should contain -bsp flag, got: $argv") + // When sbt script is available, argv uses the sbt script with "bsp" command. + // When not, argv falls back to direct java invocation with "-bsp" flag. + val usesSbtScript = argv.last == "bsp" && !argv.head.contains("java") + val usesJavaDirect = argv.head.contains("java") && argv.contains("-bsp") + assert( + usesSbtScript || usesJavaDirect, + s"argv should either use sbt script with 'bsp' command or java with '-bsp' flag, got: $argv" + ) // Test execution of the generated argv // Run the BSP command with a very short timeout to verify it starts correctly diff --git a/launcher-package/src/universal/bin/sbt.bat b/launcher-package/src/universal/bin/sbt.bat index e9be0c68c..89c1d00e7 100755 --- a/launcher-package/src/universal/bin/sbt.bat +++ b/launcher-package/src/universal/bin/sbt.bat @@ -442,6 +442,11 @@ if "%~0" == "shutdownall" ( goto args_loop ) +if "%~0" == "bsp" ( + set sbt_args_client=0 + goto args_loop +) + if "%~0" == "--script-version" ( set sbt_args_print_sbt_script_version=1 goto args_loop diff --git a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala index 1e2f170e5..a08d29f10 100644 --- a/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala +++ b/main-command/src/main/scala/sbt/internal/client/NetworkClient.scala @@ -1306,7 +1306,7 @@ object NetworkClient { case "--sbt-launch-jar" if i + 1 < sanitized.length => i += 1 launchJar = Option(sanitized(i).replace("%20", " ")) - case "-bsp" | "--bsp" => bsp = true + case "-bsp" | "--bsp" | "bsp" => bsp = true case "-no-server" | "--no-server" => System.setProperty("sbt.server.autostart", "false") case a if a.startsWith("--autostart=") => diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 0bbc13f88..f7bbb12b9 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -75,7 +75,7 @@ private[sbt] object xMain: .map(_.trim) .filterNot(_ == DashDashServer) val isClient: String => Boolean = cmd => (cmd == JavaClient) || (cmd == DashDashClient) - val isBsp: String => Boolean = cmd => (cmd == "-bsp") || (cmd == "--bsp") + val isBsp: String => Boolean = cmd => (cmd == "-bsp") || (cmd == "--bsp") || (cmd == "bsp") val isNew: String => Boolean = cmd => (cmd == "new") || (cmd == "init") lazy val isServer = !userCommands.exists(c => isBsp(c) || isClient(c)) // keep this lazy to prevent project directory created prematurely diff --git a/protocol/src/main/scala/sbt/internal/bsp/BuildServerConnection.scala b/protocol/src/main/scala/sbt/internal/bsp/BuildServerConnection.scala index 340daad06..f45b4e65e 100644 --- a/protocol/src/main/scala/sbt/internal/bsp/BuildServerConnection.scala +++ b/protocol/src/main/scala/sbt/internal/bsp/BuildServerConnection.scala @@ -26,42 +26,48 @@ object BuildServerConnection { private[sbt] def writeConnectionFile(sbtVersion: String, baseDir: File): Unit = { val bspConnectionFile = new File(baseDir, ".bsp/sbt.json") - val javaHome = Util.javaHome - val classPath = System.getProperty("java.class.path") val sbtScript = Option(System.getProperty("sbt.script")) + .map(_.replace("%20", " ")) + .filter(_.nonEmpty) .orElse(sbtScriptInPath) - .map(script => s"-Dsbt.script=$script") - val sbtOptsArgs = parseSbtOpts(sys.env.get("SBT_OPTS")) - - val sbtLaunchJar = classPath - .split(File.pathSeparator) - .find(jar => SbtLaunchJar.findFirstIn(jar).nonEmpty) - .map(_.replace(" ", "%20")) - .map(jar => s"--sbt-launch-jar=$jar") - - val argv = - Vector( - s"$javaHome/bin/java", - "-Xms100m", - "-Xmx100m", - ) ++ - sbtOptsArgs ++ - Vector( - "-classpath", - classPath, - ) ++ - sbtScript ++ - Vector("xsbt.boot.Boot", "-bsp") ++ - (if (sbtScript.isEmpty) sbtLaunchJar else None) + val argv = sbtScript match + case Some(script) => + Vector(script, "bsp") + case None => + buildFallbackArgv val details = BspConnectionDetails(name, sbtVersion, bspVersion, languages, argv) val json = Converter.toJson(details).get IO.write(bspConnectionFile, CompactPrinter(json), append = false) } - private def sbtScriptInPath: Option[String] = { + private[sbt] def buildFallbackArgv: Vector[String] = { + val javaHome = Util.javaHome + val classPath = System.getProperty("java.class.path") + val sbtOptsArgs = parseSbtOpts(sys.env.get("SBT_OPTS")) + val sbtLaunchJar = classPath + .split(File.pathSeparator) + .find(jar => SbtLaunchJar.findFirstIn(jar).nonEmpty) + .map(_.replace(" ", "%20")) + .map(jar => s"--sbt-launch-jar=$jar") + + Vector( + s"$javaHome/bin/java", + "-Xms100m", + "-Xmx100m", + ) ++ + sbtOptsArgs ++ + Vector( + "-classpath", + classPath, + ) ++ + Vector("xsbt.boot.Boot", "-bsp") ++ + sbtLaunchJar + } + + private[sbt] def sbtScriptInPath: Option[String] = { val fileName = if (Properties.isWin) "sbt.bat" else "sbt" val envPath = sys.env.collectFirst { case (k, v) if k.toUpperCase() == "PATH" => v @@ -72,7 +78,7 @@ object BuildServerConnection { allPaths .map(_.resolve(fileName)) .find(file => Files.exists(file) && Files.isExecutable(file)) - .map(_.toString.replace(" ", "%20")) + .map(_.toString) } private[sbt] def parseSbtOpts(sbtOpts: Option[String]): Vector[String] = diff --git a/protocol/src/test/scala/sbt/internal/bsp/BuildServerConnectionSpec.scala b/protocol/src/test/scala/sbt/internal/bsp/BuildServerConnectionSpec.scala index 2c153c9e1..df86b7216 100644 --- a/protocol/src/test/scala/sbt/internal/bsp/BuildServerConnectionSpec.scala +++ b/protocol/src/test/scala/sbt/internal/bsp/BuildServerConnectionSpec.scala @@ -45,3 +45,18 @@ object BuildServerConnectionSpec extends BasicTestSuite: test("parseSbtOpts should handle whitespace-separated options"): val result = BuildServerConnection.parseSbtOpts(Some(" -Dfoo=bar -Xmx1g ")) assert(result == Vector("-Dfoo=bar", "-Xmx1g")) + + test("sbtScriptInPath should return None when sbt is not in PATH"): + val result = BuildServerConnection.sbtScriptInPath + result match + case Some(path) => assert(path.nonEmpty) + case None => assert(true) + + test("buildFallbackArgv should include java path and -bsp flag"): + val argv = BuildServerConnection.buildFallbackArgv + assert(argv.head.contains("java"), s"argv should start with java, got: ${argv.head}") + assert(argv.contains("-bsp"), s"argv should contain -bsp, got: $argv") + assert(argv.contains("-Xms100m"), s"argv should contain -Xms100m, got: $argv") + assert(argv.contains("-Xmx100m"), s"argv should contain -Xmx100m, got: $argv") + assert(argv.contains("-classpath"), s"argv should contain -classpath, got: $argv") + assert(argv.contains("xsbt.boot.Boot"), s"argv should contain xsbt.boot.Boot, got: $argv") diff --git a/sbt b/sbt index 1bbb0e544..5ab69d315 100755 --- a/sbt +++ b/sbt @@ -826,6 +826,7 @@ process_args () { -Dsbt.color=never|-Dsbt.log.noformat=true) addJava "$1" && use_colors=0 && shift ;; "-D*"|-D*) addJava "$1" && shift ;; -J*) addJava "${1:2}" && shift ;; + bsp) use_sbtn=0 && addResidual "-bsp" && shift ;; *) addResidual "$1" && shift ;; esac done diff --git a/sbtw/src/main/scala/sbtw/Main.scala b/sbtw/src/main/scala/sbtw/Main.scala index 227ed2333..fe6db0421 100644 --- a/sbtw/src/main/scala/sbtw/Main.scala +++ b/sbtw/src/main/scala/sbtw/Main.scala @@ -51,8 +51,11 @@ object Main: else System.err.println("[error] sbt requires at least JDK 8+, you have " + javaVer) return 1 + 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 = shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion) + val useNativeClient = + if bspMode then false + else shouldRunNativeClient(opts.copy(client = clientOpt), buildPropsVersion) if useNativeClient then val scriptPath = sbtBinDir.getAbsolutePath.replace("\\", "/") + "/sbt.bat"