From 0e66c3f75f4e732688d75970bf88babfbfc8aa8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Mon, 2 Sep 2024 22:10:36 +0200 Subject: [PATCH 1/4] Add MiMa CLI --- build.sbt | 19 ++- .../com/typesafe/tools/mima/cli/MimaCli.scala | 133 ++++++++++++++++++ .../tools/mima/cli/ProblemFormatter.scala | 103 ++++++++++++++ 3 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala create mode 100644 cli/src/main/scala/com/typesafe/tools/mima/cli/ProblemFormatter.scala 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 + +} From 567f4f396f2a15e8038d541476bd29141fa4010d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 4 Sep 2024 17:54:27 +0200 Subject: [PATCH 2/4] Only use Scala 3 in cli --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 93c0ba77..676e50df 100644 --- a/build.sbt +++ b/build.sbt @@ -82,7 +82,7 @@ val cli = crossProject(JVMPlatform) .crossType(CrossType.Pure) .settings( name := "mima-cli", - crossScalaVersions ++= Seq(scala213, scala3), + crossScalaVersions ++= Seq(scala3), scalacOptions ++= compilerOptions(scalaVersion.value), libraryDependencies += munit.value % Test, MimaSettings.mimaSettings, From 558cf7da757ce08aa540fa1b54332e0dc0a7f197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 18 Jun 2025 14:24:40 +0200 Subject: [PATCH 3/4] Add CLI readme --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 83735811..06ef51d8 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,55 @@ import com.github.lolgab.mill.mima._ Please check [this page](https://github.com/lolgab/mill-mima) for further information. +### CLI + +You can use MiMa using its command-line interface - it's the most straightforward way to compare two jars and see some human-readable descriptions of the issues. + +You can launch it with Coursier: + +```bash +cs launch com.typesafe:mima-cli_3:latest.release -- old.jar new.jar +``` + +Or create a reusable script: + +```bash +cs bootstrap com.typesafe:mima-cli_3:latest.release --output mima +./mima old.jar new.jar +``` + +Here are the usage instructions: + +``` +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 ':' + + -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 +``` + + ## Filtering binary incompatibilities When MiMa reports a binary incompatibility that you consider acceptable, such as a change in an internal package, From 3b218c04962bc8774c6d30c9963d2d955003c7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 18 Jun 2025 14:25:34 +0200 Subject: [PATCH 4/4] remove todo --- cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala | 1 - 1 file changed, 1 deletion(-) 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 index 2ab64c78..7e893e4c 100644 --- a/cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala +++ b/cli/src/main/scala/com/typesafe/tools/mima/cli/MimaCli.scala @@ -19,7 +19,6 @@ case class Main( 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)