How I SBT - II

HKT HKT
Views
Article hero image

Previously, I showed you how to write a SBT build definition without knowing much at all. Neither did I talk about simple things like directory structure nor about advanced things like scope or axis, yet you could pretty easily write a SBT build definition for a library and setup publishing. All by writing Scala code (DSL for SBT).

Today, we are going to talk about Settings and Tasks.

Settings

While I was repeatedly using the term settings in a general way, SBT has the concept of Settings (defined as SettingKey[T]); along with Tasks. Settings are like variables; typed! They are evaluated once and cached when SBT loads your build definition. Any assignments using := you saw in the previous post is a Setting.

name := ...,
scalaVersion := ...,

When I say evaluated once, think of val x = <value>. However, the value may be mutable itself but x is not. Why do I say that? Because the value of Settings like libraryDependencies, dependencyOverrides may be updated.

libraryDependencies ++= <dependency-1> :: <dependency-2> :: Nil

Assigning libraryDependencies := <dependencies> will reset the value to <dependencies> (unlike ++= or +=)

In any case, the Settings are evaluted once and cached for the build no matter how many times you have assigned or updated in your build definition.

Settings recap:

  • Produce a value. Value assigned using :=.
  • Evaluated once when sbt starts up, and cached.
  • Can only depend on other settings
  • For values that don’t change during a build

Let us take a look at how one of the Settings, say version, is defined in SBT:

val version: : SettingKey[String] =
  settingKey[String]("The version/revision of the current module.")

Would you like to define your own (custom) setting? You will have to wait until we talk about plugins. Because defining a custom setting directly in build.sbt has little value. Providing a setting via plugins is a standard mechanism for custom plugins.

Hold your horses. On to next Tasks (pun intended).

Tasks

If Settings are like variables, Tasks are like functions. In fact, they are. They are executed and produce values as many times as they are referenced for their value. A lot of the commands that you issue to SBT on the command line are actually backed by tasks - compile, clean, publishLocal, test:compile etc.

Here is how to declare one of your own:

lazy val greet = taskKey[Unit]("Greets the user before compilation starts")
greet := {
  val whoami = System.getProperty("'user.name'")
  println(s"May the force be with you, $whoami")
}

// .... then in your build definition
.settings(
  Compile / compile := (Compile / compile).dependsOn(greet).value
)

Alright, so …

  • Tasks can be reassigned. Here, the task Compile / compile is reassigned by evaluating based on its current value, which is queried using .value. Note that the value of a Task can be queried only within another Task. For instance, you cannot someSetting := someTask.value, which a lot of developers havea trouble with in their intial days with SBT.
  • Compile / compile denotes compilation task of the main sources. Test / compile denotes compilation task of the test sources.
    • compile is the Task.
    • Compile and Test are Scopes. I won’t say anything more about Scopes; not now.
  • .dependsOn says that the given task (Compile / compile) depends on another task - our task, greet. That means, every time you issue a compile command, you will be see the Star Wars message first then the code will compile. After all, it is fair to greet and bless before you undertake a big endeavor.

Tasks and Return Values

The greet task does not return any value. But Tasks may return value:

lazy val arbString: task[String]("Generates a random string")
arbString := {
  scala.util.Random.nextString(10) // Implementation not important here
}

Why do Tasks have to return value? So that they can be compose with other Tasks . It means, one Task can reference another for its value:

lazy val rndFileName: task[String]("Generates a random file Name")
rndFileName := {
  val token = arbString.value
  s"file-$token.tmp"
}

Tasks and Inputs

Tasks can also take inputs:

lazy val genRndStr = inputKey[String]("Generates a random string of specified length")

genRndStr := {
  import complete.DefaultParsers.spaceDelimited
  import scala.util.Random

  val args: Seq[String] = spaceDelimited("<arg>").parsed
  val length: Int       = args.headOption.map(_.toInt).getOrElse(10)
  Random.alphanumeric.take(length).mkString
}

You can to invoke genRndStr:

  • From command line
    sbt 'show genRndStr' # Default length = 10
    sbt 'show genRndStr 24'
    
  • Within the build file
    val token = genRndStr.toTask(24).value
    // use token in other tasks / logic
    

The SBT Shell

Meet one of SBT’s super powers - the SBT shell. The epitome of its advantages is that it loads the JVM once so the successive commands you issue are way faster than invoking them one at a time from your OS shell. But its real power is in giving you an interactive space to inspect your build definition.

$ sbt
sbt:petStore>
sbt:petStore> projects
  api
  core-lib
* petStore
  integration-tests
  • $ sbt is launching the sbt shell from the OS prompt, which drops you into the sbt shell sbt:petStore>
  • petStore is the name of an imaginary Scala project, which has sub-projects listed by the projects command
  • * in the project list denotes the current project

There are other powerful commands like inspect that allow you inspect the build definition like tasks, settings etc.

sbt:snorriRoot> inspect genRndStr
[info] Input task: java.lang.String
[info] Description:
[info] 	Generates a random alphanumeric string of specified length
[info] Provided by:
[info] 	ProjectRef(uri("file:...../pet-store"), "petStore") / genRndStr
[info] Defined at:
[info] 	......../pet-store/build.sbt:13
[info] Reverse dependencies:
[info] 	testGenRndStr
[info] Delegates:
[info] 	genRndStr
[info] 	ThisBuild / genRndStr
[info] 	Global / genRndStr

The helpΒ command in the sbt shell is the man page for all things that you can do in the sbt shell.

Other Tidbits:

  • sbt -client launches the client (shell) that connects to the SBT build server, which is started new if one is already running.
  • sbtn launches the native version of the same.

Cliffhanger

I hope this post gave a quick and clear picture of Settings and Tasks. They are orthogonal and at the same time complementary. For instance, you can think of the name := <app-name> as an independent Setting while the scalacOptions ++= ... is a Setting consumed by the compile task.

I know I mentioned that I will cover plugins in this post. But I like to push it to the next post since this post went longer than expected. But we now know the most we need to move to the next stage - Plugins. In the next post, I will talk about Plugins, which should make you feel confident and content about SBT. Then I hope you should be able to appreciate the modularity and flexibility of SBT.

scala sbt series