diff --git a/modules/build/src/main/scala/scala/build/internal/MainClass.scala b/modules/build/src/main/scala/scala/build/internal/MainClass.scala index d89f3ccd2a..71a3fe3033 100644 --- a/modules/build/src/main/scala/scala/build/internal/MainClass.scala +++ b/modules/build/src/main/scala/scala/build/internal/MainClass.scala @@ -7,6 +7,7 @@ import java.io.{ByteArrayInputStream, InputStream} import java.nio.file.NoSuchFileException import java.util.jar.{Attributes, JarFile} +import scala.annotation.tailrec import scala.build.internal.zip.WrappedZipInputStream import scala.build.{Logger, retry} @@ -17,18 +18,22 @@ object MainClass { * Case declaration order is the JEP 512 resolution order: `main(String[])` before `main()`, and * within each signature the static form before the instance form. * - * Detection is per-class: the scan only sees methods declared in the class file it reads. A - * `main` inherited from a superclass or a Java interface is not detected, even though the JVM - * launcher resolves it through the hierarchy (the same already holds for a classic inherited - * `static main(String[])`). Scala traits are an exception: the compiler emits a mixin forwarder - * into the implementing class, so that class declares `main` itself and is detected. + * Hierarchy is resolved within the scanned classpath entry: a `main` declared on a superclass or + * Java interface in that entry is visible on concrete subclasses. Static methods are not + * inherited from interfaces. Hiding works per visibility tier: the nearest declaration of a + * parameter signature hides same-or-narrower declarations further up the superclass chain, so a + * private or non-void `main` makes that signature unusable, whereas a public `main` is never + * hidden by a narrower declaration below it. Hiding never crosses parameter signatures. + * Ancestors that live outside the scanned entry (dependency JARs, the JDK) are still unresolved. + * Scala traits remain a special case: the compiler emits a mixin forwarder into the implementing + * class, so that class declares `main` itself. */ - enum MainMethodKind(val requiresJep512: Boolean): - case StaticWithArgs extends MainMethodKind(false) - case NonPublicStaticWithArgs extends MainMethodKind(true) - case InstanceWithArgs extends MainMethodKind(true) - case StaticNoArgs extends MainMethodKind(true) - case InstanceNoArgs extends MainMethodKind(true) + enum MainMethodKind(val requiresJep512: Boolean, val isStatic: Boolean): + case StaticWithArgs extends MainMethodKind(false, true) + case NonPublicStaticWithArgs extends MainMethodKind(true, true) + case InstanceWithArgs extends MainMethodKind(true, false) + case StaticNoArgs extends MainMethodKind(true, true) + case InstanceNoArgs extends MainMethodKind(true, false) /** Whether a JVM of version `jvmVersion` can launch this main method shape. The JEP 512 shapes * need JDK 25 or newer, or JDK 21 or newer with `--enable-preview`. @@ -40,14 +45,47 @@ object MainClass { final case class MainClassCandidate(className: String, kind: MainMethodKind) - private val stringArrayDescriptor = "([Ljava/lang/String;)V" - private val noArgDescriptor = "()V" + private val stringArrayParams = "([Ljava/lang/String;)" + private val noArgParams = "()" + private val noArgDescriptor = "()V" + + /** A `main` declared by a single class, for a single parameter signature. `kindOpt` is empty when + * the declaration cannot be launched, in which case it only ever hides. + */ + private final case class MainDeclaration(isPublic: Boolean, kindOpt: Option[MainMethodKind]): + /** Only reachable for bytecode declaring several `main` methods of one parameter signature, + * which javac cannot emit; keeps the shape that would resolve first. + */ + def merge(other: MainDeclaration): MainDeclaration = + val mergedKindOpt = (kindOpt, other.kindOpt) match { + case (Some(kind), Some(otherKind)) => + MainMethodKind.values.find(k => k == kind || k == otherKind) + case _ => kindOpt.orElse(other.kindOpt) + } + MainDeclaration(isPublic || other.isPublic, mergedKindOpt) + + private final case class ClassInfo( + className: String, + superClassOpt: Option[String], + interfaces: Seq[String], + isInstantiable: Boolean, + hasNonPrivateNoArgCtor: Boolean, + declaredMains: Map[Boolean, MainDeclaration] + ) private class MainMethodChecker extends asm.ClassVisitor(asm.Opcodes.ASM9) { - private var nameOpt: Option[String] = None - private var classAccess: Int = 0 - private var hasNonPrivateNoArgCtor: Boolean = false - private var mainKinds: Set[MainMethodKind] = Set.empty + private var nameOpt: Option[String] = None + private var superClassOpt: Option[String] = None + private var interfaces: Seq[String] = Nil + private var classAccess: Int = 0 + private var hasNonPrivateNoArgCtor: Boolean = false + private var declaredMains: Map[Boolean, MainDeclaration] = Map.empty + + private def recordMain(hasArgs: Boolean, declaration: MainDeclaration): Unit = + declaredMains += hasArgs -> declaredMains.get(hasArgs).fold(declaration)(_.merge(declaration)) + + private def dotted(internalName: String): String = + internalName.replace('/', '.').replace('\\', '.') override def visit( version: Int, @@ -58,7 +96,9 @@ object MainClass { interfaces: Array[String] ): Unit = { classAccess = access - nameOpt = Some(name.replace('/', '.').replace('\\', '.')) + nameOpt = Some(dotted(name)) + superClassOpt = Option(superName).map(dotted) + this.interfaces = Option(interfaces).toSeq.flatten.map(dotted) } override def visitMethod( @@ -74,35 +114,98 @@ object MainClass { val isPublic = (access & asm.Opcodes.ACC_PUBLIC) != 0 if name == "" && descriptor == noArgDescriptor && !isPrivate then hasNonPrivateNoArgCtor = true - else if name == "main" && !isPrivate then - (isStatic, descriptor, isPublic) match { - case (true, `stringArrayDescriptor`, true) => mainKinds += StaticWithArgs - case (true, `stringArrayDescriptor`, false) => mainKinds += NonPublicStaticWithArgs - case (false, `stringArrayDescriptor`, _) => mainKinds += InstanceWithArgs - case (true, `noArgDescriptor`, _) => mainKinds += StaticNoArgs - case (false, `noArgDescriptor`, _) => mainKinds += InstanceNoArgs - case _ => () - } + else if name == "main" then + val hasArgsOpt = + if descriptor.startsWith(stringArrayParams) then Some(true) + else if descriptor.startsWith(noArgParams) then Some(false) + else None + for hasArgs <- hasArgsOpt do + val kindOpt = Option.when(!isPrivate && descriptor.endsWith(")V")) { + (hasArgs, isStatic, isPublic) match { + case (true, true, true) => StaticWithArgs + case (true, true, false) => NonPublicStaticWithArgs + case (true, false, _) => InstanceWithArgs + case (false, true, _) => StaticNoArgs + case (false, false, _) => InstanceNoArgs + } + } + recordMain(hasArgs, MainDeclaration(isPublic, kindOpt)) null } - def candidateOpt: Option[MainClassCandidate] = { - import MainMethodKind.* - val isAbstractOrInterface = (classAccess & asm.Opcodes.ACC_ABSTRACT) != 0 || - (classAccess & asm.Opcodes.ACC_INTERFACE) != 0 - if isAbstractOrInterface then None + def classInfoOpt: Option[ClassInfo] = + nameOpt.map { className => + val isAbstractOrInterface = (classAccess & asm.Opcodes.ACC_ABSTRACT) != 0 || + (classAccess & asm.Opcodes.ACC_INTERFACE) != 0 + ClassInfo( + className = className, + superClassOpt = superClassOpt, + interfaces = interfaces, + isInstantiable = !isAbstractOrInterface, + hasNonPrivateNoArgCtor = hasNonPrivateNoArgCtor, + declaredMains = declaredMains + ) + } + } + + private def candidates(classInfos: Seq[ClassInfo]): Seq[MainClassCandidate] = { + val byName = classInfos.map(info => info.className -> info).toMap + + /** The declaration of `main` that `className` sees for the given parameter signature, looking + * only at declarations visible enough for `publicOnly`. The nearest one wins and hides + * everything further up the superclass chain. + */ + def nearestDeclaration( + className: String, + hasArgs: Boolean, + publicOnly: Boolean + ): Option[MainDeclaration] = { + @tailrec + def loop(pending: Option[String], seen: Set[String]): Option[MainDeclaration] = + pending.flatMap(byName.get).filterNot(info => seen.contains(info.className)) match { + case None => None + case Some(info) => + info.declaredMains.get(hasArgs).filter(_.isPublic || !publicOnly) match { + case None => loop(info.superClassOpt, seen + info.className) + case someDeclaration => someDeclaration + } + } + loop(Some(className), Set.empty) + } + + def fromInterfaces( + className: String, + hasArgs: Boolean, + seen: Set[String] + ): Option[MainMethodKind] = + if seen.contains(className) then None else - // Instance shapes are only invocable when a non-private zero-arg constructor exists. - val invocableKinds = mainKinds.filter { - case StaticWithArgs | NonPublicStaticWithArgs | StaticNoArgs => true - case InstanceWithArgs | InstanceNoArgs => hasNonPrivateNoArgCtor + byName.get(className).flatMap { info => + val seen0 = seen + className + info.declaredMains.get(hasArgs).flatMap(_.kindOpt).filterNot(_.isStatic).orElse { + info.interfaces.view.flatMap(fromInterfaces(_, hasArgs, seen0)).headOption + .orElse(info.superClassOpt.flatMap(fromInterfaces(_, hasArgs, seen0))) + } } - MainMethodKind.values.find(invocableKinds.contains) - .flatMap(kind => nameOpt.map(MainClassCandidate(_, kind))) + + def visibleKind(className: String, hasArgs: Boolean): Option[MainMethodKind] = + // a public `main` stays reachable however narrow a declaration below it is + nearestDeclaration(className, hasArgs, publicOnly = true) + .orElse(nearestDeclaration(className, hasArgs, publicOnly = false)) + .fold(fromInterfaces(className, hasArgs, Set.empty))(_.kindOpt) + + classInfos.filter(_.isInstantiable).flatMap { info => + // constructors are not inherited, so instance shapes depend on this class's own constructor + val invocableKinds = Seq(true, false) + .flatMap(visibleKind(info.className, _)) + .filter(kind => kind.isStatic || info.hasNonPrivateNoArgCtor) + .toSet + MainMethodKind.values.find(invocableKinds.contains) + .map(MainClassCandidate(info.className, _)) } } - private def findInClass(path: os.Path, logger: Logger): Iterator[MainClassCandidate] = + private def findInClass(path: os.Path, logger: Logger): Iterator[ClassInfo] = try { val is = retry()(logger)(os.read.inputStream(path)) findInClass(is, logger) @@ -115,12 +218,12 @@ object MainClass { Iterator.empty } - private def findInClass(is: InputStream, logger: Logger): Iterator[MainClassCandidate] = + private def findInClass(is: InputStream, logger: Logger): Iterator[ClassInfo] = try retry()(logger) { val reader = new ClassReader(is) val checker = new MainMethodChecker reader.accept(checker, 0) - checker.candidateOpt.iterator + checker.classInfoOpt.iterator } catch { case e: ArrayIndexOutOfBoundsException => @@ -135,7 +238,7 @@ object MainClass { } finally is.close() - private def findInJar(path: os.Path, logger: Logger): Iterator[MainClassCandidate] = + private def findInJar(path: os.Path, logger: Logger): Iterator[ClassInfo] = try retry()(logger) { val content = os.read.bytes(path) val jarInputStream = WrappedZipInputStream.create(new ByteArrayInputStream(content)) @@ -167,8 +270,8 @@ object MainClass { case _ => None } - def find(output: os.Path, logger: Logger): Seq[MainClassCandidate] = - output match { + def find(output: os.Path, logger: Logger): Seq[MainClassCandidate] = { + val classInfos: Seq[ClassInfo] = output match { case o if os.isFile(o) && o.last.endsWith(".class") => findInClass(o, logger).toVector case o if os.isFile(o) && o.last.endsWith(".jar") => @@ -185,4 +288,6 @@ object MainClass { .toVector case _ => Vector.empty } + candidates(classInfos) + } } diff --git a/modules/build/src/test/scala/scala/build/tests/MainClassTests.scala b/modules/build/src/test/scala/scala/build/tests/MainClassTests.scala index 514a92ad39..d39df939c9 100644 --- a/modules/build/src/test/scala/scala/build/tests/MainClassTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/MainClassTests.scala @@ -46,6 +46,51 @@ class MainClassTests extends TestUtil.ScalaCliBuildSuite { private def findKinds(dir: os.Path): Seq[(String, MainMethodKind)] = MainClass.find(dir, TestLogger()).map(c => c.className -> c.kind) + /** Builds a `far.FarBase` <- `near.NearBase` <- `near.Child` hierarchy in which `NearBase` + * declares a `main` narrower than the one on `FarBase`, and passes the directory holding all + * three classes to `f`. javac rejects such a declaration, so `NearBase` and `Child` are compiled + * against a `FarBase` that declares no `main` and the real `FarBase` class file replaces the + * stub afterwards. + */ + private def withNarrowedMainHierarchy[T]( + farBaseMain: String, + nearBaseMain: String + )(f: os.Path => T): T = + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase { + | $farBaseMain + |} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (root, _, maybeFarBuild) => + val farBaseClassFile = maybeFarBuild.orThrow.successfulOpt.get.output / "far" / + "FarBase.class" + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase {} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + s"""package near; + |public abstract class NearBase extends far.FarBase { + | $nearBaseMain + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (_, _, maybeNearBuild) => + val merged = root / "merged" + os.copy(maybeNearBuild.orThrow.successfulOpt.get.output, merged, createFolders = true) + os.copy.over(farBaseClassFile, merged / "far" / "FarBase.class") + f(merged) + } + } + test("MainMethodKind.isSupportedByJvm covers the JEP 512 version matrix") { val jep512Kinds = Seq( MainMethodKind.NonPublicStaticWithArgs, @@ -184,6 +229,149 @@ class MainClassTests extends TestUtil.ScalaCliBuildSuite { } } + test("a private main of the same signature hides an inherited main further up the hierarchy") { + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase { + | static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + """package near; + |public abstract class NearBase extends far.FarBase { + | private static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin, + os.rel / "near" / "DirectChild.java" -> + """package near; + |public class DirectChild extends far.FarBase {} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow.successfulOpt.get + val found = findKinds(build.output).toMap + expect(!found.contains("near.Child")) + expect(found.get("near.DirectChild").contains(MainMethodKind.NonPublicStaticWithArgs)) + } + } + + test("a non-void main of the same signature hides an inherited main further up the hierarchy") { + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase { + | static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + """package near; + |public abstract class NearBase extends far.FarBase { + | public static int main(String[] args) { return 0; } + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin, + os.rel / "near" / "DirectChild.java" -> + """package near; + |public class DirectChild extends far.FarBase {} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow.successfulOpt.get + val found = findKinds(build.output).toMap + expect(!found.contains("near.Child")) + expect(found.get("near.DirectChild").contains(MainMethodKind.NonPublicStaticWithArgs)) + } + } + + test("a private main(String[]) does not hide an inherited no-arg main") { + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase { + | void main() {} + |} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + """package near; + |public abstract class NearBase extends far.FarBase { + | private static void main(String[] args) {} + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow.successfulOpt.get + expect( + findKinds(build.output).toMap.get("near.Child").contains(MainMethodKind.InstanceNoArgs) + ) + } + } + + test("a non-void main(String[]) does not hide an inherited no-arg main") { + TestInputs( + os.rel / "far" / "FarBase.java" -> + s"""//> using jvm $jep512MinJava + |package far; + |public abstract class FarBase { + | void main() {} + |} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + """package near; + |public abstract class NearBase extends far.FarBase { + | public static int main(String[] args) { return 0; } + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin + ).withBuild(baseOptions, buildThreads, None, buildTests = false) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow.successfulOpt.get + expect( + findKinds(build.output).toMap.get("near.Child").contains(MainMethodKind.InstanceNoArgs) + ) + } + } + + test("a public main is not hidden by a nearer private main of the same signature") { + withNarrowedMainHierarchy( + farBaseMain = "public static void main(String[] args) {}", + nearBaseMain = "private static void main(String[] args) {}" + ) { output => + expect(findKinds(output).toMap.get("near.Child").contains(MainMethodKind.StaticWithArgs)) + } + } + + test("a public main is not hidden by a nearer non-public main of the same signature") { + withNarrowedMainHierarchy( + farBaseMain = "public static void main(String[] args) {}", + nearBaseMain = "protected static void main(String[] args) {}" + ) { output => + expect(findKinds(output).toMap.get("near.Child").contains(MainMethodKind.StaticWithArgs)) + } + } + + test("a public no-arg main is not hidden by a nearer private one") { + withNarrowedMainHierarchy( + farBaseMain = "public void main() {}", + nearBaseMain = "private void main() {}" + ) { output => + expect(findKinds(output).toMap.get("near.Child").contains(MainMethodKind.InstanceNoArgs)) + } + } + test("detect instance mains inside a jar of compiled classes") { TestInputs( os.rel / "Hello.java" -> diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala index f93efb53a4..8f75334e13 100755 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -1331,6 +1331,108 @@ abstract class RunTestDefinitions } } + test(s"run a Scala class inheriting a no-arg main from a Java class on JDK $javaVersion") { + TestUtil.retryOnCi() { + TestInputs( + os.rel / "AbstractBase.java" -> + """public abstract class AbstractBase { + | public void main() { System.out.println("hello from " + getClass().getSimpleName()); } + |} + |""".stripMargin, + os.rel / "A.scala" -> "class A extends AbstractBase" + ).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", ".", extraOptions, "--jvm", javaVersion) + .call(cwd = root) + expect(res.out.trim() == "hello from A") + } + } + } + + test(s"run a Scala class inheriting a no-arg main from a Java interface on JDK $javaVersion") { + TestUtil.retryOnCi() { + TestInputs( + os.rel / "Iface.java" -> + """public interface Iface { + | default void main() { System.out.println("hello from " + getClass().getSimpleName()); } + |} + |""".stripMargin, + os.rel / "B.scala" -> "class B extends Iface" + ).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", ".", extraOptions, "--jvm", javaVersion) + .call(cwd = root) + expect(res.out.trim() == "hello from B") + } + } + } + + test(s"run a Java class inheriting a no-arg main from a Java class on JDK $javaVersion") { + TestUtil.retryOnCi() { + TestInputs( + os.rel / "AbstractBase.java" -> + """public abstract class AbstractBase { + | public void main() { System.out.println("hello from " + getClass().getSimpleName()); } + |} + |""".stripMargin, + os.rel / "JChild.java" -> "public class JChild extends AbstractBase {}" + ).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", ".", extraOptions, "--jvm", javaVersion) + .call(cwd = root) + expect(res.out.trim() == "hello from JChild") + } + } + } + + test(s"run a Java class inheriting a no-arg main from a Java interface on JDK $javaVersion") { + TestUtil.retryOnCi() { + TestInputs( + os.rel / "Iface.java" -> + """public interface Iface { + | default void main() { System.out.println("hello from " + getClass().getSimpleName()); } + |} + |""".stripMargin, + os.rel / "JImpl.java" -> "public class JImpl implements Iface {}" + ).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", ".", extraOptions, "--jvm", javaVersion) + .call(cwd = root) + expect(res.out.trim() == "hello from JImpl") + } + } + } + + test( + s"do not treat a class as a main class when a nearer private main hides an inherited one on JDK $javaVersion" + ) { + TestUtil.retryOnCi() { + TestInputs( + os.rel / "far" / "FarBase.java" -> + """package far; + |public abstract class FarBase { + | static void main(String[] args) { + | System.out.println("FarBase.main"); + | } + |} + |""".stripMargin, + os.rel / "near" / "NearBase.java" -> + """package near; + |public abstract class NearBase extends far.FarBase { + | private static void main(String[] args) { + | System.out.println("NearBase.main"); + | } + |} + |""".stripMargin, + os.rel / "near" / "Child.java" -> + """package near; + |public class Child extends NearBase {} + |""".stripMargin + ).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", ".", extraOptions, "--jvm", javaVersion) + .call(cwd = root, mergeErrIntoOut = true, check = false) + expect(res.exitCode != 0) + expect(res.out.text().contains("No main class found")) + } + } + } + test(s"run a Java compact source with an instance main on JDK $javaVersion") { TestUtil.retryOnCi() { TestInputs(