1. Introduction

In this article, we’re going to focus on Ammonite scripting. In particular, we’ll focus on how to write and run Scala scripts, rather than focus on how to use Ammonite as a REPL (Read-Eval-Print Loop) or on its integration with sbt.

We’ll first look at what Ammonite is. Then, we’ll dive into the details of scripting and see how we can write and run Ammonite scripts without the burden of creating an sbt-based Scala project.

2. What Is Ammonite?

Ammonite is an alternative Scala runtime. It was created with the goal of making the execution of Scala code simpler and faster, removing the need for heavyweight projects based on full-fledged build systems (such as sbt).

Ammonite provides several tools for doing so:

  • a Scala REPL, with some improvements with respect to the default Scala REPL, such as syntax highlighting and multi-line editing
  • a Scala runtime, capable of running Scala code
  • a system shell whose syntax is based on Scala to avoid the need for complex Bash scripts
  • a Scala library, named Ammonite-Ops, to deal with file system operations in a concise way

For the purposes of this tutorial, we’ll be focusing on Scala scripting.

3. Writing and Running Ammonite Scripts

In this section, we’re going to see how Ammonite scripting works. We’ll start with some basic scripts and then move up to more complex ones.

3.1. Our First Script

Normally, when we want to run some Scala code (for example, to experiment with a new library), we have to create a new project, set up a build file, import plug-ins, and so on. However, sometimes, a full-fledged project contains a lot of redundant code, especially if we just want to experiment. Ammonite scripts aim at reducing such a burden by letting us write files that can be run directly from the command line. In the simplest case, such scripts are simply a sequence of statements.

Let’s see an example:

case class Coordinates(x: Int, y: Int) {
  def moveX(offset: Int): Coordinates = Coordinates(x + offset, y)
  def moveY(offset: Int): Coordinates = Coordinates(x, y + offset)

val point = Coordinates(10, 15)

val movedPoint = point.moveX(7)

Our script simply defines a new case class, Coordinates, and then uses it to instantiate a new value, point. As if we were writing standard Scala code, we can define methods in Coordinates and then use them in the program. We do that by invoking moveX() on point, which returns a new instance of Coordinates.

In order to run the script, we can just save it as a .sc file, such as SimpleScript.sc, and then run it with amm SimpleScript.scAmmonite will compile the script the first time it’s run. Let’s run it:

$ amm SimpleScript.sc

The example above points out some differences with the standard Scala code. First, we don’t need to wrap our code in an object extending App or defining a main method. Secondly, we can write statements that normally wouldn’t be allowed at the top level, such as println or variable initializations.

There’s also a “watch mode”, where the runtime won’t exit when a script completes, but instead, it will run it every time the file changes. This mode can be enabled by specifying the -w flag in the amm command: amm -w SimpleScript.sc.

3.2. Imports and Libraries

Ammonite lets us break our scripts into multiple, smaller scripts. Again, we can do that without setting up a project and configuring source directories:

// Constants.sc

val aValue = 5
val anotherValue = 10

// Imports.sc

import $file.Constants, Constants._

println(s"Imported value: $aValue")

In this case, we defined two scripts, Constants.sc and Imports.sc. The former defines a couple of values that are later imported by the latter via import $file.ConstantsImport.sc is then free to use any value defined by Constants.sc. Let’s run the example:

$ amm Imports.sc
Imported value: 5

In addition to other Ammonite scripts, we can also import external Ivy and Maven libraries. For example, to import the Play library we can just use import $ivy.`com.typesafe.play::play:2.8.8` and then use it in our script. Ammonite will import the library before compiling the script and show an error if it cannot find it.

Finally, Ammonite comes with some libraries for common use cases, such as for making HTTP calls or dealing with JSON objects.

3.3. Arguments

We can parameterize our Ammonite scripts by defining a method marked as @main:

// Arguments.sc

def main(name: String, age: Int) = {
  println(s"Hello, $name. I'm $age")

Let’s first run the example above passing all the parameters:

$ amm Arguments.sc Baeldung 6
Hello, Baeldung. I'm 6

On the other hand, if we omit an argument, such as age, we’ll get an error:

$ amm Arguments.sc Baeldung
Missing argument: --age <int>
Expected Signature: main
  --name <str>
  --age <int>

We can pass arguments also by specifying their names in the amm command: amm Arguments.sc –name Baeldung –age 6.

Finally, if we specify an argument with the wrong type, we’ll get an error as well:

$ amm Arguments.sc Baeldung A
Invalid argument --age <int> failed to parse "A" due to java.lang.NumberFormatException: For input string: "A"
Expected Signature: main
  --name <str>
  --age <int>

Ammonite defines built-in parsers for arguments of type Int, Double, and String. It also supports varargs. In the latter case, we can pass how many arguments we want, and they’ll be aggregated into a Seq[String]:

// Varargs.sc

def main(fruits: String*) = {
  fruits foreach {println(_)}

Let’s run it:

$ amm Varargs.sc banana orange apple

If we use varargs, we may not specify the name of the argument via –fruit. If we attempt to do so, –fruit will be considered a part of the varargs itself:

$ amm Varargs.sc --fruits banana orange apple

3.4. Multiple @main Methods

We saw above that a @main method allows us to pass arguments to our scripts. Ammonite allows defining multiple @main methods. In this case, the first argument is used to determine which method to run. For example, let’s consider a script with two @main methods, funA() and funB():

// MultipleMains.sc

def funA(arg: String): Unit = {
  println(s"""funA called with param "$arg"""")

def funB(n: Int): Unit = {
  println(s"""funB called with param "$n"""")

Let’s run it as usual:

$ amm MultipleMains.sc funA "Test string"
funA called with param "Test string"

$ amm MultipleMains.sc funB 15
funB called with param "15"

Notice that Ammonite is able to recognize the space in the argument “Test string” thanks to our using quotes when passing it as a parameter.

Finally, if we don’t specify the name of the @main method, Ammonite will give us an error:

$ amm MultipleMains.sc
Need to specify a sub command: funA, funB

3.5. Working with Files

Ammonite scripting also lets us interact with the file system as in standard Scala programs. For example, we can use Source, from scala.io, to read a file and print the number of its lines:

// FileSystem.sc

import scala.io.Source

def countLines(path: String): Unit = {
  val src = Source.fromFile(path)

Let’s run the example, passing, as an argument, a path to the script itself:

$ amm FileSystem.sc ./FileSystem.sc

On the other hand, if we pass in a path to a file that doesn’t exist, we’ll get a FileNotFoundException.

This example also shows that relative paths in Ammonite start from the working directory where the script is being run.

3.6. Script Documentation

The @arg annotation can be used to add document the behavior of the script and the meaning of the arguments:

// Doc.sc

@arg(doc = "Sum two numbers and print out the result")
def add(
  @arg(doc = "The first number")
  n1: Int,
  @arg(doc = "The second number")
  n2: Int
): Unit = {
  println(s"$n1 + $n2 = ${n1 + n2}")

In this case, Ammonite will show the description of the parameters if we don’t specify any of them:

$ amm Doc.sc
Missing arguments: --n1 <int> --n2 <int>
Expected Signature: add
  --n1 <int>  The first number
  --n2 <int>  The second number

4. Conclusion

In this article, we looked at the basics of Ammonite scripting. With Ammonite-based Scala scripts, we won’t have to create heavyweight projects anymore if we just need a fast way to write exploratory code.

As usual, the code is available over on GitHub.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.