Skip to main content

Getting started

info

This article requires knowledge of the Scala language (how to define a class or method) as well as Scala tooling (the REPL, and basics of dependency management and unit tests).

In this article we show how to use Scala CLI to create a basic script, followed by small project with features like dependencies, tests, and IDE support. We aim to provide you with a knowledge of how to create and develop your first projects using Scala CLI.

First, let's verify if Scala CLI is properly installed with a simple "hello world" test:

echo 'println("Hello")' | scala-cli -
Hello

Running this command the first time may take a bit longer than usual and prints a fair number of logging output because Scala CLI needs to download all the artifacts it needs to compile and run the code.

Scripting

In that example we actually just created a Scala Script. To demonstrate this more fully, let's create a script in a hello.sc file that greets more properly:

def helloMessage(names: Seq[String]) = names match
case Nil =>
"Hello!"
case names =>
names.mkString("Hello: ", ", ", "!")

println(helloMessage(args.toSeq))

When that script is given no names, it prints "Hello!", and when it’s given one or more names it prints the string that's created in the second case statement. With Scala CLI we run the script like this:

scala-cli hello.sc
Hello

To provide arguments to the script we add them after --:

scala-cli hello.sc -- Jenny Jake
Hello Jenny, Jake!

You may wonder what kind of Scala version was used under the hood. The answer is the latest stable version which was tested in Scala CLI. If you want to specify the Scala version you can use -S or --scala option. More about setting Scala version in the dedicated cookbook.

Scala CLI offers many more features dedicated for scripting, as described in the dedicated guide.

Dependencies

Now let's build something more serious. For this example, it's best to start with some prototyping inside the REPL. We can start a REPL session by running scala-cli repl. (If desired, you can also set the Scala version with -S or --scala.)

note

Scala CLI reuses most of its options across all of its commands.

One of the main strengths of Scala is its ecosystem. Scala CLI is designed in a way to expose the Scala ecosystem to all usages of Scala, and running the REPL is no exception.

To demonstrate this, let's start prototyping with os-lib — a Scala interface to common OS filesystem and subprocess methods. To experiment with os-lib in the REPL, we simply need to add the parameter --dep com.lihaoyi::os-lib:0.9.0, as shown here:

scala-cli repl --dep com.lihaoyi::os-lib:0.9.0
scala> os.pwd
val res0: os.Path = ...

scala> os.walk(os.pwd)
val res1: IndexedSeq[os.Path] = ArraySeq(...)

A project

Now it's time to write some logic, based on the prototyping we just did. We'll create a filter function to display all files with the given filename extension in the current directory.

For the consistency of our results, let's create a new directory and cd to it:

mkdir scala-cli-getting-started
cd scala-cli-getting-started

Now we can write our logic in a file named files.scala:

//> using dep com.lihaoyi::os-lib:0.9.0

def filesByExtension(
extension: String,
dir: os.Path = os.pwd): Seq[os.Path] =
os.walk(dir).filter { f =>
f.last.endsWith(s".$extension") && os.isFile(f)
}

As you may have noticed, we specified a dependency within files.scala using the //> using dep com.lihaoyi::os-lib:0.9.0 syntax. With Scala CLI, you can provide configuration information with using directives — a dedicated syntax that can be embedded in any .scala file. For more details, see our dedicated guide for using directives.

Now let's check if our code compiles. We do that by running:

scala-cli compile .

Notice that this time we didn’t provide a path to single files, but rather used a directory; in this case, the current directory. For project-like use cases, we recommend providing directories rather than individual files. For most cases, specifying the current directory (.) is a best choice.

IDE support

Some people are fine using the command line only, but most Scala developers use an IDE. To demonstrate this, let's open Metals with your favorite editor inside scala-cli-getting-started directory:

At the present moment, support for IntelliJ is often problematic. But know that we are working on making it as rock-solid as Metals.

Actually, in this case, we cheated a bit by running the compilation first. In order for Metals or IntelliJ to pick up a Scala CLI project, we need to generate a BSP connection detail file. Scala CLI generates these details by default every time compile, run, or test are run. We also expose a setup-ide command to manually control creation of the connection details file. For more information on this, see our IDE guide.

Tests

With our IDE in place, how can we test if our code works correctly? The best way is to create a unit test. The simplest way to add a test using scala-cli is by creating a file whose name ends with .test.scala, such as files.test.scala. (There are also other ways to mark source code files as containing a test, as described in tests guide.)

We also need to add a test framework. Scala CLI support most popular test frameworks, and for this guide we will stick with munit. To add a test framework, we just need an ordinary dependency, and once again we'll add that with the using directive:

//> using dep org.scalameta::munit:1.0.0-M1

class TestSuite extends munit.FunSuite {
test("hello") {
val expected = Set("files.scala", "files.test.scala")
val obtained = filesByExtension("scala").map(_.last).toSet
assertEquals(obtained, expected)
}
}

Now we can run our tests at the command line:

scala-cli test .
Compiling project (test, Scala 3.0.2, JVM)
Compiled project (test, Scala 3.0.2, JVM)
TestSuite:
+ hello 0.058s

or directly within Metals:

A project, vol 2

With our code ready and tested, now it's time to turn it into a command-line tool that counts files by their extension. For this we can write a simple script. A great feature of Scala CLI is that scripts and Scala sources can be mixed:

val (ext, directory) = args.toSeq match
case Seq(ext) => (ext, os.pwd)
case Seq(ext, directory) => (ext, os.Path(directory))
case other =>
println(s"Expected: `extension [directory]` but got: `${other.mkString(" ")}`")
sys.exit(1)

val files = filesByExtension(ext, directory)
files.map(_.relativeTo(directory)).foreach(println)

As you probably noticed, we are using os-lib in our script without any using directive, How is that possible? The way it works is that configuration details provided by using directives are global, and apply to all files. Since files.scala and countByExtension.sc are compiled together, the using directives in files.scala are used when compiling both files. (Note that defining a library dependency in more than one file is an anti-pattern.)

Now let's run our code, looking for all files that end with the .scala extension:

scala-cli . -- scala
files.scala
.scala-build/project_940fb43dce/src_generated/main/countByExtension.scala
files.test.scala

Seeing that output, you may wonder, why do we have an additional .scala file under the .scala-build dir? The way this works is that under the hood, Scala CLI sometimes needs to preprocess source code files — such as scripts. So these preprocessed files are created under the .scala-build directory, and then compiled from there.

Packaging

We could stop here and call Scala CLI on our set of sources every time. Scala CLI uses caches aggressively, so rollup runs are reasonably fast — less than 1,500 milliseconds on tested machine — but sometimes this isn't fast enough, or shipping sources and compiling them may be not convenient.

For these use cases, Scala CLI offers means to package your project. For example, we can run this command to generate a thin, executable jar file, with the compiled code inside:

scala-cli --power package . -o countByExtension

The default binary name is app, so in this example we provide the -o flag to make the name of the binary countByExtension. Now we can run our project like this:

./countByExtension scala

This time it only took 350 milliseconds, so this is a big improvement. When you create a binary file (a runnable jar) like this, it's self-contained, and can be shipped to your colleagues or deployed.

We can reduce the startup time even further using Scala Native, or by packaging our application to other formats like Docker container, assembly, or even OS-specific packages (.dep, .pkg, etc.). See those resources for more information.

Summary

With this guide we've only scratched the surface of what Scala CLI can do. For many more details, we've prepared a set of cookbooks that showcase solutions to common problems, as well as a detailed set of guides for our commands.

We also have a dedicated room on Scala discord where you can ask for help or discuss anything that's related to Scala CLI. For more in-depth discussions, we're using Github discussions in our repo; this is the best place to suggest a new feature or any improvements.