Revisiting Java for Shell Scripting

 · 6 min
Hiroyoshi Urushima on Unsplash

Around 6 years ago, I wrote about using Java for shell scripts. It was a hacky and fragile way to convert some Java code into a shebanged file containing the content of a Jar file.

However, Java evolved quite a bit since that article, so it’s time to look at it again.

Running Java Code Directly

The main reason for the previous approach of packing a Jar file into a plain text file was Java’s lack of running uncompiled code. Even though it “works,” it’s also super fragile, as it breaks each time an editor saves the file and screws up the encoding.

Around 19 months after my original article, Oracle released Java 11, which contained a quite important new feature regarding Java’s shell scripting capabilities: JEP 330.

This enhancement proposal is about “launching single-file source-code programs” by using java directly instead of the old javac/java combo:

shell
# BEFORE JAVA 11

$ javac MyScript.java # produces MyScript.class
$ java MyScript       # runs the code


# JAVA 11+

$ java Myscript.java

Instead of telling java which .class file to look up by the class name, we tell it to use a Java file directly. In fact, it doesn’t even need to be a file ending in .java.

The added argument --source enables “source-file mode” and specifies that the file is containing source code in a specific version:

shell
$ java --source 11 MyScriptHasNoFileExtension

Not Your Typical Java Environment

Running a single class without compiling it first or converting it into a jar file is already quite a nice improvement. However, JEP 330 isn’t only about combining the compile and run steps into a single call. Running java in “source-file mode”, either by providing a .java file or specifying --source, sets certain expectations to the context applying to compilation and running your code.

Interpreting Command Line Options

Command-line options specified before the source are respected, including --class-path, --module-path, --add-exports, --add-modules, --limit-modules, --patch-module, --upgrade-module-path, --enable-preview, and variants of those.

Any other options, like -processor or -Werror aren’t passed along.

Argument files (@-files) are still supported.

Compiling the Code

The compilation process also has certain assumptions and restrictions:

  • Any command-line options relevant to compilation are taken into account.
  • Only the specified source file is used. No other files are found or compiled, which is equivalent to an empty source path value.
  • No annotation processing, as if -proc:none were set.
  • If --source <version> is set, it also sets --release <version>` implicitly.
  • The source file is compiled as an unnamed module.

One thing that totally breaks from the Java conventions you’re used to is the following:

  • The source file can contain more than one top-level type, like classes and interfaces.
  • The file isn’t forced to have the same name as any top-level type.

JLS §7.6 specifies how top-level type declarations are supposed to work.

Even though we could use nested classes, splitting them up into top-level ones keeps it more straightforward, in my opinion.

Execution Phase

The execution of the compiled code also isn’t done the usual way:

  • The first top-level type found is executed, so make sure it is a class and has a public static void main(String[]) to run.
  • Adding a main method to any other class has no effect.
  • A custom class loader delegates to the application’s class loader, so classes in the application classpath cannot use any types from the source file.
  • In addition to any --add-module, the compiled code is run as if --add-modules=ALL-DEFAULT is set.
  • Any arguments after the source file’s name are passed to the executed main method.

Now that we know how it works and what to expect, let’s take a look at how to use it in a shebang file.


Shebang Files

To make running Java as simple as other shell scripts, it needs to be able to run in a shebang file. The simplest variant is:

shell
#!/path/to/java --source <version>

# ...

It works fine, but still has a hardcoded path to the java executable, as the shebang expects it to be this way.

To mitigate, we can “abuse” then env command to provide an environment where $PATH is available:

shell
#!/usr/bin/env -S java --source <version>

# ...

That’s it!

Put in your Java code, make the file executable with chmod +x MyScriptFile, and you can run it with ./MyScriptFile.

Now you have a Java shell script that’s easy to edit and doesn’t require any additional steps like before!

Another thing is that the JEP anticipated using shebang files. You can still run the script file with java --source <version> <source-file> without worrying about the shebang. In the case of an exception, the resulting line numbers are even adapted to make sense in the context of the file.


Java 21 Improvements

With the release of Java 21, there’s now a preview JEP available that makes the scripting approach even simpler: JEP 445: Unnamed Classes and Instance Main Methods (Preview)

I’ve written an article about it, but in essence, it simplifies the required ceremony for a minimal program:

java
// BEFORE JEP 445
public class FullBlownCeremony {
  public static void main(String... args) {
    System.out.println("Hello, World!");
  }
}

// WITH JEP 445
void main() {
  System.out.println("Hello, World!");
}

You still need to wrap your code in the void main() method, but you no longer require arguments, or a surrounding class. And the best thing, this doesn’t affect your ability to add additional top-level types to the file!

To use this, though, you need to enable preview features in your java call:

shell
#!/usr/bin/env -S java --source 21 --enable-preview

void main() {
  // ...
}

This is starting to look more and more like a real scripting file!


Why not Groovy or Kotlin?

Groovy generally has a more dynamic and “scripty” feel to it, as it doesn’t require as much boilerplate to run, and also supports shebang files.

Kotlin, supports scripting explicitly, at least as an experimental feature.

Both Groovy and Kotlin even have a big advantage over Java here, as both have built-in dependency management.

Groovy has the @Grab annotation (also known as “Grape”), whereas Kotline has @file:... to specify repositories and load dependencies or other local files.

To be honest, if you want to have a scripting-like experience on the JVM, Groovy or Kotlin are a better choice than Java. But using a familiar and your preferred language can easily outshine a “better” suited one.


A Functional Approach to Java Cover Image
Interested in using functional concepts and techniques in your Java code?
Check out my book!
Available in English, Polish, Korean, and soon, Chinese.

Conclusion

Java has gained a lot of bells and whistles to better support the use case of simple scripting since my previous article on the topic, and it looks like the OpenJDK is working to simplify the minimal requirements even further in the future.

It still might not be the top contender for scripting in general, but I like to have a familiar language at my disposal if I want to, and not all of my colleagues speak as much Bash as I do.


Resources