Building the Java Lox Interpreter with Gradle
“Everything starts with a class in Java. Stick that in a text file, and go get your IDE or Makefile or whatever set up. I’ll be right here when you’re ready. Good? OK!” - Crafting Interpreters, 4.1 - The Interpreter Framework
“Or Whatever Setup”
Ok so we’re reading Crafting Interpreters, and the book wants to write its examples in Java. There are a lot of ways you could go about building and executing a Java project. Since I am a masochist who refuses to let an IDE set up my project for me, I’ll need to choose that setup for myself.
One could, like the cavemen of olden days, just use javac
to build
her code, and java
to run it. Go ahead and try this if you like, I
do not intend to go into detail about why this is tedious. Besides the
fact that simply building and executing your programs with the java
executables is less straightforward than one might prefer, there are
some additional benefits to using a build tool. I personally do not
want to work on a project of any size without the ability to write
tests, and writing tests usually entails dependencies. A few chapters
in, the book has you write a code generation tool to get around Java’s
tragic verbosity, and its nice to have a task runner and a separate
build pipeline to deal with this tool. I’ll explain how I used Gradle
to do each of these things.
Gradle vs Maven vs Ant
There are a few different popular build tools to consider when starting a new Java project, and I spent almost zero time evaluating them. I decided to use Gradle because:
- Gradle is the standard tool for building Android projects
- Gradle uses Kotlin (or Groovy) instead of XML as the configuration language
- I have used Gradle before
Are there good reasons to choose one of the others? Maybe!
Getting Started
Install java, install gradle
I won’t go into detail here because this is system dependent. I used my system package manager to install java 21 and the global gradle that you’ll use to initialize your project. If you need more assitance getting gradle installed, check this out: https://gradle.org/install/.
Create a new gradle project
Once you have gradle installed, you can get your project set up pretty easily with this command:
gradle init --package com.craftinginterpreters.lox
--project-name lox
--java-version 21
--test-framework junit
--dsl kotlin
--type java-application
--no-incubating
--no-split-project
More about gradle init: https://docs.gradle.org/current/userguide/build_init_plugin.html
If you leave out all the options to gradle init, it will prompt you to make a selection for each of these.
- package: Creates directory structure that matches the package name
- project-name: Self explanatory. Not sure if this has any effect on anything other than just being the nanme of the project
- java-version: The version of Java you want to use.
- test-framework: By default it prompts you to choose “junit” which really means junit 4. That’s what I chose, but I was unaware that there is a junit 5, and you can select it here by choosing “jupiter-junit” instead. Since I did not find this out until later, I’ll use junit 4 for my examples in the next post, but it should be easy enough to translate to junit 5 if you prefer to use that instead.
- dsl: You can choose to configure the project with Kotlin or Groovy. Since its plausible I may want to learn Kotlin in the future, I preferred to choose that. The downside is that it’s often easier to find examples online written in Groovy.
- type: This allows you to pick a project template.
java-appliation
is “a command line application written in java” which is exactly what we want. More about the application plugin: https://docs.gradle.org/current/userguide/application_plugin.html - no-incubating: You can choose between the bleeding edge and stable versions of Gradle. I decided to go with the stable version since it was pretty likely that I would be going through this book on and off over a long period of time. I want the build to “just work” even if I haven’t touched it in 6 months.
- no-split-project: This allows you to have multiple projects with separate build files. We don’t really need that for a project this small.
Once you’ve run gradle init configured to your liking, you should have java files named App.java and AppTest.java created inside this directory structure. I would change them both to Lox.java and LoxTest.java and rename the classes within. You’ll also want to checkout app/build.gradle.kts and change the main class to match. This will make it a little easier to follow along with the book which names its entrypoint Lox instead of App.
Check this out for more info about initializing Gradle: https://docs.gradle.org/current/userguide/build_init_plugin.html
Dependencies
You probably won’t need to add any more dependencies, since gradle init will install the test framework for you. If you updated the main class to Lox, your app/build.gradle.kts should look like this:
/*
* This file was generated by the Gradle 'init' task.
*
* This generated file contains a sample Java application project to get you started.
* For more details on building Java & JVM projects, please refer to https://docs.gradle.org/8.12/userguide/building_java_projects.html in the Gradle documentation.
*/
plugins {
// Apply the application plugin to add support for building a CLI application in Java.
application
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
}
dependencies {
// Use JUnit test framework.
testImplementation(libs.junit)
// This dependency is used by the application.
implementation(libs.guava)
}
// Apply a specific Java toolchain to ease working on different environments.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
// Define the main class for the application.
mainClass = "com.craftinginterpreters.lox.Lox"
}
You’ll notice that gradle added an additional dependency called guava. Guava is a kind of supplemental standard library, with lots of neat doodads and utilities. Code from the book won’t take advantage of that library, so you can go ahead and deleted the depenedency. You can read more about guava here if you are curious: https://github.com/google/guava
The Gradle Wrapper
gradle init
creates a script called gradlew
in the root of your
project. This is the primary way you’ll interact with gradle. It will
install the correct version of gradle that your project requires if
its missing, and use that gradle to run your tasks. This makes it
really easy to get set up on another machine.
Unit Tests
You can run your tests with ./gradlew test
. I often run them with
the --info
flag which will allow exceptions thrown and
System.out.println to get printed out in the console for you. I’ll
discuss writing test cases in a future post.
Configuring tasks
You’ll primarily interact with gradle by running tasks. So far we’ve
used tasks that come with gradle like init
, and tasks that gradle
defines for your project like run
and test
. The behavior of
existing tasks can be configured to our liking. In addition we can
create our own tasks.
Configuring run
java-application comes with a run
task that executes our
application. The jlox application will run accept input in two
different forms:
- From a .lox sources files
- As a REPL accepting strings of lox code from standard input
By default gradle will not wire up standard input to your tasks, so we’ll need to enable that explicitly for the REPL to work properly. To test this out you can setup your Lox.java file to look like this:
/*
* This source file was generated by the Gradle 'init' task
*/
package com.craftinginterpreters.lox;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class Lox {
public static void main(String[] args) throws IOException {
System.out.println("Input string:");
InputStreamReader input = new InputStreamReader(System.in);
BufferedReader reader = new BufferedReader(input);
String line = reader.readLine();
System.out.println("Line:");
System.out.println(line);
}
}
If you try ./gradlew run
without modifying the run task it should
print null
without waiting for your input.
Add this to your build.gradle.kts to configure the run
task:
tasks.named<JavaExec>("run") {
// Needed for the repl
standardInput = System.`in`
}
Now try running that again. It should wait for your input now, but you’ll also see a bunch of “gradle stuff” that interrupts the input experience.
Cleaning up the command line output
A few command line arguments can clean up the output from gradle to give you a better experience interacting with the REPL:
./gradlew run -q --console=plain
Custom tasks
Later in the book we write a code generation tool. We can create
custom task to run this code. You can create a placeholder file to
test this out and put it in app/src/main/java/com/craftinginterpreters/tool/GenerateAst.java
:
package com.craftinginterpreters.tool;
public class GenerateAst {
public static void main(String[] args) {
System.out.println("Generate AST");
}
}
Then add this to your build.gradle.kts
:
tasks.register<JavaExec>("generateAst") {
classpath = sourceSets.named("tool").get().runtimeClasspath
mainClass.set("com.craftinginterpreters.tool.GenerateAst")
}
You now run this tasks like so:
./gradlew app:generateAst
Passing command line arguments to your tasks
Crafting Interperters will instruct you to accept a directory name as
a command line argument where you will output the generated
code. Let’s go ahead and test that out too. Let’s tweak the code for
GenerateAst.java
a bit to print out the first command line arg you
pass:
package com.craftinginterpreters.tool;
public class GenerateAst {
public static void main(String[] args) {
System.out.println("Generate AST to file: " + args[0]);
}
}
And now you can pass an argument to your task like so:
./gradlew app:generateAst --args="$PWD/app/src/main/java/com/craftinginterpreters/lox"
Using sourceSets to isolate unrelated code
Ultimately Crafting Interpreters will instruct you to fill this file
with code that outputs a new source file with interfaces you’ll import
into the rest of your code. One problem you may run into is that if
you add new methods to these interfaces, it will immediately break the
rest of your code because you’ve failed to implement them yet. But
what if you need to run app:generateAst
again? app:generateAst
shouldn’t really depend on the rest of the code, and indeed Gradle
does give us a tool to deal with this called sourceSets.
The first thing we’re going to want to do is to move the tool
directory out of app/src/main
into its own top level directory
app/src/tool
. I tried as much as possible to avoid modifying the
code in the book, but I think we’re fighting against the tides if we
try to keep source code that should be isolated inside of main. Let’s
move
app/src/main/java/com/craftinginterpreters/tool/GenerateAst.java
to
app/src/tool/java/com/craftinginterpreters/tool/GenerateAst.java
.
Add this sourceSets
section and update your generateAst
task in
your build.gradle.kts
file:
sourceSets {
create("tool") {
java {
srcDir("src/tool")
}
}
}
tasks.register<JavaExec>("generateAst") {
classpath = sourceSets.named("tool").get().runtimeClasspath
mainClass.set("com.craftinginterpreters.tool.GenerateAst")
}
Now you can break whatever you want in the primary codebase without affecting the tool.
Pass command line arguments from the task configuration
A quick quality of life improvement is to configure the default command line arguments for generateAst right in the task configuration.
Update your task configuration like so:
tasks.register<JavaExec>("generateAst") {
classpath = sourceSets.named("tool").get().runtimeClasspath
mainClass.set("com.craftinginterpreters.tool.GenerateAst")
args = listOf(project.file("app/src/main/java/com/craftinginterpreters/lox").getAbsolutePath())
}
Now you can just run:
./gradlew app:generateAst
And you retain the ability to pass the args explicitly if you want.
Task dependencies
Ok was that really necessary? You’ve got ctrl-r after all. We can
build on this by making generateAst
a task dependency for run
and test
. Now
you don’t have to worry about forgetting to run generateAst
at all. You can just run
run
and test
and be confident that all of the generated code is in place.
Using dependsOn
for task dependencies
The first thing I tried was to have run
and test
depend on
generateAst
directly. Unfortunately this approach has a
problem. What we want is to run generateAst
not just before run
or
test
execute their code. We actually need it to run before run
and
test
compile their code.
Gradle’s compilation tasks
It turns out, each SourceSet comes with its own set of compilation
tasks. For the “main” SourceSet, that task is called
“compileJava”. (For the tool SourceSet, the task gets a suffix:
“compileJavaTool”. This is a common convention in gradle, where
suffixes are omitted when things refer to the “main” SourceSet.) To
make sure that the compilation of the “main” SourceSet is dependent on
generateAst
, let’s fix our build file like so:
val generateAst by tasks.register<JavaExec>("generateAst") {
group = "build"
description = "Generates source code using the AST tool"
classpath = sourceSets.named("tool").get().runtimeClasspath
mainClass.set("com.craftinginterpreters.tool.GenerateAst")
args = listOf(project.file("src/main/java/com/craftinginterpreters/lox").getAbsolutePath())
}
tasks.named("compileJava") {
dependsOn(generateAst)
}
Conceptually, this is a better approach anyway. The code we generate
with generateAst
should always be generated before will compile
“main”, and any future tasks that depend on “main” will get to take
advantage of our tool.
fin.
Here’s the finished build.gradle.kts
file:
/*
* This file was generated by the Gradle 'init' task.
*
* This generated file contains a sample Java application project to get you started.
* For more details on building Java & JVM projects, please refer to https://docs.gradle.org/8.12/userguide/building_java_projects.html in the Gradle documentation.
*/
plugins {
// Apply the application plugin to add support for building a CLI application in Java.
application
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
}
dependencies {
// Use JUnit test framework.
testImplementation(libs.junit)
}
// Apply a specific Java toolchain to ease working on different environments.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.named<JavaExec>("run") {
// Needed for the repl
standardInput = System.`in`
dependsOn("generateAst")
}
tasks.named("test") {
dependsOn("generateAst")
}
tasks.register<JavaExec>("generateAst") {
classpath = sourceSets.named("tool").get().runtimeClasspath
mainClass.set("com.craftinginterpreters.tool.GenerateAst")
args = listOf(project.file("app/src/main/java/com/craftinginterpreters/lox").getAbsolutePath())
}
sourceSets {
create("tool") {
java {
srcDir("src/tool")
}
}
}
application {
// Define the main class for the application.
mainClass = "com.craftinginterpreters.lox.Lox"
}
At this point I’m pretty satisfied with the project set up. Now we’ve got:
- Unit tests
- Easy to build and run the project
- Control the amount of log output
- Code generation that runs automatically whenever we build
These quality of life improvements improved my experience working through the Java section of the book quite a bit. We love Gradle, we’re all gradle-pilled now.