Terminal Utilities
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 aSubCommandSet
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
andlong
(or both), where there can be one singlelong
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 anegateName
long name, and a singlenegateShortName
char.Property
: Properties are generickey
+value
options, which is stored into a map-like consumer using aPutter
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
andlong
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 thewithSubCommands
method on theArgParser.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 ownArgParser
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 aflag
. - If the field is of type
Map
, it is considered aproperty
. - If the field is of type
Collection
, it is considered a repeatedoption
. - 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
.
- If the field is annotated with
- parse each public
method
with 1 (or 2 arguments for putters) following pojo naming, so thatset*
,add
,addTo
,put
andputIn
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 argument is
- 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.
- If the method is annotated with
- 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 andgenerateArgs
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. TheTerminal
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 asInputLine
, 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):