Repository: scala-steward-org/scala-steward (scala)

Issue:
Extract dependencies from Gradle Version Catalogs
Gradle has [Version Catalogs](https://docs.gradle.org/current/userguide/version_catalogs.html) which are lists of dependencies in `gradle/libs.versions.toml` files which Scala Steward could easily extract dependencies from and therefore partially support Gradle builds. A Version Catalog does not include resolvers, so in a first step Scala Steward would only use the [default resolver](https://github.com/scala-steward-org/scala-steward/blob/eae30d0ce47334c04e94dd47410e2992d2c3e5eb/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala#L319-L324) to look for new versions.

Pull request description:
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.

The support is only partial, because
* a default resolver is assumed (as mentioned above)
* dependencies and plugins that are defined in other Gradle build files are ignored
* [additional version catalogs](https://docs.gradle.org/current/userguide/version_catalogs.html#sec:multiple-catalogs) are ignored
* a version catalog with a custom name is ignored

Closes: #3534

Patch:
diff --git a/build.sbt b/build.sbt
index b053eda9bd..a13e7c59a2 100644
--- a/build.sbt
+++ b/build.sbt
@@ -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,
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 44670f058b..e1d23c7a71 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -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
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index 09ed1ac883..0c76bfa787 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -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
@@ -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],
@@ -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]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
index 6635bbb170..be868f6f09 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
@@ -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
@@ -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],
@@ -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]])] =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala
new file mode 100644
index 0000000000..292ca5182a
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/GradleAlg.scala
@@ -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)
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala
new file mode 100644
index 0000000000..b75fa68f62
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/gradleParser.scala
@@ -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))
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala
new file mode 100644
index 0000000000..a98de6153d
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/gradle/package.scala
@@ -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)
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
index 235df83ac9..20fd3bfd4d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
@@ -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,
@@ -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
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index fd64f5920d..0d411b236a 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -44,4 +44,5 @@ object Dependencies {
   val scalaStewardMillPluginArtifactName = "scala-steward-mill-plugin"
   val scalaStewardMillPlugin =
     "org.scala-steward" % s"${scalaStewardMillPluginArtifactName}_mill0.10_2.13" % "0.18.0"
+  val tomlj = "org.tomlj" % "tomlj" % "1.1.1"
 }


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
index 9a5bc67634..d89d9e5e90 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
@@ -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") +:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala
new file mode 100644
index 0000000000..31118ff8fc
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/GradleAlgTest.scala
@@ -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)
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala
new file mode 100644
index 0000000000..8b10095b9a
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/gradle/gradleParserTest.scala
@@ -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))
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
index 5034112012..68d153a204 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
@@ -947,6 +947,44 @@ class RewriteTest extends FunSuite {
     runApplyUpdate(update, original, expected)
   }
 
+  test("Gradle Version Catalog") {
+    val update = ("org.tomlj".g % "tomlj".a % "1.0.0" %> "1.1.1").single
+    val original = Map(
+      "gradle/libs.versions.toml" ->
+        """|[libraries]
+           |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.0.0" }
+           |""".stripMargin
+    )
+    val expected = Map(
+      "gradle/libs.versions.toml" ->
+        """|[libraries]
+           |tomlj = { group = "org.tomlj", name = "tomlj", version = "1.1.1" }
+           |""".stripMargin
+    )
+    runApplyUpdate(update, original, expected)
+  }
+
+  test("Gradle Version Catalog with version.ref") {
+    val update = ("org.tomlj".g % "tomlj".a % "1.0.0" %> "1.1.1").single
+    val original = Map(
+      "gradle/libs.versions.toml" ->
+        """|[versions]
+           |tomlj = "1.0.0"
+           |[libraries]
+           |tomlj = { group = "org.tomlj", name = "tomlj", version.ref = "tomlj" }
+           |""".stripMargin
+    )
+    val expected = Map(
+      "gradle/libs.versions.toml" ->
+        """|[versions]
+           |tomlj = "1.1.1"
+           |[libraries]
+           |tomlj = { group = "org.tomlj", name = "tomlj", version.ref = "tomlj" }
+           |""".stripMargin
+    )
+    runApplyUpdate(update, original, expected)
+  }
+
   private def runApplyUpdate(
       update: Update.Single,
       files: Map[String, String],