Java is not traditionally used for command-line applications because of the high start-up time. Some build tools, like Scala’s sbt, will instead start a shell that takes commands. Instead of issuing commands one-by-one in, say, Bash, the sbt shell will stay active, consequently eliminating the start-up overhead. Many programming languages offer similar shells to compile and evaluate code on demand (so-called Read Eval Print Loops, or REPL for short). Even Java provides such a REPL these days.
This post uses JLine 3.x and logback-classic 1.2.x.
A classic Java library that aids in implementing such shells is JLine.
Its main user-facing abstraction is a LineReader
, that
- runs on a terminal
- offers command completion
- offers syntax highlighting
- can parse commands
- provides history out-of-the box
And these are just some of the features.
With very little setup, you can create an application that shows the user some prompt expecting input. That input can be edited, users can press the up and down keys to navigate history, use Ctrl-combinations to skip between tokens; in other words, they get basic editing experience. When the user presses enter, JLine passes the parsed line to the application which can process the commands and start the loop again.
The virtual-terminal-based implementation of line editing is out of scope for this article. But suffice to say that all reading and printing from the terminal must be done through JLine, as can be illustrated with the following scenario: Assume that your application spawns some background threads that may print some log output. If the prompt is currently waiting for input, other threads might corrupt your terminal.
Naturally, this is very confusing to the user: user input and log output get entangled in the terminal.
The reason for this is that log output in command-line applications will usually be written to the standard output or error streams. Luckily, the most popular logging frameworks can be configured to use different output mechanisms. In this example, I will explain how the above output can be straightened out using Logback, although the technique will be applicable to other frameworks.
We start by creating a custom appender class:
This piece of code creates a very simple Logback appender that uses the printAbove
method of a JLine LineReader
to print output above a prompt.
Looking back at the example from above, this would lead to the following output:
In essence, printAbove
saves whatever the prompt looked like at the point where log output should be printed, clears the line, prints the log, and reconstructs the prompt, including user input.
If the user was typing something while log was printed out, it will visually look like the prompt is moving down one line.
Note that the above code snippet assumes that the lineReader
will be configured and initialized statically.
In our scenario, there is a class CLI
that contains the LineReader
as a static variable.
Now, for the configuration of Logback:
This configuration in logback.xml
will tell Logback to use the JLineAppender
we have just created.
As opposed to other common appenders, it does not offer the flexibility of defining our own patterns.
Consult the documentation for how this could be implemented.
Instead, the JLineAppender
uses the TTLL
layout, which at time of writing is equivalent to the pattern %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
.
To summarize: with just a little configuration effort, it is possible to combine stock Java libraries for a more pleasant command-line user experience.