Java Single-File Scripts

This page documents the use cases and features of Mill’s single-file Java modules, or "scripts". These are Java modules with a single source file, with build configuration included in a header comment block at the top of the file.

Java scripts can be standalone or part of a larger Mill project. They can be run via ./mill Foo.java from the command line, or have other tasks on them executed via ./mill Foo.java:compile or ./mill Foo.java:assembly. Apart from being limited to a single source file, single-file script modules otherwise support all the same tasks that normal JavaModules do.

Script Use Cases

Mill Single-file Java programs can make it more convenient to script simple command-line workflows interacting with files, subprocesses, and HTTP endpoints. This section walks through a few examples where one-off single-file Java programs can be useful in day-to-day development.

While initially single-file Java programs may be a bit more verbose than the equivalent Bash script containing cp or curl commands, as the script grows in complexity the value of IDE support, typechecking, and JVM libraries makes writing them in Java an attractive proposition. This is especially true if you already have developers fluent in Java which may not be as familiar with the intricacies of writing robust and maintainable Bash code.

JSON API Client

For example, below is a simple script using Jackson, Unirest-Java, and PicoCLI, to write a program that crawls wikipedia and saves the crawl results to a file:

JsonApiClient.java (download, browse)
//| mvnDeps:
//| - info.picocli:picocli:4.7.6
//| - com.konghq:unirest-java:3.14.5
//| - com.fasterxml.jackson.core:jackson-databind:2.17.2

import com.fasterxml.jackson.databind.*;
import kong.unirest.Unirest;
import picocli.CommandLine;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.Callable;

@CommandLine.Command(name = "Crawler", mixinStandardHelpOptions = true)
public class JsonApiClient implements Callable<Integer> {

  @CommandLine.Option(names = {"--start-article"}, required = true, description = "Starting title")
  private String startArticle;

  @CommandLine.Option(names = {"--depth"}, required = true, description = "Depth of crawl")
  private int depth;

  private static final ObjectMapper mapper = new ObjectMapper();

  public static List<String> fetchLinks(String title) throws Exception {
    var response = Unirest.get("https://en.wikipedia.org/w/api.php")
      .queryString("action", "query")
      .queryString("titles", title)
      .queryString("prop", "links")
      .queryString("format", "json")
      .header("User-Agent", "WikiFetcherBot/1.0 (https://example.com; contact@example.com)")
      .asString();

    if (!response.isSuccess())
      throw new IOException("Unexpected code " + response.getStatus());

    var root = mapper.readTree(response.getBody());
    var pages = root.path("query").path("pages");
    var links = new ArrayList<String>();

    for (var it = pages.elements(); it.hasNext();) {
      var linkArr = it.next().get("links");
      if (linkArr != null && linkArr.isArray()) {
        for (var link : linkArr) {
          var titleNode = link.get("title");
          if (titleNode != null) links.add(titleNode.asText());
        }
      }
    }
    return links;
  }

  public Integer call() throws Exception {
    var seen = new HashSet<>(Set.of(startArticle));
    var current = new HashSet<>(Set.of(startArticle));

    for (int i = 0; i < depth; i++) {
      var next = new HashSet<String>();
      for (var article : current) {
        for (var link : fetchLinks(article)) {
          if (!seen.contains(link)) next.add(link);
        }
      }
      seen.addAll(next);
      current = next;
    }

    try (var w = Files.newBufferedWriter(Paths.get("fetched.json"))) {
      mapper.writerWithDefaultPrettyPrinter().writeValue(w, seen);
    }
    return 0;
  }

  public static void main(String[] args) {
    System.exit(new CommandLine(new JsonApiClient()).execute(args));
  }
}
> ./mill JsonApiClient.java --start-article=singapore --depth=2

> cat fetched.json
..."Calling code",...
..."+65",...
..."British Empire",...
..."1st Parliament of Singapore",...

HTML Web Scraper

Below is another web crawler, but instead of interacting with Wikipedia via a JSON API it scrapes the website’s HTML pages using JSoup. JSoup is not bundled with Mill, but can be included easily via mvnDeps header declaration:

HtmlScraper.java (download, browse)
//| mvnDeps: [org.jsoup:jsoup:1.7.2]
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
import java.util.*;

public class HtmlScraper {
  static List<String> fetchLinks(String title) throws IOException {
    String url = "https://en.wikipedia.org/wiki/" + title;
    Document doc = Jsoup.connect(url)
      .header("User-Agent", "Mozilla/5.0 (compatible; JsoupBot/1.0; +https://example.com/bot)")
      .get();

    List<String> links = new ArrayList<>();
    for (Element a : doc.select("main p a")) {
      var href = a.attr("href");
      if (href.startsWith("/wiki/")) links.add(href.substring(6));
    }
    return links;
  }

  public static void main(String[] args) throws Exception {
    if (args.length < 2) {
      System.err.println("Usage: java WikiCrawler <startArticle> <depth>");
      System.exit(1);
    }

    var startArticle = args[0];
    var depth = Integer.parseInt(args[1]);

    var seen = new HashSet<String>(Set.of(startArticle));
    var current = new HashSet<String>(Set.of(startArticle));

    for (int i = 0; i < depth; i++) {
      var next = new HashSet<String>();
      for (String article : current) {
        for (String link : fetchLinks(article)) {
          if (seen.add(link)) next.add(link);
        }
      }
      current = next;
    }

    for (String s : seen) System.out.println(s);
  }
}
> ./mill HtmlScraper.java singapore 1
Hokkien
Conscription_in_Singapore
Malaysia_Agreement
Government_of_Singapore
...

Web Server

This example shows off running a simple webserver in a single-file script. While a production-quality website is a lot more complex and would need a proper build.mill file, sometimes you just need a simple webserver running for local testing or experimentation. In these scenarios, writing a single-file script in Java to run the server is a great way to start, and it can always grow into a more full-featured project later if necessary.

In this example, the webserver script uses the Spring Boot framework to run the web server

WebServer.java (download, browse)
//| mvnDeps: [org.springframework.boot:spring-boot-starter-web:3.2.0]
package example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

@SpringBootApplication
@RestController
public class WebServer {
  public static void main(String[] args) throws Exception {
    SpringApplication.run(WebServer.class, args);
    Thread.sleep(Integer.MAX_VALUE);
  }

  @PostMapping("/reverse-string")
  public String reverseString(@RequestBody String body) {
    return new StringBuilder(body).reverse().toString();
  }
}
> ./mill WebServer.java:runBackground

> curl -d 'helloworld' localhost:8080/reverse-string
dlrowolleh

Database Queries

This example shows populating and querying a simple database using a Java script. While you often want to query databases using SQL directly, sometimes it is useful to be be able to query them programmatically from Java, possibly making use of the Java libraries and application code you use in the rest of your codebase. It uses a local Sqlite database populated by the contents of a local sqlite-customers.sql file.

In this case, we make use of the popular JOOQ library to perform the queries:

Database.java (download, browse)
//| mvnDeps:
//| - org.jooq:jooq:3.19.8
//| - org.xerial:sqlite-jdbc:3.43.0.0
import org.jooq.*;
import org.jooq.impl.DSL;
import java.sql.*;
import java.time.LocalDate;
import java.util.List;

import static org.jooq.impl.DSL.*;

public class Database {
  public static void main(String[] args) throws Exception {
    // Initialize database
    try (var conn = DriverManager.getConnection("jdbc:sqlite:./file.db")) {
      var ctx = DSL.using(conn, SQLDialect.SQLITE);

      // Populate database from SQL file - execute using raw JDBC connection
      String sql = java.nio.file.Files.readString(
        java.nio.file.Path.of("sqlite-customers.sql"),
        java.nio.charset.StandardCharsets.UTF_8
      );
      try (java.sql.Statement stmt = conn.createStatement()) {
        for (String statement : sql.split(";")) {
          String trimmed = statement.trim();
          if (!trimmed.isEmpty()) stmt.execute(trimmed);
        }
      }

      var buyer = table("buyer");
      var buyerId = field(name("buyer", "id"), Integer.class);
      var buyerName = field(name("buyer", "name"), String.class);
      var buyerDob = field(name("buyer", "date_of_birth"), LocalDate.class);

      var shipping = table("shipping_info");
      var shipId = field(name("shipping_info", "id"), Integer.class);
      var shipBuyerId = field(name("shipping_info", "buyer_id"), Integer.class);
      var shipDate = field(name("shipping_info", "shipping_date"), LocalDate.class);

      // Find names of buyers whose shipping date is today or later
      var names = ctx
        .select(buyerName)
        .from(buyer)
        .join(shipping)
        .on(buyerId.eq(shipBuyerId))
        .where(shipDate.ge(LocalDate.parse(args[0])))
        .fetchInto(String.class);

      names.forEach(System.out::println);
    }
  }
}
> ./mill Database.java 2011-01-01
James Bond
John Doe

Static Site Generator

This example is a small static site generator written as a single-file Java script. It lists markdown files in a post/ folder, converts them to HTML using the Commonmark-Java library, wraps them in a HTML template and writes them to HTML files in an out/ folder along with an index.html page that links to them:

StaticSite.java (download, browse)
//| mvnDeps:
//| - com.j2html:j2html:1.6.0
//| - com.atlassian.commonmark:commonmark:0.13.1

import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;

import java.nio.file.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.Collectors;

import static j2html.TagCreator.*;

public class StaticSite {
  public record PostInfo(int index, String title, String slug, Path path) {
    public static PostInfo fromPath(Path path) {
      var fileName = path.getFileName().toString();
      var matcher = Pattern.compile("(\\d+) - (.+)\\.md").matcher(fileName);
      matcher.matches();
      var titleStr = matcher.group(2).trim();
      var slugVal = titleStr.toLowerCase().replace(" ", "-");
      return new PostInfo(Integer.parseInt(matcher.group(1)), titleStr, slugVal, path);
    }
  }

  public static void main(String[] args) throws Exception {
    var cwd = Paths.get("").toAbsolutePath();
    var outPostDir = cwd.resolve("site-out/post");

    var posts = new ArrayList<PostInfo>();
    try (var dirStream = Files.list(cwd.resolve("post"))) {
      for (var pathIter = dirStream.iterator(); pathIter.hasNext(); ) {
        posts.add(PostInfo.fromPath(pathIter.next()));
      }
    }
    posts.sort(Comparator.comparingInt(PostInfo::index));

    deleteRecursively(cwd.resolve("site-out"));
    Files.createDirectories(outPostDir);

    var mdParser = Parser.builder().build();
    var mdRenderer = HtmlRenderer.builder().build();

    for (var post : posts) {
      var markdown = Files.readString(post.path());
      var renderedHtml = mdRenderer.render(mdParser.parse(markdown));
      Files.writeString(
        outPostDir.resolve(post.slug() + ".html"),
        "<!DOCTYPE html>\n" +
          html(
            body(
              h1("Blog / " + post.title()),
              div().with(rawHtml(renderedHtml))
            )
          ).renderFormatted()
      );
    }
  }

  private static void deleteRecursively(Path root) throws Exception {
    if (!Files.exists(root)) return;
    try (var walk = Files.walk(root)) {
      var paths = walk.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
      for (var p : paths) Files.delete(p);
    }
  }
}
> ./mill StaticSite.java

> cat site-out/post/my-first-post.html
<!DOCTYPE html>
<html>
    <body>
        <h1>
            Blog / My First Post
        </h1>
        <div>
            <p>Sometimes you want numbered lists:</p>
            <ol>
            <li>One</li>
            <li>Two</li>
            <li>Three</li>
            </ol>
        </div>
    </body>
</html>

Packaging Assemblies and Native Binaries

Mill Single-File Scripts can be packaged into executable assemblies or Graal native images for convenient distribution or deployment. Note that to create Graal native image binaries, you need to define your jvmId to be a graalvm version

Bar.java (download, browse)
//| jvmId: "graalvm-community:17"
//| nativeImageOptions: ["--no-fallback"]
public class Bar {
  public static void main(String[] args) {
    System.out.println("Hello World");
  }
}
> ./mill Bar.java:assembly

> ./out/Bar.java/assembly.dest/out.jar # mac/linux
Hello World

> ./mill Bar.java:nativeImage

> ./out/Bar.java/nativeImage.dest/native-executable
Hello World

Opening a Script REPL

To open a Java REPL with your script loaded and available, you can use the following command. This can be useful to interactively exercise and experiment with the code defined in your script.

./mill -i Foo.java:jshell

Custom Script Module Classes

By default, Mill single-file Java modules inherit their behavior from the builtin mill.script.JavaModule. However, you can also customize them to inherit from a custom Module class that you define as part of your meta-build in mill-build/src/. For example, if we want to add a resource file generated by processing the source file of the script, this can be done in a custom LineCountJavaModule as shown below:

Qux.java (download, browse)
//| extends: [millbuild.LineCountJavaModule]
package qux;

public class Qux {
  public static String getLineCount() throws Exception {
    return new String(
        Qux.class.getClassLoader().getResourceAsStream("line-count.txt").readAllBytes());
  }

  public static void main(String[] args) throws Exception {
    System.out.println("Line Count: " + getLineCount());
  }
}
mill-build/src/LineCountJavaModule.scala (download, browse)
package millbuild
import mill.*, javalib.*, script.*

class LineCountJavaModule(scriptConfig: ScriptModule.Config)
    extends mill.script.JavaModule(scriptConfig) {

  /** Total number of lines in module source files */
  def lineCount = Task {
    allSourceFiles().map(f => os.read.lines(f.path).size).sum
  }

  /** Generate resources using lineCount of sources */
  override def resources = Task {
    os.write(Task.dest / "line-count.txt", "" + lineCount())
    super.resources() ++ Seq(PathRef(Task.dest))
  }
}
> ./mill Qux.java
...
Line Count: 13

> ./mill show Qux.java:lineCount
13

Your custom LineCountJavaModule must be a class take a mill.script.ScriptModule.Config as a parameter that is passed to the mill.script.JavaModule. Custom script module classes allows you to customize the semantics of your Java, Scala, or Kotlin single-file script modules. If you have a large number of scripts with a similar configuration, or you need customizations that cannot be done in the YAML build header, placing these customizations in a custom script module class can let you centrally define the behavior and standardize it across all scripts that inherit it via extends.

Note that single-file scripts can only inherit from a single concrete module class, unlike normal config-based or programmatic modules that can inherit from one-or-more abstract module `trait`s. This difference arises from the way scripts are instantiated dynamically when resolved, rather than being compiled ahead-of-time like other modules are.

Relative and Absolute Script moduleDeps

Mill single-file scripts can import each other via either relative or absolute imports. For example, given a bar/Bar.java file such as below:

bar/Bar.java (download, browse)
package bar;

public class Bar {
  public static String generateHtml(String text) {
    return "<h1>" + text + "</h1>";
  }
}

It can be imported via a ./Bar.java import relative to its own enclosing folder (in this case bar/), as shown below where it is imported from the bar/BarTests.java test suite in the same folder:

bar/BarTests.java (download, browse)
//| extends: [mill.script.JavaModule.Junit4]
//| moduleDeps: [./Bar.java]
package bar;

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class BarTests {
  @Test
  public void simple() {
    assertEquals("<h1>hello</h1>", Bar.generateHtml("hello"));
  }
}

Or it can be imported via bar/Bar.java absolute import, as shown below where it is imported from the foo/Foo.java

foo/Foo.java (download, browse)
//| moduleDeps: [bar/Bar.java]
package foo;

public class Foo {
  public static void main(String[] args) throws Exception {
    System.out.println(bar.Bar.generateHtml(args[0]));
  }
}

This examples can be exercised as follows:

> ./mill bar/Bar.java:compile
> ./mill bar/BarTests.java
> ./mill foo/Foo.java hello

Project moduleDeps

Single-file scripts can depend on programmatic modules. This can be useful to integrate your scripts into a large project, where your scripts may make of classes and methods defined in your library or application code to perform domain-specific actions that may not be available in the standard library or third-party libraries.

build.mill (download, browse)
package build
import mill.*, javalib.*

object bar extends JavaModule {
  def mvnDeps = Seq(mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE")
}
Foo.java (download, browse)
//| moduleDeps: [bar]
package foo;

public class Foo {
  public static void main(String[] args) throws Exception {
    System.out.println(bar.Bar.generateHtml(args[0]));
  }
}
> ./mill Foo.java hello
<h1>hello</h1>