Repository: com-lihaoyi/mill
Pull request #4822: Find main classes with ClassGraph
Author: login: lefou
Created: 2025-03-31T09:31:13Z | Merged: 2025-03-31T14:03:28Z

Since the scalap approach doesn't work for jlink/jpackage modules and scalap seems discontinued in Scala 3 anyways,
this is an attempt to use ClassGraph (https://github.com/classgraph/classgraph) to find main classes.

Added tests for allLocalMainClasses (in the default package).

Removed all alternative implementations to find main classes.



Diff:
diff --git a/bsp/worker/src/mill/bsp/worker/MillJvmBuildServer.scala b/bsp/worker/src/mill/bsp/worker/MillJvmBuildServer.scala
index 2e2a58b20122..aebdd6458285 100644
--- a/bsp/worker/src/mill/bsp/worker/MillJvmBuildServer.scala
+++ b/bsp/worker/src/mill/bsp/worker/MillJvmBuildServer.scala
@@ -15,11 +15,12 @@ import ch.epfl.scala.bsp4j.{
 }
 import mill.Task
 import mill.bsp.worker.Utils.sanitizeUri
-import mill.scalalib.api.CompilationResult
-import mill.scalalib.{JavaModule, TestModule}
-
+import mill.scalalib.{JavaModule, RunModule, TestModule}
 import java.util.concurrent.CompletableFuture
-import scala.jdk.CollectionConverters._
+
+import scala.jdk.CollectionConverters.*
+
+import mill.api.PathRef
 
 private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer =>
 
@@ -50,10 +51,10 @@ private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer
       name,
       targetIds = _ => targetIds,
       tasks = {
-        case m: JavaModule =>
+        case m: RunModule =>
           val moduleSpecificTask = m match {
-            case m: TestModule => m.getTestEnvironmentVars()
-            case _ => m.compile
+            case m: (TestModule & JavaModule) => m.getTestEnvironmentVars()
+            case _ => m.allLocalMainClasses
           }
           Task.Anon {
             (
@@ -62,7 +63,6 @@ private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer
               m.forkWorkingDir(),
               m.forkEnv(),
               m.mainClass(),
-              m.zincWorker().worker(),
               moduleSpecificTask()
             )
           }
@@ -79,7 +79,6 @@ private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer
               forkWorkingDir,
               forkEnv,
               _,
-              _,
               testEnvVars: (String, String, String, Seq[String])
             )
           ) =>
@@ -98,15 +97,14 @@ private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer
             ev,
             state,
             id,
-            _: JavaModule,
+            _: RunModule,
             (
               runClasspath,
               forkArgs,
               forkWorkingDir,
               forkEnv,
               mainClass,
-              zincWorker,
-              compile: CompilationResult
+              localMainClasses: Seq[String]
             )
           ) =>
         val classpath = runClasspath.map(_.path).map(sanitizeUri)
@@ -118,7 +116,7 @@ private trait MillJvmBuildServer extends JvmBuildServer { this: MillBuildServer
           forkEnv.asJava
         )
 
-        val classes = mainClass.toList ++ zincWorker.discoverMainClasses(compile)
+        val classes = mainClass.toList ++ localMainClasses
         item.setMainClasses(classes.map(new JvmMainClass(_, Nil.asJava)).asJava)
         item
       case _ => ???
diff --git a/bsp/worker/src/mill/bsp/worker/MillScalaBuildServer.scala b/bsp/worker/src/mill/bsp/worker/MillScalaBuildServer.scala
index 56a4a96ffe3f..782099a6990e 100644
--- a/bsp/worker/src/mill/bsp/worker/MillScalaBuildServer.scala
+++ b/bsp/worker/src/mill/bsp/worker/MillScalaBuildServer.scala
@@ -85,12 +85,12 @@ private trait MillScalaBuildServer extends ScalaBuildServer { this: MillBuildSer
       hint = "buildTarget/scalaMainClasses",
       targetIds = _ => p.getTargets.asScala.toSeq,
       tasks = { case m: JavaModule =>
-        Task.Anon((m.zincWorker().worker(), m.compile(), m.forkArgs(), m.forkEnv()))
+        Task.Anon((m.allLocalMainClasses(), m.compile(), m.forkArgs(), m.forkEnv()))
       }
     ) {
-      case (ev, state, id, m: JavaModule, (worker, compile, forkArgs, forkEnv)) =>
+      case (ev, state, id, m: JavaModule, (classes, compile, forkArgs, forkEnv)) =>
         // We find all main classes, although we could also find only the configured one
-        val mainClasses = worker.discoverMainClasses(compile)
+        val mainClasses = classes
         // val mainMain = m.mainClass().orElse(if(mainClasses.size == 1) mainClasses.headOption else None)
         val items = mainClasses.map { mc =>
           val scalaMc = new ScalaMainClass(mc, Seq().asJava, forkArgs.asJava)
diff --git a/build.mill b/build.mill
index 64b8f5e0ce97..7eb9593a45d8 100644
--- a/build.mill
+++ b/build.mill
@@ -127,6 +127,7 @@ object Deps {
   val asmTree = ivy"org.ow2.asm:asm-tree:9.7.1"
   val bloopConfig = ivy"ch.epfl.scala::bloop-config:1.5.5".withDottyCompat(scalaVersion)
 
+  val classgraph = ivy"io.github.classgraph:classgraph:4.8.179"
   val coursierVersion = "2.1.25-M4"
   val coursier = ivy"io.get-coursier::coursier:$coursierVersion".withDottyCompat(scalaVersion)
   val coursierInterface = ivy"io.get-coursier:interface:1.0.29-M1"
@@ -175,7 +176,6 @@ object Deps {
     else ivy"org.scala-lang:scala-compiler:${scalaVersion}"
   }
   val scalafmtDynamic = ivy"org.scalameta::scalafmt-dynamic:3.8.5".withDottyCompat(scalaVersion)
-  def scalap(scalaVersion: String) = ivy"org.scala-lang:scalap:${scalaVersion}"
   def scalaReflect(scalaVersion: String) =
     if (ZincWorkerUtil.isScala3(scalaVersion))
       ivy"org.scala-lang:scala-reflect:${Deps.scala2Version}"
diff --git a/kotlinlib/src/mill/kotlinlib/KotlinModule.scala b/kotlinlib/src/mill/kotlinlib/KotlinModule.scala
index e26c388a4b82..c38fc7ddb7ad 100644
--- a/kotlinlib/src/mill/kotlinlib/KotlinModule.scala
+++ b/kotlinlib/src/mill/kotlinlib/KotlinModule.scala
@@ -47,10 +47,6 @@ trait KotlinModule extends JavaModule { outer =>
    */
   def kotlinVersion: T[String]
 
-  def allLocalMainClasses0 = Task {
-    zincWorker().worker().discoverMainClasses(localRunClasspath().map(_.path))
-  }
-
   /**
    * The dependencies of this module.
    * Defaults to add the kotlin-stdlib dependency matching the [[kotlinVersion]].
diff --git a/scalalib/api/src/mill/scalalib/api/ZincWorkerApi.scala b/scalalib/api/src/mill/scalalib/api/ZincWorkerApi.scala
index 48680376080e..d0b1004a9e32 100644
--- a/scalalib/api/src/mill/scalalib/api/ZincWorkerApi.scala
+++ b/scalalib/api/src/mill/scalalib/api/ZincWorkerApi.scala
@@ -35,11 +35,6 @@ trait ZincWorkerApi {
       auxiliaryClassFileExtensions: Seq[String]
   )(implicit ctx: ZincWorkerApi.Ctx): mill.api.Result[CompilationResult]
 
-  /**
-   * Find main classes by inspecting the Zinc compilation analysis file.
-   */
-  def discoverMainClasses(compilationResult: CompilationResult): Seq[String]
-
   def docJar(
       scalaVersion: String,
       scalaOrganization: String,
@@ -48,8 +43,4 @@ trait ZincWorkerApi {
       args: Seq[String]
   )(implicit ctx: ZincWorkerApi.Ctx): Boolean
 
-  /**
-   * Discover main classes by inspecting the classpath.
-   */
-  def discoverMainClasses(classpath: Seq[os.Path]): Seq[String]
 }
diff --git a/scalalib/api/src/mill/scalalib/classgraph/ClassgraphWorker.scala b/scalalib/api/src/mill/scalalib/classgraph/ClassgraphWorker.scala
new file mode 100644
index 000000000000..27fa71fc5c18
--- /dev/null
+++ b/scalalib/api/src/mill/scalalib/classgraph/ClassgraphWorker.scala
@@ -0,0 +1,7 @@
+package mill.scalalib.classgraph
+
+import mill.api.Ctx
+
+trait ClassgraphWorker {
+  def discoverMainClasses(classpath: Seq[os.Path])(implicit ctx: Ctx): Seq[String]
+}
diff --git a/scalalib/classgraph-worker/src/mill/scalalib/classgraph/impl/ClassgraphWorkerImpl.scala b/scalalib/classgraph-worker/src/mill/scalalib/classgraph/impl/ClassgraphWorkerImpl.scala
new file mode 100644
index 000000000000..271d46a1ccf6
--- /dev/null
+++ b/scalalib/classgraph-worker/src/mill/scalalib/classgraph/impl/ClassgraphWorkerImpl.scala
@@ -0,0 +1,44 @@
+package mill.scalalib.classgraph.impl
+
+import java.io.File
+
+import scala.jdk.CollectionConverters._
+import scala.util.Using
+
+import io.github.classgraph.ClassGraph
+import mill.api.Ctx
+import mill.scalalib.classgraph.ClassgraphWorker
+
+class ClassgraphWorkerImpl() extends ClassgraphWorker {
+
+  def discoverMainClasses(classpath: Seq[os.Path])(implicit ctx: Ctx): Seq[String] = {
+
+    val cp = classpath.map(_.toNIO.toString()).mkString(File.pathSeparator)
+    ctx.log.debug(s"Scanning for mainclasses: ${cp}")
+
+    val mainClasses = Using.resource(
+      new ClassGraph()
+        .overrideClasspath(cp)
+        .enableMethodInfo()
+        .scan()
+    ) { scan =>
+      scan
+        .getAllClasses()
+        .filter { classInfo =>
+          val mainMethods = classInfo.getMethodInfo().filter { m =>
+            m.getName() == "main" && m.isPublic() && m.isStatic() && {
+              val ps = m.getParameterInfo()
+              ps.length == 1 &&
+              ps(0).getTypeSignatureOrTypeDescriptor().toString() == "java.lang.String[]"
+            }
+          }
+          !mainMethods.isEmpty()
+        }
+        .getNames()
+    }
+
+    ctx.log.debug(s"Found main classes: ${mainClasses}")
+    mainClasses.asScala.toList
+  }
+
+}
diff --git a/scalalib/package.mill b/scalalib/package.mill
index 7d04d040329b..2ef0c931ba73 100644
--- a/scalalib/package.mill
+++ b/scalalib/package.mill
@@ -29,7 +29,8 @@ object `package` extends RootModule with build.MillStableScalaModule {
     }
   }
   def testIvyDeps = super.testIvyDeps() ++ Agg(build.Deps.TestDeps.scalaCheck)
-  def testTransitiveDeps = super.testTransitiveDeps() ++ Seq(worker.testDep())
+  def testTransitiveDeps =
+    super.testTransitiveDeps() ++ Seq(worker.testDep(), `classgraph-worker`.testDep())
   def testForkEnv = {
     val locale = if (Properties.isMac) "en_US.UTF-8" else "C.utf8"
     super.testForkEnv() ++ Map("LC_ALL" -> locale)
@@ -93,11 +94,16 @@ object `package` extends RootModule with build.MillStableScalaModule {
 
   object worker extends build.MillPublishScalaModule with BuildInfo {
     def moduleDeps = Seq(api, build.main.util)
-    def ivyDeps = Agg(build.Deps.zinc, build.Deps.log4j2Core, build.Deps.scalap(scalapVersion()))
+    def ivyDeps = Agg(build.Deps.zinc, build.Deps.log4j2Core)
     def buildInfoPackageName = "mill.scalalib.worker"
     def buildInfoObjectName = "Versions"
     def buildInfoMembers = Seq(
       BuildInfo.Value("zinc", build.Deps.zinc.version, "Version of Zinc.")
     )
   }
+
+  object `classgraph-worker` extends build.MillPublishScalaModule {
+    def moduleDeps = Seq(api, build.main.util)
+    def ivyDeps = Agg(build.Deps.classgraph)
+  }
 }
diff --git a/scalalib/src/mill/scalalib/JavaModule.scala b/scalalib/src/mill/scalalib/JavaModule.scala
index 0ecf89ce1df3..5e987bc93a2f 100644
--- a/scalalib/src/mill/scalalib/JavaModule.scala
+++ b/scalalib/src/mill/scalalib/JavaModule.scala
@@ -99,8 +99,6 @@ trait JavaModule
     Artifact.fromDepJava(_: Dep)
   }
 
-  def allLocalMainClasses0 = Task { zincWorker().worker().discoverMainClasses(compile()) }
-
   /**
    * Mandatory ivy dependencies that are typically always required and shouldn't be removed by
    * overriding [[ivyDeps]], e.g. the scala-library in the [[ScalaModule]].
diff --git a/scalalib/src/mill/scalalib/RunModule.scala b/scalalib/src/mill/scalalib/RunModule.scala
index b6bf3276e6f0..c669e0f28935 100644
--- a/scalalib/src/mill/scalalib/RunModule.scala
+++ b/scalalib/src/mill/scalalib/RunModule.scala
@@ -1,19 +1,23 @@
 package mill.scalalib
 
 import java.lang.reflect.Modifier
+
 import mainargs.arg
 import mill.api.JsonFormatters.pathReadWrite
 import mill.api.{Ctx, PathRef, Result}
 import mill.constants.ServerFiles
-import mill.define.{Command, Task}
+import mill.define.{Command, ModuleRef, Task}
 import mill.util.Jvm
 import mill.{Args, T}
 import os.{Path, ProcessOutput}
-
 import scala.util.control.NonFatal
 
+import mill.scalalib.classgraph.ClassgraphWorkerModule
+
 trait RunModule extends WithZincWorker {
 
+  val classgraphWorkerModule: ModuleRef[ClassgraphWorkerModule] = ModuleRef(ClassgraphWorkerModule)
+
   /**
    * Any command-line parameters you want to pass to the forked JVM.
    */
@@ -46,27 +50,10 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
-    }
+    classgraphWorkerModule().classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
   }
 
   def finalMainClassOpt: T[Either[String, String]] = Task {
@@ -78,8 +65,8 @@ trait RunModule extends WithZincWorker {
           case Seq(main) => Right(main)
           case mains =>
             Left(
-              s"Multiple main classes found (${mains.mkString(",")}) " +
-                "please explicitly specify which one to use by overriding `mainClass` " +
+              s"Multiple main classes found (${mains.mkString(",")}). " +
+                "Please explicitly specify which one to use by overriding `mainClass` " +
                 "or using `runMain <main-class> <...args>` instead of `run`"
             )
         }
@@ -340,4 +327,5 @@ object RunModule {
       }
     }
   }
+
 }
diff --git a/scalalib/src/mill/scalalib/classgraph/ClassgraphWorkerModule.scala b/scalalib/src/mill/scalalib/classgraph/ClassgraphWorkerModule.scala
new file mode 100644
index 000000000000..71d9deebced7
--- /dev/null
+++ b/scalalib/src/mill/scalalib/classgraph/ClassgraphWorkerModule.scala
@@ -0,0 +1,40 @@
+package mill.scalalib.classgraph
+
+import mill.{T, Task}
+import mill.api.{Ctx, PathRef}
+import mill.define.{Discover, ExternalModule, Worker}
+import mill.scalalib.{CoursierModule, Dep}
+
+trait ClassgraphWorkerModule extends CoursierModule {
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
+    new ClassgraphWorker with AutoCloseable {
+      private val classLoader = mill.util.Jvm.createClassLoader(
+        classPath = classgraphWorkerClasspath().map(_.path),
+        parent = getClass().getClassLoader()
+      )
+
+      private val worker = classLoader
+        .loadClass("mill.scalalib.classgraph.impl.ClassgraphWorkerImpl")
+        .getConstructor().newInstance().asInstanceOf[ClassgraphWorker]
+
+      override def discoverMainClasses(classpath: Seq[os.Path])(implicit ctx: Ctx): Seq[String] =
+        worker.discoverMainClasses(classpath)
+
+      override def close(): Unit = {
+        classLoader.close()
+      }
+    }
+  }
+
+}
+
+object ClassgraphWorkerModule extends ExternalModule with ClassgraphWorkerModule {
+  override protected def millDiscover: Discover = Discover[this.type]
+}
diff --git a/scalalib/test/src/mill/scalalib/ScalaRunTests.scala b/scalalib/test/src/mill/scalalib/ScalaRunTests.scala
index 004b7bd46434..2eb39f785cdf 100644
--- a/scalalib/test/src/mill/scalalib/ScalaRunTests.scala
+++ b/scalalib/test/src/mill/scalalib/ScalaRunTests.scala
@@ -47,10 +47,16 @@ object ScalaRunTests extends TestSuite {
         )
       }
       test("runCross") {
-        def cross(eval: UnitTester, v: String, expectedOut: String): Unit = {
+        def cross(eval: UnitTester, v: String, expectedOut: String): String = {
 
           val runResult = eval.outPath / "hello-mill"
 
+          val Right(classes) =
+            eval.apply(HelloWorldTests.CrossHelloWorld.core(v).allLocalMainClasses): @unchecked
+          val found = classes.value
+          val expected = Seq("Main", "Shim")
+          assert(found == expected)
+
           val Right(result) = eval.apply(
             HelloWorldTests.CrossHelloWorld.core(v).runMain("Shim", runResult.toString)
           ): @unchecked
@@ -61,6 +67,7 @@ object ScalaRunTests extends TestSuite {
             os.exists(runResult),
             os.read(runResult) == expectedOut
           )
+          expectedOut
         }
 
         test("v2123") - UnitTester(HelloWorldTests.CrossHelloWorld, resourcePath).scoped { eval =>
@@ -119,6 +126,16 @@ object ScalaRunTests extends TestSuite {
           eval.apply(HelloWorldWithoutMain.core.run()): @unchecked
       }
 
+      test("allLocalMainClasses") - UnitTester(HelloWorldWithoutMain, resourcePath).scoped {
+        eval =>
+          val Right(result) = eval.apply(HelloWorldWithoutMain.core.allLocalMainClasses): @unchecked
+
+          val found = result.value
+          val expected = Seq("Main")
+          assert(found == expected)
+          found
+      }
+
       test("runDiscoverMainClass") - UnitTester(HelloWorldWithoutMain, resourcePath).scoped {
         eval =>
           // Make sure even if there isn't a main class defined explicitly, it gets
diff --git a/scalalib/worker/src/mill/scalalib/worker/DiscoverMainClassesMain.scala b/scalalib/worker/src/mill/scalalib/worker/DiscoverMainClassesMain.scala
deleted file mode 100644
index a87bad280d6b..000000000000
--- a/scalalib/worker/src/mill/scalalib/worker/DiscoverMainClassesMain.scala
+++ /dev/null
@@ -1,91 +0,0 @@
-package mill.scalalib.worker
-
-import java.io.{File, PrintWriter}
-import mill.api.{internal, DummyOutputStream}
-import scala.annotation.tailrec
-import scala.tools.nsc.{CloseableRegistry, Settings}
-import scala.tools.nsc.classpath.{AggregateClassPath, ClassPathFactory}
-import scala.tools.scalap.{ByteArrayReader, Classfile, JavaWriter}
-import scala.util.Using
-
-@internal object DiscoverMainClassesMain {
-  // copied from ModuleUtils
-  private def recursive[T <: String](start: T, deps: T => Seq[T]): Seq[T] = {
-
-    @tailrec def rec(
-        seenModules: List[T],
-        toAnalyze: List[(List[T], List[T])]
-    ): List[T] = {
-      toAnalyze match {
-        case Nil => seenModules
-        case traces :: rest =>
-          traces match {
-            case (_, Nil) => rec(seenModules, rest)
-            case (trace, cand :: remaining) =>
-              if (trace.contains(cand)) {
-                // cycle!
-                val rendered =
-                  (cand :: (cand :: trace.takeWhile(_ != cand)).reverse).mkString(" -> ")
-                val msg = s"cycle detected: ${rendered}"
-                println(msg)
-                throw sys.error(msg)
-              }
-              rec(
-                if (seenModules.contains(cand)) seenModules
-                else { seenModules ++ Seq(cand) },
-                toAnalyze = ((cand :: trace, deps(cand).toList)) :: (trace, remaining) :: rest
-              )
-          }
-      }
-    }
-
-    rec(
-      seenModules = List(),
-      toAnalyze = List((List(start), deps(start).toList))
-    ).reverse
-  }
-
-  def main(args: Array[String]): Unit = {
-    val classpath = args(0).split(",").map(os.Path(_)).toSeq
-    apply(classpath).foreach(println)
-  }
-
-  def normalizeClassFilePath(p: os.Path): Option[os.Path] = {
-    val newPath = p.ext match {
-      case "tasty" => p / os.up / s"${p.baseName}.class"
-      case _ => p
-    }
-    Option.when(os.exists(newPath)) { newPath }
-  }
-
-  def apply(classpath: Seq[os.Path]): Seq[String] = {
-    val cp = classpath.map(_.toNIO.toString()).mkString(File.pathSeparator)
-
-    val settings = new Settings()
-    Using.resource(new CloseableRegistry) { registry =>
-      val path = AggregateClassPath(
-        new ClassPathFactory(settings, registry).classesInExpandedPath(cp)
-      )
-
-      val mainClasses = for {
-        foundPackage <- recursive("", (p: String) => path.packages(p).map(_.name))
-        classFile <- path.classes(foundPackage)
-        path0 = os.Path(classFile.file.file)
-        // In Scala 3 sometimes `.classes` returns `.tasty` files rather than
-        // `.class` files, so make sure we convert them to `.class` files
-        path <- normalizeClassFilePath(path0).toList
-        if path.ext == "class"
-        cf = new Classfile(new ByteArrayReader(os.read.bytes(path)))
-        jw = new JavaWriter(cf, new PrintWriter(DummyOutputStream))
-        method <- cf.methods
-        static = jw.isStatic(method.flags)
-        methodName = jw.getName(method.name)
-        methodType = jw.getType(method.tpe)
-        if static && methodName == "main" && methodType == "(scala.Array[java.lang.String]): scala.Unit"
-        className = jw.getClassName(cf.classname)
-      } yield className
-
-      mainClasses
-    }
-  }
-}
diff --git a/scalalib/worker/src/mill/scalalib/worker/ZincWorkerImpl.scala b/scalalib/worker/src/mill/scalalib/worker/ZincWorkerImpl.scala
index 547a1c362951..a1853ca7b7b6 100644
--- a/scalalib/worker/src/mill/scalalib/worker/ZincWorkerImpl.scala
+++ b/scalalib/worker/src/mill/scalalib/worker/ZincWorkerImpl.scala
@@ -6,7 +6,6 @@ import mill.constants.CodeGenConstants
 import mill.scalalib.api.{CompilationResult, Versions, ZincWorkerApi, ZincWorkerUtil}
 import os.Path
 import sbt.internal.inc.{
-  Analysis,
   CompileFailed,
   FreshCompilerCache,
   ManagedLoggedReporter,
@@ -385,35 +384,6 @@ class ZincWorkerImpl(
 
   }
 
-  /**
-   * Discover main classes by inspecting the classpath.
-   *
-   * This implementation uses the Scala API of `scalap` to inspect the classfiles for `public static main` methods.
-   *
-   * In contrast to [[discoverMainClasses()]], this version does not need a successful zinc compilation,
-   * which makes it independent of the actual used compiler.
-   * It should also work for JVM bytecode generated by Kotlin and other languages.
-   *
-   * This implementation is only in this "zinc"-specific module, because this module is already shared between all `JavaModule`s.
-   */
-  override def discoverMainClasses(classpath: Seq[os.Path]): Seq[String] = {
-    DiscoverMainClassesMain(classpath)
-  }
-
-  def discoverMainClasses(compilationResult: CompilationResult): Seq[String] = {
-    def toScala[A](o: Optional[A]): Option[A] = if (o.isPresent) Some(o.get) else None
-
-    toScala(fileAnalysisStore(compilationResult.analysisFile).get())
-      .map(_.getAnalysis)
-      .flatMap {
-        case analysis: Analysis =>
-          Some(analysis.infos.allInfos.values.flatMap(_.getMainClasses).toSeq.sorted)
-        case _ =>
-          None
-      }
-      .getOrElse(Seq.empty[String])
-  }
-
   override def compileJava(
       upstreamCompileOutput: Seq[CompilationResult],
       sources: Seq[os.Path],


Reviews:
user: login: lihaoyi
body: Looks fine to me, if the tests pass on the various languages and JVM versions that gives pretty good confidence 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T12:01:14Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T12:02:23Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T12:03:37Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T12:04:39Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T12:09:21Z
user: login: lihaoyi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T12:18:58Z
user: login: lefou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T13:20:23Z
user: login: lihaoyi
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T13:57:07Z

Review comments:
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lihaoyi
body: Should this go on `ZincWorkerImpl`? It's mostly shared across all `RunModule`s, and doesn't seem to have any local configuration
created_at: 2025-03-31T12:02:23Z
author_association: MEMBER
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lefou
body: It has a different classpath. No need for zinc, but loads classgraph dependency.
created_at: 2025-03-31T12:03:37Z
author_association: MEMBER
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
+    val cp = classgraphWorkerClasspath().map(_.path)
+    Task.log.debug(s"Create classloader for classpath: ${cp}")
+    val cl = mill.util.Jvm.createClassLoader(cp, getClass().getClassLoader())
+    val klass = "mill.scalalib.classgraph.impl.ClassgraphWorkerImpl"
+    Task.log.debug(s"Loading class: ${klass}")
+    val cls = cl.loadClass(klass)
+    Task.log.debug(s"Instantiating class: ${klass}")
+    val worker = cls.getConstructor().newInstance().asInstanceOf[ClassgraphWorker]
+
+    object Worker extends ClassgraphWorker with AutoCloseable {
+      override def discoverMainClasses(classpath: Seq[Path])(implicit ctx: Ctx): Seq[String] =
+        worker.discoverMainClasses(classpath)
+
+      override def close(): Unit = {
+        cl.close()
+      }
     }
+    Worker
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lihaoyi
body: This code is pretty awkward.

* I'd just make it one big `new ClassgraphWorker extends AutoCloseable{}` and put everything inside.
* The logging can probably be removed; anyone who wants to debug stuff can add it, but it really makes the code much harder to read
* The naming needs to be improved. `klass` vs `cls` is super confusing
created_at: 2025-03-31T12:04:39Z
author_association: MEMBER
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
+    val cp = classgraphWorkerClasspath().map(_.path)
+    Task.log.debug(s"Create classloader for classpath: ${cp}")
+    val cl = mill.util.Jvm.createClassLoader(cp, getClass().getClassLoader())
+    val klass = "mill.scalalib.classgraph.impl.ClassgraphWorkerImpl"
+    Task.log.debug(s"Loading class: ${klass}")
+    val cls = cl.loadClass(klass)
+    Task.log.debug(s"Instantiating class: ${klass}")
+    val worker = cls.getConstructor().newInstance().asInstanceOf[ClassgraphWorker]
+
+    object Worker extends ClassgraphWorker with AutoCloseable {
+      override def discoverMainClasses(classpath: Seq[Path])(implicit ctx: Ctx): Seq[String] =
+        worker.discoverMainClasses(classpath)
+
+      override def close(): Unit = {
+        cl.close()
+      }
     }
+    Worker
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lefou
body: That means I need two "worker" classes. The `AutoClosable` handling obviously needs to happen outside the real `ClassgraphWorker`, but since the `ClassgraphWorker` is instantiated from the managed classloader, it can't (or shouldn't) provide the cleanup logic.

I can cleanup further...
created_at: 2025-03-31T12:09:21Z
author_association: MEMBER
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lihaoyi
body: Maybe put it on a `object ClassgraphWorker extends ExternalModule`? So it'll be resolved once globally rather than once-per-`RunModule`
created_at: 2025-03-31T12:18:58Z
author_association: MEMBER
diff_hunk: @@ -46,27 +48,37 @@ trait RunModule extends WithZincWorker {
   def mainClass: T[Option[String]] = None
 
   /**
-   * Same as [[allLocalMainClasses]], but only for modules with a custom
-   * JVM version configured
-   */
-  def allLocalMainClasses0: T[Seq[String]] = Task { Seq.empty[String] }
-
-  /**
-   * All main classes detected in this module that can serve as program entrypoints
+   * All main classes detected in this module that can serve as program entry-points.
    */
   def allLocalMainClasses: T[Seq[String]] = Task {
-    if (zincWorker().javaHome().isEmpty) allLocalMainClasses0()
-    else {
-      Jvm.callProcess(
-        mainClass = "mill.scalalib.worker.DiscoverMainClassesMain",
-        classPath = zincWorker().classpath().map(_.path).toVector,
-        mainArgs = Seq(localRunClasspath().map(_.path).mkString(",")),
-        javaHome = zincWorker().javaHome().map(_.path),
-        stdin = os.Inherit,
-        stdout = os.Pipe,
-        cwd = Task.dest
-      ).out.lines()
+    classgraphWorker().discoverMainClasses(localRunClasspath().map(_.path))
+  }
+
+  def classgraphWorkerClasspath: T[Seq[PathRef]] = T {
+    zincWorker().defaultResolver().classpath(Seq(
+      Dep.millProjectModule("mill-scalalib-classgraph-worker")
+    ))
+  }
+
+  def classgraphWorker: Worker[ClassgraphWorker] = Task.Worker {
+    val cp = classgraphWorkerClasspath().map(_.path)
+    Task.log.debug(s"Create classloader for classpath: ${cp}")
+    val cl = mill.util.Jvm.createClassLoader(cp, getClass().getClassLoader())
+    val klass = "mill.scalalib.classgraph.impl.ClassgraphWorkerImpl"
+    Task.log.debug(s"Loading class: ${klass}")
+    val cls = cl.loadClass(klass)
+    Task.log.debug(s"Instantiating class: ${klass}")
+    val worker = cls.getConstructor().newInstance().asInstanceOf[ClassgraphWorker]
+
+    object Worker extends ClassgraphWorker with AutoCloseable {
+      override def discoverMainClasses(classpath: Seq[Path])(implicit ctx: Ctx): Seq[String] =
+        worker.discoverMainClasses(classpath)
+
+      override def close(): Unit = {
+        cl.close()
+      }
     }
+    Worker
path: scalalib/src/mill/scalalib/RunModule.scala
user: login: lefou
body: fixed
created_at: 2025-03-31T13:20:23Z
author_association: MEMBER

PR comments:
user: login: lefou
created_at: 2025-03-31T14:03:53Z
body: @lihaoyi Do we want this backported to 0.12?
author_association: MEMBER
user: login: lihaoyi
created_at: 2025-03-31T14:06:21Z
body: @lefou sure let's do that, it's not a breaking change and doesn't rely on any of the other new stuff
author_association: MEMBER