How I SBT - VII

HKT HKT
Views
Article hero image

This post is the final part of the series on SBT. I hope I covered everything needed to break the ice and change the perspective on SBT. I have touched on most ingredients you need to write a decent fully-functional build definition. But there is a lot more to SBT.

In this post, I will discuss some things, nifty and a la carte, on SBT.

Task Ordering

Def.sequential

SBT provides dependsOn to denote the dependency of one task on another. But you can also chain commands to run sequentially. The execution of the tasks continue as long as each command in the list passes, and stops with an error if any task in the sequence errors out.

For instance, you can write a sequence task like this:

lazy val compileWithMsg = taskKey[Unit]("Prints start and end times of compilation")
lazy val compileStartTime = taskKey[Unit]("Prints compilation start time")
lazy val compileSuccessfulTime = taskKey[Unit]("Prints time only if compilation successful")

lazy val hamster =
  project
    .in(file("."))
    .settings(
      compileStartTime := {
        streams.value.log.info(s"Compilation started @ ${LocalDateTime.now}")
      },
      compileSuccessfulTime := {
        streams.value.log.info(s"Compilation successful @ ${LocalDateTime.now}")
      },
      compileWithMsg := Def.sequential(
        compileStartTime,
        Compile / compile,
        compileSuccessfulTime
      ).value
    )

If you invoke compileWithMsg task, it will run three tasks in succession - compileStartTime, Compile / compile, and compileSuccessfulTime. Each task will proceed to the next only if it succeeds. For instance, if the compilation failed, then compileSuccessfulTime will not be executed.

Def.taskDyn

In the sequential case above, the task returns a value and not a task. That means it cannot be chained with other tasks. Also, it loses the value of Compile / compile. Def.sequential follows a dumb sequential execution.

If you want to return a task for chaining, then you can use Def.taskDyn for rewiring the Compile / compile task.

lazy val hamster =
  project
    .in(file("."))
    .settings(
      Compile / compile := (Def.taskDyn {
        compileStartTime.value
        val c = (Compile / compile).value
        Def.task {
          val x = compileSuccessfulTime.toTask.value
          c
        }
      }).value
  )

This might look the same as its sequential counterpart, but the point to note is that we are returning the (rewired) task, which is re-assigned to compile. So you can now issue the compile task and see the log messages instead of defining a new command to do the same. A practical use case of taskDyn - linting, can be seen in the official documentation.

Logging to SBT

SBT provides a way - streams.value.log, to log messages during the build process. Typically, you would log in _Tasks.

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

How is it different from just using println, you may ask. In the following ways:

  • Keeps the log associated with the task/project logging it. Useful in multi-module projects.
  • Can be redirected to a build log file, and not just write to console
  • SBT streams log offers logging levels

Commands

Commands are actions that can be invoked from the sbt shell.

For example, here is a greet command you can run from the sbt shell:

val greetCmd = Command.args("greet", "<arg>") { (state, args) =>
  if (args.length != 1) {
    state.log.warn("Missing argument <name>")
    state
  } else {
    val name = args(0)
    state.log.info(s"Hello $name")
    state
  }
}

lazy val hamster =
  project
    .in(file("."))
    .settings(
      ...
      commands += greetCmd
    )
    ...

greet expects an argument, and prints a greeting. Or a missing argument message if no argument provided.

greet-cmd

We can write a useful and practical command to list a file or folder (default current project folder). That allows running shell commands1 directly without having to leave the sbt shell.

val lsCmd = Command.args("ls", "<arg>") { (state, args) =>
  import scala.sys.process._
  val ps  = args.mkString(" ")
  val out = s"ls -la $ps".!!
  state.log.info(out)
  state
}

lazy val hamster =
  project
    .in(file("."))
    .settings(
      ...
      commands += lsCmd
    )
    ...

Running ls in the sbt shell will list the current project folder.

Running tests

sbt test runs the unit test cases of the project.

If you want to run a specific test class/suite …

sbt 'testOnly **SomeTestSuite'

To run a specific test method within a suite

sbt 'testOnly **SomeTestSuite -- -z someTestMethod'

To run specific tagged tests

# search all suites for tagged tests
sbt 'testOnly -- -n <tag-name>'

# search in only SomeTestSuiteName for tagges tests
sbt 'testOnly **SomeTestSuite -- -n <tag-name>'

See here for more information about tagging.

Scopes / Axes

I’ve only briefly touched on scopes/axes in this series, but there’s much more to explore. You would delve into it when the need arises.

Here is my take on scopes/axes:

  • A general understanding is certainly essential. Other than that, using it day-to-day is just muscle memory. You’ll need to learn a little more than just dipping your toes in when you’re working on, especially planning to write, scoped tasks. Official documentation FTW!
  • It seems to scare off people, especially newcomers.

Consider scopes as a hierarchy of slots from which to pick your entity (setting or task). If SBT doesn’t find it locally, it keeps going up one level until it hits the global scope to see if the desired entity exists. If not, SBT will report an error. However, you can restrict entities to a specific set of scopes. For example, compile exists only under Compile and Test.

sbt shell

sbt shell is the REPL for SBT. Since sbt shell loads the JVM and the build definition once2 in memory, running commands is faster than invoking individual commands in the OS shell. Compilation is incremental.

Besides, the shell has also a range of commands:

  • reload - To reload the changes to the build definition (especially made after loading the shell)
  • To inspect and troubleshoot your build definition - inspect, inspect tree, show.
sbt:hamster> inspect Compile / compile
[info] Task: xsbti.compile.CompileAnalysis
[info] Description:
[info] 	Compiles sources.
[info] Provided by:
[info] 	ProjectRef(uri("file:/home/xxx/projects/hamster/"), "hamster") / Compile / compile
[info] Defined at:
[info] 	(sbt.Defaults.configTasks) Defaults.scala:933
[info] Dependencies:
[info] 	Compile / fileConverter
[info] 	Compile / enableBinaryCompileAnalysis
[info] 	Compile / compileIncSetup
[info] 	Compile / managedFileStampCache
[info] 	Compile / enableConsistentCompileAnalysis
[info] 	Compile / manipulateBytecode
[info] Reverse dependencies:
[info] 	Compile / bspBuildTargetCompileItem
...
  • console - Drops you in the scala REPL! Use :q or :quit to get back to SBT shell.
  • Prefix a command with ~ to watch the project and run the command when the sources change in the project. For example, ~compile will watch for source files in the project, run compilation if any of the source files change, and goes to back to watch mode again.
sbt:hamster> ~compile
[success] Total time: 1 s, completed Dec 3, 2024, 3:31:49 AM
[info] 1. Monitoring source files for hamster/compile...
[info]    Press <enter> to interrupt or '?' for more options.
[info] Build triggered by /home/badrobot/Downloads/hamster/util/src/main/scala/Util.scala. Running 'compile'.
[warn] build source files have changed
[warn] modified files:
[warn]   /home/badrobot/Downloads/hamster/build.sbt
[warn] Apply these changes by running `reload`.
[warn] Automatically reload the build when source changes are detected by setting `Global / onChangedBuildSource := ReloadOnSourceChanges`.
[warn] Disable this warning by setting `Global / onChangedBuildSource := IgnoreSourceChanges`.
[info] compiling 1 Scala source to /home/badrobot/Downloads/hamster/util/target/scala-2.13/classes ...
[success] Total time: 2 s, completed Dec 3, 2024, 3:32:45 AM
[info] 2. Monitoring source files for hamster/compile...
[info]    Press <enter> to interrupt or '?' for more options.
  • You can use show <any-setting> to display the value of any setting in the build definition.
show libraryDependencies
[info] * org.scala-lang:scala3-library:3.5.2
[info] * org.typelevel:cats-core:2.12.0
[info] * org.scala-lang:scala-reflect:2.13.14
[info] * org.reflections:reflections:0.10.2
[info] * co.blocke:scala-reflection:2.0.8
[info] * org.scalacheck:scalacheck:1.17.1:test
[info] * org.scalatest:scalatest:3.2.18:test
[info] * org.scalatestplus:scalacheck-1-17:3.2.18.0:test
  • You can chain commands in the shell by delimiting with ;. For example, clean; compile; test.

When I work on a project, I leave the sbt shell open in the project folder alongside IntelliJ.

Chao

I hope this series was helpful. What I have covered in this series is the shallows of SBT. There are many things in SBT that you will discover and master as you progress. Official documentation has everything you need. It may be a bit tricky to locate. Also, you may not find some things in the official documentation. You will find those across the web from fellow patrons of SBT, like this series 😉.

You will have fun with SBT as a developer because you are writing code to write your build definition. In that regard, SBT is unique compared to other build tools.

I have had fun with SBT. I hope you do, too.


  1. !! runs a shell command and returns the output of the command. See https://www.scala-sbt.org/1.x/docs/Process.html ↩︎

  2. If there are updates to build.sbt or the project/ folder after you start the shell, you would have to use the reload command to reload the build definition. ↩︎

scala sbt series