diff --git a/build.sbt b/build.sbt index b053eda9b..a13e7c59a 100644 --- a/build.sbt +++ b/build.sbt @@ -149,6 +149,7 @@ lazy val core = myCrossProject("core") Dependencies.monocleCore, Dependencies.refined, Dependencies.scalacacheCaffeine, + Dependencies.tomlj, Dependencies.logbackClassic % Runtime, Dependencies.catsLaws % Test, Dependencies.circeLiteral % Test, diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md index 44670f058..e1d23c7a7 100644 --- a/docs/repo-specific-configuration.md +++ b/docs/repo-specific-configuration.md @@ -130,7 +130,7 @@ updates.allowPreReleases = [ { groupId = "com.example", artifactId="foo" } ] updates.limit = 5 # The extensions of files that should be updated. -# Default: [".mill",".sbt",".sbt.shared",".sc",".scala",".scalafmt.conf",".sdkmanrc",".yml","build.properties","mill-version","pom.xml"] +# Default: [".mill",".sbt",".sbt.shared",".sc",".scala",".scalafmt.conf",".sdkmanrc",".yml","build.properties","libs.versions.toml","mill-version","pom.xml"] updates.fileExtensions = [".scala", ".sbt", ".sbt.shared", ".sc", ".yml", ".md", ".markdown", ".txt"] # If "on-conflicts", Scala Steward will update the PR it created to resolve conflicts as diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala index 09ed1ac88..0c76bfa78 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala @@ -25,6 +25,7 @@ import org.http4s.client.Client import org.http4s.headers.`User-Agent` import org.scalasteward.core.application.Config.ForgeCfg import org.scalasteward.core.buildtool.BuildToolDispatcher +import org.scalasteward.core.buildtool.gradle.GradleAlg import org.scalasteward.core.buildtool.maven.MavenAlg import org.scalasteward.core.buildtool.mill.MillAlg import org.scalasteward.core.buildtool.sbt.SbtAlg @@ -61,6 +62,7 @@ final class Context[F[_]](implicit val filterAlg: FilterAlg[F], val forgeRepoAlg: ForgeRepoAlg[F], val gitAlg: GitAlg[F], + val gradleAlg: GradleAlg[F], val hookExecutor: HookExecutor[F], val httpJsonClient: HttpJsonClient[F], val logger: Logger[F], @@ -176,6 +178,7 @@ object Context { implicit val versionsCache: VersionsCache[F] = new VersionsCache[F](config.cacheTtl, versionsStore) implicit val updateAlg: UpdateAlg[F] = new UpdateAlg[F] + implicit val gradleAlg: GradleAlg[F] = new GradleAlg[F](config.defaultResolver) implicit val mavenAlg: MavenAlg[F] = new MavenAlg[F](config) implicit val sbtAlg: SbtAlg[F] = new SbtAlg[F](config) implicit val scalaCliAlg: ScalaCliAlg[F] = new ScalaCliAlg[F] diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala index 6635bbb17..be868f6f0 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala @@ -18,6 +18,7 @@ package org.scalasteward.core.buildtool import cats.Monad import cats.syntax.all.* +import org.scalasteward.core.buildtool.gradle.GradleAlg import org.scalasteward.core.buildtool.maven.MavenAlg import org.scalasteward.core.buildtool.mill.MillAlg import org.scalasteward.core.buildtool.sbt.SbtAlg @@ -29,6 +30,7 @@ import org.scalasteward.core.scalafmt.ScalafmtAlg import org.typelevel.log4cats.Logger final class BuildToolDispatcher[F[_]](implicit + gradleAlg: GradleAlg[F], logger: Logger[F], mavenAlg: MavenAlg[F], millAlg: MillAlg[F], @@ -53,7 +55,7 @@ final class BuildToolDispatcher[F[_]](implicit buildTools.traverse_(_.runMigration(buildRoot, migration)) }) - private val allBuildTools = List(mavenAlg, millAlg, sbtAlg, scalaCliAlg) + private val allBuildTools = List(gradleAlg, mavenAlg, millAlg, sbtAlg, scalaCliAlg) private val fallbackBuildTool = List(sbtAlg) private def findBuildTools(buildRoot: BuildRoot): F[(BuildRoot, List[BuildToolAlg[F]])] = diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala new file mode 100644 index 000000000..292ca5182 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2018-2025 Scala Steward contributors + * + * 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.scalasteward.core.buildtool.gradle + +import better.files.File +import cats.Monad +import cats.syntax.all.* +import org.scalasteward.core.buildtool.{BuildRoot, BuildToolAlg} +import org.scalasteward.core.data.Scope.Dependencies +import org.scalasteward.core.data.{Resolver, Scope} +import org.scalasteward.core.io.{FileAlg, WorkspaceAlg} +import org.typelevel.log4cats.Logger + +final class GradleAlg[F[_]](defaultResolver: Resolver)(implicit + fileAlg: FileAlg[F], + override protected val logger: Logger[F], + workspaceAlg: WorkspaceAlg[F], + F: Monad[F] +) extends BuildToolAlg[F] { + override def name: String = "Gradle" + + override def containsBuild(buildRoot: BuildRoot): F[Boolean] = + libsVersionsToml(buildRoot).flatMap(fileAlg.isRegularFile) + + override def getDependencies(buildRoot: BuildRoot): F[List[Dependencies]] = + libsVersionsToml(buildRoot) + .flatMap(fileAlg.readFile) + .map(_.getOrElse("")) + .map(gradleParser.parseDependenciesAndPlugins) + .map { case (dependencies, plugins) => + val ds = Option.when(dependencies.nonEmpty)(Scope(dependencies, List(defaultResolver))) + val ps = Option.when(plugins.nonEmpty)(Scope(plugins, List(pluginsResolver))) + ds.toList ++ ps.toList + } + + private def libsVersionsToml(buildRoot: BuildRoot): F[File] = + workspaceAlg.buildRootDir(buildRoot).map(_ / "gradle" / libsVersionsTomlName) +} diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala new file mode 100644 index 000000000..b75fa68f6 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2018-2025 Scala Steward contributors + * + * 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.scalasteward.core.buildtool.gradle + +import cats.implicits.* +import org.scalasteward.core.data.{ArtifactId, Dependency, GroupId, Version} +import org.tomlj.{Toml, TomlTable} +import scala.jdk.CollectionConverters.* + +object gradleParser { + def parseDependenciesAndPlugins(input: String): (List[Dependency], List[Dependency]) = { + val parsed = Toml.parse(input) + val versionsTable = getTableSafe(parsed, "versions") + val librariesTable = getTableSafe(parsed, "libraries") + val pluginsTable = getTableSafe(parsed, "plugins") + + val dependencies = collectEntries(librariesTable, parseDependency(_, versionsTable)) + val plugins = collectEntries(pluginsTable, parsePlugin(_, versionsTable)) + + (dependencies, plugins) + } + + private def collectEntries[A: Ordering](table: TomlTable, f: TomlTable => Option[A]): List[A] = { + val aSet = table.entrySet().asScala.map(_.getValue).flatMap { + case t: TomlTable => f(t) + case _ => None + } + aSet.toList.sorted + } + + private def parseDependency(lib: TomlTable, versions: TomlTable): Option[Dependency] = + for { + case (groupId, artifactId) <- parseModuleObj(lib).orElse(parseModuleString(lib)) + version <- parseVersion(lib, versions) + } yield Dependency(groupId, artifactId, version) + + private def parseModuleObj(lib: TomlTable): Option[(GroupId, ArtifactId)] = + for { + groupId <- getStringSafe(lib, "group").map(GroupId(_)) + artifactId <- getStringSafe(lib, "name").map(ArtifactId(_)) + } yield (groupId, artifactId) + + private def parseModuleString(lib: TomlTable): Option[(GroupId, ArtifactId)] = + getStringSafe(lib, "module").flatMap { + _.split(':') match { + case Array(g, a) => Some((GroupId(g), ArtifactId(a))) + case _ => None + } + } + + private def parsePlugin(plugin: TomlTable, versions: TomlTable): Option[Dependency] = + for { + id <- getStringSafe(plugin, "id") + groupId = GroupId(id) + artifactId = ArtifactId(s"$id.gradle.plugin") + version <- parseVersion(plugin, versions) + } yield Dependency(groupId, artifactId, version) + + private def parseVersion(table: TomlTable, versions: TomlTable): Option[Version] = { + def versionString = getStringSafe(table, "version") + def versionRef = getStringSafe(table, "version.ref").flatMap(getStringSafe(versions, _)) + versionString.orElse(versionRef).map(Version.apply) + } + + private def getTableSafe(table: TomlTable, key: String): TomlTable = + Option + .when(table.contains(key) && table.isTable(key))(table.getTableOrEmpty(key)) + .getOrElse(emptyTable) + + private val emptyTable: TomlTable = Toml.parse("") + + private def getStringSafe(table: TomlTable, key: String): Option[String] = + Option.when(table.contains(key) && table.isString(key))(table.getString(key)) +} diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala new file mode 100644 index 000000000..a98de6153 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2018-2025 Scala Steward contributors + * + * 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.scalasteward.core.buildtool + +import org.scalasteward.core.data.Resolver + +package object gradle { + val libsVersionsTomlName = "libs.versions.toml" + + val pluginsResolver: Resolver.MavenRepository = + Resolver.MavenRepository("gradle-plugins", "https://plugins.gradle.org/m2/", None, None) +} diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala index 235df83ac..20fd3bfd4 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala @@ -22,11 +22,9 @@ import eu.timepit.refined.types.numeric.NonNegInt import io.circe.generic.semiauto.deriveCodec import io.circe.refined.* import io.circe.{Codec, Decoder} -import org.scalasteward.core.buildtool.maven.pomXmlName -import org.scalasteward.core.buildtool.mill.MillAlg -import org.scalasteward.core.buildtool.sbt.buildPropertiesName +import org.scalasteward.core.buildtool.{gradle, maven, mill, sbt} import org.scalasteward.core.data.{GroupId, Update} -import org.scalasteward.core.scalafmt.scalafmtConfName +import org.scalasteward.core.scalafmt import org.scalasteward.core.update.FilterAlg.{ FilterResult, IgnoredByConfig, @@ -106,16 +104,17 @@ object UpdatesConfig { val defaultFileExtensions: Set[String] = Set( ".mill", - MillAlg.millVersionName, ".sbt", ".sbt.shared", ".sc", ".scala", - scalafmtConfName, ".sdkmanrc", ".yml", - buildPropertiesName, - pomXmlName + gradle.libsVersionsTomlName, + maven.pomXmlName, + mill.MillAlg.millVersionName, + sbt.buildPropertiesName, + scalafmt.scalafmtConfName ) val defaultLimit: Option[NonNegInt] = None diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala index 9a5bc6763..d89d9e5e9 100644 --- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala +++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala @@ -41,12 +41,14 @@ class BuildToolDispatcherTest extends FunSuite { } val expectedState = initial.copy(trace = - Cmd("test", "-f", s"$repoDir/pom.xml") +: + Cmd("test", "-f", s"$repoDir/gradle/libs.versions.toml") +: + Cmd("test", "-f", s"$repoDir/pom.xml") +: Cmd("test", "-f", s"$repoDir/build.sc") +: Cmd("test", "-f", s"$repoDir/build.mill") +: Cmd("test", "-f", s"$repoDir/build.mill.scala") +: Cmd("test", "-f", s"$repoDir/build.sbt") +: allGreps ++: + Cmd("test", "-f", s"$repoDir/mvn-build/gradle/libs.versions.toml") +: Cmd("test", "-f", s"$repoDir/mvn-build/pom.xml") +: Cmd("test", "-f", s"$repoDir/mvn-build/build.sc") +: Cmd("test", "-f", s"$repoDir/mvn-build/build.mill") +: diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala new file mode 100644 index 000000000..31118ff8f --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala @@ -0,0 +1,33 @@ +package org.scalasteward.core.buildtool.gradle + +import munit.CatsEffectSuite +import org.scalasteward.core.TestSyntax.* +import org.scalasteward.core.buildtool.BuildRoot +import org.scalasteward.core.data.{Repo, Scope} +import org.scalasteward.core.mock.MockContext.context.* +import org.scalasteward.core.mock.{MockEffOps, MockState} + +class GradleAlgTest extends CatsEffectSuite { + test("getDependencies") { + val repo = Repo("gradle-alg", "test-getDependencies") + val buildRoot = BuildRoot(repo, ".") + val buildRootDir = workspaceAlg.buildRootDir(buildRoot).unsafeRunSync() + + val initial = MockState.empty.addFiles( + buildRootDir / "gradle" / libsVersionsTomlName -> + """|[libraries] + |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" } + |[plugins] + |kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.20-Beta1" } + |""".stripMargin + ) + val obtained = initial.flatMap(gradleAlg.getDependencies(buildRoot).runA) + val kotlinJvm = + "org.jetbrains.kotlin.jvm".g % "org.jetbrains.kotlin.jvm.gradle.plugin".a % "2.1.20-Beta1" + val expected = List( + List("org.tomlj".g % "tomlj".a % "1.1.1").withMavenCentral, + Scope(List(kotlinJvm), List(pluginsResolver)) + ) + assertIO(obtained, expected) + } +} diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala new file mode 100644 index 000000000..8b10095b9 --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala @@ -0,0 +1,59 @@ +package org.scalasteward.core.buildtool.gradle + +import munit.FunSuite +import org.scalasteward.core.TestSyntax.* + +class gradleParserTest extends FunSuite { + test("parseDependenciesAndPlugins: valid input") { + val input = + """|[versions] + |groovy = "3.0.5" + |checkstyle = "8.37" + | + |[libraries] + |groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" } + |groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" } + |groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" } + |commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer="3.9" } } + |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" } + | + |[bundles] + |groovy = ["groovy-core", "groovy-json", "groovy-nio"] + | + |[plugins] + |versions = { id = "com.github.ben-manes.versions", version = "0.45.0" } + |""".stripMargin + val obtained = gradleParser.parseDependenciesAndPlugins(input) + val expected = ( + List( + "org.codehaus.groovy".g % "groovy".a % "3.0.5", + "org.codehaus.groovy".g % "groovy-json".a % "3.0.5", + "org.codehaus.groovy".g % "groovy-nio".a % "3.0.5", + "org.tomlj".g % "tomlj".a % "1.1.1" + ), + List( + "com.github.ben-manes.versions".g % "com.github.ben-manes.versions.gradle.plugin".a % "0.45.0" + ) + ) + assertEquals(obtained, expected) + } + + test("parseDependenciesAndPlugins: empty input") { + val obtained = gradleParser.parseDependenciesAndPlugins("") + assertEquals(obtained, (List.empty, List.empty)) + } + + test("parseDependenciesAndPlugins: malformed input") { + val input = + """|versions] + |groovy = "3.0.5" + |[libraries] + |groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" + |foo = { module = "bar:qux:foo", version = "1" } + |[plugins] + |foo = "" + |""".stripMargin + val obtained = gradleParser.parseDependenciesAndPlugins(input) + assertEquals(obtained, (List.empty, List.empty)) + } +} diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala index 503411201..68d153a20 100644 --- a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala +++ b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala @@ -947,6 +947,44 @@ class RewriteTest extends FunSuite { runApplyUpdate(update, original, expected) } + test("Gradle Version Catalog") { + val update = ("org.tomlj".g % "tomlj".a % "1.0.0" %> "1.1.1").single + val original = Map( + "gradle/libs.versions.toml" -> + """|[libraries] + |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.0.0" } + |""".stripMargin + ) + val expected = Map( + "gradle/libs.versions.toml" -> + """|[libraries] + |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" } + |""".stripMargin + ) + runApplyUpdate(update, original, expected) + } + + test("Gradle Version Catalog with version.ref") { + val update = ("org.tomlj".g % "tomlj".a % "1.0.0" %> "1.1.1").single + val original = Map( + "gradle/libs.versions.toml" -> + """|[versions] + |tomlj = "1.0.0" + |[libraries] + |tomlj = { group = "org.tomlj", name = "tomlj", version.ref = "tomlj" } + |""".stripMargin + ) + val expected = Map( + "gradle/libs.versions.toml" -> + """|[versions] + |tomlj = "1.1.1" + |[libraries] + |tomlj = { group = "org.tomlj", name = "tomlj", version.ref = "tomlj" } + |""".stripMargin + ) + runApplyUpdate(update, original, expected) + } + private def runApplyUpdate( update: Update.Single, files: Map[String, String], diff --git a/project/Dependencies.scala b/project/Dependencies.scala index fd64f5920..0d411b236 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -44,4 +44,5 @@ object Dependencies { val scalaStewardMillPluginArtifactName = "scala-steward-mill-plugin" val scalaStewardMillPlugin = "org.scala-steward" % s"${scalaStewardMillPluginArtifactName}_mill0.10_2.13" % "0.18.0" + val tomlj = "org.tomlj" % "tomlj" % "1.1.1" }