diff --git a/build.sbt b/build.sbt index d0d02703..93c0ba77 100644 --- a/build.sbt +++ b/build.sbt @@ -56,7 +56,7 @@ val root = project.in(file(".")).settings( mimaFailOnNoPrevious := false, publish / skip := true, ) -aggregateProjects(core.jvm, core.native, sbtplugin, functionalTests) +aggregateProjects(core.jvm, core.native, cli.jvm, sbtplugin, functionalTests) val munit = Def.setting("org.scalameta" %%% "munit" % "1.0.0") @@ -65,7 +65,6 @@ val core = crossProject(JVMPlatform, NativePlatform).crossType(CrossType.Pure).s crossScalaVersions ++= Seq(scala213, scala3), scalacOptions ++= compilerOptions(scalaVersion.value), libraryDependencies += munit.value % Test, - testFrameworks += new TestFramework("munit.Framework"), MimaSettings.mimaSettings, apiMappings ++= { // WORKAROUND https://github.com/scala/bug/issues/9311 @@ -77,9 +76,22 @@ val core = crossProject(JVMPlatform, NativePlatform).crossType(CrossType.Pure).s } .toMap }, - ).nativeSettings(mimaPreviousArtifacts := Set.empty) +val cli = crossProject(JVMPlatform) + .crossType(CrossType.Pure) + .settings( + name := "mima-cli", + crossScalaVersions ++= Seq(scala213, scala3), + scalacOptions ++= compilerOptions(scalaVersion.value), + libraryDependencies += munit.value % Test, + MimaSettings.mimaSettings, + // cli has no previous release, + // but also we don't care about its binary compatibility as it's meant to be used standalone + mimaPreviousArtifacts := Set.empty + ) + .dependsOn(core) + val sbtplugin = project.enablePlugins(SbtPlugin).dependsOn(core.jvm).settings( name := "sbt-mima-plugin", scalacOptions ++= compilerOptions(scalaVersion.value), @@ -99,7 +111,6 @@ val functionalTests = Project("functional-tests", file("functional-tests")) libraryDependencies += "io.get-coursier" %% "coursier" % "2.1.10", libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value, libraryDependencies += munit.value, - testFrameworks += new TestFramework("munit.Framework"), scalacOptions ++= compilerOptions(scalaVersion.value), //Test / run / fork := true, //Test / run / forkOptions := (Test / run / forkOptions).value.withWorkingDirectory((ThisBuild / baseDirectory).value), diff --git a/cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala b/cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala new file mode 100644 index 00000000..2ab64c78 --- /dev/null +++ b/cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala @@ -0,0 +1,133 @@ +package com.typesafe.tools.mima.cli + +import com.typesafe.tools.mima.lib.MiMaLib + +import java.io.File +import scala.annotation.tailrec + +case class Main( + classpath: Seq[File] = Nil, + oldBinOpt: Option[File] = None, + newBinOpt: Option[File] = None, + formatter: ProblemFormatter = ProblemFormatter() +) { + + def run(): Int = { + val oldBin = oldBinOpt.getOrElse( + throw new IllegalArgumentException("Old binary was not specified") + ) + val newBin = newBinOpt.getOrElse( + throw new IllegalArgumentException("New binary was not specified") + ) + // TODO: should have some machine-readable output here, as an option + val problems = new MiMaLib(classpath) + .collectProblems(oldBin, newBin, Nil) + .flatMap(formatter.formatProblem) + problems.foreach(println) + problems.size + } + +} + +object Main { + + def main(args: Array[String]): Unit = + try System.exit(parseArgs(args.toList, Main()).run()) + catch { + case err: IllegalArgumentException => + println(err.getMessage()) + printUsage() + } + + def printUsage(): Unit = println( + s"""Usage: + | + |mima [OPTIONS] oldfile newfile + | + | oldfile: Old (or, previous) files - a JAR or a directory containing classfiles + | newfile: New (or, current) files - a JAR or a directory containing classfiles + | + |Options: + | -cp CLASSPATH: + | Specify Java classpath, separated by '${File.pathSeparatorChar}' + | + | -v, --verbose: + | Show a human-readable description of each problem + | + | -f, --forward-only: + | Show only forward-binary-compatibility problems + | + | -b, --backward-only: + | Show only backward-binary-compatibility problems + | + | -g, --include-generics: + | Include generic signature problems, which may not directly cause bincompat + | problems and are hidden by default. Has no effect if using --forward-only. + | + | -j, --bytecode-names: + | Show bytecode names of fields and methods, rather than human-readable names + | + |""".stripMargin + ) + + @tailrec + private def parseArgs(remaining: List[String], current: Main): Main = + remaining match { + case Nil => current + case ("-cp" | "--classpath") :: cpStr :: rest => + parseArgs( + rest, + current.copy(classpath = + cpStr.split(File.pathSeparatorChar).toSeq.map(new File(_)) + ) + ) + + case ("-f" | "--forward-only") :: rest => + parseArgs( + rest, + current.copy(formatter = + current.formatter.copy(showForward = true, showBackward = false) + ) + ) + + case ("-b" | "--backward-only") :: rest => + parseArgs( + rest, + current.copy(formatter = + current.formatter.copy(showForward = false, showBackward = true) + ) + ) + + case ("-j" | "--bytecode-names") :: rest => + parseArgs( + rest, + current.copy(formatter = + current.formatter.copy(useBytecodeNames = true) + ) + ) + + case ("-v" | "--verbose") :: rest => + parseArgs( + rest, + current.copy(formatter = + current.formatter.copy(showDescriptions = true) + ) + ) + + case ("-g" | "--include-generics") :: rest => + parseArgs( + rest, + current.copy(formatter = + current.formatter.copy(showIncompatibleSignature = true) + ) + ) + + case filename :: rest if current.oldBinOpt.isEmpty => + parseArgs(rest, current.copy(oldBinOpt = Some(new File(filename)))) + case filename :: rest if current.newBinOpt.isEmpty => + parseArgs(rest, current.copy(newBinOpt = Some(new File(filename)))) + case wut :: _ => + throw new IllegalArgumentException(s"Unknown argument $wut") + } + +} diff --git a/cli/src/main/scala/com/typesafe/tools/mima/cli/ProblemFormatter.scala b/cli/src/main/scala/com/typesafe/tools/mima/cli/ProblemFormatter.scala new file mode 100644 index 00000000..93ac2b1f --- /dev/null +++ b/cli/src/main/scala/com/typesafe/tools/mima/cli/ProblemFormatter.scala @@ -0,0 +1,103 @@ +package com.typesafe.tools.mima.cli + +import com.typesafe.tools.mima.core.AbstractMethodProblem +import com.typesafe.tools.mima.core.DirectMissingMethodProblem +import com.typesafe.tools.mima.core.FinalMethodProblem +import com.typesafe.tools.mima.core.InaccessibleFieldProblem +import com.typesafe.tools.mima.core.InaccessibleMethodProblem +import com.typesafe.tools.mima.core.IncompatibleFieldTypeProblem +import com.typesafe.tools.mima.core.IncompatibleMethTypeProblem +import com.typesafe.tools.mima.core.IncompatibleResultTypeProblem +import com.typesafe.tools.mima.core.IncompatibleSignatureProblem +import com.typesafe.tools.mima.core.MemberInfo +import com.typesafe.tools.mima.core.MemberProblem +import com.typesafe.tools.mima.core.MissingFieldProblem +import com.typesafe.tools.mima.core.MissingMethodProblem +import com.typesafe.tools.mima.core.NewMixinForwarderProblem +import com.typesafe.tools.mima.core.Problem +import com.typesafe.tools.mima.core.ReversedAbstractMethodProblem +import com.typesafe.tools.mima.core.ReversedMissingMethodProblem +import com.typesafe.tools.mima.core.TemplateProblem +import com.typesafe.tools.mima.core.UpdateForwarderBodyProblem + +case class ProblemFormatter( + showForward: Boolean = true, + showBackward: Boolean = true, + showIncompatibleSignature: Boolean = false, + useBytecodeNames: Boolean = false, + showDescriptions: Boolean = false +) { + + private def str(problem: TemplateProblem): String = + s"${if (useBytecodeNames) problem.ref.bytecodeName + else problem.ref.fullName}: ${problem.getClass.getSimpleName.stripSuffix("Problem")}${description(problem)}" + + private def str(problem: MemberProblem): String = + s"${memberName(problem.ref)}: ${problem.getClass.getSimpleName.stripSuffix("Problem")}${description(problem)}" + + private def description(problem: Problem): String = + if (showDescriptions) ": " + problem.description("new") else "" + + private def memberName(info: MemberInfo): String = + if (useBytecodeNames) + bytecodeFullName(info) + else + info.fullName + + private def bytecodeFullName(info: MemberInfo): String = { + val pkg = info.owner.owner.fullName.replace('.', '/') + val clsName = info.owner.bytecodeName + val memberName = info.bytecodeName match { + case "" => "\"\"" + case name => name + } + val sig = info.descriptor + + s"$pkg/$clsName.$memberName$sig" + } + + // format: off + def formatProblem(problem: Problem): Option[String] = problem match { + case prob: TemplateProblem if showBackward => Some(str(prob)) + case _: TemplateProblem => None + + case problem: MemberProblem => problem match { + case prob: AbstractMethodProblem if showBackward => Some(str(prob)) + case _: AbstractMethodProblem => None + + case problem: MissingMethodProblem => problem match { + case prob: DirectMissingMethodProblem if showBackward => Some(str(prob)) + case _: DirectMissingMethodProblem => None + case prob: ReversedMissingMethodProblem if showForward => Some(str(prob)) + case _: ReversedMissingMethodProblem => None + } + + case prob: ReversedAbstractMethodProblem if showForward => Some(str(prob)) + case _: ReversedAbstractMethodProblem => None + case prob: MissingFieldProblem if showBackward => Some(str(prob)) + case _: MissingFieldProblem => None + case prob: InaccessibleFieldProblem if showBackward => Some(str(prob)) + case _: InaccessibleFieldProblem => None + case prob: IncompatibleFieldTypeProblem if showBackward => Some(str(prob)) + case _: IncompatibleFieldTypeProblem => None + case prob: InaccessibleMethodProblem if showBackward => Some(str(prob)) + case _: InaccessibleMethodProblem => None + case prob: IncompatibleMethTypeProblem if showBackward => Some(str(prob)) + case _: IncompatibleMethTypeProblem => None + case prob: IncompatibleResultTypeProblem if showBackward => Some(str(prob)) + case _: IncompatibleResultTypeProblem => None + case prob: FinalMethodProblem if showBackward => Some(str(prob)) + case _: FinalMethodProblem => None + case prob: UpdateForwarderBodyProblem if showBackward => Some(str(prob)) + case _: UpdateForwarderBodyProblem => None + case prob: NewMixinForwarderProblem if showBackward => Some(str(prob)) + case _: NewMixinForwarderProblem => None + + case prob: IncompatibleSignatureProblem + if showBackward && showIncompatibleSignature => Some(str(prob)) + case _: IncompatibleSignatureProblem => None + } + } + // format: on + +}