When working on a Java project, we might want to have a place where we can just play around with the code we write. We need a "scratch" file where we can access the Java classes we write in our main sourceset. The scratch file is actually a Java source file with a main method where we can create instances of the Java code we write and invoke methods on them. This gives back a fast feedback loop, and we can use it to play around with our Java classes without the need to write a test for it. It gives great flexiblity during development. We must make sure the scratch file will not be packed in the JAR file with our production code.

To support this in our Gradle build file we can add a new sourceset that can access all classes we write in the main sourceset. Also we want to have new configurations for this sourceset so we can add dependencies that are only used by our scratch file. And finally we want a new task to run our scratch file. By default our scratch file will not be part of the JAR file with the classes from the main sourceset.

In the following example build script we first define the common configuration for a Java project with a dependency on the Log4j2 library. Notice we use the toolchain feature of Gradle to use Java 15 to compile and run our Java code. Using the toolchain definition Gradle will look for a Java 15 JDK on our computer and if it cannot find one can even download it automatically.

Next we define a new sourceset dev so we can create a Scratch.java file in the directory src/dev/java and we define the compile and runtime classpath to be dependent on the main source set output. As a bonus we also can use the src/dev/resources directory for resource files we want to have in the classpath when we run our Scratch.java file.

If we want to define dependencies that are only used by our Scratch class file we must add extra configurations: devImplementation and devRuntimeOnly. These configurations extend from the implementation and runtimeOnly configurations added by the java-library plugin. So all dependencies needed by classes in the main sourceset will also be available in the configurations for the dev sourceset.

Finally, we add a new task runDev that executes the main method in the Scratch.java file in the src/dev/java directory.

// File: build.gradle.kts
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(platform("org.apache.logging.log4j:log4j-bom:2.14.0"))
    implementation("org.apache.logging.log4j:log4j-api")
    implementation("org.apache.logging.log4j:log4j-core")
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(15))
    }
}

//------------------------------------------------------------------------------
// Configure "dev" sourceset for running Scratch class
//------------------------------------------------------------------------------

// Create new dev sourceset with a compile and runtime classpath dependency
// on the main sourceset. This allows us to use the classes we create in
// the main sourceset in our dev sourceset.
// The directories src/dev/java and src/dev/resources are recognized
// this sourceset.
val dev: SourceSet by sourceSets.creating {
    compileClasspath += sourceSets.main.get().output
    runtimeClasspath += sourceSets.main.get().output
}

// Create implementation and runtimeOnly configurations for the dev sourceset.
// These configurations can be used to define dependencies that only
// apply for the source files in the dev sourceset.
val devImplementation: Configuration by configurations.getting {
    extendsFrom(configurations.implementation.get())
}
val devRuntimeOnly: Configuration by configurations.getting {
    extendsFrom(configurations.runtimeOnly.get())
}

// Create a new task "runDev" that will run the compiled Scratch.java file
// in the root of src/dev/java. The classpath will contains all dependencies
// from the devImplementation and devRuntimeOnly configurations.
val runDev by tasks.registering(JavaExec::class) {
    description = "Run Scratch file."
    group = "dev"
    classpath = dev.runtimeClasspath
    mainClass.set("Scratch")
}

dependencies {
    // Here we add an extra dependency only for the dev sourceset.
    devImplementation("org.apache.commons:commons-lang3:3.12.0")
}

Now we have our build file with scratch file support so it is time to have some sample code.

First we create a simple Java file in our main sourceset together with a Log4j2 configuration properties file:

// File: src/main/java/mrhaki/Sample.java
package mrhaki;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Sample {
    private static Logger log = LogManager.getFormatterLogger(Sample.class);

    public String sayHello(String name) {
        log.info("sayHello(name=%s)", name);
        return "Hello %s".formatted(name);
    }
}
# File: src/main/resource/log4j2.properties
appender.console.type=Console
appender.console.name=STDOUT
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %m%n

rootLogger.level=ERROR
rootLogger.appenderRef.stdout.ref=STDOUT

To play around with our Sample class we add a scratch file and also an extra Log4j2 configuration properties file to change the configuration when we run our scratch file:

// File: src/dev/java/Scratch.java
import mrhaki.Sample;
import org.apache.commons.lang3.SystemUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Scratch {
    private static Logger log = LogManager.getFormatterLogger(Scratch.class);

    public static void main(String[] args) {
        log.info("Running dev with Java %s.", SystemUtils.JAVA_VERSION);
        Sample sample = new Sample();
        sample.sayHello("mrhaki");
    }
}
# File: src/dev/resources/log4j2.properties
rootLogger.level=DEBUG

To execute our scratch file we invoke the runDev task from the command-line:

$ gw runDev

> Task :runDev
Running dev with Java 15.0.2.
sayHello(name=mrhaki)

BUILD SUCCESSFUL in 1s
5 actionable tasks: 5 executed

Written with Gradle 6.8.3.

shadow-left