Write & test your own scala SBT plugin…

There are a lot of cool sbt plugins available in the market which makes the developer's life very easy. It helps automate a lot of manual tasks.

But have you ever wondered how those works? Or came across a situation where some sbt plugins fall short of your needs, then you had to make some tweaks/hacks in plugin’s configurations which is by the way very interesting job when you know how plugins are usually written.

This blog post will help you write a simple SBT plugin and explain how to test them.

So let’s begin …

We will be creating a plugin called sbt-zip that creates a distributable zip file containing the files from the directory specified in plugins settings.

First of all, create a simple scala project with the following directory structure:

  1. project/plugins.sbt
  2. build.sbt
  3. src/main/scala directory

Let’s start with plugins.sbt which resides in a project directory. Build from the project directory is called meta-build which knows how to build your build (build.sbt), so it makes sense to start with the project directory rather than build.sbt :)

Add scripted plugin dependency to our project in plugins.sbt. It provides a framework for testing our plugin. We will see where it shines, later in this blog post.

libraryDependencies += "org.scala-sbt" %% "scripted-plugin" % sbtVersion.value

Let’s move to build.sbt which looks like this:

lazy val root = (project in file("."))
.settings(
name := "sbt-zip",
organization := "io.kpritam.sbt",
version := "0.1-SNAPSHOT",
sbtPlugin := true,
scriptedLaunchOpts += ("-Dplugin.version=" + version.value),
scriptedLaunchOpts ++= sys.process.javaVmArguments.filter(
a => Seq("-Xmx", "-Xms", "-XX", "- Dsbt.log.noformat").exists(a.startsWith)
),
scriptedBufferLog := false
)

Here `scriptedLaunchOpts` setting is the sequence of options which are passed to JVM launching scripted tasks and setting sbtPlugin flag to true, adds sbt as a dependency and automatically creates plugins descriptor file at sbt/sbt.autoplugins.

Setting `scriptedBufferLog` flag to false displays logs on the console when we run plugin tests using scripted tasks.

At this point, we have everything we need to start writing our sbt plugin.

Before you write your own plugin, I would encourage you to read SBT Best Practices.

The next section explains defining sbt Keys and Tasks required for our plugin.

Start with creating `sbtzip` package in `src/main/scala` directory.

Plugins usually follow a convention where Keys are specified in ${PluginName}Keys.scala and Tasks are defined in ${PluginName}Plugin.scala. In our case those are ZipKeys.scala and ZipPlugin.scala respectively.

ZipKeys.scala contains the following Keys:

trait ZipKeys {lazy val sourceZipDir = settingKey[File]("source directory to generate zip from.")lazy val targetZipDir = settingKey[File]("target directory to store generated zip.")lazy val zip = taskKey[Unit]("Generates zip file which includes all files from sourceZipDir")}

Here we have defined three keys. A consumer of our plugin can initialize the values of sourceZipDir and targetZipDir keys. Our plugin defines a default value for targetZipDir which will be used by zip task if a consumer of our plugin does not override this value.

One can initialize Keys value as shown below:

targetZipDir := target.value / “zip”

Sbt defines three types of keys:

  • SettingKey[T]: a key for a value computed once (the value is computed when loading the subproject, and kept around).
  • TaskKey[T]: a key for a value, called a task, that has to be recomputed each time, potentially with side effects.
  • InputKey[T]: a key for a task that has command line arguments as input. Check out Input Tasks for more details.

These definitions are taken from sbt’s Basic-Def page.

ZipPlugin uses keys defined in ZipKeys.scala to evaluate zip task as shown in below code:

object ZipPlugin extends AutoPlugin {override val trigger: PluginTrigger = noTriggeroverride val requires: Plugins = plugins.JvmPluginobject autoImport extends ZipKeysimport autoImport._override lazy val projectSettings: Seq[Setting[_]] =Seq(
targetZipDir := target.value / "zip",
zip := zipTask.value
)
private def zipTask = Def.task {
val log = sLog.value
lazy val zip = new File(targetZipDir.value,
sourceZipDir.value.getName + ".zip")
log.info("Zipping file...")
IO.zip(Path.allSubpaths(sourceZipDir.value), zip)
zip
}
}

The plugin needs to be extended from AutoPlugin. By doing this, we can override the below methods:

  • trigger: Determines whether this AutoPlugin will be activated for this project when the `requires` clause is satisfied.
  • requires: Defines the dependencies of the plugin.
  • projectSettings: Sequence of settings to be added to the project scope where this plugin is activated.

We have written a simple zipTask in ZipPlugin which basically retrieves the value of sourceZipDir key and create a zip file containing all the files from sourceZipDir at location targetZipDir.value

For more details about sbt tasks, visit sbt’s Tasks page.

At this point, our simple plugin is ready to use. But before making it available to the entire world, it is always a good idea to test.

You can find an extensive guide on testing any sbt plugin here.

This section is focused on testing our sbt-zip plugin.

Let’s start by creating new directory structure: src/sbt-test/sbt-zip/simple. Treat `simple` as a new sbt project which uses our plugin.

This project will have it’s own:

  1. build (build.sbt)
  2. meta-build (project/*)
  3. src directory.

Note that, creating dir `src/sbt-test` is important. Remember we added a scripted-plugin dependency in the beginning. If you look at the plugins default configuration, you will see:

sbtTestDirectory := sourceDirectory.value / "sbt-test"

which means when we run tests using a scripted plugin, it will look for this directory. Of course, you can override this configuration.

Configure our test projects build.sbt as follows:

lazy val root = (project in file("."))
.enablePlugins(ZipPlugin)
.settings(
scalaVersion := "2.12.4",
version := "0.1",
sourceZipDir := crossTarget.value
)

The first thing we are doing here is enabling our ZipPlugin and initializing the value of key sourceZipDir to crossTarget.value. That means ZipPlugin will generate a zip file containing all the files from crossTarget.value directory which in our case will be `target/scala-2.12`

Configure project/plugins.sbt as follows:

sys.props.get("plugin.version") match {
case Some(x) => addSbtPlugin("io.kpritam.sbt" % "sbt-zip" % x)
case _ => sys.error("""|The system property 'plugin.version' is not defined.
|Specify this property using the scriptedLaunchOpts -D.""".stripMargin)
}

Basically we are adding our newly created ZipPlugin dependency in a simple project. And the little trick here is to test our plugin against cross-version if required is to get plugin version number from system property.

Till now we have set up our test project simple. Now, add a script to test our ZipPlugin. Make sure that the script name is `test` as scripted plugin looks for a file with name `test` in the sbt test directory.

Add below scenario to test file:

> zip$ exists target/zip/scala-2.12.zip
  • `> task` executes sbt task
  • `$ exists` checks if file exists

Complete script syntax is available at sbt’s Testing-sbt-plugins page.

Then to run a test, enter into sbt-zip projects sbt console and run:

sbt:sbt-zip> scripted

The scripted task will perform two operations -

  1. Publish plugin locally which ll be resolved by our test project (simple)
  2. Run tests from sbt-test directory

Now you are ready to publish your plugin to any artifactory for example, bintray and make it available to the entire world.

ZipPlugin referred in this blog is available at my GitHub repository: https://github.com/kpritam/sbt-zip

Developer @Thoughtworks