How I SBT - III

HKT HKT
Views
Article hero image

Previously, we discussed how to quickly write a simple build.sbt without fuss. We briefly understood how it is processed by SBT along with Settings and Tasks. We did that without having to know about the build folder structure1 et al.; until now.

Build Folder Structure

Below is the project and build folder structure. There are two things that SBT is interested in your project - build.sbt and project/ 2.

root of the project
├ src/main/scala/...
├ project/
  ├ build.properties
  ├ ...
├ build.sbt
├ .gitignore

The build.properties contains the sbt.version = x.y.z 3 to instruct the SBT to ensure that the aforementioned version of SBT is used for building your project. The sbt command by itself is a launcher to download / pick the relevant version of SBT.

In fact, build.sbt alone is enough for SBT to create the project/ folder. SBT creates build.properties automatically, if one does not already exist. Try it …

AnimatedImage.gif

The project/ folder is where the Plugins also go!

Plugins

A Plugin is a way in SBT to add specific features or functionalities to the build. It allows adding code to customize and extend the build capabilities of your project; such as adding or updating Settings, defining new Tasks, code generation etc. Plugins may be shared across projects, if written in a generic enough way.

There are tons of community plugins. Here is a just glimpse of the popular ones:

  • sbt-pack - A sbt plugin for creating distributable Scala packages
  • sbt-dependency-graph4 - sbt plugin to create a dependency graph for your project
  • sbt-docker - Create Docker images directly from sbt
  • sbt-prompt5 - An SBT plugin for making your SBT prompt more awesome

There are dozens of very popular plugins that do different things. I just listed what came to my mind at the time of this writing.

To add a plugin to your project, add addSbtPlugin("org" %% "name" % "<version>") to project/plugins.sbt .

Using the name plugins.sbt is a convention. But it can be named anything as long as the extension is .sbt .

Add the following to project/plugins.sbt to add the docker plugin to your project.

addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.9.0")

You may add the desired plugins like mentioned above. Since sbt-dependency-graph is bundled with, you use the helper addDependencyTreePlugin 6.

Depending on the Plugin, you may have to enablePlugin 7 explicitly. Or they may be activated automatically. In other words, they will be executed without developer intervention.

Global.sbt

SBT files under ~/.sbt/1.0 are processed / loaded for all SBT projects on the machine. The convention is to create a global.sbt although you can name it anything, and create any number of sbt files.

So, if you addSbtPlugin in global.sbt, it will be made available for all SBT projects on your machine. Depending on the trigger (discussed below), the plugin may be automatically activated.

On my machine, in global.sbt, I have customizations for the prompt of my SBT shell.

Holy grail of SBT - Custom Plugins

Plugins by itself may be considered the Holy Grail of SBT. But custom Plugins is the cherry on top. It is easy and fun to write SBT Plugins. You don’t believe me. Try writing a Maven or Gradle plugin. 🙄

Alright, let us write one. The Plugin we are going to write is one that configures your build with a preset of compiler options. Imagine writing one for your company that all teams would have to use as a standard.

import sbt.*

object MyCompilerOptionsPlugin extends AutoPlugin {
  override def projectSettings: Seq[Setting[_]] = Seq(
    scalacOptions ++=
      "-encoding" ::
      "utf8" ::
      "-deprecation" ::
      "-feature" ::
      "-unchecked" ::
      "-Xlint:adapted-args" ::
      "-Xfatal-warnings" ::
      "-Wvalue-discard" ::
      // ... whatever else
      Nil
  )
}
Use -Xsource3 compiler option to use import sbt.* rather than import sbt._. The aforementioned compiler option backports some of the Scala 3 syntax to Scala 2 😎 - *, &, ?, as` etc.

Pardon the name. Otherwise, the plugin is pretty generic8. Let us dissect.

  • There is hardly a need for more than one instance of our Plugin. Hence, we declare it as an object.
  • Our Plugin extends AutoPlugin - the base type9 of Plugins, which provides the ability to enable it based on certain conditions. Or automatically. In our case, it is neither. We would explicitly enablePlugin in our build definition.
lazy val petStore =
  project
    .in(file("."))
    .settings(
      name := ...
      version :=  ...
      organization := ...
      scalaVersion := ...
    )
    // ENABLING THE PLUGIN HERE, which will update the settings
    // of this project with the compiler options set in the plugin
    .enablePlugins(MyCompilerOptionsPlugin)
    .settings(
       ...
    )
  • If you like to automatically enable the plugin without having to explicit call enablePlugins then you would add this to our Plugin definition:
object MyCompilerOptionsPlugin extends AutoPlugin {
  override def trigger = allRequirements

  override def projectSettings: Seq[Setting[_]] = ...
}
  • trigger controls when the Plugin is applied to the project
  • allRequirements is a predefined value in SBT to apply our Plugin unconditionally to all projects in the build. Note that so far we are dealing with a single project.
  • If we don’t specify the trigger value, SBT defaults it to a predefined noTrigger, which means it has to be explicitly enabled.
  • Conditionally applying may be handled through enablePlugins or disablePlugins. The other option, which I don’t recommend, is through Plugin logic by bypassing if certain conditions don’t meet.
  • There are cases where our Plugins may have to depend on others. Say you are going to wrap around the sbt-scalafmt for augmenting with whatever. In such a case, if the former was not applied, our Plugin would fail miserably. And will fail the build. SBT allows to capture such dependencies via requires.
import sbt.*
import org.scalafmt.sbt.ScalafmtPlugin

object MyCompilerOptionsPlugin extends AutoPlugin {
  override def requires = ScalafmtPlugin && plugins.JvmPlugin
  
  override def trigger = allRequirements

  override def projectSettings: Seq[Setting[_]] = ...
}

Plugin Scope

A Plugin may be configured to apply to the project or build. projectSettings get applied to every project in the build. So, they will be invoked once per project in the build. buildSettings get applied once for the entire build. In fact, you could add the settings in MyCompilerOptionsPlugin to the build in addition you could also add the scalaVersion, version, and organization. Because in all likelihood they will be the same for all projects in the build.

object MyCompilerOptionsPlugin extends AutoPlugin {
  override def requires = ...
  override def trigger = allRequirements

  override def buildSettings: Seq[Setting[_]] = Seq(
    version := "0.1.0-SNAPSHOT",
    organization := "hkt",
    scalaVersion := "2.13.15",
    scalacOptions ++= ... same options as before ...
  )
}

If that is the case, is there a need for projectSettings, you ask. Sure yes. There are project level settings to be applied such as library dependencies, compilation source and resources paths, settings for code generation specific to the project etc.

I am liberal in defining Plugins in my project. I group settings and throw them into a Plugin, make it as generic as possible, at least within the business domain .

Because a name speaks more than a bunch of settings. Don’t agree. Tell me what this does. You got one second 😄

resTask := {
  val resources = (Compile / resourceDirectory).value
  val managedResources = (Compile / resourceManaged).value
  val logger = streams.value.log

  (resources ** "*.txt").get.foreach { file =>
    val content = IO.read(file)
    val processedContent = s"# DO NOT EDIT\n\n$content"
    val targetFile = managedResources / file.getName
    IO.write(targetFile, processedContent)
    logger.info(s"Processed resource: ${file.getName}")
  }
}

However, if I create a PrependDoNotEditToTxtResourcesPlugin then the blob of logic is not that intimidating.

Order of loading Plugins

While this is important to know, in practice, the order should not matter much. Plugins tend to be fairly orthogonal in the functionality they add or augment. Our compiler options plugin has little overlap with say coverage plugin. But there are situations when you might see ordering problems such as _Setting_s assuming unexpected value. In such cases, knowing the ordering should come in handy.

There is an overall order in which plugins are loaded:

  • Global plugins (~/.sbt/1.0)
  • Plugins listed in order in project/plugins.sbt
  • Auto plugins defined in project/ folder.
    • Automatic enabled plugins (trigger=allRequirments). Order of plugins loaded is not deterministic. If two plugins write to the same settings (say organization), the last one to write to the settings wins.
    • Plugins enabled in build.sbt in the order listed.

Cliffhanger

I think we can stop there for now. I believe you should be able to write Plugins now to your heart’s content. There’s more to Plugins that what I have talked about here. Refer the official documentation.

Looking back, you should be able to write a decent SBT build definition with Settings, Tasks, and Plugins ; built-in and custom. Remember my style / tip to write the build definition in a functional style.


  1. I am not talking about the project structure of a JVM application - src/main/[scala|java|...]. But whatever hierarchy and paths are required for the build tool; SBT in our case. ↩︎

  2. There are some other files that SBT uses from the root of the project (or in your project folders), which is not important now. We may not discuss it all. Because they are not that important as there are other ways to achieve what those files do. ↩︎

  3. I have not used SBT versions that used to have more than only sbt.version property. It is before my time with it. They say there may be other properties, which today should go into other files in the project/ folder. Pardon the digression. ↩︎

  4. Bundled and shipped with SBT ↩︎

  5. It is great plugin. I don’t use this plugin but I will show you later how I do the same. ↩︎

  6. … instead of addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2") ↩︎

  7. You can disablePlugin too. But you can’t disable one (say B) that is a prerequisite for a plugin (say A) you are using. You would have to disable A,and cannot disable B↩︎

  8. Take it with a grain of salt when I say generic. If you want a really generic plugin, you should be using sbt-tpolecat↩︎

  9. There are regular plugins. Then there is AutoPlugin. Am not sure of the current support for regular plugins. I have not written one. You will mostly see only the latter out there. ↩︎

scala sbt series