Custom SBT Task to List Direct Dependencies of a Project

YadukrishnanYadukrishnan
4 min read

Introduction

Recently, at work, there was a requirement to list out the direct dependencies of the our SBT project, something like Bill Of Materials.

SBT itself provides the dependencies as list or tree or searchable HTML page using the built-in commands such as dependencyList , dependencyTree and so on. Similarly, the GitHub also provides a SBOM option to download all the dependencies list.

However, since they provide dependencies including transitive ones, it was not what I was looking for. So, with the help of my colleagues, we wrote a simple custom sbt task to get the details. There might have been some other approaches available, but I wanted to get this quickly without a lot of research :)

Requirement

As you might already know, we can write custom tasks in SBT to perform tasks or retrieve build related information.

We can write any file with extension .sbt and place it in the main root directory. On sbt load, all the .sbt files will be loaded and available for execution.

We can get the information of direct dependencies using the command sbt libraryDependencies . But this prints the information directly into console as a text data. However, we want to process and filter the data as per our requirement.

At the end, we want to generate a markdown file that contains the list of libraries and their version. Also, it should have separate tables for main dependencies and test dependencies.

Implementation

There are multiple modules and not all the modules are aggregated in the main root project. As a result, we need to get the dependencies from each sub module and aggregate them into the resultant output.

Let's write the custom task for this:

import sbt.*
import Keys.*
import scala.collection.mutable.ListBuffer

lazy val generateDeps = taskKey[Unit]("Generates direct library dependencies of the project")
generateDeps := Def.taskDyn {

  def formatAsMarkdownTable(
      artifacts: Seq[(String, String, String, Option[String])]
  ): String = {
    val header = "| Group ID | Artifact ID | Version |\n| --- | --- | --- |"
    val rows = artifacts
      .groupBy { case (groupId, artId, version, _) =>
        s"| $groupId | $artId | $version |"
      }
      .keys
      .toSeq
      .sorted
    (header +: rows).mkString("\n")
  }

  val projectRefs: Seq[ProjectRef] = loadedBuild.value.allProjectRefs.map(_._1)
  val dependencies: ListBuffer[(String, String, String, Option[String])] =
    ListBuffer.empty
  Def
    .sequential {
      projectRefs
        .map { projectRef =>
          Def.task {
            val allDeps =
              (projectRef / libraryDependencies).value.sortBy(_.organization)
            allDeps.map { dep =>
              val configurations = dep.configurations
              val res =
                (dep.organization, dep.name, dep.revision, configurations)
              dependencies.append(res)
              res
            }
          }
        }
    }
    .map { _ =>
      val mainDeps = dependencies.filter(_._4.isEmpty).sortBy(_._1).distinct
      val testDeps = dependencies.filter(_._4.nonEmpty).sortBy(_._1).distinct
      val mainDepsTable = formatAsMarkdownTable(mainDeps)
      val testDepsTable = formatAsMarkdownTable(testDeps)
      val markdownContent = s"""# List of direct dependencies
                             |
                             |This document lists all direct dependencies of the project.
                             |
                             |## Main Libraries
                             |
                             |$mainDepsTable
                             |
                             |## Test Libraries
                             |
                             |$testDepsTable
                             |
                             |""".stripMargin

      val file = new File("directDependencies.md")
      IO.write(file, markdownContent)
      ()
    }
}.value

We wrapped the entire implementation in Def.taskDyn. This allows to create a dynamic task. Since we are iterating through the modules and generating the dependencies list, we need to make use of dynamic task as it is not known at the compile time.

We can get the list of defined sbt modules using loadedBuild.value.allProjectRefs.

Iterating through each of the modules, we use the Def.task to create a task that uses the projectDependencies to get the list of dependencies in each module. We can extract the value of the projectDependencies task by using the value method. Then we can extract the required information such as artifactId, groupId, scope and so on.

Notice that we used Def.sequential {}. Since we are creating a task per module dynamically, we need to use the sequential method to execute them one by one. Please note that I could't find a way to collect the results from each of the tasks, so I used a mutable ListBuffer to store this info from each task.

Once that is available, we can simple format in the way we want it and write to a a file. If we want we can even add this task to the sbt load to generate on every load of sbt:

lazy val generateDepsTask: State => State = { s: State =>
  "generateDeps" :: s
}
Global / onLoad := {
  val previous = (Global / onLoad).value
  generateBomTask.compose(previous)
}

May be there is a simpler way to do this, please comment if there is a better way for this.

Conclusion

This SBT task can generate the list of direct dependencies. SBT is very powerful, however, it is a bit tricky to understand and do advanced stuff with it.

Hope someone will benefit from reading this.

The sbt code and the generated sample dependencies markdown file is available here.

0
Subscribe to my newsletter

Read articles from Yadukrishnan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yadukrishnan
Yadukrishnan

Travel | Movies | History | Nature I am a software developer. Started my career as a Java developer, but later switched to Scala. I wish to write Scala articles which are easier for newbies to follow. I love to travel, prefer to be alone or in very small group. Love to watch movies/series.