diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 752710b..bc4e007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,6 @@ jobs: java-version: ${{ matrix.java }} distribution: temurin - uses: coursier/cache-action@v6 - - run: sbt test + - run: sbt publishLocal test - if: matrix.os != 'windows-latest' run: sbt scalafmtSbtCheck scalafmtCheckAll "rules2_13/scalafixAll --check OrganizeImports" diff --git a/input/src/main/scala/fix/SeparateEachFileWarnTest.scala b/input/src/main/scala/fix/SeparateEachFileWarnTest.scala new file mode 100644 index 0000000..0d25770 --- /dev/null +++ b/input/src/main/scala/fix/SeparateEachFileWarnTest.scala @@ -0,0 +1,7 @@ +/* +rule = SeparateEachFileWarn + */ +package fix + +trait SeparateEachFileWarnTest // assert: SeparateEachFileWarn +class SeparateEachFileWarnTest1 diff --git a/input/src/main/scala/fix/SeparateEachFileWarnTest2.scala b/input/src/main/scala/fix/SeparateEachFileWarnTest2.scala new file mode 100644 index 0000000..e2f3cfb --- /dev/null +++ b/input/src/main/scala/fix/SeparateEachFileWarnTest2.scala @@ -0,0 +1,10 @@ +/* +rule = SeparateEachFileWarn +SeparateEachFileWarn.limit = 5 + */ +package fix + +trait SeparateEachFileWarnTest2_1 +trait SeparateEachFileWarnTest2_2 +trait SeparateEachFileWarnTest2_3 +trait SeparateEachFileWarnTest2_4 diff --git a/output/src/main/scala/fix/SeparateEachFileWarnTest.scala b/output/src/main/scala/fix/SeparateEachFileWarnTest.scala new file mode 100644 index 0000000..705d6d1 --- /dev/null +++ b/output/src/main/scala/fix/SeparateEachFileWarnTest.scala @@ -0,0 +1,4 @@ +package fix + +trait SeparateEachFileWarnTest +class SeparateEachFileWarnTest1 diff --git a/output/src/main/scala/fix/SeparateEachFileWarnTest2.scala b/output/src/main/scala/fix/SeparateEachFileWarnTest2.scala new file mode 100644 index 0000000..03e16fe --- /dev/null +++ b/output/src/main/scala/fix/SeparateEachFileWarnTest2.scala @@ -0,0 +1,6 @@ +package fix + +trait SeparateEachFileWarnTest2_1 +trait SeparateEachFileWarnTest2_2 +trait SeparateEachFileWarnTest2_3 +trait SeparateEachFileWarnTest2_4 diff --git a/rules/src/main/scala/fix/SeparateEachFileConfig.scala b/rules/src/main/scala/fix/SeparateEachFileConfig.scala new file mode 100644 index 0000000..5ad99f0 --- /dev/null +++ b/rules/src/main/scala/fix/SeparateEachFileConfig.scala @@ -0,0 +1,36 @@ +package fix + +import java.util.Locale +import metaconfig.ConfDecoder +import metaconfig.generic.Surface +import scalafix.lint.LintSeverity + +case class SeparateEachFileConfig( + limit: Int, + severity: LintSeverity +) + +object SeparateEachFileConfig { + + val default: SeparateEachFileConfig = SeparateEachFileConfig( + limit = 2, + severity = LintSeverity.Warning, + ) + + implicit val surface: Surface[SeparateEachFileConfig] = + metaconfig.generic.deriveSurface[SeparateEachFileConfig] + + private implicit val lintSeverityDecoderInstance: ConfDecoder[LintSeverity] = { conf => + conf.as[String].map(_.toUpperCase(Locale.ROOT)).map { + case "ERROR" => + LintSeverity.Error + case "INFO" => + LintSeverity.Info + case _ => + LintSeverity.Warning + } + } + + implicit val decoder: ConfDecoder[SeparateEachFileConfig] = + metaconfig.generic.deriveDecoder(default) +} diff --git a/rules/src/main/scala/fix/SeparateEachFileRewrite.scala b/rules/src/main/scala/fix/SeparateEachFileRewrite.scala new file mode 100644 index 0000000..b151f63 --- /dev/null +++ b/rules/src/main/scala/fix/SeparateEachFileRewrite.scala @@ -0,0 +1,84 @@ +package fix + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import metaconfig.Configured +import scala.meta._ +import scala.meta.inputs.Input +import scalafix.v1._ + +class SeparateEachFileRewrite(config: SeparateEachFileConfig) extends SyntacticRule("SeparateEachFileRewrite") { + + def this() = this(SeparateEachFileConfig.default) + + override def withConfiguration(config: Configuration): Configured[Rule] = + config.conf + .getOrElse("SeparateEachFileRewrite")(this.config) + .map(newConfig => new SeparateEachFileRewrite(newConfig)) + + private def maxOption[A: Ordering](xs: Seq[A]): Option[A] = + if (xs.isEmpty) None else Option(xs.max) + + override def fix(implicit doc: SyntacticDocument): Patch = { + PartialFunction + .condOpt(doc.input) { + case f: Input.File => + f.path.toFile + case f: Input.VirtualFile => + new File(f.path) + } + .foreach { input => + val topLevelValues = doc.tree.collect { + case t: (Stat.WithTemplate & Member & Stat.WithMods) + if isTopLevel(t) && t.templ.inits.isEmpty && !t.is[Defn.Object] => + t + } + + if ( + (topLevelValues.lengthCompare(config.limit) >= 0) && topLevelValues.forall(_.mods.forall(!_.is[Mod.Sealed])) + ) { + val headerLastPos = { + maxOption( + doc.tree.collect { + case i: Import if isTopLevel(i) => i.pos.end + } + ).orElse( + maxOption( + doc.tree.collect { case p: Pkg => + p.ref.pos.end + } + ) + ).getOrElse(0) + } + val header = doc.input.text.take(headerLastPos) + + assert(input.delete()) + + val xs = doc.tree.collect { + case t: (Stat.WithTemplate & Member) if isTopLevel(t) => + t + }.groupBy(_.name.value) + + xs.foreach { case (k, v) => + Files.write( + new File( + input.getParentFile, + s"${k}.scala" + ).toPath, + (header + "\n\n" + v + .sortBy(_.getClass.getName) + .map { x => + (doc.comments.leading(x).toSeq.sortBy(_.pos.start).map(_.toString) :+ x.toString).mkString("\n") + } + .mkString("\n", "\n\n", "\n")).getBytes(StandardCharsets.UTF_8) + ) + } + } + } + + Patch.empty + } + + private def isTopLevel(t: Tree): Boolean = t.parent.forall(_.is[Pkg]) +} diff --git a/rules/src/main/scala/fix/SeparateEachFileWarn.scala b/rules/src/main/scala/fix/SeparateEachFileWarn.scala new file mode 100644 index 0000000..3806ef9 --- /dev/null +++ b/rules/src/main/scala/fix/SeparateEachFileWarn.scala @@ -0,0 +1,38 @@ +package fix + +import metaconfig.Configured +import scala.meta._ +import scalafix.v1._ + +class SeparateEachFileWarn(config: SeparateEachFileConfig) extends SyntacticRule("SeparateEachFileWarn") { + + def this() = this(SeparateEachFileConfig.default) + + override def withConfiguration(config: Configuration): Configured[Rule] = + config.conf.getOrElse("SeparateEachFileWarn")(this.config).map(newConfig => new SeparateEachFileWarn(newConfig)) + + override def fix(implicit doc: SyntacticDocument): Patch = { + val topLevelValues = doc.tree.collect { + case t: (Stat.WithTemplate & Stat.WithMods & Member) + if t.parent.forall(_.is[Pkg]) && t.templ.inits.isEmpty && !t.is[Defn.Object] => + t + } + + if ((topLevelValues.lengthCompare(config.limit) >= 0) && topLevelValues.forall(_.mods.forall(!_.is[Mod.Sealed]))) { + Patch.lint( + Diagnostic( + id = "", + message = Seq( + s"too many top level classes. please separate file. ${topLevelValues.size} ", + topLevelValues.map(_.name.value).mkString("[", ", ", "]") + ).mkString(""), + position = topLevelValues.head.pos, + severity = config.severity + ) + ) + } else { + Patch.empty + } + } + +} diff --git a/sbt-test/SeparateEachFileRewrite/test-1/build.sbt b/sbt-test/SeparateEachFileRewrite/test-1/build.sbt new file mode 100644 index 0000000..d340495 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/build.sbt @@ -0,0 +1,16 @@ +ThisBuild / scalafixDependencies += "com.github.xuwei-k" %% "scalafix-rules" % sys.props("scalafix-rules.version") + +TaskKey[Unit]("check") := { + val names = Set("X1.scala", "X2.scala", "A.scala") + val actualNames = file("src/main/scala/foo").listFiles().map(_.getName).toSet + assert(actualNames == names, actualNames) + if(!scala.util.Properties.isWin) { + names.foreach { + f => + val actual = IO.read(file(s"src/main/scala/foo/${f}")) + val expect = IO.read(file(s"expect/${f}")) + sys.process.Process(Seq("diff", s"src/main/scala/foo/${f}", s"expect/${f}")).! + assert(actual == expect, actual) + } + } +} diff --git a/sbt-test/SeparateEachFileRewrite/test-1/expect/A.scala b/sbt-test/SeparateEachFileRewrite/test-1/expect/A.scala new file mode 100644 index 0000000..58d9868 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/expect/A.scala @@ -0,0 +1,6 @@ +package foo + +import scala.util.Try + + +object A diff --git a/sbt-test/SeparateEachFileRewrite/test-1/expect/X1.scala b/sbt-test/SeparateEachFileRewrite/test-1/expect/X1.scala new file mode 100644 index 0000000..75b1e22 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/expect/X1.scala @@ -0,0 +1,11 @@ +package foo + +import scala.util.Try + + +/** + * x1 + */ +trait X1 { + def y: Try[Int] +} diff --git a/sbt-test/SeparateEachFileRewrite/test-1/expect/X2.scala b/sbt-test/SeparateEachFileRewrite/test-1/expect/X2.scala new file mode 100644 index 0000000..25c5055 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/expect/X2.scala @@ -0,0 +1,12 @@ +package foo + +import scala.util.Try + + +// x2 class +class X2 + +/** + * x2 object + */ +object X2 diff --git a/sbt-test/SeparateEachFileRewrite/test-1/project/plugins.sbt b/sbt-test/SeparateEachFileRewrite/test-1/project/plugins.sbt new file mode 100644 index 0000000..45b3184 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % sys.props("scalafix.version")) diff --git a/sbt-test/SeparateEachFileRewrite/test-1/src/main/scala/foo/A.scala b/sbt-test/SeparateEachFileRewrite/test-1/src/main/scala/foo/A.scala new file mode 100644 index 0000000..6707d4a --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/src/main/scala/foo/A.scala @@ -0,0 +1,20 @@ +package foo + +import scala.util.Try + +/** + * x2 object + */ +object X2 + +/** + * x1 + */ +trait X1 { + def y: Try[Int] +} + +// x2 class +class X2 + +object A diff --git a/sbt-test/SeparateEachFileRewrite/test-1/test b/sbt-test/SeparateEachFileRewrite/test-1/test new file mode 100644 index 0000000..4d40bb2 --- /dev/null +++ b/sbt-test/SeparateEachFileRewrite/test-1/test @@ -0,0 +1,2 @@ +> scalafix SeparateEachFileRewrite +> check