Skip to content

Commit

Permalink
Merge pull request #3542 from scala-steward-org/topic/gradle-version-…
Browse files Browse the repository at this point in the history
…catalog

Extract dependencies from Gradle Version Catalogs
  • Loading branch information
fthomas authored Jan 28, 2025
2 parents 91ff127 + 7ea401d commit af9dad6
Show file tree
Hide file tree
Showing 13 changed files with 315 additions and 11 deletions.
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ lazy val core = myCrossProject("core")
Dependencies.monocleCore,
Dependencies.refined,
Dependencies.scalacacheCaffeine,
Dependencies.tomlj,
Dependencies.logbackClassic % Runtime,
Dependencies.catsLaws % Test,
Dependencies.circeLiteral % Test,
Expand Down
2 changes: 1 addition & 1 deletion docs/repo-specific-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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]])] =
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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))
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") +:
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading

0 comments on commit af9dad6

Please sign in to comment.