Skip to Content
Course content

85: sbt Plugins and Custom Tasks

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been looking through our current project, and I noticed we have dozens of TODO comments scattered across the codebase. It's starting to feel like a graveyard of "I'll fix this later" promises. I want a way to scan the source tree and print a summary of these TODOs directly from the sbt shell, so I don't have to rely on my IDE's search tool every time.

Trying to cheat with a simple method

My first instinct is to just throw a helper method right into build.sbt. It seems easy enough. I'll just write a Scala function that reads the files and prints the lines.

def listTodos() = {
  val files = new java.io.File("src").walk().toList
  files.filter(_.getName.endsWith(".scala")).forEach { f =>
    scala.io.Source.fromFile(f).getLines().filter(_.contains("TODO")).foreach(println)
  }
}

I load this into sbt and try to run listTodos. It works... technically. But it's just a method call in the shell. It doesn't feel like a part of the build. I can't make it depend on other tasks, and it doesn't have access to sbt's internal logging system. If I run this in a CI environment, it might just vanish into stdout without any proper formatting. It's a script, not a task.

Turning a method into a real Task

To make this a first-class citizen in sbt, I need a taskKey. This is how sbt tracks what a task is and what it returns. I'll define a key and then assign a task to it. I'll make it return a Unit since we're just printing information.

val listTodos = taskKey[Unit]("Scans source files for TODO comments")

listTodos := {
  val files = new java.io.File("src").walk().toList
  files.filter(_.getName.endsWith(".scala")).forEach { f =>
    scala.io.Source.fromFile(f).getLines().filter(_.contains("TODO")).foreach(println)
  }
}

Now when I run sbt listTodos, it feels right. It's an official part of the project. But I've hit a snag: println is a bad habit in sbt. Because sbt runs tasks in parallel and handles logging specifically, using println can lead to interleaved output or messages that get swallowed. I need to use the sbt logger.

Getting the console to actually talk back

To get the logger, I need the streams task. This is a built-in sbt task that provides access to the standard output and error streams. I have to tell sbt that my listTodos task depends on streams.

listTodos := {
  val log = streams.value.log
  val root = baseDirectory.value // Using the sbt-aware base directory instead of "src"
  
  val files = new java.io.File(root, "src").walk().toList
  files.filter(_.getName.endsWith(".scala")).forEach { f =>
    scala.io.Source.fromFile(f).getLines().filter(_.contains("TODO")).foreach { line =>
      log.info(s"Found TODO in ${f.getName}: $line")
    }
  }
}

Note the .value calls. In sbt, tasks are essentially "recipes." When you call .value, you're telling sbt: "Go execute that task now and give me the result." This is where the magic happens—if streams or baseDirectory were complex tasks themselves, sbt would handle their execution order automatically.

Packaging this as an AutoPlugin

This works great for one project. But what if I have five different Scala services? I don't want to copy-paste this logic into every build.sbt. That's where AutoPlugin comes in. I can wrap this logic into a plugin that automatically adds the task to any project that enables it.

I'll create a file in project/TodoPlugin.scala (since anything in the project/ folder is compiled and available to the build):

import sbt._
import Keys._

object TodoPlugin extends AutoPlugin {
  override def trigger = allRequirements // This makes it activate automatically

  object autoInterface {
    val listTodos = taskKey[Unit]("Scans source files for TODO comments")
  }

  import autoInterface._

  lazy val projectSettings = Seq(
    listTodos := {
      val log = streams.value.log
      val root = baseDirectory.value
      // ... same logic as before ...
      log.info("TODO scan complete.")
    }
  )
}

By extending AutoPlugin and setting trigger = allRequirements, I've essentially told sbt: "Whenever you see this plugin in the classpath, just inject these settings into every project." Now my build.sbt is clean again, and I have a reusable tool across my entire organization.




📋 Practical Task

Build a "Dependency Version Checker" Task

Your goal is to create a custom sbt task that checks if a specific "forbidden" library version is being used in the project. This is common in large teams to prevent developers from accidentally using a version of a library known to have a critical bug.

  • Create a taskKey named checkForbiddenDeps.
  • The task should access the update task (which contains the resolved dependencies of the project).
  • Iterate through the resolved dependencies and check if any of them match a specific organization and version (e.g., "org.apache.logging.log4j" version "2.14.1").
  • If a forbidden version is found, use streams.value.log.error to print a warning; otherwise, print a success message.
  • Implement this as an AutoPlugin within the project/ directory so it is automatically applied.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.