Terminal Utilities

GitLab Docs Pipeline Coverage License
Modules containing utilities for enhancing terminal programs. Use of this library only makes sense for a simple CLI program that does not use ncurses or similar terminal graphic library.

See morimekta.net/utils for procedures on releases.

Getting Started

To add to maven:

<!-- -->
<dependencies>
    <dependency>
        <groupId>net.morimekta.utils</groupId>
        <artifactId>terminal-args</artifactId>
        <version>5.0.1</version>
    </dependency>
    <dependency>
        <groupId>net.morimekta.utils</groupId>
        <artifactId>terminal-io</artifactId>
        <version>5.0.1</version>
    </dependency>
</dependencies>

To add to gradle:

implementation 'net.morimekta.utils:terminal-args:5.0.1'
implementation 'net.morimekta.utils:terminal-io:5.0.1'

Argument Parser

The ArgParser interface, and some associated interfaces builds up a simple but powerful CLI argument parser. It does not hold to the parsed arguments itself, but uses closures / callbacks to put or update the values into whatever holding structure you choose.

import net.morimekta.terminal.args.ArgException;

import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;

import static net.morimekta.terminal.args.ArgParser.argParser;
import static net.morimekta.terminal.args.ArgHelp.argHelp;
import static net.morimekta.terminal.args.Flag.flagShort;
import static net.morimekta.terminal.args.Argument.argument;

class SimpleApp {
    public static void main(String[] args) {
        try {
            var help = new AtomicBoolean();
            var lines = new ArrayList<String>();
            var parser = argParser("simple", "0.0.1", "Test Application")
                    .add(flagShort("h?", "Show help", help::set))
                    .add(argument("print", "Strings to print one on each line", lines::add))
                    .parse(args);
            if (help.get()) {
                argHelp(e.getParser()).printHelp(System.out);
                return;
            }
            // run the actual program...
            lines.forEach(System.out::println);
        } catch (ArgException e) {
            if (e.getParser() != null) {
                // Invalid argument.
                argHelp(e.getParser()).printPreamble(System.err);
                System.err.println();
                System.err.println(e.getMessage());
            } else {
                // Invalid parser setup.
                e.printStackTrace();
            }
            System.exit(1);
        }
    }
}
  • ArgParser: Main type of the argument parsing. This class takes care of parsing the arguments, and has a builder for building the argument parser. The builder can add options or arguments, and change to a SubCommandSet builder, which can add subCommands.
  • Option: Options are the default way of setting flag values. Wherever you want a --arg value pair in the argument parsing, this is the way. It has two naming options: short and long (or both), where there can be one single long name, which MUST start with --. There can be multiple short names, using one char each. The purpose of the multi-keyed is mainly for [--help | -h | -?] (which has then 2 separate short char names).
  • Flag: Flags are pure boolean options, so they do not accept any value. Flags may have a negateName long name, and a single negateShortName char.
  • Property: Properties are generic key + value options, which is stored into a map-like consumer using a Putter implementation.
  • Argument: Arguments follows after options, and are handled as string values. There can be multiple arguments and the first argument that 'accepts' something will get it, so the order of the arguments are important.
  • ArgHelp: Helper to display usage, error preambles etc for an argument parser.
  • ValueParser: Helper to parse values into other values, including validation that the value is usable. Contains both simple parsers for e.g. int and long types, but also for input files, directories and enum value matching.

Sub Command Set

In addition to options and argument, you can specify sub-commands for an ArgParser. Sub-commands will have some instance class of their own, and will be applied via a setter if arguments are parsed properly. This is defined via the SubCommandDef generic type, and it is up to the developer to set what it is.

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import static net.morimekta.terminal.args.ArgHelp.argHelp;
import static net.morimekta.terminal.args.ArgParser.argParser;
import static net.morimekta.terminal.args.Flag.flagShort;
import static net.morimekta.terminal.args.Option.optionShort;
import static net.morimekta.terminal.args.SubCommand.subCommand;

class SimpleApp {
    public static class Cmd implements Runnable {
        private AtomicInteger i = new AtomicInteger();

        public Cmd(ArgParser.Builder parser) {
            parser.add(optionShort("i", "Integer", i::set));
        }

        public void run() {
            System.out.println("i = " + i.get());
        }
    }

    public static void main(String[] args) {
        var help = new AtomicBoolean();
        var cmd = new AtomicReference<>();
        var parser = argParser("simple", "0.0.1", "Test Application")
                .add(flagShort("h?", "Show help", help::set))
                .withSubCommands("cmd", "Do something", cmd::set)
                .add(subCommand("foo", "Foo!!!1!", Cmd::new))
                .parse(args);
        if (help.get()) {
            argHelp(e.getParser()).printHelp(System.out);
            return;
        }
        // validate after help, the sub-command is by default mandatory, so this will
        // throw an `ArgException` if no command was set.
        parser.validate();
        // run the actual program...
        cmd.get().run();
    }
}
  • SubCommandSet: This is an internal type, that behaves as an argument, but MUST be placed last. The only way to instantiate it normally is to use the withSubCommands method on the ArgParser.Builder. This will stop allowing options and arguments, and instead allowing sub-commands. Sub-commands are argument like commands that have sub-argument-parsers of their own.
  • SubCommand: Definition of a sub-command. It requires an instance factory for the specific sub-command that will be called immediately with a sub-command specific argument parser builder. It can then internally instantiate its own ArgParser using the same types and methods as above.

Generated Options [incubating]

You can generate arguments to populate fields in an object using the method ArgParser.Builder#generateArgs(Object). This will try to generate arguments in the form of flags, options and properties to fill in boolean values, normal fields (and list fields), and map fields respectively, and will try to go through standard POJO methods to see if they follow standard naming and use those to set the respective values.

The rules are approximately as follows.

  • parse each public non-final field.
    • If the field is annotated with ArgIgnore, it is skipped.
    • If the field is of type bool, it is considered a flag.
    • If the field is of type Map, it is considered a property.
    • If the field is of type Collection, it is considered a repeated option.
    • If the field is an object without a known parser, use the option name as a name prefix and parse the object recursively.
    • Otherwise, it is a non-repeated option.
  • parse each public method with 1 (or 2 arguments for putters) following pojo naming, so that set*, add, addTo, put and putIn as prefix will be detected as a setter method.
    • If the method is annotated with ArgIgnore, it is skipped.
    • If the method has 2 arguments, it is checked for being a 'putter', and considered a property if value parsers are found.
    • If the method has 1 argument:
      • If the argument is bool it is considered a flag.
      • If the argument can be parsed to from string it is considered an option.
      • If the argument name starts with 'add' or 'addTo' it is considered repeated.
    • If the method has an object without a known parser, use the option name as a name prefix and parse the object recursively. Apply this also if there is also only a getter method, and no setter.
  • Default option name is the name (excluding name prefix for methods) formatted using snake_case.
  • Name can be overridden by ArgOption.
  • Short option chars are only set using ArgOption.
  • There is a default (usually non-helpful) usage description.
  • If any of the argument annotations except ArgIgnore is set on a field or method and the argument properties is not determinable, it is considered a failure and generateArgs will throw an exception.

Annotations

Annotations used to control how the arguments are generated. If any of these are set on a field or method (except ArgIgnore), then it is declared, and argument generation will fail if not able to set up the argument.

  • ArgIgnore: Ignore the given field or method.
  • ArgOptions: Specify name (override), short option char and usage string.
  • ArgIsRepeated: The argument should be considered repeatable.
  • ArgIsRequired: The argument is required (at least once).
  • ArgIsHidden: The arguments is hidden in help output (unless asked to show hidden).
  • ArgValueParser: Specify argument value parser.
  • ArgKeyParser: Specify argument key parser (only allowed on properties).

Terminal IO

The terminal handling is a way to interact with the terminal in a more uniform manner, without needing ncurses or similar. Alternative libraries are Laterna. This library is fully java, but uses some unix tty CLI bindings to e.g. get terminal size.

import net.morimekta.terminal.Terminal;

class Test {
    public static void main(String... args) {
        try (var terminal = new Terminal()) {
            terminal.info("Starting");
            terminal.pressToContinue("Continue?");
            terminal.warn("Not sure about this...");
            if (terminal.confirm("Are you really sure?", false)) {
                terminal.fatal("Noooo!");
                throw new UnsupportedOperationException();
            } else {
                terminal.error("Told you so.");
            }
        }
    }
}

With output like this:

[info] Starting
Continue?
[warn] Not sure about this...
Are you really sure? [y/N]: Yes
[FATAL] Noooo!
  • Terminal: Handle terminal state and does simple logging, input and output.
  • LineBuffer: Utility that manages a set of lines that are printed to the terminal. Will manage which line is where and can jump up to replace any existing line in the buffer.
  • LinePrinter: Interface for a simple line-based printer and logger. The Terminal is a line printer implementation, and can also provide a print stream that behaves mostly as a line printer, and ensures correct line handling for RAW vs COOKED mode.

Terminal Input

Various utilities for handling input form the user.

  • InputConfirmation: Get a simple yes / no confirmation from user.
  • InputLine: Read a text input from terminal with inline edit capability.
  • InputPassword: Same as InputLine, but hides the text (replaced with a fill char).

Progress

Utility showing progress on some ongoing task (or tasks). Example is using apache HTTP client to download a large file with a nice progress bar based on the terminal size and a Spinner.

import net.morimekta.terminal.args.ArgParser;
import net.morimekta.terminal.args.Argument;
import net.morimekta.terminal.args.Option;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.HttpClientBuilder;

import java.nio.file.Files;
import java.nio.file.Path;
import java.io.BufferedOutputStream;
import java.util.ArrayList;

import static java.nio.file.StandardOpenOption.CREATE;
import static java.nio.file.StandardOpenOption.CREATE_NEW;
import static net.morimekta.terminal.args.ValueParser.outputFile;

class WGet {
    private static String url;
    private static Path   file;

    public static void main(String... arguments) {
        ArgParser.argParser("wget", "0.1.0", "Get a URL and save to file")
                 .add(Argument.argument("url", "The URL to get", s -> url = s).required())
                 .add(Argument.argument("file", "The URL to get", outputFile(f -> file = f)).required())
                 .parse(arguments);

        var get = new HttpGet(url);
        System.out.println(url + " -> " + file);
        // This sets up the progress line itself.
        try (ProgressLine p = new ProgressLine(DefaultSpinners.ASCII, "GET")) {
            try (var client = HttpClientBuilder.create().build()) {
                try (var response = client.execute(get)) {
                    final var len = response.getEntity().getContentLength();

                    var dl = 0L;
                    try (var iStream = response.getEntity().getContent();
                         var fStream = Files.newOutputStream(file, CREATE, CREATE_NEW);
                         var oStream = new BufferedOutputStream(fStream)) {
                        byte[] buffer = new byte[1024];
                        int l;
                        while ((l = iStream.read(buffer)) > 0) {
                            dl += l;
                            oStream.write(buffer, 0, l);
                            // This line updates the progress.
                            p.onNext(new Progress(dl, len));
                        }
                    }
                    // This ensures output with 100% at the end...
                    p.onNext(new Progress(len, len));
                }
            }
        }
    }
}

Giving output like this:

http://my-url -> my-file
GET: [#####################################              ]  73% v /
GET: [###################################################] 100% v @     0.9 s
  • Progress: Progress state used to pass current state to the progress line or progress manager classes.
  • ProgressLine: Display a single progress bar with spinner until the task is complete.
  • ProgressManager: Manages a set of progress entries, starting each when previous tasks are done, and displaying a continuous set of progress lines with logging above them all.
  • Spinner: Interface to generate progress status lines.
  • DefaultSpinners: A set of default spinners using ASCII or simple unicode block characters.

Selection

Selection: Show a list of selection lines, and allow the user to use arrows or numeric shortcuts to select from it, or to do some action on the selections.

class GitBranch {
    public static void main(String[] args) {
        ArrayList<Branch> branches = Git.loadBranches();
        Branch current = branches.stream().filter(i -> i.current()).findFirst();
        try (Selection<Branch> selection = Selection
                .newBuilder(branches)
                .printer(b -> b.name() + (b.current() ? " *" : ""))
                .initial(current)
                .hiddenOn(Char.LF, SelectionReaction.SELECT)
                .on('q', "quit", SelectionReaction.EXIT)
                .on('d', "delete", b -> {
                    Git.delete(b);
                    branches.remove(b);
                    SelectionReaction.UPDATE_KEEP_POSITION;
                })
                .build()) {
            Branch selected = selection.runSelection();
            if (selected != null) {
                Git.checkout(selected);
            }
        }
    }
}
Select [q=quit, d=delete]
  1 | main *
  2 | my-branch
Your choice (1..2 or q,d):