diff --git a/build.sbt b/build.sbt index 3f41515..9f692c6 100644 --- a/build.sbt +++ b/build.sbt @@ -34,6 +34,12 @@ lazy val `typelevel-scalafix-rules` = project // typelevel/cats Scalafix rules lazy val cats = scalafixProject("cats") .inputSettings( + semanticdbOptions += "-P:semanticdb:synthetics:on", + libraryDependencies ++= Seq( + "org.typelevel" %% "cats-core" % CatsVersion + ) + ) + .outputSettings( libraryDependencies ++= Seq( "org.typelevel" %% "cats-core" % CatsVersion ) diff --git a/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests1.scala b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests1.scala new file mode 100644 index 0000000..3076057 --- /dev/null +++ b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests1.scala @@ -0,0 +1,56 @@ +/* +rule = TypelevelCatsRemoveInstanceImports + */ +package fix + +import cats.Semigroup +import scala.concurrent.Future + +object RemoveInstanceImportsTests1 { + { + import cats.instances.option._ + import cats.instances.int._ + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + import cats.instances.all._ + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + import cats.implicits._ + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + import cats.instances.option._ + import cats.instances.int._ + import cats.syntax.semigroup._ + Option(1) |+| Option(2) + } + + { + import cats.implicits._ + 1.some |+| 2.some + } + + { + import cats.instances.future._ + import cats.instances.int._ + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } + + { + import cats.instances.all._ + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } + + { + import cats.implicits._ + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } +} diff --git a/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests2.scala b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests2.scala new file mode 100644 index 0000000..387eb2b --- /dev/null +++ b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests2.scala @@ -0,0 +1,10 @@ +/* +rule = TypelevelCatsRemoveInstanceImports + */ +package fix + +import cats.implicits._ + +object RemoveInstanceImportsTests2 { + val x = "hello".some +} diff --git a/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests3.scala b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests3.scala new file mode 100644 index 0000000..311b990 --- /dev/null +++ b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests3.scala @@ -0,0 +1,18 @@ +/* +rule = TypelevelCatsRemoveInstanceImports + */ +package fix + +import cats._ +import cats.implicits._ + +object RemoveInstanceImportsTests3 { + + def doSomethingMonadic[F[_]: Monad](x: F[Int]): F[String] = + for { + a <- x + b <- Monad[F].pure("hi") + c <- Monad[F].pure("hey") + } yield a.toString + b + c + +} diff --git a/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests4.scala b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests4.scala new file mode 100644 index 0000000..92a2943 --- /dev/null +++ b/modules/cats/input/src/main/scala/fix/RemoveInstanceImportsTests4.scala @@ -0,0 +1,50 @@ +/* +rule = TypelevelCatsRemoveInstanceImports + */ +package fix + +import cats.implicits._ +import cats.Order + +sealed trait Resolver extends Product with Serializable { + import Resolver._ + + val path: String = { + val url = this match { + case MavenRepository(_, location, _) => location + case IvyRepository(_, pattern, _) => pattern.takeWhile(!Set('[', '(')(_)) + } + url.replace(":", "") + } +} + +object Resolver { + final case class Credentials(user: String, pass: String) + + final case class MavenRepository(name: String, location: String, credentials: Option[Credentials]) + extends Resolver + final case class IvyRepository(name: String, pattern: String, credentials: Option[Credentials]) + extends Resolver + + val mavenCentral: MavenRepository = + MavenRepository("public", "https://repo1.maven.org/maven2/", None) + + implicit val resolverOrder: Order[Resolver] = + Order.by { + case MavenRepository(name, location, _) => (1, name, location) + case IvyRepository(name, pattern, _) => (2, name, pattern) + } +} + +final case class Scope[A](value: A, resolvers: List[Resolver]) + +object Scope { + + def combineByResolvers[A: Order](scopes: List[Scope[List[A]]]): List[Scope[List[A]]] = + scopes.groupByNel(_.resolvers).toList.map { case (resolvers, group) => + Scope(group.reduceMap(_.value).distinct.sorted, resolvers) + } + + implicit def scopeOrder[A: Order]: Order[Scope[A]] = + Order.by((scope: Scope[A]) => (scope.value, scope.resolvers)) +} diff --git a/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests1.scala b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests1.scala new file mode 100644 index 0000000..cafa078 --- /dev/null +++ b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests1.scala @@ -0,0 +1,43 @@ +package fix + +import cats.Semigroup +import scala.concurrent.Future + +object RemoveInstanceImportsTests1 { + { + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + Semigroup[Option[Int]].combine(Some(1), Some(2)) + } + + { + import cats.syntax.semigroup._ + Option(1) |+| Option(2) + } + + { + import cats.syntax.all._ + 1.some |+| 2.some + } + + { + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } + + { + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } + + { + import scala.concurrent.ExecutionContext.Implicits.global + Semigroup[Future[Int]] + } +} diff --git a/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests2.scala b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests2.scala new file mode 100644 index 0000000..58c62b8 --- /dev/null +++ b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests2.scala @@ -0,0 +1,7 @@ +package fix + +import cats.syntax.all._ + +object RemoveInstanceImportsTests2 { + val x = "hello".some +} diff --git a/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests3.scala b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests3.scala new file mode 100644 index 0000000..c765dd9 --- /dev/null +++ b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests3.scala @@ -0,0 +1,15 @@ +package fix + +import cats._ +import cats.syntax.all._ + +object RemoveInstanceImportsTests3 { + + def doSomethingMonadic[F[_]: Monad](x: F[Int]): F[String] = + for { + a <- x + b <- Monad[F].pure("hi") + c <- Monad[F].pure("hey") + } yield a.toString + b + c + +} diff --git a/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests4.scala b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests4.scala new file mode 100644 index 0000000..9155573 --- /dev/null +++ b/modules/cats/output/src/main/scala/fix/RemoveInstanceImportsTests4.scala @@ -0,0 +1,47 @@ +package fix + +import cats.implicits._ +import cats.Order + +sealed trait Resolver extends Product with Serializable { + import Resolver._ + + val path: String = { + val url = this match { + case MavenRepository(_, location, _) => location + case IvyRepository(_, pattern, _) => pattern.takeWhile(!Set('[', '(')(_)) + } + url.replace(":", "") + } +} + +object Resolver { + final case class Credentials(user: String, pass: String) + + final case class MavenRepository(name: String, location: String, credentials: Option[Credentials]) + extends Resolver + final case class IvyRepository(name: String, pattern: String, credentials: Option[Credentials]) + extends Resolver + + val mavenCentral: MavenRepository = + MavenRepository("public", "https://repo1.maven.org/maven2/", None) + + implicit val resolverOrder: Order[Resolver] = + Order.by { + case MavenRepository(name, location, _) => (1, name, location) + case IvyRepository(name, pattern, _) => (2, name, pattern) + } +} + +final case class Scope[A](value: A, resolvers: List[Resolver]) + +object Scope { + + def combineByResolvers[A: Order](scopes: List[Scope[List[A]]]): List[Scope[List[A]]] = + scopes.groupByNel(_.resolvers).toList.map { case (resolvers, group) => + Scope(group.reduceMap(_.value).distinct.sorted, resolvers) + } + + implicit def scopeOrder[A: Order]: Order[Scope[A]] = + Order.by((scope: Scope[A]) => (scope.value, scope.resolvers)) +} diff --git a/modules/cats/rules/src/main/resources/META-INF/services/scalafix.v1.Rule b/modules/cats/rules/src/main/resources/META-INF/services/scalafix.v1.Rule index d1b5ca0..7a45b3b 100644 --- a/modules/cats/rules/src/main/resources/META-INF/services/scalafix.v1.Rule +++ b/modules/cats/rules/src/main/resources/META-INF/services/scalafix.v1.Rule @@ -1,3 +1,4 @@ org.typelevel.fix.MapSequence org.typelevel.fix.UnusedShowInterpolator org.typelevel.fix.As +org.typelevel.fix.CatsRemoveInstanceImports \ No newline at end of file diff --git a/modules/cats/rules/src/main/scala/org/typelevel/fix/CatsRemoveInstanceImports.scala b/modules/cats/rules/src/main/scala/org/typelevel/fix/CatsRemoveInstanceImports.scala new file mode 100644 index 0000000..c80ab73 --- /dev/null +++ b/modules/cats/rules/src/main/scala/org/typelevel/fix/CatsRemoveInstanceImports.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.fix + +import scalafix.v1._ +import scala.meta._ + +class CatsRemoveInstanceImports extends SemanticRule("TypelevelCatsRemoveInstanceImports") { + + override def fix(implicit doc: SemanticDocument): Patch = doc.tree.collect { + // e.g. "import cats.instances.int._" or "import cats.instances.all._" + case CatsInstancesImport(i) => removeImportLine(doc)(i) + + // "import cats.implicits._" + case CatsImplicitsImport(i) => + try { + // These are all the sibling trees of the import + val siblings: List[Tree] = i.parent.fold(List.empty[Tree])(_.children).filterNot(_ == i) + + def usesImplicitConversion: Boolean = + siblings.exists(treeContainsFunctionApplicationSymbolMatching(catsKernelConversion)) + + def usesSyntax: Boolean = + siblings.exists(treeContainsFunctionApplicationSymbolMatching(catsSyntax)) + + if (usesImplicitConversion) { + // the import is used to enable an implicit conversion, + // so we have to keep it + Patch.empty + } else if (usesSyntax) { + // the import is used to enable an extension method, + // so replace it with "import cats.syntax.all._" + Patch.replaceTree(i, "import cats.syntax.all._") + } else { + // the import is only used to import instances, + // so it's safe to remove + removeImportLine(doc)(i) + } + } catch { + case e: scalafix.v1.MissingSymbolException => + // see https://github.com/typelevel/cats/pull/3566#issuecomment-684007028 + // and https://github.com/scalacenter/scalafix/issues/1123 + doc.input match { + case Input.File(path, _) => + println( + s"Skipping rewrite of 'import cats.implicits._' in file ${path.syntax} because we ran into a Scalafix bug. $e" + ) + case _ => + println( + s"Skipping rewrite of 'import cats.implicits._' because we ran into a Scalafix bug. $e" + ) + } + e.printStackTrace() + Patch.empty + } + }.asPatch + + // Recursively searches for a function symbol matching the predicate + // in all the function applications among all the children of the Tree t + private def treeContainsFunctionApplicationSymbolMatching( + f: Symbol => Boolean + )(t: Tree)(implicit doc: SemanticDocument): Boolean = + t match { + case t: Term => + t.synthetics.exists { + case ApplyTree(fn, _) => fn.symbol.fold(false)(f) + case _ => false + } || t.children.exists(treeContainsFunctionApplicationSymbolMatching(f)) + case t => t.children.exists(treeContainsFunctionApplicationSymbolMatching(f)) + } + + private def catsKernelConversion(symbol: Symbol): Boolean = + symbol.value.contains("cats/kernel") // && symbol.value.contains("Conversion") + + private def catsSyntax(symbol: Symbol): Boolean = symbol.value + .contains("cats") && (symbol.value.contains("syntax") || symbol.value.contains("Ops")) + + private def removeImportLine(doc: SemanticDocument)(i: Import): Patch = + Patch.removeTokens(i.tokens) + removeWhitespaceAndNewlineBefore(doc)(i.tokens.start) + + private def removeWhitespaceAndNewlineBefore(doc: SemanticDocument)(index: Int): Patch = + Patch.removeTokens( + doc.tokens + .take(index) + .takeRightWhile(t => t.is[Token.Space] || t.is[Token.Tab] || t.is[Token.LF]) + ) +} + +object CatsInstancesImport { + def unapply(t: Tree): Option[Import] = t match { + case i @ Import( + Importer(Term.Select(Term.Select(Name("cats"), Name("instances")), _), _) :: _ + ) => + Some(i) + case _ => None + } +} + +object CatsImplicitsImport { + def unapply(t: Tree): Option[Import] = t match { + case i @ Import(Importer(Term.Select(Name("cats"), Name("implicits")), _) :: _) => + Some(i) + case _ => None + } +}