Getting Started with JLine: A Java Library for Interactive Command-Line Input

Sep 3, 2018 · 1738 words

We all know that software user interfaces are generally divided into GUI (Graphical User Interface) and CLI (Command Line User Interface). For those of us who frequently use Linux, the command line interface is certainly very familiar. Whether it is the interface for entering commands in a Shell or the internal interactive interface of software like GDB, both are command line interfaces. However, when we develop our own software and want to write a serious CLI, we find that manually creating a user-friendly command line interface is actually quite difficult. This is because a good command line interface, beyond simple input/output, needs to support some common command line features.

For me, a qualified command line software interface should support these three features:

  • Auto-completion: When the TAB key is pressed, content is completed at the current cursor position. Based on contextual information, the completion could be for a command or a file path.
  • Command History: When the up/down arrow keys are pressed, the previous/next command is displayed.
  • Line Editing: The ability to use Emacs shortcuts for in-line editing, such as Ctrl+A to move the cursor to the beginning of the line and Ctrl+E to move it to the end.

Those familiar with Linux will notice that the three features mentioned above are all functions of GNU Readline. We don’t need to manually code these features in our software; we just need to use such a library. In fact, many software programs in GNU/Linux use the GNU Readline library, which has made GNU Readline a de facto standard for command line interaction. GNU Readline is a C library. When using other languages, we need to find libraries with corresponding functionality (which are often wrappers around the underlying GNU Readline library). For the Java language, JLine is such a library that helps you build a command line interactive interface.

This article aims to introduce the basic usage of JLine3 through an example. JLine3 does not have a “Hello, world!” example, and its wiki is written very briefly. Although there is a sample program Example.java, it is relatively complex and difficult to understand. I hope the content of this article will help you understand how to use JLine3.

Basic Framework

We will try to design a command line user interface for a software called Fog. Users can enter four types of commands:

text
CREATE [FILE_NAME]
OPEN [FILE_NAME] AS [FILE_VAR]
WRITE TIME|DATE|LOCATION TO [FILE_VAR]
CLOSE [FILE_VAR]

Next, we will write the command line interface for the Fog software step by step. First, use JLine3 to build the most basic REPL (Read-Eval-Print Loop) framework:

java
Terminal terminal = TerminalBuilder.builder()
        .system(true)
        .build();

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .build();

String prompt = "fog> ";
while (true) {
    String line;
    try {
        line = lineReader.readLine(prompt);
        System.out.println(line);
    } catch (UserInterruptException e) {
        // Do nothing
    } catch (EndOfFileException e) {
        System.out.println("\nBye.");
        return;
    }
}

Except for setting the command prompt, no special settings are made here. The command line will print the line entered by the user exactly as it is. When the user inputs Ctrl+D (End of line), the program will exit.

Even though we have only written a framework, the program already possesses the command history and line editing features provided by default by JLine3. At this point, pressing the up/down arrow keys will display the previous/next command, and Emacs shortcuts like Ctrl+A and Ctrl+E can be used for in-line editing.

Command Completion

Simple Completion and Composite Completion

Since command completion is closely related to the program’s command format, we must define the completion method ourselves. According to the wiki, the way to define command completion in JLine3 is to create an instance of the Completer class and pass it into the LineReader. JLine3 has several built-in completers, the most common being FileNameCompleter (for completing filenames) and StringsCompleter (for completing based on several predefined strings, used for command names or parameter names). For example, the four commands of the Fog program start with CREATE, OPEN, WRITE, and CLOSE respectively, so we can use a StringsCompleter to complete the first word of the command:

java
Completer commandCompleter = new StringsCompleter("CREATE", "OPEN", "WRITE", "CLOSE");

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(commandCompleter)
        .build();

However, this completion method only supports the first word of each command. What if we want to provide completion at various possible points in the command? This is where we need to combine completers to form a composite completer. Generally, a simple completer like StringsCompleter is only responsible for completing one word. To achieve completion for an entire command, you need to use several different completers in combination. ArgumentCompleter is a composite completer used to complete an entire command. It can combine multiple completers, with each completer responsible for completing the i-th word in the command. Taking the CREATE command as an example, this command has two words: the first word requires string completion, and the second word requires filename completion. Thus, we use ArgumentCompleter to combine StringsCompleter and FileNameCompleter:

java
Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter()
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(createCompleter)
        .build();

Based on the two parameters of ArgumentCompleter, it will complete CREATE when entering the first word and complete the filename when entering the second word. However, a problem arises during testing: after you have entered CREATE and a filename, if you try to trigger completion at the third word, the filename completion still appears. This is because ArgumentCompleter defaults to using the last completer after you have “exhausted” all completers (i.e., starting from the third word). This is not the desired effect. To solve this, we can add a NullCompleter at the end:

java
Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter(),
        NullCompleter.INSTANCE
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(createCompleter)
        .build();

NullCompleter performs no completion. This way, from the third word onwards, no redundant completion will occur.

Similarly, we add the completion definition for the OPEN command:

java
Completer createCompleter = new ArgumentCompleter(
        new StringsCompleter("CREATE"),
        new Completers.FileNameCompleter(),
        NullCompleter.INSTANCE
);

Completer openCompleter = new ArgumentCompleter(
        new StringsCompleter("OPEN"),
        new Completers.FileNameCompleter(),
        new StringsCompleter("AS"),
        NullCompleter.INSTANCE
);

Completer fogCompleter = new AggregateCompleter(
        createCompleter,
        openCompleter
);

LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(fogCompleter)
        .build();

There are two points to note here:

  1. The CREATE and OPEN commands have their own defined completers, which are then combined using AggregateCompleter. AggregateCompleter is another type of composite completer that combines multiple possible completion methods. To use an analogy, ArgumentCompleter is like a series circuit, while AggregateCompleter is like a parallel circuit.
  2. The ArgumentCompleter for the OPEN command only defines completion for the first three words. This is because the fourth word is a user-defined file variable; the user could enter any name, so it cannot be completed.

Dynamic Completion

The completion for the WRITE command is slightly different from the first two. According to the program semantics, only file variables defined by the user in an OPEN command can be used in a WRITE command. Therefore, this should be considered during completion. We need to dynamically adjust the completion candidates at runtime: whenever a user opens a file using the OPEN command, the completer should be adjusted to include the new file variable in the candidates. We need to know how to modify the completer dynamically. Although the creation and passing of the completer to the LineReader are static, at runtime, completion candidates are retrieved by calling Completer.complete(). Thus, we can inherit from Completer and override the complete() method to implement dynamic candidate adjustment.

java
public class FileVarsCompleter implements Completer {

    Completer completer;

    public FileVarsCompleter() {
        this.completer = new StringsCompleter();
    }

    @Override
    public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
        completer.complete(reader, line, candidates);
    }

    public void setFileVars(List<String> fileVars) {
        this.completer = new StringsCompleter(fileVars);
    }
}

When setFileVars() is called, a new StringsCompleter is created, thereby expanding the candidates. In the REPL, you just need to call setFileVars() after the user enters an OPEN command.

java
public class Fog {

    private static List<String> fileVars = new ArrayList<>();
    private static FileVarsCompleter fileVarsCompleter = new FileVarsCompleter();

    public static void main(String[] args) throws IOException {

        // ...

        Completer writeCompleter = new ArgumentCompleter(
                new StringsCompleter("WRITE"),
                new StringsCompleter("TIME", "DATE", "LOCATION"),
                new StringsCompleter("TO"),
                fileVarsCompleter,
                NullCompleter.INSTANCE
        );

        Completer fogCompleter = new AggregateCompleter(
                createCompleter,
                openCompleter,
                writeCompleter
        );

        // ...

        String prompt = "fog> ";
        while (true) {
            String line;
            try {
                line = lineReader.readLine(prompt);
                System.out.println(line);
                if (line.startsWith("OPEN")) {
                    fileVars.add(line.split(" ")[3]);
                    fileVarsCompleter.setFileVars(fileVars);
                }
            } catch (UserInterruptException e) {
                // Do nothing
            } catch (EndOfFileException e) {
                System.out.println("\nBye.");
                return;
            }
        }
    }
}

Command History

As mentioned earlier, by default, JLine3 already supports command history lookup. However, we want to add a special feature: comments entered by the user (starting with #) should not enter the command history, so they don’t interfere with history lookups.

In JLine3, History is responsible for controlling the behavior of history records, with DefaultHistory being the default implementation. Looking at the source code, we find that the add() method is its core behavior. A line of command entered by the user is added to the command history via the add() method.

java
@Override
public void add(Instant time, String line) {
    Objects.requireNonNull(time);
    Objects.requireNonNull(line);

    if (getBoolean(reader, LineReader.DISABLE_HISTORY, false)) {
        return;
    }

    // ...

    internalAdd(time, line);

    // ...
}

Similarly, we can filter out comment content by inheriting and overriding the add() method so that it is not added to the command history:

java
public final class FogHistory extends DefaultHistory {

    private static boolean isComment(String line) {
        return line.startsWith("#");
    }

    @Override
    public void add(Instant time, String line) {
        if (isComment(line)) {
            return;
        }
        super.add(time, line);
    }
}

Then we set up the LineReader like this:

java
LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(fogCompleter)
        .history(new FogHistory())
        .build();

Summary

We find that the various functions of JLine3 are designed quite clearly, with corresponding interfaces and default implementations. If we want to customize certain features, it can generally be done through inheritance and overriding. The source code of JLine3 is also relatively easy to understand; when encountering difficulties, you can read the source code yourself to find clues.

The complete code for the example program in this article can be found at jline3-demo.