Skip to content

Commit a29adba

Browse files
authored
Ensure non-self executable JVM launchers' setup-ide produces working BSP connection JSON (#3876)
1 parent 1884c5d commit a29adba

File tree

6 files changed

+175
-25
lines changed

6 files changed

+175
-25
lines changed

build.mill.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,7 @@ trait CliIntegration extends SbtModule with ScalaCliPublishModule with HasTests
10571057
|
10581058
|/** Build-time constants. Generated by mill. */
10591059
|object Constants {
1060+
| def cliVersion = "${publishVersion()}"
10601061
| def allJavaVersions = Seq(${Java.allJavaVersions.sorted.mkString(", ")})
10611062
| def bspVersion = "${Deps.bsp4j.dep.versionConstraint.asString}"
10621063
| def bloopMinimumJvmVersion = ${Java.minimumBloopJava}

modules/cli/src/main/scala/scala/cli/commands/CommandUtils.scala

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package scala.cli.commands
22

33
import java.io.File
4-
import java.nio.file.Paths
4+
import java.nio.file.{Files, Paths}
55

66
import scala.build.Os
77
import scala.cli.ScalaCli
@@ -48,8 +48,8 @@ object CommandUtils {
4848
.toString
4949
).toOption
5050
currentLauncherPathOpt.map(currentLauncherPath =>
51-
scalaCLICanonicalPathsFromPATH.get(os.Path(currentLauncherPath))
52-
.getOrElse(currentLauncherPath)
51+
scalaCLICanonicalPathsFromPATH
52+
.getOrElse(os.Path(currentLauncherPath), currentLauncherPath)
5353
)
5454
}
5555
.getOrElse(programName)
@@ -59,4 +59,29 @@ object CommandUtils {
5959
def printablePath(path: os.Path): String =
6060
if (path.startsWith(Os.pwd)) "." + File.separator + path.relativeTo(Os.pwd).toString
6161
else path.toString
62+
63+
extension (launcher: os.Path) {
64+
def isJar: Boolean =
65+
if os.isFile(launcher) then
66+
val mimeType = Files.probeContentType(launcher.toNIO)
67+
mimeType match
68+
case "application/java-archive" | "application/x-java-archive" => true
69+
case "application/zip" =>
70+
// Extra check: ensure META-INF/MANIFEST.MF exists inside
71+
val jarFile = new java.util.jar.JarFile(launcher.toIO)
72+
try jarFile.getEntry("META-INF/MANIFEST.MF") != null
73+
finally jarFile.close()
74+
case _ => false
75+
else false
76+
def hasSelfExecutablePreamble: Boolean = {
77+
// Read first 2 bytes raw: look for shebang '#!'
78+
val in = Files.newInputStream(launcher.toNIO)
79+
try
80+
val b1 = in.read()
81+
val b2 = in.read()
82+
b1 == '#' && b2 == '!'
83+
finally
84+
in.close()
85+
}
86+
}
6287
}

modules/cli/src/main/scala/scala/cli/commands/setupide/SetupIde.scala

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import scala.build.internal.Constants
1717
import scala.build.internals.EnvVar
1818
import scala.build.options.{BuildOptions, Scope}
1919
import scala.cli.CurrentParams
20+
import scala.cli.commands.CommandUtils.{hasSelfExecutablePreamble, isJar}
2021
import scala.cli.commands.shared.{SharedBspFileOptions, SharedOptions}
22+
import scala.cli.commands.util.JvmUtils
2123
import scala.cli.commands.{CommandUtils, ScalaCommand}
2224
import scala.cli.errors.FoundVirtualInputsError
2325
import scala.cli.launcher.LauncherOptions
@@ -155,13 +157,32 @@ object SetupIde extends ScalaCommand[SetupIdeOptions] {
155157
s"-J-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:$port,suspend=y"
156158
)
157159

158-
val launcher = launcherOptions.scalaRunner.initialLauncherPath
159-
.getOrElse(CommandUtils.getAbsolutePathToScalaCli(progName))
160+
val launcher = os.Path {
161+
launcherOptions.scalaRunner.initialLauncherPath
162+
.getOrElse(CommandUtils.getAbsolutePathToScalaCli(progName))
163+
}
160164
val finalLauncherOptions = launcherOptions.copy(cliVersion =
161165
launcherOptions.cliVersion.orElse(launcherOptions.scalaRunner.predefinedCliVersion)
162166
)
167+
168+
val launcherCommand =
169+
if launcher.isJar && !launcher.hasSelfExecutablePreamble
170+
then
171+
List(
172+
value {
173+
JvmUtils.getJavaCmdVersionOrHigher(
174+
javaVersion =
175+
math.max(Constants.minimumInternalJavaVersion, Constants.minimumBloopJavaVersion),
176+
options = buildOptions
177+
)
178+
}.javaCommand,
179+
"-jar",
180+
launcher.toString
181+
)
182+
else List(launcher.toString)
183+
163184
val bspArgs =
164-
List(launcher) ++
185+
launcherCommand ++
165186
finalLauncherOptions.toCliArgs ++
166187
launcherJavaPropArgs ++
167188
List("bsp") ++

modules/integration/src/test/scala/scala/cli/integration/BspSuite.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,11 @@ trait BspSuite { _: ScalaCliSuite =>
199199
expect(expectedPrefixes.exists(uri.startsWith))
200200
}
201201

202-
protected def readBspConfig(root: os.Path): Details = {
203-
val bspFile = root / ".bsp" / "scala-cli.json"
202+
protected def readBspConfig(
203+
root: os.Path,
204+
connectionJsonFileName: String = "scala-cli.json"
205+
): Details = {
206+
val bspFile = root / ".bsp" / connectionJsonFileName
204207
expect(os.isFile(bspFile))
205208
val content = os.read.bytes(bspFile)
206209
// check that we can decode the connection details

modules/integration/src/test/scala/scala/cli/integration/BspTestDefinitions.scala

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import scala.concurrent.duration._
1515
import scala.jdk.CollectionConverters._
1616
import scala.util.Properties
1717

18-
abstract class BspTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs
19-
with BspSuite with ScriptWrapperTestDefinitions {
18+
abstract class BspTestDefinitions extends ScalaCliSuite
19+
with TestScalaVersionArgs
20+
with BspSuite
21+
with ScriptWrapperTestDefinitions
22+
with CoursierScalaInstallationTestHelper {
2023
_: TestScalaVersion =>
2124
protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions
2225

@@ -31,7 +34,7 @@ abstract class BspTestDefinitions extends ScalaCliSuite with TestScalaVersionArg
3134
os.proc(TestUtil.cli, "setup-ide", ".", extraOptions).call(cwd = root, stdout = os.Inherit)
3235
val details = readBspConfig(root)
3336
expect(details.argv.length >= 2)
34-
expect(details.argv(1) == "bsp")
37+
expect(details.argv.dropWhile(_ != TestUtil.cliPath).drop(1).head == "bsp")
3538
}
3639
}
3740

@@ -63,7 +66,11 @@ abstract class BspTestDefinitions extends ScalaCliSuite with TestScalaVersionArg
6366
expectedIdeEnvsFile.toString,
6467
root.toString
6568
)
66-
expect(details.argv == expectedArgv)
69+
if (TestUtil.isJvmBootstrappedCli) {
70+
expect(details.argv.head.endsWith("java"))
71+
expect(details.argv.drop(1).head == "-jar")
72+
}
73+
expect(details.argv.dropWhile(_ != TestUtil.cliPath) == expectedArgv)
6774
expect(os.isFile(expectedIdeOptionsFile))
6875
expect(os.isFile(expectedIdeInputsFile))
6976
expect(os.isFile(expectedIdeEnvsFile))
@@ -108,7 +115,7 @@ abstract class BspTestDefinitions extends ScalaCliSuite with TestScalaVersionArg
108115
(root / "directory" / Constants.workspaceDirName / "ide-envs.json").toString,
109116
(root / "directory" / "simple.sc").toString
110117
)
111-
expect(details.argv == expectedArgv)
118+
expect(details.argv.dropWhile(_ != TestUtil.cliPath) == expectedArgv)
112119
}
113120
}
114121

@@ -2220,14 +2227,90 @@ abstract class BspTestDefinitions extends ScalaCliSuite with TestScalaVersionArg
22202227
}
22212228
}
22222229

2230+
for {
2231+
useScalaWrapper <- Seq(false, true)
2232+
if actualScalaVersion.coursierVersion >= "3.5.0".coursierVersion
2233+
withLauncher = (root: os.Path) =>
2234+
(f: Seq[os.Shellable] => Unit) =>
2235+
if (useScalaWrapper)
2236+
withScalaRunnerWrapper(
2237+
root = root,
2238+
localBin = root / "local-bin",
2239+
localCache = Some(root / "local-cache"),
2240+
scalaVersion = actualScalaVersion,
2241+
shouldCleanUp = false
2242+
)(launcher => f(Seq(launcher)))
2243+
else
2244+
f(Seq(TestUtil.cli))
2245+
launcherString = if (useScalaWrapper) "coursier scala installation" else "Scala CLI"
2246+
connectionJsonFileName = if (useScalaWrapper) "scala.json" else "scala-cli.json"
2247+
}
2248+
test(
2249+
s"setup-ide with scala wrapper prepares valid BSP connection json with a valid launcher ($launcherString)"
2250+
) {
2251+
TestUtil.retryOnCi() {
2252+
val scriptName = "example.sc"
2253+
TestInputs(
2254+
os.rel / scriptName -> s"""println("Hello")"""
2255+
)
2256+
.fromRoot { root =>
2257+
withLauncher(root) { launcher =>
2258+
val javaHome =
2259+
os.Path(
2260+
os.proc(TestUtil.cs, "java-home", "--jvm", "zulu:8").call().out.trim(),
2261+
os.pwd
2262+
)
2263+
os.proc(launcher, "setup-ide", scriptName, extraOptions)
2264+
.call(cwd = root, env = Map("JAVA_HOME" -> javaHome.toString))
2265+
val expectedIdeLauncherFile =
2266+
root / Constants.workspaceDirName / "ide-launcher-options.json"
2267+
expect(expectedIdeLauncherFile.toNIO.toFile.exists())
2268+
val bspConfig = readBspConfig(root, connectionJsonFileName)
2269+
val bspLauncherCommand = {
2270+
val launcherPrefix = bspConfig.argv.takeWhile(_ != TestUtil.cliPath)
2271+
launcherPrefix :+ bspConfig.argv.drop(launcherPrefix.length).head
2272+
}
2273+
expect(bspLauncherCommand.last == TestUtil.cliPath)
2274+
if (TestUtil.isJvmBootstrappedCli) {
2275+
// this launcher is not self-executable and has to be launched with `java -jar`
2276+
expect(bspLauncherCommand.head.endsWith("java"))
2277+
expect(bspLauncherCommand.drop(1) == List("-jar", TestUtil.cliPath))
2278+
val bspJavaVersionResult = os.proc(bspLauncherCommand.head, "-version")
2279+
.call(
2280+
cwd = root,
2281+
env = Map("JAVA_HOME" -> javaHome.toString),
2282+
mergeErrIntoOut = true
2283+
)
2284+
val bspJavaVersion = TestUtil.parseJavaVersion(bspJavaVersionResult.out.trim()).get
2285+
// the bsp launcher has to know to run itself on a supported JVM
2286+
expect(bspJavaVersion >= math.max(
2287+
Constants.minimumInternalJvmVersion,
2288+
Constants.bloopMinimumJvmVersion
2289+
))
2290+
}
2291+
else
2292+
expect(bspLauncherCommand == List(TestUtil.cliPath))
2293+
val r = os.proc(bspLauncherCommand, "version", "--cli-version")
2294+
.call(cwd = root, env = Map("JAVA_HOME" -> javaHome.toString))
2295+
expect(r.out.trim() == Constants.cliVersion)
2296+
}
2297+
}
2298+
}
2299+
}
2300+
22232301
test("setup-ide passes Java props to the BSP configuration correctly") {
22242302
val scriptName = "hello.sc"
22252303
TestInputs(os.rel / scriptName -> s"""println("Hello")""").fromRoot { root =>
22262304
val javaProps = List("-Dfoo=bar", "-Dbar=baz")
22272305
os.proc(TestUtil.cli, javaProps, "setup-ide", scriptName, extraOptions)
22282306
.call(cwd = root)
22292307
val bspConfig = readBspConfig(root)
2230-
expect(bspConfig.argv.head == TestUtil.cliPath)
2308+
if (TestUtil.isJvmBootstrappedCli) {
2309+
expect(bspConfig.argv.head.endsWith("java"))
2310+
expect(bspConfig.argv.drop(1).head == "-jar")
2311+
expect(bspConfig.argv.dropWhile(_ != TestUtil.cliPath).head == TestUtil.cliPath)
2312+
}
2313+
else expect(bspConfig.argv.head == TestUtil.cliPath)
22312314
expect(bspConfig.argv.containsSlice(javaProps))
22322315
expect(bspConfig.argv.indexOfSlice(javaProps) < bspConfig.argv.indexOf("bsp"))
22332316
}

modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,21 @@ import scala.Console._
1212
import scala.annotation.tailrec
1313
import scala.concurrent.duration.{Duration, DurationInt, FiniteDuration}
1414
import scala.concurrent.{Await, ExecutionContext, Future}
15-
import scala.util.Properties
15+
import scala.util.{Properties, Try}
1616

1717
object TestUtil {
1818

19-
val cliKind: String = sys.props("test.scala-cli.kind")
20-
val isNativeCli: Boolean = cliKind.startsWith("native")
21-
val isJvmCli: Boolean = cliKind.startsWith("jvm")
22-
val isCI: Boolean = System.getenv("CI") != null
23-
val isM1: Boolean = sys.props.get("os.arch").contains("aarch64")
24-
val cliPath: String = sys.props("test.scala-cli.path")
25-
val debugPortOpt: Option[String] = sys.props.get("test.scala-cli.debug.port")
26-
val detectCliPath: String = if (TestUtil.isNativeCli) TestUtil.cliPath else "scala-cli"
27-
val cli: Seq[String] = cliCommand(cliPath)
28-
val ltsEqualsNext: Boolean = Constants.scala3Lts equals Constants.scala3Next
19+
val cliKind: String = sys.props("test.scala-cli.kind")
20+
val isNativeCli: Boolean = cliKind.startsWith("native")
21+
val isJvmCli: Boolean = cliKind.startsWith("jvm")
22+
val isJvmBootstrappedCli: Boolean = cliKind.startsWith("jvmBootstrapped")
23+
val isCI: Boolean = System.getenv("CI") != null
24+
val isM1: Boolean = sys.props.get("os.arch").contains("aarch64")
25+
val cliPath: String = sys.props("test.scala-cli.path")
26+
val debugPortOpt: Option[String] = sys.props.get("test.scala-cli.debug.port")
27+
val detectCliPath: String = if (TestUtil.isNativeCli) TestUtil.cliPath else "scala-cli"
28+
val cli: Seq[String] = cliCommand(cliPath)
29+
val ltsEqualsNext: Boolean = Constants.scala3Lts equals Constants.scala3Next
2930

3031
lazy val legacyScalaVersionsOnePerMinor: Seq[String] =
3132
Constants.legacyScala3Versions.sorted.reverse.distinctBy(_.split('.').take(2).mkString("."))
@@ -402,4 +403,20 @@ object TestUtil {
402403
def printStderrUntilRerun(timeout: Duration)(implicit ec: ExecutionContext): Unit =
403404
TestUtil.printStderrUntilCondition(proc, timeout)(_.contains("re-run"))()
404405
}
406+
407+
// based on the implementation from bloop-rifle:
408+
// https://github.com/scalacenter/bloop/blob/65b0b290fddd6d4256665014a7d16531e29ded4f/bloop-rifle/src/main/scala/bloop/rifle/VersionUtil.scala#L13-L30
409+
def parseJavaVersion(input: String): Option[Int] = {
410+
val jvmReleaseRegex = "(1[.])?(\\d+)"
411+
def jvmRelease(jvmVersion: String): Option[Int] = for {
412+
regexMatch <- jvmReleaseRegex.r.findFirstMatchIn(jvmVersion)
413+
versionString <- Option(regexMatch.group(2))
414+
versionInt <- Try(versionString.toInt).toOption
415+
} yield versionInt
416+
for {
417+
firstMatch <- s""".*version .($jvmReleaseRegex).*""".r.findFirstMatchIn(input)
418+
versionNumberGroup <- Option(firstMatch.group(1))
419+
versionInt <- jvmRelease(versionNumberGroup)
420+
} yield versionInt
421+
}
405422
}

0 commit comments

Comments
 (0)