de.unkrig.commons.util.CommandLineOptions Maven / Gradle / Ivy
/*
* de.unkrig.commons - A general-purpose Java class library
*
* Copyright (c) 2015, Arno Unkrig
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
* following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the
* following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
* following disclaimer in the documentation and/or other materials provided with the distribution.
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
* products derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package de.unkrig.commons.util;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Pattern;
import de.unkrig.commons.lang.AssertionUtil;
import de.unkrig.commons.lang.ObjectUtil;
import de.unkrig.commons.lang.protocol.ProducerUtil;
import de.unkrig.commons.lang.protocol.ProducerUtil.FromArrayProducer;
import de.unkrig.commons.nullanalysis.NotNullByDefault;
import de.unkrig.commons.nullanalysis.Nullable;
import de.unkrig.commons.text.Notations;
import de.unkrig.commons.text.Notations.Phrase;
import de.unkrig.commons.text.StringStream;
import de.unkrig.commons.text.StringStream.UnexpectedElementException;
import de.unkrig.commons.text.pattern.Glob;
import de.unkrig.commons.text.pattern.Pattern2;
import de.unkrig.commons.text.pattern.PatternUtil;
import de.unkrig.commons.util.CommandLineOptionException.ArgumentConversionFailed;
import de.unkrig.commons.util.CommandLineOptionException.ConflictingOptions;
import de.unkrig.commons.util.CommandLineOptionException.DuplicateOption;
import de.unkrig.commons.util.CommandLineOptionException.OptionProcessingException;
import de.unkrig.commons.util.CommandLineOptionException.OptionArgumentMissing;
import de.unkrig.commons.util.CommandLineOptionException.RequiredOptionGroupMissing;
import de.unkrig.commons.util.CommandLineOptionException.RequiredOptionMissing;
import de.unkrig.commons.util.CommandLineOptionException.UnrecognizedOption;
import de.unkrig.commons.util.annotation.CommandLineOption;
import de.unkrig.commons.util.annotation.CommandLineOptionGroup;
import de.unkrig.commons.util.annotation.RegexFlags;
/**
* Parses "command line options" from the args of your {@code main()} method and configures a Java bean
* accordingly.
*
* @see #parse(String[], Object)
*/
public final
class CommandLineOptions {
static { AssertionUtil.enableAssertionsForThisClass(); }
/**
* Detects a "command line option", i.e. a "-" followed by at least one character.
*/
private static final Pattern REGEX_OPTION = Pattern.compile("-.+");
/**
* Detects a "compact command lime option", like "-lar" for the UNIX "ls" command.
*
* - {@code group(1)}:
- The first option letter (for "-lar": "l")
* - {@code group(2)}:
- The following option letters (for "-lar": "ar")
*
*/
private static final Pattern REGEX_COMPACT_OPTIONS = Pattern.compile("-([^\\-])(.*)");
private CommandLineOptions() {}
/**
* Invokes methods of the target object based on the args.
*
* All public methods of the target (including those declared by superclasses) are regarded candidates iff
* they are annotated with the {@link de.unkrig.commons.util.annotation.CommandLineOption} annotation.
*
*
* The possible "names" of the command line option are derived from the {@link
* de.unkrig.commons.util.annotation.CommandLineOption#name() name} element of the {@link
* de.unkrig.commons.util.annotation.CommandLineOption} annotation, or, if that is missing, from the method name
* (see examples below).
*
*
* When an element of args equals such a name, then the following elements in args are
* converted to match the parameter types of the method (see example code below). After that, the method is
* invoked with the arguments.
*
*
* Parsing terminates iff either
*
*
* - The args are exhausted
* - The special arg {@code "--"} is reached (which is consumed)
* - The special arg {@code "-"} is reached (which is notconsumed)
* - A "normal" command line arguments appears, i.e. one that does not start with "-"
*
*
* Example:
*
*
* public
* class MyMain {
*
* // ...
*
* // This one maps to "-font-size <x>" and "--font-size <x>".
* @CommandLineOption public static void
* setFontSize(double size) { System.out.println("fontSize=" + size); }
*
* // This one maps to "-help" and "--help".
* @CommandLineOption public static void
* help() {}
*
* // This one maps to "-alpha" and "--alpha".
* @CommandLineOption(name = "alpha") public static void
* method1() {}
*
* // This one maps to "-beta" and "--gamma".
* @CommandLineOption(name = { "-beta", "--gamma" }) public static void
* method2() {}
*
* // This one maps to "-foo <a> <b> <c>". (A single dashes may be used instead of the double dash.)
* @CommandLineOption public static void
* foo(int one, String two, java.util.Pattern three) {}
*
* // This one maps to "--person [ --name <>name> ] [ --age <age> ]". (Single dashes may be used
* // instead of the double dashes.)
* @CommandLineOption public static void
* addPerson(Person p) {}
*
* public static
* class Person {
* public void setName(String name) {}
* public void setAge(int age) {}
* }
* }
*
* ...
*
* final MyMain main = new MyMain();
*
* String[] args = { "--font-size", "17.5", "a", "b" };
* System.out.println(Arrays.toString(args)); // Prints "[--font-size, 17.5, a, b]".
*
* args = MainBean.parseCommandLineOptions(args, main); // Prints "fontSize=17.5".
*
* System.out.println(Arrays.toString(args); // Prints "[a, b]".
*
*
* Option cardinality
*
*
* To enforce that a particular command line option must be given a specific number of times, use the
* {@link de.unkrig.commons.util.annotation.CommandLineOption#cardinality() cardinality()} element of the {@link
* de.unkrig.commons.util.annotation.CommandLineOption @CommandLineOption} annotation:
*
*
* @CommandLineOption(cardinality = CommandLineOption.Cardinality.MANDATORY)
* setColor(String color) {
* this.color = color;
* }
*
*
* For this example, it is now guaranteed that {@link #parse(String[], Object)} will invoke the "{@code
* setColor()}" method exactly once.
*
*
* The default value for the command line option cardinality is {@link
* de.unkrig.commons.util.annotation.CommandLineOption.Cardinality#OPTIONAL}.
*
*
* Option groups
*
*
* To enforce that a particular number of a set of command line options must be given, use the {@link
* de.unkrig.commons.util.annotation.CommandLineOption#group() group()} element of the {@link
* de.unkrig.commons.util.annotation.CommandLineOption @CommandLineOption} annotation:
*
*
* @CommandLineOption(group = Sources.class)
* setFile(File file) {
* this.file = file;
* }
*
* @CommandLineOption(group = Sources.class)
* setStdin() {
* this.stdin = true;
* }
*
* // This interface solely serves as the "connector" between the related command line options; it is (typically)
* // not used otherwise.
* @CommandLineOptionGroup(cardinality = CommandLineOptionGroup.Cardinality.EXACTLY_ONE)
* interface Sources {}
*
*
* For this example, it is now guaranteed that {@link #parse(String[], Object)} will invoke exactly one of the
* methods "{@code setFile()}" and "{@code setStdin()}".
*
*
* The default value for the command line option group cardinality is {@link
* de.unkrig.commons.util.annotation.CommandLineOptionGroup.Cardinality#ZERO_OR_ONE ZERO_OR_ONE}.
*
*
* Argument conversion
*
*
* args strings are converted to match the parameter types of the methods as follows:
*
*
* -
*
*
* @return The args, less the elements that were parsed as command line
* options
* @throws CommandLineOptionException An error occurred during the parsing; typically a command-line application
* would print the message of the exception to STDERR and call "{@code
* System.exit(1)}"
*/
public static String[]
parse(String[] args, Object target) throws CommandLineOptionException {
StringStream ss = new StringStream(ProducerUtil.fromArray(args));
Parser p = new Parser(target.getClass());
// Parse all command line options and apply them to the "target".
p.parseOptions(ss, target);
// Consume the optional separator "--" between the command line options and the "normal" command line
// arguments.
if (!ss.peekRead("--") && ss.peek(CommandLineOptions.REGEX_OPTION)) {
throw new UnrecognizedOption(AssertionUtil.notNull(ss.group(0)));
}
// Return the remaining command line arguments.
return ss.readRest();
}
private static
class Parser {
// CONFIGURATION
/** Option name => {@link Method} */
private final Map allOptions = new LinkedHashMap();
/** The options that must appear at most once. */
private final Set singularOptions = new HashSet();
/** The options that must appear at least once. */
private final List requiredOptions = new ArrayList();
/** The option groups with cardinality 1 or less. */
private final Set> singularOptionGroups = new HashSet>();
/** The option groups with cardinality 0 or more. */
private final List> requiredOptionGroups = new ArrayList>();
/** Maps option methods to the set of groups that each is a member of. */
private final Map>>
optionToOptionGroups = new HashMap>>();
// PARSING STATE
/** Options that were parsed so far. */
private final Set actualOptions = new HashSet();
/** Options groups that were parsed so far. */
private final Set> actualOptionGroups = new HashSet>();
/**
* Analyzes the targetClass for any command line option setters.
*
* @throws AssertionError A {@link CommandLineOption @CommandLineOption} refers to a type that is not
* annotated with {@link CommandLineOptionGroup @CommandLineOptionGroup}
* @throws AssertionError Two methods map to the same name
*/
Parser(Class> targetClass) {
// Identify all command line options that apply to the target class, and populate the "methodCache".
Method[] methods = targetClass.getMethods();
Arrays.sort(methods, new Comparator() {
@NotNullByDefault(false) @Override public int
compare(Method m1, Method m2) { return m1.toString().compareTo(m2.toString()); }
});
for (Method m : methods) {
CommandLineOption clo = m.getAnnotation(CommandLineOption.class);
if (clo == null) continue;
// Determine the "names" of the command-line option - either from the "name" element of the
// "@CommandLineOption" annotation, or from the method name.
String[] names = clo.name();
if (names.length == 0) {
String n = m.getName();
if (n.startsWith("set")) {
n = n.substring(3);
} else
if (n.startsWith("add")) {
n = n.substring(3);
}
names = new String[] { Notations.fromCamelCase(n).toLowerCaseHyphenated() };
}
for (String name : names) {
for (String name2 : (
name.startsWith("-")
? new String[] { name }
: new String[] { "-" + name, "--" + name }
)) {
Method prev = this.allOptions.put(name2, m);
assert prev == null : "Two methods map to option \"" + name2 + "\": \"" + prev + "\" and \"" + m + "\""; // SUPPRESS CHECKSTYLE LineLength
}
}
// Process the option cardinality.
{
CommandLineOption.Cardinality c = clo.cardinality();
if (c == CommandLineOption.Cardinality.MANDATORY || c == CommandLineOption.Cardinality.OPTIONAL) {
this.singularOptions.add(m);
}
if (
c == CommandLineOption.Cardinality.MANDATORY
|| c == CommandLineOption.Cardinality.ONCE_OR_MORE
) {
this.requiredOptions.add(m);
}
}
// Process the option group cardinality.
for (Class> optionGroup : clo.group()) {
CommandLineOptionGroup clog = optionGroup.getAnnotation(CommandLineOptionGroup.class);
if (clog == null) {
throw new AssertionError(
"Option group class \""
+ optionGroup
+ "\" lacks the \"@CommandLineOptionGroup\" annotation"
);
}
Set> optionGroups = this.optionToOptionGroups.get(m);
if (optionGroups == null) {
this.optionToOptionGroups.put(m, (optionGroups = new HashSet>()));
}
optionGroups.add(optionGroup);
CommandLineOptionGroup.Cardinality c = clog.cardinality();
if (
c == CommandLineOptionGroup.Cardinality.EXACTLY_ONE
|| c == CommandLineOptionGroup.Cardinality.ZERO_OR_ONE
) this.singularOptionGroups.add(optionGroup);
if (
c == CommandLineOptionGroup.Cardinality.EXACTLY_ONE
|| c == CommandLineOptionGroup.Cardinality.ONE_OR_MORE
) this.requiredOptionGroups.add(optionGroup);
}
}
}
/**
* Parses tokens on the ss that map to options of the target.
*
* @return Whether the next token(s) on ss were parsed as an option of
* the target
* @throws CommandLineOptionException An error occurred during the parsing
* @throws EX ss threw an exception
*/
private void
parseOptions(StringStream ss, Object target) throws EX, CommandLineOptionException {
// Parse as many command line options as possible.
while (this.parseNextOption(ss, target));
// Verify that all "required" options were parsed.
for (Method option : this.requiredOptions) {
if (!this.actualOptions.contains(option)) {
throw new RequiredOptionMissing(option, this.optionNames(option));
}
}
for (Class> optionGroup : this.requiredOptionGroups) {
if (!this.actualOptionGroups.contains(optionGroup)) {
throw new RequiredOptionGroupMissing(optionGroup, this.optionNames(optionGroup));
}
}
}
/**
* @return All names of the option
*/
private String[]
optionNames(Method option) {
List result = new ArrayList();
for (Entry e : this.allOptions.entrySet()) {
final String optionName = e.getKey();
final Method o = e.getValue();
if (o != option) continue;
result.add(optionName);
}
return result.toArray(new String[result.size()]);
}
/**
* @return All names of all options that are members of the optionGroup
*/
private String[]
optionNames(Class> optionGroup) {
List result = new ArrayList();
for (Entry e : this.allOptions.entrySet()) {
final String optionName = e.getKey();
final Method option = e.getValue();
for (Class> g : option.getAnnotation(CommandLineOption.class).group()) {
if (g == optionGroup) {
result.add(optionName);
break;
}
}
}
return result.toArray(new String[result.size()]);
}
/**
* Parses an option of the target iff the next token on the ss maps to an option of the
* target.
*
* @return Whether the next token(s) on ss were parsed as an option of the
* target
* @throws CommandLineOptionException An error occurred during the parsing
* @throws EX ss threw an exception
*/
private boolean
parseNextOption(StringStream ss, Object target) throws CommandLineOptionException, EX {
if (ss.atEnd()) return false;
// Special option "--" indicates the end of the command line options.
if (ss.peek("--")) return false;
// "-" is not a command line option, but a normal command line argument (typically means "STDIN" or
// "STDOUT").
if (ss.peek("-")) return false;
// Is it a "verbose" (non-compact) option?
if (this.parseNextVerboseOption(ss, target)) return true;
// Iff the first letter after "-" can be interpreted as a single-letter option, then interpret ALL letters
// as such.
// This feature is called "compact command line options": E.g. "-lar" is equivalent with "-l -a -r".
// Even options with arguments are possible, e.g. "-abc AA BB CC" in place of "-a AA -b BB -c CC".
COMPACT_OPTIONS: if (ss.peek(CommandLineOptions.REGEX_COMPACT_OPTIONS)) {
char firstOptionLetter = AssertionUtil.notNull(ss.group(1)).charAt(0);
String followingOptionLetters = AssertionUtil.notNull(ss.group(2));
{
String firstOptionName = "-" + firstOptionLetter;
Method firstOption = this.getOptionByName(firstOptionName);
if (firstOption == null) break COMPACT_OPTIONS;
// Only now that we have verified that the first letter can be interpreted as a single-letter
// option, we consume the token.
ss.consume();
this.applyCommandLineOption(firstOptionName, firstOption, ss, target);
}
for (int i = 0; i < followingOptionLetters.length(); i++) {
String optionName = "-" + followingOptionLetters.charAt(i);
Method optionMethod = this.getOptionByName(optionName);
if (optionMethod == null) {
throw new UnrecognizedOption(optionName);
}
this.applyCommandLineOption(optionName, optionMethod, ss, target);
}
return true;
}
return false;
}
/**
* @return Whether the next tokens on ss could be parsed as an option for
* the target
* @throws CommandLineOptionException An error occurred during the parsing
* @throws EX ss threw an exception
*/
private boolean
parseNextVerboseOption(StringStream ss, Object target) throws CommandLineOptionException, EX {
String s = ss.next();
if (s == null) return false;
// Special handling for verbose option syntax "--regex=abc".
ALTERNATE_VERBOSE_SYNTAX: {
int ioeq = s.indexOf('=');
if (ioeq == -1) break ALTERNATE_VERBOSE_SYNTAX;
final String optionName = s.substring(0, ioeq);
final String singleArgument = s.substring(ioeq + 1);
Method option = this.getOptionByName(optionName);
if (option == null) return false;
// Notice: "Method.getParameterCount()" is only available in Java 8+.
if (option.getParameterTypes().length != 1) throw new UnrecognizedOption(s);
ss.consume();
this.applyCommandLineOption(
optionName, // optionName
option, // option
new StringStream(ProducerUtil.fromElements(singleArgument)), // stringStream
target // target
);
return true;
}
// Parse syntax "-foo arg1 arg2 arg3".
String optionName = s;
Method option = this.getOptionByName(optionName);
if (option == null) return false;
ss.consume();
this.applyCommandLineOption(
optionName, // optionName
option, // option
ss, // stringStream
target // target
);
return true;
}
/**
* @return {@code null} iff there is no applicable method
*/
@Nullable public Method
getOptionByName(String optionName) {
return this.allOptions.get(optionName);
}
/**
* Parses the command line option's arguments from the args and invokes the method.
*
* The arguments for the method are parsed from the command line, see {@link #getArgument(String[], int[],
* Annotation[], Class)}.
*
*
* Iff the method is a {@code varargs} method, then all remaining arguments are converted to an
* array.
*
*
* @throws CommandLineOptionException An error occurred during the parsing
* @throws EX The stringStream threw an exception
*/
public void
applyCommandLineOption(
String optionName,
Method option,
StringStream stringStream,
@Nullable Object target
) throws CommandLineOptionException, EX {
if (this.singularOptions.contains(option) && this.actualOptions.contains(option)) {
throw new DuplicateOption(option, optionName, this.optionNames(option));
}
Set> optionGroups = this.optionToOptionGroups.get(option);
if (optionGroups != null) {
for (Class> optionGroup : optionGroups) {
if (
this.singularOptionGroups.contains(optionGroup)
&& this.actualOptionGroups.contains(optionGroup)
) {
throw new ConflictingOptions(optionGroup, option, optionName);
}
}
}
Class>[] methodParametersTypes = option.getParameterTypes();
Annotation[][] methodParametersAnnotations = option.getParameterAnnotations();
assert methodParametersTypes.length == methodParametersAnnotations.length;
// Convert the command line option arguments into method call arguments.
Object[] methodArgs = new Object[methodParametersTypes.length];
for (int i = 0; i < methodArgs.length; i++) {
try {
methodArgs[i] = this.getArgument(
stringStream, // stringStream
methodParametersAnnotations[i], // annotations
methodParametersTypes[i] // targetType
);
} catch (UnexpectedElementException uee) {
throw new OptionArgumentMissing(option, optionName, i);
}
}
// Now that the "methodArgs" array is filled, invoke the method.
try {
option.invoke(target, methodArgs);
} catch (InvocationTargetException ite) {
throw new OptionProcessingException(optionName, ite.getTargetException());
} catch (Exception e) {
throw new OptionProcessingException(optionName, e);
}
this.actualOptions.add(option);
if (optionGroups != null) {
for (Class> optionGroup : optionGroups) {
this.actualOptionGroups.add(optionGroup);
}
}
}
/**
* Creates and returns an object of the given targetType from tokens from ss.
*
* -
* For an array target type, all remaining tokens from ss are parsed as array elements.
*
* -
* For {@link Pattern} target type, the next token from ss is converted through {@link
* Pattern2#compile(String, int)}, with the flags as configured by the {@link RegexFlags} annotation of
* the method.
*
* -
* For {@link Glob} target type, the next token from ss is converted through {@link
* Glob#compile(String, int)}, with the flags as configured by the {@link RegexFlags} annotation of
* the method.
*
* -
* For a bean target type (a class that has only the zero-arg constructor), the bean is instantiated and as
* many tokens from ss as possible are parsed as options of the bean.
*
* -
* For {@link InetAddress} target type, the next token from ss is converted through {@link
* InetAddress#getByName(String)}, except "any", which maps to {@code null} (the "wildcard address").
*
* -
* For any other target type, the next token from ss is converted to the target type, as
* described {@link ObjectUtil#fromString(String, Class) here}.
*
*
*
* @throws CommandLineOptionException An error occurred during the parsing
* @throws UnexpectedElementException The ss has too few tokens
* @throws EX The ss threw an exception
* @throws AssertionError Bean creation failed
*/
@Nullable private Object
getArgument(StringStream ss, Annotation[] annotations, Class> targetType)
throws CommandLineOptionException, UnexpectedElementException, EX {
// Special case: The target type is an array type. (This handles also the case of a VARARGS method.)
if (targetType.isArray()) {
final Class> componentType = targetType.getComponentType();
// Treat all remaining arguments as array elements (even those starting with a dash!).
List