Skip to content

Commit

Permalink
Extract dependencies from Gradle Version Catalogs
Browse files Browse the repository at this point in the history
This adds partial support for Gradle builds that use a [version
catalog](https://docs.gradle.org/current/userguide/version_catalogs.html)
(i.e. a `gradle/libs.versions.toml` file). Dependencies are extracted
from the version catalog just by parsing the `libs.versions.toml` file.
Since the version catalog only contains libraries and no resolvers, the
default resolver is used for the `Scope` of these libraries. This is
one reason why this Gradle support is only partial. The other is that
additional dependencies and plugins that are defined in other Gradle
build files are also ignored.

Closes: #3534
  • Loading branch information
fthomas committed Jan 13, 2025
1 parent 757f9bb commit 4b19b78
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 @@ -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,
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"

Check warning on line 34 in modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala

View check run for this annotation

Codecov / codecov/patch

modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala#L34

Added line #L34 was not covered by tests

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] =
parseVersion(lib, versions).flatMap { version =>
parseModuleObj(lib, version).orElse(parseModuleString(lib, version))
}

private def parseModuleObj(lib: TomlTable, version: Version): Option[Dependency] =
for {
groupId <- getStringSafe(lib, "group").map(GroupId(_))
artifactId <- getStringSafe(lib, "name").map(ArtifactId(_))
} yield Dependency(groupId, artifactId, version)

private def parseModuleString(lib: TomlTable, version: Version): Option[Dependency] =
for {
module <- getStringSafe(lib, "module")
(groupId, artifactId) <- module.split(':') match {
case Array(g, a) => Some((GroupId(g), ArtifactId(a)))
case _ => None
}
} yield Dependency(groupId, artifactId, version)

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 4b19b78

Please sign in to comment.