Skip to content

Commit 74cbed5

Browse files
committed
Extract dependencies from Gradle Version Catalogs
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
1 parent 5b13f31 commit 74cbed5

File tree

13 files changed

+315
-11
lines changed

13 files changed

+315
-11
lines changed

build.sbt

+1
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ lazy val core = myCrossProject("core")
149149
Dependencies.monocleCore,
150150
Dependencies.refined,
151151
Dependencies.scalacacheCaffeine,
152+
Dependencies.tomlj,
152153
Dependencies.logbackClassic % Runtime,
153154
Dependencies.catsLaws % Test,
154155
Dependencies.circeLiteral % Test,

docs/repo-specific-configuration.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ updates.allowPreReleases = [ { groupId = "com.example", artifactId="foo" } ]
130130
updates.limit = 5
131131

132132
# The extensions of files that should be updated.
133-
# Default: [".mill",".sbt",".sbt.shared",".sc",".scala",".scalafmt.conf",".sdkmanrc",".yml","build.properties","mill-version","pom.xml"]
133+
# Default: [".mill",".sbt",".sbt.shared",".sc",".scala",".scalafmt.conf",".sdkmanrc",".yml","build.properties","libs.versions.toml","mill-version","pom.xml"]
134134
updates.fileExtensions = [".scala", ".sbt", ".sbt.shared", ".sc", ".yml", ".md", ".markdown", ".txt"]
135135

136136
# If "on-conflicts", Scala Steward will update the PR it created to resolve conflicts as

modules/core/src/main/scala/org/scalasteward/core/application/Context.scala

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import org.http4s.client.Client
2525
import org.http4s.headers.`User-Agent`
2626
import org.scalasteward.core.application.Config.ForgeCfg
2727
import org.scalasteward.core.buildtool.BuildToolDispatcher
28+
import org.scalasteward.core.buildtool.gradle.GradleAlg
2829
import org.scalasteward.core.buildtool.maven.MavenAlg
2930
import org.scalasteward.core.buildtool.mill.MillAlg
3031
import org.scalasteward.core.buildtool.sbt.SbtAlg
@@ -61,6 +62,7 @@ final class Context[F[_]](implicit
6162
val filterAlg: FilterAlg[F],
6263
val forgeRepoAlg: ForgeRepoAlg[F],
6364
val gitAlg: GitAlg[F],
65+
val gradleAlg: GradleAlg[F],
6466
val hookExecutor: HookExecutor[F],
6567
val httpJsonClient: HttpJsonClient[F],
6668
val logger: Logger[F],
@@ -176,6 +178,7 @@ object Context {
176178
implicit val versionsCache: VersionsCache[F] =
177179
new VersionsCache[F](config.cacheTtl, versionsStore)
178180
implicit val updateAlg: UpdateAlg[F] = new UpdateAlg[F]
181+
implicit val gradleAlg: GradleAlg[F] = new GradleAlg[F](config.defaultResolver)
179182
implicit val mavenAlg: MavenAlg[F] = new MavenAlg[F](config)
180183
implicit val sbtAlg: SbtAlg[F] = new SbtAlg[F](config)
181184
implicit val scalaCliAlg: ScalaCliAlg[F] = new ScalaCliAlg[F]

modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala

+3-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package org.scalasteward.core.buildtool
1818

1919
import cats.Monad
2020
import cats.syntax.all.*
21+
import org.scalasteward.core.buildtool.gradle.GradleAlg
2122
import org.scalasteward.core.buildtool.maven.MavenAlg
2223
import org.scalasteward.core.buildtool.mill.MillAlg
2324
import org.scalasteward.core.buildtool.sbt.SbtAlg
@@ -29,6 +30,7 @@ import org.scalasteward.core.scalafmt.ScalafmtAlg
2930
import org.typelevel.log4cats.Logger
3031

3132
final class BuildToolDispatcher[F[_]](implicit
33+
gradleAlg: GradleAlg[F],
3234
logger: Logger[F],
3335
mavenAlg: MavenAlg[F],
3436
millAlg: MillAlg[F],
@@ -53,7 +55,7 @@ final class BuildToolDispatcher[F[_]](implicit
5355
buildTools.traverse_(_.runMigration(buildRoot, migration))
5456
})
5557

56-
private val allBuildTools = List(mavenAlg, millAlg, sbtAlg, scalaCliAlg)
58+
private val allBuildTools = List(gradleAlg, mavenAlg, millAlg, sbtAlg, scalaCliAlg)
5759
private val fallbackBuildTool = List(sbtAlg)
5860

5961
private def findBuildTools(buildRoot: BuildRoot): F[(BuildRoot, List[BuildToolAlg[F]])] =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2018-2025 Scala Steward contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.scalasteward.core.buildtool.gradle
18+
19+
import better.files.File
20+
import cats.Monad
21+
import cats.syntax.all.*
22+
import org.scalasteward.core.buildtool.{BuildRoot, BuildToolAlg}
23+
import org.scalasteward.core.data.Scope.Dependencies
24+
import org.scalasteward.core.data.{Resolver, Scope}
25+
import org.scalasteward.core.io.{FileAlg, WorkspaceAlg}
26+
import org.typelevel.log4cats.Logger
27+
28+
final class GradleAlg[F[_]](defaultResolver: Resolver)(implicit
29+
fileAlg: FileAlg[F],
30+
override protected val logger: Logger[F],
31+
workspaceAlg: WorkspaceAlg[F],
32+
F: Monad[F]
33+
) extends BuildToolAlg[F] {
34+
override def name: String = "Gradle"
35+
36+
override def containsBuild(buildRoot: BuildRoot): F[Boolean] =
37+
libsVersionsToml(buildRoot).flatMap(fileAlg.isRegularFile)
38+
39+
override def getDependencies(buildRoot: BuildRoot): F[List[Dependencies]] =
40+
libsVersionsToml(buildRoot)
41+
.flatMap(fileAlg.readFile)
42+
.map(_.getOrElse(""))
43+
.map(gradleParser.parseDependenciesAndPlugins)
44+
.map { case (dependencies, plugins) =>
45+
val ds = Option.when(dependencies.nonEmpty)(Scope(dependencies, List(defaultResolver)))
46+
val ps = Option.when(plugins.nonEmpty)(Scope(plugins, List(pluginsResolver)))
47+
ds.toList ++ ps.toList
48+
}
49+
50+
private def libsVersionsToml(buildRoot: BuildRoot): F[File] =
51+
workspaceAlg.buildRootDir(buildRoot).map(_ / "gradle" / libsVersionsTomlName)
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2018-2025 Scala Steward contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.scalasteward.core.buildtool.gradle
18+
19+
import cats.implicits.*
20+
import org.scalasteward.core.data.{ArtifactId, Dependency, GroupId, Module, Version}
21+
import org.tomlj.{Toml, TomlTable}
22+
import scala.jdk.CollectionConverters.*
23+
24+
object gradleParser {
25+
def parseDependenciesAndPlugins(input: String): (List[Dependency], List[Dependency]) = {
26+
val parsed = Toml.parse(input)
27+
val versionsTable = getTableSafe(parsed, "versions")
28+
val librariesTable = getTableSafe(parsed, "libraries")
29+
val pluginsTable = getTableSafe(parsed, "plugins")
30+
31+
val dependencies = collectEntries(librariesTable, parseDependency(_, versionsTable))
32+
val plugins = collectEntries(pluginsTable, parsePlugin(_, versionsTable))
33+
34+
(dependencies, plugins)
35+
}
36+
37+
private def collectEntries[A: Ordering](table: TomlTable, f: TomlTable => Option[A]): List[A] = {
38+
val aSet = table.entrySet().asScala.map(_.getValue).flatMap {
39+
case t: TomlTable => f(t)
40+
case _ => None
41+
}
42+
aSet.toList.sorted
43+
}
44+
45+
private def parseDependency(lib: TomlTable, versions: TomlTable): Option[Dependency] =
46+
for {
47+
case (groupId, artifactId) <- parseModuleObj(lib).orElse(parseModuleString(lib))
48+
version <- parseVersion(lib, versions)
49+
} yield Dependency(groupId, artifactId, version)
50+
51+
private def parseModuleObj(lib: TomlTable): Option[(GroupId, ArtifactId)] =
52+
for {
53+
groupId <- getStringSafe(lib, "group").map(GroupId(_))
54+
artifactId <- getStringSafe(lib, "name").map(ArtifactId(_))
55+
} yield (groupId, artifactId)
56+
57+
private def parseModuleString(lib: TomlTable): Option[(GroupId, ArtifactId)] =
58+
getStringSafe(lib, "module").flatMap {
59+
_.split(':') match {
60+
case Array(g, a) => Some((GroupId(g), ArtifactId(a)))
61+
case _ => None
62+
}
63+
}
64+
65+
private def parsePlugin(plugin: TomlTable, versions: TomlTable): Option[Dependency] =
66+
for {
67+
id <- getStringSafe(plugin, "id")
68+
groupId = GroupId(id)
69+
artifactId = ArtifactId(s"$id.gradle.plugin")
70+
version <- parseVersion(plugin, versions)
71+
} yield Dependency(groupId, artifactId, version)
72+
73+
private def parseVersion(table: TomlTable, versions: TomlTable): Option[Version] = {
74+
def versionString = getStringSafe(table, "version")
75+
def versionRef = getStringSafe(table, "version.ref").flatMap(getStringSafe(versions, _))
76+
versionString.orElse(versionRef).map(Version.apply)
77+
}
78+
79+
private def getTableSafe(table: TomlTable, key: String): TomlTable =
80+
Option
81+
.when(table.contains(key) && table.isTable(key))(table.getTableOrEmpty(key))
82+
.getOrElse(emptyTable)
83+
84+
private val emptyTable: TomlTable = Toml.parse("")
85+
86+
private def getStringSafe(table: TomlTable, key: String): Option[String] =
87+
Option.when(table.contains(key) && table.isString(key))(table.getString(key))
88+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2018-2025 Scala Steward contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.scalasteward.core.buildtool
18+
19+
import org.scalasteward.core.data.Resolver
20+
21+
package object gradle {
22+
val libsVersionsTomlName = "libs.versions.toml"
23+
24+
val pluginsResolver: Resolver.MavenRepository =
25+
Resolver.MavenRepository("gradle-plugins", "https://plugins.gradle.org/m2/", None, None)
26+
}

modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala

+7-8
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ import eu.timepit.refined.types.numeric.NonNegInt
2222
import io.circe.generic.semiauto.deriveCodec
2323
import io.circe.refined.*
2424
import io.circe.{Codec, Decoder}
25-
import org.scalasteward.core.buildtool.maven.pomXmlName
26-
import org.scalasteward.core.buildtool.mill.MillAlg
27-
import org.scalasteward.core.buildtool.sbt.buildPropertiesName
25+
import org.scalasteward.core.buildtool.{gradle, maven, mill, sbt}
2826
import org.scalasteward.core.data.{GroupId, Update}
29-
import org.scalasteward.core.scalafmt.scalafmtConfName
27+
import org.scalasteward.core.scalafmt
3028
import org.scalasteward.core.update.FilterAlg.{
3129
FilterResult,
3230
IgnoredByConfig,
@@ -106,16 +104,17 @@ object UpdatesConfig {
106104
val defaultFileExtensions: Set[String] =
107105
Set(
108106
".mill",
109-
MillAlg.millVersionName,
110107
".sbt",
111108
".sbt.shared",
112109
".sc",
113110
".scala",
114-
scalafmtConfName,
115111
".sdkmanrc",
116112
".yml",
117-
buildPropertiesName,
118-
pomXmlName
113+
gradle.libsVersionsTomlName,
114+
maven.pomXmlName,
115+
mill.MillAlg.millVersionName,
116+
sbt.buildPropertiesName,
117+
scalafmt.scalafmtConfName
119118
)
120119

121120
val defaultLimit: Option[NonNegInt] = None

modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala

+3-1
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ class BuildToolDispatcherTest extends FunSuite {
4141
}
4242

4343
val expectedState = initial.copy(trace =
44-
Cmd("test", "-f", s"$repoDir/pom.xml") +:
44+
Cmd("test", "-f", s"$repoDir/gradle/libs.versions.toml") +:
45+
Cmd("test", "-f", s"$repoDir/pom.xml") +:
4546
Cmd("test", "-f", s"$repoDir/build.sc") +:
4647
Cmd("test", "-f", s"$repoDir/build.mill") +:
4748
Cmd("test", "-f", s"$repoDir/build.mill.scala") +:
4849
Cmd("test", "-f", s"$repoDir/build.sbt") +:
4950
allGreps ++:
51+
Cmd("test", "-f", s"$repoDir/mvn-build/gradle/libs.versions.toml") +:
5052
Cmd("test", "-f", s"$repoDir/mvn-build/pom.xml") +:
5153
Cmd("test", "-f", s"$repoDir/mvn-build/build.sc") +:
5254
Cmd("test", "-f", s"$repoDir/mvn-build/build.mill") +:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.scalasteward.core.buildtool.gradle
2+
3+
import munit.CatsEffectSuite
4+
import org.scalasteward.core.TestSyntax.*
5+
import org.scalasteward.core.buildtool.BuildRoot
6+
import org.scalasteward.core.data.{Repo, Scope}
7+
import org.scalasteward.core.mock.MockContext.context.*
8+
import org.scalasteward.core.mock.{MockEffOps, MockState}
9+
10+
class GradleAlgTest extends CatsEffectSuite {
11+
test("getDependencies") {
12+
val repo = Repo("gradle-alg", "test-getDependencies")
13+
val buildRoot = BuildRoot(repo, ".")
14+
val buildRootDir = workspaceAlg.buildRootDir(buildRoot).unsafeRunSync()
15+
16+
val initial = MockState.empty.addFiles(
17+
buildRootDir / "gradle" / libsVersionsTomlName ->
18+
"""|[libraries]
19+
|tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" }
20+
|[plugins]
21+
|kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.20-Beta1" }
22+
|""".stripMargin
23+
)
24+
val obtained = initial.flatMap(gradleAlg.getDependencies(buildRoot).runA)
25+
val kotlinJvm =
26+
"org.jetbrains.kotlin.jvm".g % "org.jetbrains.kotlin.jvm.gradle.plugin".a % "2.1.20-Beta1"
27+
val expected = List(
28+
List("org.tomlj".g % "tomlj".a % "1.1.1").withMavenCentral,
29+
Scope(List(kotlinJvm), List(pluginsResolver))
30+
)
31+
assertIO(obtained, expected)
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.scalasteward.core.buildtool.gradle
2+
3+
import munit.FunSuite
4+
import org.scalasteward.core.TestSyntax.*
5+
6+
class gradleParserTest extends FunSuite {
7+
test("parseDependenciesAndPlugins: valid input") {
8+
val input =
9+
"""|[versions]
10+
|groovy = "3.0.5"
11+
|checkstyle = "8.37"
12+
|
13+
|[libraries]
14+
|groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
15+
|groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
16+
|groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }
17+
|commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer="3.9" } }
18+
|tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" }
19+
|
20+
|[bundles]
21+
|groovy = ["groovy-core", "groovy-json", "groovy-nio"]
22+
|
23+
|[plugins]
24+
|versions = { id = "com.github.ben-manes.versions", version = "0.45.0" }
25+
|""".stripMargin
26+
val obtained = gradleParser.parseDependenciesAndPlugins(input)
27+
val expected = (
28+
List(
29+
"org.codehaus.groovy".g % "groovy".a % "3.0.5",
30+
"org.codehaus.groovy".g % "groovy-json".a % "3.0.5",
31+
"org.codehaus.groovy".g % "groovy-nio".a % "3.0.5",
32+
"org.tomlj".g % "tomlj".a % "1.1.1"
33+
),
34+
List(
35+
"com.github.ben-manes.versions".g % "com.github.ben-manes.versions.gradle.plugin".a % "0.45.0"
36+
)
37+
)
38+
assertEquals(obtained, expected)
39+
}
40+
41+
test("parseDependenciesAndPlugins: empty input") {
42+
val obtained = gradleParser.parseDependenciesAndPlugins("")
43+
assertEquals(obtained, (List.empty, List.empty))
44+
}
45+
46+
test("parseDependenciesAndPlugins: malformed input") {
47+
val input =
48+
"""|versions]
49+
|groovy = "3.0.5"
50+
|[libraries]
51+
|groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy"
52+
|foo = { module = "bar:qux:foo", version = "1" }
53+
|[plugins]
54+
|foo = ""
55+
|""".stripMargin
56+
val obtained = gradleParser.parseDependenciesAndPlugins(input)
57+
assertEquals(obtained, (List.empty, List.empty))
58+
}
59+
}

0 commit comments

Comments
 (0)