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 17, 2025
1 parent 5b13f31 commit b5d3b0a
Show file tree
Hide file tree
Showing 16 changed files with 323 additions and 17 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, Module, 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[Module] =
for {
groupId <- getStringSafe(lib, "group").map(GroupId(_))
artifactId <- getStringSafe(lib, "name").map(ArtifactId(_))
} yield (groupId, artifactId)

private def parseModuleString(lib: TomlTable): Option[Module] =
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 @@ -17,17 +17,19 @@
package org.scalasteward.core

package object data {
type Module = (GroupId, ArtifactId)

val scalaLangGroupId: GroupId = GroupId("org.scala-lang")

val scala2LangModules: List[(GroupId, ArtifactId)] =
val scala2LangModules: List[Module] =
List(
(scalaLangGroupId, ArtifactId("scala-compiler")),
(scalaLangGroupId, ArtifactId("scala-library")),
(scalaLangGroupId, ArtifactId("scala-reflect")),
(scalaLangGroupId, ArtifactId("scalap"))
)

val scala3LangModules: List[(GroupId, ArtifactId)] =
val scala3LangModules: List[Module] =
List(
"scala3-compiler",
"scala3-library",
Expand All @@ -42,7 +44,7 @@ package object data {
"tasty-core"
).map(artifactId => (scalaLangGroupId, ArtifactId(artifactId)))

val scalaLangModules: List[(GroupId, ArtifactId)] =
val scalaLangModules: List[Module] =
scala2LangModules ++ scala3LangModules

val scalaNextMinVersion: Version = Version("3.4.0-NIGHTLY")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package org.scalasteward.core.repocache
import cats.syntax.all.*
import io.circe.Codec
import io.circe.generic.semiauto.*
import org.scalasteward.core.data.{ArtifactId, DependencyInfo, GroupId, Scope}
import org.scalasteward.core.data.{DependencyInfo, Module, Scope}
import org.scalasteward.core.git.Sha1
import org.scalasteward.core.repoconfig.RepoConfig

Expand All @@ -29,7 +29,7 @@ final case class RepoCache(
maybeRepoConfig: Option[RepoConfig],
maybeRepoConfigParsingError: Option[String]
) {
def dependsOn(modules: List[(GroupId, ArtifactId)]): Boolean =
def dependsOn(modules: List[Module]): Boolean =
dependencyInfos.exists(_.value.exists { info =>
modules.exists { case (groupId, artifactId) =>
info.dependency.groupId === groupId &&
Expand Down
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 @@ -29,7 +29,7 @@ package object scalafmt {
ArtifactId(core, s"${core}_$defaultScalaBinaryVersion")
}

val scalafmtModule: (GroupId, ArtifactId) =
val scalafmtModule: Module =
(scalafmtGroupId, scalafmtArtifactId)

def isScalafmtCoreUpdate(update: Update.Single): Boolean =
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)
}
}
Loading

0 comments on commit b5d3b0a

Please sign in to comment.