Interactive Java consoles with JLine and ConsoleUI

The command-line interface (CLI) is the inner world of software development. From the shell, we have direct access to all the operating system’s capabilities, and with that comes the power to compose and orchestrate all aspects of the software. Many tools and frameworks incorporate command lines. Not only that, but the command prompt is the root magic of working with software systems; it’s the home of near unlimited possibilities. 

In this article, we’ll take a tour of building sophisticated interactive command-line interface (CLI) applications and REPLs (read–eval–print loops, or interactive shells) in Java. We’ll set up a basic demo application in Java and use the JLine and ConsoleUI libraries to add the features that we need.

The Java-based REPL

Our demonstration is based on a theoretical application that examines a software project’s working directory and gathers information about the projects there. The application also is able to create new projects in the directory. The example application will start a REPL that accepts two commands, describe and create, which can be tab-completed. The describe command will list the folder hierarchy of the working directory with color coding (using paging if necessary), while create initiates an interactive menu that lets the user choose what kind of project to create—Java, JavaScript, or Python. If it’s a Java application, we’ll allow a multi-select of additional features the user can add (database or REST API) that will let us see a nested menu. 

We’ll just use these features to explore the JLine capabilities, rather than actually implementing them.

The demo application

For this tour, you’ll need a Java JDK and Maven installed. We’ll start by creating a fresh application with a Maven archetype, like what’s shown in Listing 1.

Listing 1. Create a new application with Maven


mvn archetype:generate -DgroupId=com.infoworld -DartifactId=jline3 -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

Maven will use these commands to lay out a new project for us. Before we go any further, let’s also add all the dependencies we’ll need, and also set the Java version to 11 (any version from Java 8 forward should work), as I’ve done in Listing 2. This applies to the pom.xml file in the project root (leave the rest of the pom.xml as-is).

Listing 2. Add dependencies and set the Java version


<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.8.1</version>
      <configuration>
        <source>11</source>
        <target>11</target>
      </configuration>
    </plugin>
  </plugins>
</build>
<dependencies>
  <dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>3.8.1</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.jline</groupId>
    <artifactId>jline</artifactId>
    <version>3.16.0</version>
  </dependency>
  <dependency>
    <groupId>org.fusesource.jansi</groupId>
    <artifactId>jansi</artifactId>
    <version>2.4.0</version>
  </dependency>
  <dependency>
    <groupId>org.jline</groupId>
    <artifactId>jline-terminal-jansi</artifactId>
    <version>3.20.0</version>
  </dependency>
</dependencies>

Next, let’s modify the main class in src/main/java/com/infoworld/App.java to start a REPL loop. Modify App.java using the code in Listing 3.

Listing 3. A simple REPL loop


package com.infoworld;
import org.jline.reader.*;
import org.jline.reader.impl.*;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.impl.completer.*;
import org.jline.terminal.TerminalBuilder;
import org.jline.terminal.Terminal;

import java.io.IOException;
import java.util.*;

public class App {

    public static void main(String[] args) throws IOException {
        Terminal terminal = TerminalBuilder.terminal();
        LineReader reader = LineReaderBuilder.builder()
                .terminal(terminal)
                .completer(new StringsCompleter("describe", "create"))
                .build();

        while (true) {
            String line = reader.readLine("> ");
            if (line == null || line.equalsIgnoreCase("exit")) {
                break;
            }
            reader.getHistory().add(line);
            System.out.println("You said: " + line);
        }
    }
}

Listing 3 creates a very simple program that watches for lines of user input and echoes them back. To that, I added a “completer,” which holds the two commands we support, describe and create. That means when the user is typing at the prompt, they can tab to complete these commands. Tabbing twice will offer a menu with the available commands. JLine has made this very easy with the fluent-style .completer(new StringsCompleter("describe", "create")) method call. JLine has several built-in completers, in addition to Strings, and you can also build your own.

At heart, the REPL is an infinite while loop, which breaks when the user enters exit.

You can test it out by running the Maven exec:java command shown in Listing 4.

Listing 4. Run the program with Maven


mvn clean package exec:java -Dexec.mainClass=com.infoworld.App

You’ll get the carrot prompt, the echo response, and the tab-completion commands.

Handling REPL commands

Now that we have the echo REPL working with auto-complete, let’s actually handle the commands. We’ll do this with typical Java, by comparing the string entered with the commands and calling methods for each. For now, create won’t do anything, but we’ll implement the logic to output the directory hierarchy, as shown in Listing 5.

Listing 5. REPL with the describe command


package com.infoworld;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.Stream;
import org.jline.reader.LineReader;
import org.jline.reader.impl.completer.StringsCompleter;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;

public class App {
  public static void main(String[] args) throws IOException {
    Terminal terminal = TerminalBuilder.terminal();

    LineReader reader =
        LineReaderBuilder.builder()
            .terminal(terminal)
            .completer(new StringsCompleter("describe", "create"))
            .parser(new DefaultParser())
            .build();

    while (true) {
      String line = reader.readLine("> ");
      if (line == null || line.equalsIgnoreCase("exit")) {
        break;
      }
      reader.getHistory().add(line);

      if (line.equalsIgnoreCase("describe")) {
        Path path = Paths.get(".");
        System.out.println(getDirectoryHierarchy(path));
      } else if (line.equalsIgnoreCase("create")) {
        System.out.println(“TBD”);
      } else {
        System.out.println("Unknown command: " + line);
      }
    }
  }

  public static String getDirectoryHierarchy(Path path) {
    StringBuilder sb = new StringBuilder();
    try (Stream<Path> paths = Files.walk(path)) {
      paths.sorted()
          .forEach(
              p -> {
                int depth = path.relativize(p).getNameCount();
                for (int i = 0; i < depth; i++) {
                  sb.append(" ");
                }
    if (p.toFile().isDirectory()) {
                  sb.append("https://www.infoworld.com/"); 
                }
                sb.append(p.getFileName()).append("n");
              });
    } catch (IOException e) {
      e.printStackTrace();
    }
    return sb.toString();
  }
}

Now when you run the application, if you enter the describe command, you’ll see an indent-formatted list of the working directory. The work of building that string up happens in getDirectoryHierarchy(). That method uses normal Java from the java.nio.file package to walk the directory and output each file and directory, indenting a space for each level of directory we go down. This work is mainly done with path.relativize(p).getNameCount(), which says: from my current path (.) give me the relative path to the current one (e.g., ./src/main/java). The getNameCount() just counts the number of names in that path—three, in this case. For each name, we add a space.


Source link