Repository: com-lihaoyi/mill
Pull request #4881: Make BSP/install only create the .bsp/mill-bsp.json file
Author: login: alexarchambault
Created: 2025-04-07T09:01:35Z | Merged: 2025-04-25T18:02:54Z

This makes `./mill mill.bsp.BSP/install` only create a `.bsp/mill-bsp.json` file, and not write the BSP worker class path to a `.bsp/mill-….resources` file any more, which shouldn't be needed after #4948.

This also makes `./mill mill.bsp.BSP/install` work even when the build doesn't compile, so that IDEs can load Mill projects in that case too.


Diff:
diff --git a/integration/ide/bsp-modules/src/BspModulesTests.scala b/integration/ide/bsp-modules/src/BspModulesTests.scala
index 5b19fd0be552..a41159be47e8 100644
--- a/integration/ide/bsp-modules/src/BspModulesTests.scala
+++ b/integration/ide/bsp-modules/src/BspModulesTests.scala
@@ -11,7 +11,7 @@ object BspModulesTests extends UtestIntegrationTestSuite {
     test("BSP module with foreign modules") {
       test("can be installed") - integrationTest { tester =>
         import tester._
-        val res = eval("mill.bsp/install")
+        val res = eval("mill.bsp.BSP/install")
         assert(res.isSuccess)
         os.exists(workspacePath / Constants.bspDir / s"${Constants.serverName}.json") ==> true
       }
diff --git a/integration/ide/bsp-server/src/BspInstallDebugTests.scala b/integration/ide/bsp-server/src/BspInstallDebugTests.scala
index 95b8efc86d48..6024d1405f02 100644
--- a/integration/ide/bsp-server/src/BspInstallDebugTests.scala
+++ b/integration/ide/bsp-server/src/BspInstallDebugTests.scala
@@ -13,9 +13,9 @@ object BspInstallDebugTests extends UtestIntegrationTestSuite {
   override val debugLog: Boolean = true
 
   def tests: Tests = Tests {
-    test("BSP install forwards --debug option to server") - integrationTest { tester =>
+    def runTest(bspInstallOption: String) = integrationTest { tester =>
       import tester._
-      eval("mill.bsp/install").isSuccess ==> true
+      eval(bspInstallOption).isSuccess ==> true
       val jsonFile = workspacePath / Constants.bspDir / s"${Constants.serverName}.json"
       assert(os.exists(jsonFile))
       val contents = os.read(jsonFile)
@@ -24,5 +24,11 @@ object BspInstallDebugTests extends UtestIntegrationTestSuite {
         contents.contains(s""""bspVersion":"${bsp4jVersion}"""")
       )
     }
+    test("BSP install via external command forwards --debug option to server") {
+      runTest("mill.bsp.BSP/install")
+    }
+    test("BSP install forwards --debug option to server") {
+      runTest("--bsp-install")
+    }
   }
 }
diff --git a/integration/ide/bsp-server/src/BspServerTestUtil.scala b/integration/ide/bsp-server/src/BspServerTestUtil.scala
index fe7ef85be7da..15388e458d9f 100644
--- a/integration/ide/bsp-server/src/BspServerTestUtil.scala
+++ b/integration/ide/bsp-server/src/BspServerTestUtil.scala
@@ -179,15 +179,17 @@ object BspServerTestUtil {
       success = true
       value
     } finally {
-      proc.stdin.close()
-      proc.stdout.close()
-
-      proc.join(30000L)
-
-      if (!success && outputOnErrorOnly) {
-        System.err.println(" == BSP server output ==")
-        System.err.write(stderr.toByteArray)
-        System.err.println(" == end of BSP server output ==")
+      try {
+        proc.stdin.close()
+        proc.stdout.close()
+
+        proc.join(30000L)
+      } finally {
+        if (!success && outputOnErrorOnly) {
+          System.err.println(" == BSP server output ==")
+          System.err.write(stderr.toByteArray)
+          System.err.println(" == end of BSP server output ==")
+        }
       }
     }
   }
diff --git a/integration/ide/bsp-server/src/BspServerTests.scala b/integration/ide/bsp-server/src/BspServerTests.scala
index d744002f4edb..592ae0b68df9 100644
--- a/integration/ide/bsp-server/src/BspServerTests.scala
+++ b/integration/ide/bsp-server/src/BspServerTests.scala
@@ -39,7 +39,7 @@ object BspServerTests extends UtestIntegrationTestSuite {
     test("requestSnapshots") - integrationTest { tester =>
       import tester._
       eval(
-        "mill.bsp/install",
+        "--bsp-install",
         stdout = os.Inherit,
         stderr = os.Inherit,
         check = true,
diff --git a/runner/bsp/src/mill/bsp/BSP.scala b/runner/bsp/src/mill/bsp/BSP.scala
index 33acde94fc39..1ebee73f90d6 100644
--- a/runner/bsp/src/mill/bsp/BSP.scala
+++ b/runner/bsp/src/mill/bsp/BSP.scala
@@ -1,19 +1,11 @@
 package mill.bsp
 
-import mill.define.{ModuleCtx, PathRef}
-import mill.{T, Task, given}
-import mill.define.{Command, Discover, Evaluator, ExternalModule, Mirrors}
 import mill.util.BuildInfo
-import mill.scalalib.{CoursierModule, Dep}
-import Mirrors.autoMirror
-import mill.api.internal.{BspServerResult, internal}
+import mill.define.WorkspaceRoot
 
-object BSP extends ExternalModule with CoursierModule {
-  lazy val millDiscover = Discover[this.type]
+import java.io.PrintStream
 
-  private def bspWorkerLibs: T[Seq[PathRef]] = Task {
-    defaultResolver().classpath(Seq(Dep.millProjectModule("mill-runner-bsp-worker")))
-  }
+private[mill] object BSP {
 
   /**
    * Installs the mill-bsp server. It creates a json file
@@ -29,34 +21,16 @@ object BSP extends ExternalModule with CoursierModule {
    * reason, the message and stacktrace of the exception will be
    * printed to stdout.
    */
-  def install(jobs: Int = 1): Command[(PathRef, ujson.Value)] = Task.Command {
-    // we create a file containing the additional jars to load
-    val libUrls = bspWorkerLibs().map(_.path.toNIO.toUri.toURL).iterator.toSeq
-    val cpFile =
-      Task.workspace / Constants.bspDir / s"${Constants.serverName}-${BuildInfo.millVersion}.resources"
-    os.write.over(
-      cpFile,
-      libUrls.mkString("\n"),
-      createFolders = true
-    )
-    createBspConnection(jobs, Constants.serverName)
-  }
-
-  private def createBspConnection(
-      jobs: Int,
-      serverName: String
-  )(implicit ctx: mill.define.TaskCtx): (PathRef, ujson.Value) = {
+  def install(jobs: Int, withDebug: Boolean, errStream: PrintStream): Unit = {
     // we create a json connection file
-    val bspFile = ctx.workspace / Constants.bspDir / s"${serverName}.json"
-    if (os.exists(bspFile)) ctx.log.warn(s"Overwriting BSP connection file: ${bspFile}")
-    else ctx.log.info(s"Creating BSP connection file: ${bspFile}")
-    val withDebug = ctx.log.debugEnabled
-    if (withDebug) ctx.log.debug(
+    val bspFile = WorkspaceRoot.workspaceRoot / Constants.bspDir / s"${Constants.serverName}.json"
+    if (os.exists(bspFile)) errStream.println(s"Overwriting BSP connection file: ${bspFile}")
+    else errStream.println(s"Creating BSP connection file: ${bspFile}")
+    if (withDebug) errStream.println(
       "Enabled debug logging for the BSP server. If you want to disable it, you need to re-run this install command without the --debug option."
     )
     val connectionContent = bspConnectionJson(jobs, withDebug)
     os.write.over(bspFile, connectionContent, createFolders = true)
-    (PathRef(bspFile), upickle.default.read[ujson.Value](connectionContent))
   }
 
   private def bspConnectionJson(jobs: Int, debug: Boolean): String = {
@@ -85,4 +59,5 @@ object BSP extends ExternalModule with CoursierModule {
     )
   }
 
+  def defaultJobCount: Int = 1
 }
diff --git a/runner/bsp/src/mill/bsp/package.scala b/runner/bsp/src/mill/bsp/package.scala
deleted file mode 100644
index 01627db43830..000000000000
--- a/runner/bsp/src/mill/bsp/package.scala
+++ /dev/null
@@ -1,4 +0,0 @@
-package mill.bsp
-import mill.define.ExternalModule
-
-object `package` extends ExternalModule.Alias(BSP)
diff --git a/runner/src/mill/runner/MillCliConfig.scala b/runner/src/mill/runner/MillCliConfig.scala
index 981d7bb5183b..c99627e65246 100644
--- a/runner/src/mill/runner/MillCliConfig.scala
+++ b/runner/src/mill/runner/MillCliConfig.scala
@@ -25,6 +25,8 @@ case class MillCliConfig(
     noServer: Flag = Flag(),
     @arg(doc = """Enable BSP server mode.""")
     bsp: Flag,
+    @arg(doc = """Create mill-bsp.json with Mill details under .bsp/""")
+    bspInstall: Flag,
     @arg(name = "version", short = 'v', doc = "Show mill version information and exit.")
     showVersion: Flag = Flag(),
     @arg(
diff --git a/runner/src/mill/runner/MillMain.scala b/runner/src/mill/runner/MillMain.scala
index dc772690ba9a..d6a9728f2a76 100644
--- a/runner/src/mill/runner/MillMain.scala
+++ b/runner/src/mill/runner/MillMain.scala
@@ -10,6 +10,7 @@ import scala.jdk.CollectionConverters.*
 import scala.util.Properties
 import mill.api.internal.{BspServerResult, internal}
 import mill.api.{DummyInputStream, Logger, MillException, Result, SystemStreams}
+import mill.bsp.BSP
 import mill.constants.{OutFiles, ServerFiles, Util}
 import mill.client.lock.Lock
 import mill.define.WorkspaceRoot
@@ -161,16 +162,50 @@ object MillMain {
               checkMillVersionFromFile(WorkspaceRoot.workspaceRoot, streams.err)
             }
 
-            // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
-            val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
             val maybeThreadCount =
               parseThreadCount(config.threadCountRaw, Runtime.getRuntime.availableProcessors())
 
+            // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
+            val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeJobCountOpt = {
+              def defaultJobCount =
+                maybeThreadCount.toOption.getOrElse(BSP.defaultJobCount)
+
+              val viaEmulatedExternalCommand = Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                config.leftoverArgs.value.tail match {
+                  case Seq() => defaultJobCount
+                  case Seq("--jobs", value) =>
+                    val asIntOpt = value.toIntOption
+                    asIntOpt.getOrElse {
+                      streams.err.println(
+                        s"Warning: ignoring --jobs value passed to ${config.leftoverArgs.value.head}"
+                      )
+                      defaultJobCount
+                    }
+                  case other =>
+                    streams.err.println(
+                      s"Warning: ignoring leftover arguments passed to ${config.leftoverArgs.value.head}"
+                    )
+                    defaultJobCount
+                }
+              }
+
+              viaEmulatedExternalCommand.orElse {
+                Option.when(config.bspInstall.value)(defaultJobCount)
+              }
+            }
+
             val (success, nextStateCache) = {
               if (config.repl.value) {
                 streams.err.println("The --repl mode is no longer supported.")
                 (false, stateCache)
 
+              } else if (bspInstallModeJobCountOpt.isDefined) {
+                BSP.install(bspInstallModeJobCountOpt.get, config.debugLog.value, streams.err)
+                (true, stateCache)
               } else if (!bspMode && config.leftoverArgs.value.isEmpty) {
                 println(MillCliConfigParser.shortUsageText)
 
diff --git a/website/docs/modules/ROOT/pages/cli/installation-ide.adoc b/website/docs/modules/ROOT/pages/cli/installation-ide.adoc
index a8f0b9cfdeee..e4a750d95ab3 100644
--- a/website/docs/modules/ROOT/pages/cli/installation-ide.adoc
+++ b/website/docs/modules/ROOT/pages/cli/installation-ide.adoc
@@ -163,7 +163,7 @@ run this command to generate the BSP configuration files:
 
 [source,console]
 ----
-> ./mill mill.bsp/install
+> ./mill --bsp-install
 ----
 
 ==== IntelliJ IDEA XML Support


Reviews:
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T22:50:12Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T22:54:27Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T18:08:54Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T14:33:49Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T14:43:34Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T14:59:07Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T16:17:07Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T16:26:19Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T17:06:11Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T17:12:07Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T18:24:48Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T18:25:15Z
user: login: lefou
body: We currently emulate a command task to stay compatible to previous Mill versions. But in fact we implement a cli option, so we should also make it one. I suggest `--bsp-install`. 

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T20:18:13Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-25T10:17:16Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T12:34:11Z
user: login: alexarchambault
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-25T13:15:07Z
user: login: lefou
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T17:59:13Z

Review comments:
diff_hunk: @@ -326,16 +326,15 @@ object MillMain {
 
         val bspServerHandleRes = {
           val wsRoot = WorkspaceRoot.workspaceRoot
-          mill.bsp.BspClasspathWorker(wsRoot).flatMap { worker =>
-            val logDir = wsRoot / OutFiles.out / "mill-bsp"
-            os.makeDir.all(logDir)
-            worker.startBspServer(
-              define.WorkspaceRoot.workspaceRoot,
-              splitStreams,
-              logDir,
-              true
-            )
-          }
+          val worker = mill.bsp.BspClasspathWorker(wsRoot, logger = ???)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Actually, this shouldn't be merged for now because of this (added while rebasing)
created_at: 2025-04-15T22:50:02Z
author_association: COLLABORATOR
diff_hunk: @@ -326,16 +326,15 @@ object MillMain {
 
         val bspServerHandleRes = {
           val wsRoot = WorkspaceRoot.workspaceRoot
-          mill.bsp.BspClasspathWorker(wsRoot).flatMap { worker =>
-            val logDir = wsRoot / OutFiles.out / "mill-bsp"
-            os.makeDir.all(logDir)
-            worker.startBspServer(
-              define.WorkspaceRoot.workspaceRoot,
-              splitStreams,
-              logDir,
-              true
-            )
-          }
+          val worker = mill.bsp.BspClasspathWorker(wsRoot, logger = ???)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Should be fixed
created_at: 2025-04-15T22:54:27Z
author_association: COLLABORATOR
diff_hunk: @@ -160,6 +162,27 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
+                  installArgs <- BSP.installArgsParser.constructRaw(args) match {
+                    case mainargs.Result.Success(args0) => Some(args0)
+                    case other =>
+                      pprint.err.log(other)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Will remove that
created_at: 2025-04-17T18:08:48Z
author_association: COLLABORATOR
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lihaoyi
body: This nested code to try and detect the `install` command looks very convoluted. Would just checking `config.leftoverArgs.value.headOption == Some("mill.bsp.BSP/install")` be enough?
created_at: 2025-04-18T14:33:49Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: I did that to parse any `--jobs` argument passed to it (whose value ends up being passed to the `./mill --bsp` command). I don't rely on it, but I don't know for other people out there.
created_at: 2025-04-18T14:43:34Z
author_association: COLLABORATOR
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lihaoyi
body: Seems like IntelliJ doesn't pass the flag resulting in the default of `--jobs 1` https://github.com/search?q=repo%3AJetBrains%2Fintellij-scala+%22mill.bsp.BSP%22&type=code, while Metals passes `--jobs 0` https://github.com/search?q=repo%3Ascalameta%2Fmetals%20%22mill.bsp.BSP%22&type=code

I think for now let's just ignore the `--jobs` config here to simplify our code, and either hardcode 1 or hardcode 0 (probably doesn't really matter). 
created_at: 2025-04-18T14:59:07Z
author_association: MEMBER
diff_hunk: @@ -85,4 +73,18 @@ object BSP extends ExternalModule with CoursierModule {
     )
   }
 
+  private[mill] case class InstallArgs(jobs: Int = 1)
path: bsp/src/mill/bsp/BSP.scala
user: login: lefou
body: I'd expect that you use this class in the `install` command?
created_at: 2025-04-18T16:15:17Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lefou
body: What is the situation in which this is needed at all? 
created_at: 2025-04-18T16:16:59Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lefou
body: I guess this is the special handling needed to make `install` work even when the build does not work. If we keep it here, we should add some comment sufficiently explaining the situation.

I don't like this, tbh. If we're going to intercept the `mill.bsp.BSP/install` and just fake it, we might as well remove the original `Task.Command`. Ideally, we can use the same code in both situations, to avoid some config args to be accepted on one situations but not in the other. Of course, I'd like to keep the proper `--jobs` parsing, since this is currently the only way for a user to explicitly configure how the BSP server should run.
created_at: 2025-04-18T16:26:19Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lihaoyi
body: Keep `--jobs` parsing is fine by me, as is removing the original `Task.Command`
created_at: 2025-04-18T17:06:11Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: lihaoyi
body: The goal is in general moving the BSP logic out of `ExternalModule`s into the `mill-runner`, so really we should remove the `extends ExternalModule` from bsp/src/mill/bsp/BSP.scala and just make it normal helper methods. `mill-bsp` probably shouldn't even have `ExternalModule` on its compile classpath
created_at: 2025-04-18T17:12:07Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,25 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeArgsOpt =
+              Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                val expectedSelector = Seq((
+                  Some(Segments.labels("mill", "bsp", "BSP")),
+                  Some(Segments.labels("install"))
+                ))
+
+                for {
+                  parsed <- ParseArgs(config.leftoverArgs.value, SelectMode.Separated).toOption
+                  case Seq((`expectedSelector`, args)) <- Option(parsed)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Just removed the current `mill.bsp.BSP/install` command, and simplified the handling of `--jobs` in the manual handling of `mill.bsp.BSP/install` in Mill main
created_at: 2025-04-18T18:24:48Z
author_association: COLLABORATOR
diff_hunk: @@ -85,4 +73,18 @@ object BSP extends ExternalModule with CoursierModule {
     )
   }
 
+  private[mill] case class InstallArgs(jobs: Int = 1)
path: bsp/src/mill/bsp/BSP.scala
user: login: alexarchambault
body: Just removed the original `BSP/install` command altogether (and this class too actually)
created_at: 2025-04-18T18:25:15Z
author_association: COLLABORATOR
diff_hunk: @@ -491,4 +517,9 @@ object MillMain {
     for (k <- systemPropertiesToUnset) System.clearProperty(k)
     for ((k, v) <- desiredProps) System.setProperty(k, v)
   }
+
+  private val bspInstallAliases = Seq(
+    "mill.bsp.BSP/install",
+    "mill.bsp/install"
path: runner/src/mill/runner/MillMain.scala
user: login: lefou
body: Under the assumption, that we also add a dedicated cli option, I would only keep the first alias. The second one was added after any stable release.
created_at: 2025-04-24T20:17:53Z
author_association: MEMBER
diff_hunk: @@ -491,4 +517,9 @@ object MillMain {
     for (k <- systemPropertiesToUnset) System.clearProperty(k)
     for ((k, v) <- desiredProps) System.setProperty(k, v)
   }
+
+  private val bspInstallAliases = Seq(
+    "mill.bsp.BSP/install",
+    "mill.bsp/install"
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Should be fixed
created_at: 2025-04-25T10:17:16Z
author_association: COLLABORATOR
diff_hunk: @@ -163,6 +164,33 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeJobCountOpt = {
+              val viaEmulatedExternalCommand = Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                config.leftoverArgs.value.tail match {
+                  case Seq() => BSP.defaultJobCount
+                  case Seq("--jobs", value) =>
+                    val asIntOpt = value.toIntOption
+                    asIntOpt.getOrElse {
+                      streams.err.println(
+                        s"Warning: ignoring --jobs value passed to ${config.leftoverArgs.value.head}"
+                      )
+                      BSP.defaultJobCount
+                    }
+                  case other =>
+                    streams.err.println(
+                      s"Warning: ignoring leftover arguments passed to ${config.leftoverArgs.value.head}"
+                    )
+                    BSP.defaultJobCount
+                }
+              }
+
+              viaEmulatedExternalCommand.orElse {
+                Option.when(config.bspInstall.value)(BSP.defaultJobCount)
path: runner/src/mill/runner/MillMain.scala
user: login: lefou
body: I think we should use the value of `config.threadCount` if defined, and only use `BSP.defaultJobCount` as a fallback.
created_at: 2025-04-25T12:34:11Z
author_association: MEMBER
diff_hunk: @@ -163,6 +164,33 @@ object MillMain {
 
             // special BSP mode, in which we spawn a server and register the current evaluator when-ever we start to eval a dedicated command
             val bspMode = config.bsp.value && config.leftoverArgs.value.isEmpty
+            val bspInstallModeJobCountOpt = {
+              val viaEmulatedExternalCommand = Option.when(
+                !config.bsp.value &&
+                  config.leftoverArgs.value.headOption.contains("mill.bsp.BSP/install")
+              ) {
+                config.leftoverArgs.value.tail match {
+                  case Seq() => BSP.defaultJobCount
+                  case Seq("--jobs", value) =>
+                    val asIntOpt = value.toIntOption
+                    asIntOpt.getOrElse {
+                      streams.err.println(
+                        s"Warning: ignoring --jobs value passed to ${config.leftoverArgs.value.head}"
+                      )
+                      BSP.defaultJobCount
+                    }
+                  case other =>
+                    streams.err.println(
+                      s"Warning: ignoring leftover arguments passed to ${config.leftoverArgs.value.head}"
+                    )
+                    BSP.defaultJobCount
+                }
+              }
+
+              viaEmulatedExternalCommand.orElse {
+                Option.when(config.bspInstall.value)(BSP.defaultJobCount)
path: runner/src/mill/runner/MillMain.scala
user: login: alexarchambault
body: Should be fixed
created_at: 2025-04-25T13:15:07Z
author_association: COLLABORATOR

PR comments:
user: login: lihaoyi
created_at: 2025-04-07T09:18:39Z
body: @alexarchambault for these improvements maybe you can start off targeting 0.12.x? I expected the BSP setup for 0.13.x will look very different once I'm done with the current refactorings, so we should hold off on feature work on 0.13.x until the dust has settled since the approach we take may change substantially
author_association: MEMBER
user: login: alexarchambault
created_at: 2025-04-07T09:25:46Z
body: @lihaoyi I had a few changes in the pipe for BSP too… At first, it seems they're pretty orthogonal to https://github.com/com-lihaoyi/mill/pull/4879. I think I'm more interested in having those changes land long-term in `main` rather than just `0.12.x`.
author_association: COLLABORATOR
user: login: alexarchambault
created_at: 2025-04-07T09:27:03Z
body: Another change is https://github.com/alexarchambault/mill/commit/9dc3c8cf07dc2b028d55e1bbd71006dd75da1749, that I already rebased locally on the latest `main`. It adds the ability to broadcast Mill build changes to BSP clients, and adds some new BSP integration tests, which could help refactor BSP support.
author_association: COLLABORATOR
user: login: alexarchambault
created_at: 2025-04-07T09:31:26Z
body: The changes in the PR here are in line with https://github.com/com-lihaoyi/mill/pull/4879, it seems. They allow to fetch the BSP worker class path with coursier right when we need it, rather than upfront before users even attempt to start the BSP server.
author_association: COLLABORATOR
user: login: lihaoyi
created_at: 2025-04-07T09:47:25Z
body: Ok targeting `main` works too, but regardless let's land classpath isolation in #4879 first before rebasing these smaller PRs on top of them. These smaller PRs look a lot more surgical and should be easier to rebase than the large cross-cutting refactor
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-04-08T12:58:00Z
body: @alexarchambault classloader isolation has landed, you can go ahead with your bsp improvements
author_association: MEMBER
user: login: lefou
created_at: 2025-04-08T16:04:43Z
body: So, if you only load the dependencies when the IDE is about to start the server, how do you ensure to respond to the BSP client in a timely manner? 

In our first version of the BSP server, we had a similiar approach (but via Mill task dependencies, not direct coursier) we ran into timeouts. IIUC, we can only speak the BSP protocol after we assembled the full classpath, so our BSP server is going dark from the clients POV.
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-04-15T19:16:16Z
body: I think this should fix https://github.com/com-lihaoyi/mill/issues/4868 and https://github.com/com-lihaoyi/mill/issues/1560 once this lands, since the rest of the BSP code path should already be robust against failures compiling `build.mill` and will do a best-effort configuration e.g. only configuring the meta-build, which should give editor assistance to `build.mill`

@alexarchambault what's the status on this PR? Do you think we can merge it?
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-04-15T22:18:00Z
body: We need to be careful to not break existing IDE support that may involve running `mill.bsp.BSP/install`. Perhaps we can detect that query string and special-case it as a no-op that doesn't run `MillBuilBootstrap` or run any tasks? That way existing IDEs that run it can continue to work, but since it's a no-op it won't crash on a broken build.mill, and eventually when IDEs remove the call to `mill.bsp.BSP/install` things can continue to work seamlessly
author_association: MEMBER
user: login: alexarchambault
created_at: 2025-04-15T22:27:16Z
body: > @alexarchambault what's the status on this PR? Do you think we can merge it?

Checking what the CI says, but it could be merged, yes. I have [more BSP changes to come](https://github.com/alexarchambault/mill/tree/bsp-stuff), I should open a PR for those tomorrow or the next day.
author_association: COLLABORATOR
user: login: alexarchambault
created_at: 2025-04-15T22:45:15Z
body: @lihaoyi Feel free to undraft and merge if [the CI](https://github.com/alexarchambault/mill/actions/runs/14480356058/job/40616237067) is green-enough for you.
author_association: COLLABORATOR
user: login: alexarchambault
created_at: 2025-04-15T22:45:24Z
body: > We need to be careful to not break existing IDE support that may involve running `mill.bsp.BSP/install`. Perhaps we can detect that query string and special-case it as a no-op that doesn't run `MillBuilBootstrap` or run any tasks? That way existing IDEs that run it can continue to work, but since it's a no-op it won't crash on a broken build.mill, and eventually when IDEs remove the call to `mill.bsp.BSP/install` things can continue to work seamlessly

This PR doesn't do this currently. I could add it either tomorrow or the next day. Feel free to add it on your own in the mean time if you want.
author_association: COLLABORATOR
user: login: alexarchambault
created_at: 2025-04-15T23:01:10Z
body: > So, if you only load the dependencies when the IDE is about to start the server, how do you ensure to respond to the BSP client in a timely manner?
> 
> In our first version of the BSP server, we had a similiar approach (but via Mill task dependencies, not direct coursier) we ran into timeouts. IIUC, we can only speak the BSP protocol after we assembled the full classpath, so our BSP server is going dark from the clients POV.

That might be a problem, yes. This runs fast enough on my machine on an empty cache (< 10 s), but that could take longer on a slower connection. Maybe the recent class path isolation work could allow to embed BSP directly in Mill, so that we don't need to handle it via a worker, and nothing new needs to be downloaded?
author_association: COLLABORATOR
user: login: lihaoyi
created_at: 2025-04-15T23:05:31Z
body: Sounds good, I'll let you do it then
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-04-15T23:11:39Z
body: Yes embedding the BSP code seems reasonable if the download time is a worry. Most people will be using and IDE and will need to download it anyway, so we don't save anything by making it download lazily on-demand, and now with classpath isolation it wouldnt affect user code
author_association: MEMBER
user: login: lefou
created_at: 2025-04-16T10:59:27Z
body: > Yes embedding the BSP code seems reasonable if the download time is a worry. Most people will be using and IDE and will need to download it anyway, so we don't save anything by making it download lazily on-demand, and now with classpath isolation it wouldnt affect user code

As long as we make sure, we don't mix the BSP classpath with anything a use might want to load into their own builds (without isolation classloaders) this should work. 

I think we should keep it separated though, but trigger the download (when not `--offline`) while building the project. This should be a small slowdown only on the first build, but it ensures the jars are in place.
author_association: MEMBER
user: login: lefou
created_at: 2025-04-18T18:29:45Z
body: I think we should provide a shorter way to install the BSP connection file, e.g. `--bsp-install`, in addition to the compatibility `mill.bsp.BSP/install`. We might want to revive the `mill.bsp.BSP` module at a later point. Not for installation, but maybe for ad-hoc querying/debugging of the BSP server. E.g. just issuing a BSP request on the CLI instead of the need to work over HTTP.

If we add a `--bsp-install`, we could just use the value of the normal `--jobs` option.
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-04-19T02:19:12Z
body: @alexarchambault looks like some of the tests may need to be updated I think
author_association: MEMBER