diff --git a/README.md b/README.md index 6910592..c010cec 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,39 @@ Running `sbt +install` builds the plugin jar for all compatible Scala versions a The plugin supports Scala 2.12, 2.13 with Scala 3 support coming soon! The plugin supports both ZIO 1 and ZIO 2. +### Alternative installation method + +If desired, add the following to your `build.sbt` to install the plugin in your project: + +```scala +addCompilerPlugin("com.hmemcpy" %% "zio-clippy" % ) +``` + +(replace with the latest available version from Maven Central) + + ## Additional configuration +Note: The recommended way to specify additional configuration is via a global sbt configuration file, without directly modifying your project's `build.sbt`. + +Create a file `clippy.sbt` in your global sbt directory, `~/.sbt/1.0`. You can specify the options in this file, and they will be loaded automatically by your project. + +### Original type mismatch error + To render the original type mismatch error in addition to the plugin output, add the following flag to your `scalacOptions`: ```scala -"-P:clippy:show-original-error" +scalaOptions += "-P:clippy:show-original-error" ``` - ![](.github/img/full-error.png) +### Additional types for detection + +ZIO Clippy support additional, *ZIO-like* types when parsing type mismatch errors. Any type that has 3 type parameters (e.g. `org.company.Saga[R, E, A]`) can be specified. To enable, provide a comma-separated *fully-qualified* list of names to the following option: + +```scala +scalaOptions += "-P:clippy:additional-types:zio.flow.ZFlow,org.company.Saga" +``` ## Technical information diff --git a/project/ZIOPlugin.scala b/project/ZIOPlugin.scala index d4df2c4..c5c4b2d 100644 --- a/project/ZIOPlugin.scala +++ b/project/ZIOPlugin.scala @@ -1,7 +1,7 @@ package clippy -import sbt.* -import sbt.Keys.* +import sbt._ +import sbt.Keys._ object ZIOPlugin extends AutoPlugin { @@ -13,8 +13,8 @@ object ZIOPlugin extends AutoPlugin { def afterScala2_13_12(scalaVersion: VersionNumber): Boolean = scalaVersion.matchesSemVer(SemanticSelector(">=2.13.12")) - import autoImport.* - override lazy val projectSettings: Seq[Setting[?]] = Seq( + import autoImport._ + override lazy val projectSettings: Seq[Setting[_]] = Seq( zioPluginJar := Def.taskDyn { val Some((major, minor)) = CrossVersion.partialVersion(scalaVersion.value) val versionNumber = VersionNumber(scalaVersion.value) diff --git a/src/main/scala-2.12/clippy/ZIOErrorReporter.scala b/src/main/scala-2.12/clippy/ZIOErrorReporter.scala index 62ffde9..6bb212d 100644 --- a/src/main/scala-2.12/clippy/ZIOErrorReporter.scala +++ b/src/main/scala-2.12/clippy/ZIOErrorReporter.scala @@ -6,8 +6,11 @@ import scala.reflect.internal.Reporter import scala.reflect.internal.util.Position import scala.tools.nsc.Settings import scala.tools.nsc.reporters.FilteringReporter -final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean) + +final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean, additionalTypes: List[String]) extends FilteringReporter { + + val IsZIOTypeError = new IsZIOTypeErrorExtractor(additionalTypes) override def doReport(pos: Position, msg: String, severity: Severity): Unit = severity match { case Reporter.ERROR => diff --git a/src/main/scala-2.12/clippy/package.scala b/src/main/scala-2.12/clippy/package.scala index 812e4b7..206abc0 100644 --- a/src/main/scala-2.12/clippy/package.scala +++ b/src/main/scala-2.12/clippy/package.scala @@ -5,3 +5,15 @@ package object clippy { if (cond) Some(a) else None } } + +package scala.jdk { + object CollectionConverters{ + + // copied from Scala 2.13 because I don't want to deal with imports + implicit class EnumerationHasAsScala[A](e: java.util.Enumeration[A]) { + def asScala: Iterator[A] = _root_.scala.collection.JavaConverters.enumerationAsScalaIterator(e) + } + } +} + + diff --git a/src/main/scala-2.13.12+/clippy/ZIOErrorReporter.scala b/src/main/scala-2.13.12+/clippy/ZIOErrorReporter.scala index fac1d3d..85fc68a 100644 --- a/src/main/scala-2.13.12+/clippy/ZIOErrorReporter.scala +++ b/src/main/scala-2.13.12+/clippy/ZIOErrorReporter.scala @@ -8,9 +8,11 @@ import scala.tools.nsc.Settings import scala.tools.nsc.reporters.FilteringReporter // copied over to avoid dealing with Scala 2.13.12 -final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean) +final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean, additionalTypes: List[String]) extends FilteringReporter { + val IsZIOTypeError = new IsZIOTypeErrorExtractor(additionalTypes) + override def doReport(pos: Position, msg: String, severity: Severity, actions: List[CodeAction]): Unit = severity match { case Reporter.ERROR => diff --git a/src/main/scala-2.13.x/clippy/ZIOErrorReporter.scala b/src/main/scala-2.13.x/clippy/ZIOErrorReporter.scala index 62ffde9..baab1a6 100644 --- a/src/main/scala-2.13.x/clippy/ZIOErrorReporter.scala +++ b/src/main/scala-2.13.x/clippy/ZIOErrorReporter.scala @@ -6,8 +6,10 @@ import scala.reflect.internal.Reporter import scala.reflect.internal.util.Position import scala.tools.nsc.Settings import scala.tools.nsc.reporters.FilteringReporter -final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean) +final class ZIOErrorReporter(val settings: Settings, underlying: Reporter, showOriginalError: Boolean, additionalTypes: List[String]) extends FilteringReporter { + + val IsZIOTypeError = new IsZIOTypeErrorExtractor(additionalTypes) override def doReport(pos: Position, msg: String, severity: Severity): Unit = severity match { case Reporter.ERROR => diff --git a/src/main/scala-2/clippy/Plugin.scala b/src/main/scala-2/clippy/Plugin.scala index 99667a5..9fb7406 100644 --- a/src/main/scala-2/clippy/Plugin.scala +++ b/src/main/scala-2/clippy/Plugin.scala @@ -7,7 +7,8 @@ final class Plugin(override val global: Global) extends plugins.Plugin { override val name: String = "clippy" private val knobs = Map( - "show-original-error" -> "Shows the original Scala type mismatch error" + "show-original-error" -> "Shows the original Scala type mismatch error", + "additional-types" -> "Additional types (fully-qualified) to consider when extracting type mismatches" ) override val optionsHelp: Option[String] = Some( @@ -20,16 +21,23 @@ final class Plugin(override val global: Global) extends plugins.Plugin { override def init(options: List[String], error: String => Unit): Boolean = { val (known, unknown) = options.partition(s => knobs.keys.exists(s.startsWith)) if (unknown.nonEmpty) { - error(s"Unknown options: ${unknown.mkString(", ")}") - return false + global.reporter.echo(s"ZIO Clippy - Unknown options: ${unknown.mkString(", ")}") } val showOriginalError = known.contains("show-original-error") + val additionalTypes = options.find(_.contains("additional-types")).map(extractAdditionalTypes).getOrElse(Nil) - global.reporter = new ZIOErrorReporter(global.settings, global.reporter, showOriginalError) + global.reporter = new ZIOErrorReporter(global.settings, global.reporter, showOriginalError, additionalTypes) true } + private def extractAdditionalTypes(s: String): List[String] = + s.split(":") + .tail + .flatMap(_.split(",")) + .map(_.trim) + .toList + override val components: List[PluginComponent] = Nil } diff --git a/src/main/scala/clippy/utils.scala b/src/main/scala/clippy/utils.scala index 1d8d3d9..b1fe1b6 100644 --- a/src/main/scala/clippy/utils.scala +++ b/src/main/scala/clippy/utils.scala @@ -8,6 +8,10 @@ object utils { val any = "Any" val anySet = Set(any) + implicit final class AnySyntax[A](private val a: A) extends AnyVal { + def |>[B](f: A => B): B = f(a) + } + sealed trait ErrorKind extends Product with Serializable object ErrorKind { case object Overriding extends ErrorKind @@ -67,29 +71,35 @@ object utils { def from(r: String, e: String, a: String) = Info( - R = r.split(" with ").map(normalize).toSet, - E = e, - A = a + R = r.split(" with ").map(_.trim |> normalize).toSet, + E = e.trim, + A = a.trim ) def unapply(m: Regex.Match): Option[Info] = - (m.group(1), Option(m.group(2)), Option(m.group(3)), Option(m.group(4))) match { - case ("ZIO" | "ZLayer", Some(r), Some(e), Some(a)) => Some(from(r, e, a)) - case ("UIO" | "ULayer", _, _, Some(a)) => Some(from("Any", "Nothing", a)) - case ("URIO" | "URLayer", Some(r), _, Some(a)) => Some(from(r, "Nothing", a)) - case ("Task", _, _, Some(a)) => Some(from("Any", "Throwable", a)) - case ("RIO", Some(r), _, Some(a)) => Some(from(r, "Throwable", a)) - case _ => None + (m.group(1), Option(m.group(2)), Option(m.group(3)), Option(m.group(4)), Option(m.group(5))) match { + case ("UIO" | "ULayer", _, _, _, Some(a)) => Some(from("Any", "Nothing", a)) + case ("URIO" | "URLayer", _, Some(r), _, Some(a)) => Some(from(r, "Nothing", a)) + case ("Task", _, _, _, Some(a)) => Some(from("Any", "Throwable", a)) + case ("RIO", Some(r), _, _, Some(a)) => Some(from(r, "Throwable", a)) + case (_, _, Some(r), Some(e), Some(a)) => Some(from(r, e, a)) + case _ => None } } - object IsZIOTypeError { - val mismatch = raw"zio\.(ZIO|ZLayer)\[(.+),([^,\]]+),([^,\]]+)]".r + class IsZIOTypeErrorExtractor(additionalTypes: List[String]) { + val brackets = raw"\[(.+),([^,\]]+),([^,\]]+)]" + val default = raw"zio\.(ZIO|ZLayer)" + val matches = buildRegex + private def buildRegex: Regex = { + val allRegex = default :: additionalTypes.map(_.replace(".", "\\.")) + (allRegex.mkString("(", "|", ")") + brackets).r + } private def findMismatches(msg: String, kind: ErrorKind): Option[(ErrorKind, Info, Info)] = - mismatch.findAllMatchIn(msg).toList match { - case _ :: Info(found) :: _ :: Info(required) :: Nil => Some(kind, found, required) - case Info(found) :: Info(required) :: _ => Some(kind, found, required) + matches.findAllMatchIn(msg).toList match { + case _ :: Info(found) :: _ :: Info(required) :: Nil => Some((kind, found, required)) + case Info(found) :: Info(required) :: _ => Some((kind, found, required)) case _ => None } diff --git a/src/test/scala/clippy/RegexSpec.scala b/src/test/scala/clippy/RegexSpec.scala index 5341c29..4e1595f 100644 --- a/src/test/scala/clippy/RegexSpec.scala +++ b/src/test/scala/clippy/RegexSpec.scala @@ -1,11 +1,14 @@ package clippy -import clippy.utils.IsZIOTypeError +import clippy.utils.IsZIOTypeErrorExtractor import zio.Scope import zio.test._ + object RegexSpec extends ZIOSpecDefault { override def spec: Spec[TestEnvironment with Scope, Any] = suiteAll("type mismatch") { test("parses the type mismatch error") { + val IsZIOTypeError = new IsZIOTypeErrorExtractor(Nil) + val regex = raw"""type mismatch; | found : zio.URIO[zio.Has[persistence.Persistence] with zio.console.Console with zio.console.Console,zio.ExitCode] @@ -30,6 +33,8 @@ object RegexSpec extends ZIOSpecDefault { } test("parses the return type") { + val IsZIOTypeError = new IsZIOTypeErrorExtractor(Nil) + val regex = raw"""type mismatch; | found : zio.ZIO[io.github.gaelrenoux.tranzactio.doobie.Connection with R,E,A] @@ -43,5 +48,38 @@ object RegexSpec extends ZIOSpecDefault { assertTrue(found.A == "A", required.A == "A") } + + test("parses the custom types from config") { + val IsZIOTypeError = new IsZIOTypeErrorExtractor(List("this.is.a.Test")) + + val regex = + raw"""type mismatch; + | found : this.is.a.Test[io.github.gaelrenoux.tranzactio.doobie.Connection with R,E,A] + | (which expands to) this.is.a.Test[zio.Has[doobie.util.transactor.Transactor[zio.Task]] with R,E,A] + | required: this.is.a.Test[io.github.gaelrenoux.tranzactio.doobie.Connection with R,Throwable,A] + | (which expands to) this.is.a.Test[zio.Has[doobie.util.transactor.Transactor[zio.Task]] with R,Throwable,A]""".stripMargin + + val (found, required) = regex match { + case IsZIOTypeError(_, found, required) => (found, required) + } + + assertTrue(found.A == "A", required.A == "A") + } + + + test("parses simple mismatch") { + val IsZIOTypeError = new IsZIOTypeErrorExtractor(List("this.is.a.Test")) + + val regex = + raw"""type mismatch; + | found : this.is.a.Test[Int,Nothing,Int] + | required: this.is.a.Test[Any,String,Int] [11:40]""".stripMargin + + val (found, _) = regex match { + case IsZIOTypeError(_, found, required) => (found, required) + } + + assertTrue(found.R == Set("Int")) + } } }