Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract dependencies from Gradle Version Catalogs #3542

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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] =
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
Loading