Dieser Artikel ist auch auf Deutsch verfügbar

We already looked at command-line applications in general in this column four years ago. The fundamental principles described there are still valid today. And not a lot has changed either with regard to the distribution of command-line applications written in Java.

There are nonetheless reasons for implementing these applications with Java. By doing so, a team can take advantage of existing knowledge and known libraries. In addition, the disadvantage of a longer start time can nowadays be circumvented, for example through the use of native compiling with GraalVM.

But whatever the reasons for writing command-line applications with Java, they differ with respect to the reading out and the processing of the provided arguments and options from other application types. We therefore take a closer look at this in this article. After a brief general introduction to arguments and options, we will look at four libraries that we can use to significantly reduce our workload.

Configuration of Command-Line Applications

There are three options for configuring the execution of a command-line application.

The first option is that the application reads out the configuration values. For this purpose usually either files that are found in specific locations in the file system or defined system environment variables are read out. This form of configuration is static and is therefore more suitable for global and long-term settings. Examples of this include the files ~/.gitconfig for Git or ~/.m2/settings.xml for Maven.

As a second option it is possible for the application to query the values itself by means of the standard input. This form of querying has the advantage that the input is not saved in the history of the command line. This approach is therefore particularly suitable for the inputting of passwords or other secrets. The disadvantage however is that it is less suitable for the writing of scripts.

The third possibility is the transfer of arguments and options upon start-up of the application. These are written after the call-up of the application. So the request curl --request get -v https://innoq.com consists of the two options --request with the value get and -v without value, as well as the argument https://innoq.com.

Arguments and Options

Arguments are used above all when imperative for the primary function of the application. For example, the command git add expects the files or directories that are to be added to be specified. However, it is not always imperative to specify the arguments as they can be assigned by appropriate defaults. The tool therefore uses ls in order to indicate the current folder to files when no argument is provided.

If an application requires multiple arguments, such as mv in order to move files, these are always to be specified in the order defined by the application. Depending on the application it can be imperative that all arguments come after the options.

Options in contrast are used for optional configuration and are therefore rarely imperative. An option always consists of a name and a value. For the name, the use of a short and a long form has become established. In the short form, the option is specified with a hyphen - and only one alphabetic character. The long form in contrast begins with a double hyphen -- and consists of multiple letters, usually even a whole word. The value of an option is either the argument following the option name or is written by a separator, usually the equals sign =, directly to the option name. For logical values, it is often not necessary to specify the value. The value is then set to true when the option names are specified.

Especially for options, we should define defaults that are defaulted to when the option is not specified.

In Java, we can access the arguments passed to the application via the string array declared as an argument in the main method. However, Java does not understand the concept of options. In the case of the cURL request described above, an array with the four strings --request, get, -v, and https://innoq.com are provided. It is now the job of the application to interpret these appropriately. As this can quickly become complicated, the use of an appropriate library is recommended.

Commons CLI

One of the oldest libraries I know for dealing with arguments and options is Apache Commons CLI. The implementation uses a purely programmatic programming model that can be logically subdivided into the following three phases:

Listing 1 shows a very simple example of how the code required for this looks.

class ApacheExample {

    public static void main(String[] args) {
        args = new String[]{ "foo", "--verbose", "bar", "-d=|", "baz" };

        var options = new Options()
            .addOption("v", "verbose", false, "Verbose")
            .addOption(Option.builder("d")
                .longOpt("delimiter")
                .hasArg(true)
                .desc("The delimiter to use")
                .argName("delimiter")
                .build());

        var parser = new DefaultParser();

        try {
            var cmdLine = parser.parse(options, args);
            if (cmdLine.hasOption('v')) {
                System.err.println("Running in verbose mode");
            }

            var delimiter = cmdLine.getOptionValue('d', ",");
            var result = String.join(delimiter, cmdLine.getArgs());
            System.out.println(result);
        } catch (ParseException e) {
            e.printStackTrace();
            new HelpFormatter().printHelp("apache args...", options);
        }
    }
}
Listing 1: Implementation with Commons CLI

For the defining of the options we use the options class with the available and overloaded addOption methods. In addition to the main variant, which receives an instance of the option class, the overloaded methods offer us shorter variants for the generation of such options. In the example this is used for the verbose option.

One option consists of a short or a long name. In addition, we can specify whether a value has to be specified for this option or whether it is sufficient to find out that the option has veen specified. With argName and description the generation of the help texts can also be influenced. In order to then generate such an option, we use the available builder.

After the definition of the options we use the DefaultParser in order to process the options given in the command line with those defined by us. The parse method triggers a ParseException in the case of error, for example when an undefined option is specified.

Once the processing is successfully completed, we can then analyze the specified options on the CommandLine returned by parse. For this purpose we use the hasOption and getOptionValue methods. The provided arguments are accessed using getArgs. Commons CLI does not convert the option values into specific data types and only returns a string. We can specify a type in the definition of our options and then use getOptionObject to query the values converted to this type. However, only a handful of types from the JDK, such as URL, file, and number, are supported. It is not possible to extend this mechanism to different types.

Finally, Commons CLI also offers us the option using the HelpFormatter class of displaying help text in which all possible options are displayed. This typically occurs in the event of an error or for a specific help option.

Commons CLI is thus very helpful and reduces our workload significantly. There are however some features lacking, such as support for converting different types. Moreover, although we can access the arguments, they do not appear in the help text.

So let’s look at args4j as an additional option.

args4j

In principle, args4j works exactly as Commons CLI. We define the options, and here also the arguments, start the processing, and can then work with the arguments and options.

In contrast to Commons CLI however, args4j uses an annotation-based mechanism for the definition. To this end, the fields defined in a particular class are annotated. Listing 2 shows the implementation with identical functionality to the previous Commons CLI example.

class Args4jExample {

    static class Options {

        @Option(name = "-v", aliases = "--verbose",
                usage = "Verbose")
        public boolean verbose;

        @Option(name = "-d", aliases = "--delimiter",
                usage = "The delimiter to use", metaVar = "delimiter")
        public String delimiter = ",";

        @Argument(required = true,
                usage = "Words to join", metaVar = "words")
        public List words;
    }

    public static void main(String[] args) {
        args = new String[]{ "foo", "--verbose", "bar", "-da=|", "baz" };

        var options = new Options();

        var parser = new CmdLineParser(options);

        try {
            parser.parseArgument(args);
            if (options.verbose) {
                System.err.println("Running in verbose mode");
            }

            var delimiter = options.delimiter;
            var result = String.join(delimiter, options.words);
            System.out.println(result);
        } catch (CmdLineException e) {
            e.printStackTrace();
            System.out.print("args4j");
            parser.printSingleLineUsage(System.out);
            System.out.println();
            parser.printUsage(System.out);
        }
    }
}
Listing 2: Implementation with args4j

After the definition we use CmdLineParser to process the specified options. These are provided to the annotated classes through the generation of an instance. If parseArgument is now called, the parser binds the values of the provided arguments and options to the fields defined in the class. In the event of an error an exception is created. Here too, the issuing of a help text is supported. For this purpose we directly use the generated instance of the parser and the methods printSingleLineUsage and printUsage.

In contrast to Commons CLI, args4j allows us to use any data type we want. In order to do so we have to implement the OptionHandler class and register it in the OptionHandlerRegistry before parsing.

JCommander

A further implementation, similar to args4j, is JCommander. Here too, options and arguments are defined by means of annotations, and different types are also supported.

JCommander offers us additional features though. For example, we can explicitly mark an option as a password. If this option is then specified without a value, it is queried after the application has started up by means of an interactive input (see Listing 3).

class JCommanderPasswordExample {

    static class Options {

        @Parameter(names = "--password", password = true, required = true,
                description = "The password to use")
        public String password;
    }

    public static void main(String[] args) {
        args = new String[] { "--password" };
        //args = new String[] { "--password", "Geheim!" };

        var opts = new Options();

        var jc = JCommander.newBuilder()
            .addObject(opts)
            .programName("jcommander")
            .build();

        try {
            jc.parse(args);
            System.out.println("Das Passwort ist: " + opts.password);
        } catch (ParameterException e) {
            e.printStackTrace();
            jc.usage();
        }
    }
}
Listing 3: Password options with JCommander

In addition, JCommander also provides the possibility to implement personalized validations for options. In this way we can ensure that the inputted password must comprise at least eight characters (see Listing 4).

class JCommanderValidatorExample {

    static class Options {

        @Parameter(names = "--password", password = true, required = true,
                description = "The password to use",
                validateValueWith = PasswordLengthValidator.class)
        public String password;
    }

    public static class PasswordLengthValidator
            implements IValueValidator {

        @Override
        public void validate(String name, String value)
                throws ParameterException {
            if (value.length() < 8) {
                throw new ParameterException(
                    "Parameter " + name + " must have at least 8 characters");
            }
        }
    }

...
}
Listing 4: Validation of an Option Value with JCommander

An additional, powerful feature is that JCommander also supports command-line applications that comprise multiple commands, similar to Git. For this purpose we use addCommand to register, as can be seen in Listing 5, our commands under a specific name and can then find out with getParsedCommand which command is specified.

class JCommanderCommandExample {

    static class GlobalOptions {

        @Parameter(names = "--verbose")
        public boolean verbose;
    }

    @Parameters(commandDescription = "Adds some files")
    static class AddOptions {

        @Parameter(names = "-i")
        public boolean interactive;

        @Parameter
        public List files;
    }

    @Parameters(commandDescription = "Removes some files")
    static class RmOptions {

        @Parameter(names = "-r")
        public boolean recursive;

        @Parameter
        public List files;
    }

    public static void main(String[] args) {
        args = new String[] { "--verbose", "add", "-i", "foo", "bar" };

        var opts = new GlobalOptions();
        var addOpts = new AddOptions();
        var rmOpts = new RmOptions();

        var jc = JCommander.newBuilder()
            .addObject(opts)
            .programName("jcommander")
            .addCommand("add", addOpts)
            .addCommand("rm", rmOpts)
            .build();

        try {
            jc.parse(args);
            switch (jc.getParsedCommand()) {
                case "add" -> {
                    System.out.println("add " + addOpts.files);
                    System.out.println(" Verbose: " + opts.verbose);
                    System.out.println(" Interactive: " + addOpts.interactive);
                }
                case "rm" -> {
                    System.out.println("rm " + rmOpts.files);
                    System.out.println(" Verbose: " + opts.verbose);
                    System.out.println(" Recursive: " + rmOpts.recursive);
                }
            }
        } catch (ParameterException e) {
            e.printStackTrace();
            jc.usage();
        }
    }
}
Listing 5: Support for commands by JCommander

As a final extension, JCommander allows us to write all options and arguments, separated by line breaks, in a file and then, upon start-up of our application, to specify this file with an @ as prefix as the only argument. JCommander recognizes such an @-argument and reads out the options and arguments from the specified file.

Even if it seems that JCommander already offers us everything we need, with picocli there is a library that has an even greater scope than JCommander.

picocli

picocli, like args4j and JCommander, uses the declarative annotation-based programming model. It supports all above-mentioned functionalities and others.

For example, it supports -- as an argument to show that all following values are arguments, even if one of these arguments has the same name as an option. Therefore, in Listing 6, although --recursive is specified, the option recursive remains false and --recursive appears in the arguments.

@Command(name = "picocli")
class PicocliDashDashExample implements Runnable {

    @Option(names = "recursive")
    private boolean recursive;

    @Parameters
    private List parameters;

    @Override
    public void run() {
        System.out.println("Recursive: " + recursive);
        System.out.println("Parameters: " + parameters);
    }

    public static void main(String[] args) {
        args = new String[] { "--", "--recursive", "foo" };

        new CommandLine(new PicocliDashDashExample()).execute(args);
    }
}
Listing 6: -- Arguments in picocli

Because picocli not only processes options and arguments, but we also use it to define commands that picocli can now also execute, the implementation of multiple commands in one application is even easier than with JCommander. As can be seen in Listing 7, we define in our application the available commands and implement them in their own class annotated with @Command. To get global options, we can use @ParentCommand to inject the application class and query it.

@Command(name = "picocli", subcommands = {
        PicocliCommandExample.AddCommand.class,
        PicocliCommandExample.RmCommand.class})
public class PicocliCommandExample {

    @Option(names = "--verbose")
    public boolean verbose;

    @Command(name = "add")
    static class AddCommand implements Runnable {

        @ParentCommand
        PicocliCommandExample parent;

        @Spec
        CommandSpec spec;

        @Option(names = "-i")
        public boolean interactive;

        @Parameters(arity = "1..*")
        public List files;

        @Override
        public void run() {
            var out = spec.commandLine().getOut();
            out.println("add " + files);
            out.println(" Verbose: " + parent.verbose);
            out.println(" Interactive: " + interactive);
        }
    }

    @Command(name = "rm")
    static class RmCommand implements Runnable {

        @ParentCommand
        PicocliCommandExample parent;

        @Spec
        CommandSpec spec;

        @Option(names = "-r")
        public boolean recursive;

        @Parameters(arity = "1..*")
        public List files;

        @Override
        public void run() {
            var out = spec.commandLine().getOut();
            out.println("rm " + files);
            out.println(" Verbose: " + parent.verbose);
            out.println(" Recursive: " + recursive);
        }
    }

    public static void main(String[] args) {
        args = new String[] { "--verbose", "add", "-i", "foo", "bar" };

        new CommandLine(new PicocliCommandExample()).execute(args);
    }
}
Listing 7: Commands with picocli

Listing 7 also shows that instead of displaying our result via System.out.println, we use an indirection via the CommandSpec class. This allows us to exchange in tests the PrintWriter obtained in this way and thus also to test the outputs of our application.

Another neat feature is that with Boolean options picocli can also generate a negated variant for us. If we additionally extend the annotation for interactive by the value negatable=true, --no-verbose is then also parsed and processed.

In addition to these four features, picocli offers us many more wide-ranging conveniences. It is possible for example to generate man pages for our application, and autocompletion with TAB for Bash and ZSH is also possible. By means of an annotation processor we can check the defined commands and compile-time options for accuracy, and at the same time generate the metadata required for GraalVM native images.

Finally, for picocli there are also ready-made integrations in the most common dependency-injection-based frameworks such as Guice, Spring, Micronaut, and Quarkus.

Title photo by Joel Mbugua on Unsplash.

Conclusion

In this article we learned about the four libraries Commons CLI, args4j, JCommander, and picocli that can support us in the implementation of command-line applications with Java.

In addition to the programming model – programmatic by means of code or declarative by means of annotations – these differ above all in the number of available features, such as type converting, commands, or extended validation. Depending on the use, the reduced scope of Commons CLI or args4j may suffice. Otherwise we need the larger scope of JCommander or the even more extensive possibilities offered by picocli.

Aside from these four there are of course also other libraries, such as Airline or CREST. These should also be considered in a concrete evaluation.

The code presented in this article is available on GitHub for purposes of comprehension and for tinkering around with: https://github.com/mvitz/javaspektrum-cli.