Repository: scala-steward-org/scala-steward (scala)

Issue:
[feature request] Support for Mill plugins
From looking at the code here:

https://github.com/scala-steward-org/scala-steward/blob/a2954c3d09f9ed01aaed377a0674aba5a60f90e5/modules/mill-plugin/src/main/scala/org/scalasteward/mill/plugin/StewardPlugin.scala#L44-L45

I see that Steward only considers dependencies in modules, and also the Mill version itself from [here](https://github.com/scala-steward-org/scala-steward/blob/a2954c3d09f9ed01aaed377a0674aba5a60f90e5/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala#L87-L88). However, this misses out on the Mill plugins that are included in the actual build itself like this:

```
import $ivy.`com.goyeau::mill-scalafix::0.2.10`
import $ivy.`de.tototec::de.tobiasroeser.mill.integrationtest::0.6.1`
import $ivy.`de.tototec::de.tobiasroeser.mill.vcs.version::0.2.0`
...
```

There really isn't a way to pull this information from Mill as far as I'm aware, so it'd need to manually be put together. It'd also have to consider the Mill version because take `com.goyeau::mill-scalafix::0.2.10` for example. The artifact is actually `mill-scalafix_mill0.10_2.13` to support the current `0.10.x` series of Mill.

One potential way to grab these would be to just regex them since they're almost always in this format at the to of the file. You could do this after grabbing the Mill version, and then put the artifact name together. This would then allow for PRs to also be sent in for these, which are currently missed.

Pull request:
This pr adds in the ability to detect Mill plugins that are defined in
the following way, which is new as of 0.10.x of Mill.

```
import $ivy.`com.goyeau::mill-scalafix::0.2.10`
```

The artifact for this will end up being:

```
mill-scalafix_mill0.10_2.13
```
There is no API currently for Mill to give you the plugins being used in
the build file, so instead of this logic being in the actual Mill plugin
we parse the build file.

Closes #2694

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
index 861bc884fd..cc6f39057e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
@@ -55,7 +55,11 @@ final class MillAlg[F[_]](implicit
       millBuildDeps = millBuildVersion.toSeq.map(version =>
         Scope(List(millMainArtifact(version)), List(millMainResolver))
       )
-    } yield dependencies ++ millBuildDeps
+      millPluginDeps <- millBuildVersion match {
+        case None        => F.pure(Seq.empty[Scope[List[Dependency]]])
+        case Some(value) => getMillPluginDeps(value, buildRootDir)
+      }
+    } yield dependencies ++ millBuildDeps ++ millPluginDeps
 
   override def runMigration(buildRoot: BuildRoot, migration: ScalafixMigration): F[Unit] =
     F.unit
@@ -65,6 +69,17 @@ final class MillAlg[F[_]](implicit
       millVersionFileContent <- fileAlg.readFile(buildRootDir / ".mill-version")
       version = millVersionFileContent.flatMap(parser.parseMillVersion)
     } yield version
+
+  private def getMillPluginDeps(
+      millVersion: Version,
+      buildRootDir: File
+  ): F[Seq[Scope[List[Dependency]]]] =
+    for {
+      buildConent <- fileAlg.readFile(buildRootDir / "build.sc")
+      deps = buildConent.toList.map(content =>
+        Scope(parser.parseMillPluginDeps(content, millVersion), List(millMainResolver))
+      )
+    } yield deps
 }
 
 object MillAlg {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/parser.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/parser.scala
index 04d61068d7..0692959a76 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/parser.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/parser.scala
@@ -19,6 +19,11 @@ package org.scalasteward.core.buildtool.mill
 import cats.syntax.all._
 import io.circe.{Decoder, DecodingFailure}
 import org.scalasteward.core.data.{Dependency, Resolver, Version}
+import cats.parse.Parser
+import cats.parse.Rfc5234.sp
+import org.scalasteward.core.data.GroupId
+import org.scalasteward.core.data.ArtifactId
+import scala.util.Try
 
 object parser {
   sealed trait ParseError extends RuntimeException {
@@ -43,6 +48,67 @@ object parser {
   def parseMillVersion(s: String): Option[Version] =
     Option(s.trim).filter(_.nonEmpty).map(Version.apply)
 
+  /**
+    * Used to correctly format the Mill plugin artifacts will when included look like:
+    * - import $ivy.`com.goyeau::mill-scalafix::0.2.10`
+    * However for the actual artifact if the user is on 0.10.x will look like:
+    * - mill-scalafix_mill0.10_.2.13
+    *
+    * @param artifactName name of the artifact parsed from the build file
+    * @param millVerion the current Mill version being used
+    * @return the newly put together ArtifactId
+    */
+  private def millPluginArtifact(artifactName: String, millVersion: Version): ArtifactId = {
+    def format(major: String, minor: String) = s"${major}.${minor}_2.13"
+    val millSuffix = millVersion.value.split('.') match {
+      // Basically for right now we only accept "0.9.12", which is when this syntax for Mill plugins
+      // was introduced, and all other pre v1 versions. Once it's for sure verified that v1 will also
+      // follow this pattern we can include that.
+      case Array(major, minor, patch) if major == "0" && minor == "9" && patch == "12" =>
+        format(major, minor)
+      case Array(major, minor, _) if major == "0" && Try(minor.toInt).map(_ > 9).getOrElse(false) =>
+        format(major, minor)
+      case _ => ""
+    }
+
+    ArtifactId(artifactName, Some(s"${artifactName}_mill${millSuffix}"))
+  }
+
+  def parseMillPluginDeps(s: String, millVersion: Version): List[Dependency] = {
+
+    val importParser = Parser.string("import")
+    val ivyParser = Parser.string("$ivy")
+    val backtickParser = Parser.char('`')
+    val doubleColonParser = Parser.string("::")
+    val dotParser = Parser.char('.')
+    val grabUntilColonParser = Parser.until(doubleColonParser)
+    val grabntilBacktickParser = Parser.until(backtickParser)
+
+    val parser =
+      (importParser ~
+        sp.rep ~
+        ivyParser ~
+        dotParser ~
+        backtickParser) *>
+        (grabUntilColonParser.string <*
+          doubleColonParser) ~
+        grabUntilColonParser.string ~
+        (doubleColonParser *>
+          grabntilBacktickParser.string <*
+          backtickParser)
+
+    val pluginDependencies = s
+      .split("\n")
+      .toList
+      .map { line =>
+        parser.parse(line)
+      }
+      .collect { case Right((_, ((org, artifact), version))) =>
+        Dependency(GroupId(org), millPluginArtifact(artifact, millVersion), Version(version))
+      }
+
+    pluginDependencies
+  }
 }
 
 case class Modules(modules: List[MillModule])


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillPluginParserTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillPluginParserTest.scala
new file mode 100644
index 0000000000..50f34fc84c
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillPluginParserTest.scala
@@ -0,0 +1,38 @@
+package org.scalasteward.core.buildtool.mill
+
+import munit.FunSuite
+import org.scalasteward.core.data.{ArtifactId, Dependency, GroupId, Version}
+
+class MillPluginParserTest extends FunSuite {
+
+  val supportedVersionExamples = Map("0.9" -> "0.9.12", "0.10" -> "0.10.7")
+
+  supportedVersionExamples.foreach { case (versionSuffix, millVersion) =>
+    test(s"basic-${versionSuffix}") {
+      val fileContent =
+        """|import $ivy.`com.goyeau::mill-scalafix::0.2.10`
+           |import $ivy.`de.tototec::de.tobiasroeser.mill.integrationtest::0.6.1`""".stripMargin
+
+      val expected = List(
+        Dependency(
+          GroupId("com.goyeau"),
+          ArtifactId("mill-scalafix", s"mill-scalafix_mill${versionSuffix}_2.13"),
+          Version("0.2.10")
+        ),
+        Dependency(
+          GroupId("de.tototec"),
+          ArtifactId(
+            "de.tobiasroeser.mill.integrationtest",
+            s"de.tobiasroeser.mill.integrationtest_mill${versionSuffix}_2.13"
+          ),
+          Version("0.6.1")
+        )
+      )
+
+      val parsed = parser.parseMillPluginDeps(fileContent, Version(millVersion))
+
+      assertEquals(parsed, expected)
+    }
+  }
+
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
index 49b377a684..aca0893b2f 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
@@ -533,6 +533,33 @@ class UpdateHeuristicTest extends FunSuite {
     val update = ("org.typelevel".g % "cats-core".a % "2.4.1" %> "2.4.2").single
     assertEquals(update.replaceVersionIn(original), Some(expected) -> UpdateHeuristic.original.name)
   }
+
+  test("basic mill") {
+    val original = """ivy"com.lihaoyi::requests:0.7.0"""".stripMargin
+    val expected = """ivy"com.lihaoyi::requests:0.7.1"""".stripMargin
+    val update = ("com.lihaoyi".g % "requests".a % "0.7.0" %> "0.7.1").single
+    assertEquals(update.replaceVersionIn(original), Some(expected) -> UpdateHeuristic.moduleId.name)
+  }
+
+  test("mill with variable") {
+    val original = """val requests = "0.7.0"
+                     |ivy"com.lihaoyi::requests:$requests"
+                     |""".stripMargin
+    val expected = """val requests = "0.7.1"
+                     |ivy"com.lihaoyi::requests:$requests"
+                     |""".stripMargin
+    val update = ("com.lihaoyi".g % "requests".a % "0.7.0" %> "0.7.1").single
+    assertEquals(update.replaceVersionIn(original), Some(expected) -> UpdateHeuristic.original.name)
+  }
+
+  test("mill with cross") {
+    val original = """import $ivy.`com.goyeau::mill-scalafix::0.2.9`
+                     |""".stripMargin
+    val expected = """import $ivy.`com.goyeau::mill-scalafix::0.2.10`
+                     |""".stripMargin
+    val update = ("com.goyeau".g % "mill-scalafix".a % "0.2.9" %> "0.2.10").single
+    assertEquals(update.replaceVersionIn(original), Some(expected) -> UpdateHeuristic.moduleId.name)
+  }
 }
 
 object UpdateHeuristicTest {