I've written before about how I think that while they need some bolstering (here) using the CLI tools to build Java code is more practical than you might think (here and here).
What I didn't talk about, or tip-toed around, is that writing build scripts in bash
, PowerShell
, cmd.exe
, etc. is not very cross-platform.
You can install bash
on Windows and run in WSL, but that feels unideal. An extra setup step is one thing, but needing to ask students who just learned that the command line exists to also make sure they aren't accidentally running in PowerShell
is painful.
You could also just ignore the problem. "Real" developers use Mac or Linux, right? Well those same developers sometimes pick an extra special shell for themselves like zsh
, nushell
, or fish
. You have a similar, if less serious, problem.
Using any of those shells for experimenting or testing out commands is fine. Until you write $()
or a file path they are all more or less the same. What we really need is some way of writing out commands that will work on Windows, Mac, and Linux and regardless of if someone is using bash
, Powershell
, cmd.exe
, zsh
, nushell
, or fish
.
If only we had a language that could be written once and then ran anywhere.
Since Java 22 we've been able to write java Main.java
and execute a potentially multi-file program. Before that there was a significant bootstrap problem. If you write Java code to compile Java code, who compiles that Java code?
Now that that's there, we can start to consider what it would look like to run a CLI tool from Java code and compare that to the alternatives.
The alternative I've been using is just
. just
is a command runner similar to make
but without any of the caching make
does or the wild syntax and history make
is burdened with.
You write the name of a "recipe", a :
then an indented list of commands to run.
demo:
javac --version
jlink --version
For this, if you run just demo
and then it will run each command in sequence.
$ just demo
javac --version
javac 22.0.1
jlink --version
22.0.1
If any command gives you a non-zero exit code it fails immediately.
javac --v
error: invalid flag: --v
Usage: javac <options> <source files>
use --help for a list of possible options
And, by default, it will echo the command its about to run before it runs it.
All of these properties are useful for different reasons.
just compile
is much more ergonomic to use than javac -d build --module-source-path "./*/src --module example
.Also, and it feels small but isn't, you can get a list of all the commands + a comment on how to use them with just --list
.
$ just --list
Available recipes:
demo
I want all of these properties so let's see how we can get them in Java
.
To run a command we can use the ProcessBuilder
.
import java.util.List;
public class Project {
public static void main(String[] args) {
= List.of("javac", "--version");
var cmd
= new ProcessBuilder(cmd);
var pb }
}
Then all we need to do is start the command, wait for it to finish, and record the exit status. If its non-zero throw.
public class Project {
public static void main(String[] args) {
= List.of("javac", "--version");
var cmd
= new ProcessBuilder(cmd);
var pb try {
int exitStatus = pb.inheritIO().start().waitFor();
if (exitStatus != 0) {
throw new RuntimeException(
"Non-zero exit status: " + exitStatus
);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
Yeesh. So that sucks. We haven't even gotten to printing out the command or labelling groups of commands yet.
I've been noodling on this for a time and this weekend2 I think I finally came up with a half-decent API.
import dev.mccue.tools.ExitStatusException;
import dev.mccue.tools.Tool;
public class Project {
public static void main(String[] args)
throws ExitStatusException {
Tool.ofSubprocess("javac")
.run("--version");
}
}
This will print out the command to System.err
, run it, throw an exception if needed, and pipe output to System.out
/System.err
as needed.
Problem is that now we've introduced a dependency.
Hand-waving how you get the dependency1, the command you need to run the code changes from java scripts/Main.java
to java --module-path scripts/libs --add-modules ALL-MODULE-PATH scripts/Main.java
.
That's simply too much to remember.
To deal with this we can use argument files.
If we make a file called project
at the top level of our project with the following contents.
--module-path scripts/libs --add-modules ALL-MODULE-PATH
Now we can run the script with just java @project
. This works as if all the arguments in the file were applied inline in the invocation. Most tools that come with Java, including the java
launcher itself, support this.
As for identifying groups of commands, there is a solution there too. Now that we've opened the floodgates on our Java build script having dependencies, what's one more?
import dev.mccue.tools.ExitStatusException;
import dev.mccue.tools.Tool;
import picocli.CommandLine;
@CommandLine.Command(
= "project"
name )
public final class Project {
public static void main(String[] args) {
new CommandLine(new Project()).execute(args);
}
@CommandLine.Command(name = "demo")
public void demo() throws ExitStatusException {
Tool.ofSubprocess("javac")
.run("--version");
}
}
If we use picocli
, then it's trivial. Our build script is a CLI program like any other, why not use normal CLI libraries?
We get the ability to run commands by name.
$ java @project demo
javac --version
javac 22.0.1
And we even get to list commands in a way.
$ java @project
Missing required subcommand
Usage: project [COMMAND]
Commands:
demo
Yay!
While Tool.ofSubprocess("javac").run("--version");
is complete in a sense, it's not very fun to use.
What we generally want from a Java API is method-level autocomplete. Having to separately reference a man page doesn't spark joy in me, and it shouldn't spark joy in you.
I started this particular adventure wanting to translate options from the CLI more or less 1-1. This is for two broad reasons.
-g
is painful and a lot of work.The transform I started with was for every argument that --looks-like-this
I would add a method to an arguments object that looksLikeThis
.
.run(arguments -> {
Javadoc
arguments.moduleSourcePath("./modules/*/src")
.d(Path.of("build/javadoc"))
.module("dev.mccue.tools")
});
This works pretty well, but look at these two options from javadoc
.
javadoc --help
...
--version Print version information
...
-version Include @version paragraphs
...
It has both -version
and --version
and they do wildly different things. Great. Awesome.
This is a one-off example, but CLI tools are fundamentally textual apis. --some-thing
to someThing
isn't just a stylistic change, it's a lossy transformation.
So I gave up. My only strategy now is to take arguments that --look-like-this
and turn them into ones that __look_like_this
. It might be ugly, but at least I don't run into strange problems anymore. As a side benefit, it does now look a lot more 1-1 with the CLI api.
.run(arguments -> {
Javadoc
arguments.__module_source_path("./modules/*/src")
._d(Path.of("build/javadoc"))
.__module("dev.mccue.tools")
});
I translated the spring demo repo I was using for some previous posts to use this approach. It includes running junit tests and managing multiple modules. You can find it here and the build script specifically here.
Note that the libraries I referenced, save for picocli, are very likely to change in backwards-incompatible ways as I iterate on them. Don't use them for anything serious yet, but you can find them here.
There are still some bootstrap and polish issues, but I think this approach is becoming more and more viable as its chipped away at.
Share thoughts, design feedback, etc. in the comments below.
1: jresolve --output-directory scripts/libs pkg:maven/dev.mccue/tools-jdk@2024.08.25.5
.
2: This approach/outlook on tooling has a lot of similarities to bach and the work Christian Stein has been doing. Will likely elaborate more on the difference between this approach, bach's approach, bld's approach, etc. when I personally have more mental clarity on it.