de.unkrig.commons.util.CommandLineOptions Maven / Gradle / Ivy
Show all versions of de-unkrig-commons Show documentation
/*
* 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. The name of the author may not be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.OutputStreamWriter;
import java.io.PrintStream;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.WeakHashMap;
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.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.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 {@code 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}.
*
*
* @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 CommandLineOption} */
private final Map allOptions = new HashMap();
/** The options that must appear at most once. */
private final Set singularOptions = new IdentityHashSet();
/** 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 IdentityHashSet();
/** The option groups with cardinality 0 or more. */
private final List requiredOptionGroups = new ArrayList();
private final Map
optionToGroup = new IdentityHashMap();
private final Map
optionToMethod = new IdentityHashMap();
// PARSING STATE
/** Options that were parsed so far. */
private final Set actualOptions = new IdentityHashSet();
/** Options groups that were parsed so far. */
private final Set actualOptionGroups = new IdentityHashSet();
/**
* 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".
for (Method m : targetClass.getMethods()) {
CommandLineOption option = CommandLineOptions.getOption(m);
if (option == null) continue;
this.optionToMethod.put(option, m);
// Determine the "names" of the command-line option - either from the "name" element of the
// "@CommandLineOption" annotation, or from the method name.
String[] names = option.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 }
)) {
CommandLineOption prev = this.allOptions.put(name2, option);
assert prev == null : "Two methods map to option \"" + name2 + "\"";
}
}
// Process the option cardinality.
{
CommandLineOption.Cardinality c = option.cardinality();
if (c == CommandLineOption.Cardinality.MANDATORY || c == CommandLineOption.Cardinality.OPTIONAL) {
this.singularOptions.add(option);
}
if (
c == CommandLineOption.Cardinality.MANDATORY
|| c == CommandLineOption.Cardinality.ONCE_OR_MORE
) {
this.requiredOptions.add(option);
}
}
// Process the group cardinality.
GROUP_CARDINALITY: {
Class> group = option.group();
if (group == Object.class) break GROUP_CARDINALITY;
CommandLineOptionGroup optionGroup = group.getAnnotation(CommandLineOptionGroup.class);
if (optionGroup == null) {
throw new AssertionError(
"Group class \""
+ group
+ "\" lacks the \"@CommandLineOptionGroup\" annotation"
);
}
this.optionToGroup.put(option, optionGroup);
CommandLineOptionGroup.Cardinality c = optionGroup.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 (CommandLineOption option : this.requiredOptions) {
if (!this.actualOptions.contains(option)) {
throw new RequiredOptionMissing(option, this.optionNames(option));
}
}
for (CommandLineOptionGroup optionGroup : this.requiredOptionGroups) {
if (!this.actualOptionGroups.contains(optionGroup)) {
throw new RequiredOptionGroupMissing(optionGroup, this.optionNames(optionGroup));
}
}
}
/**
* @return All names of the option
*/
private String[]
optionNames(CommandLineOption option) {
List result = new ArrayList();
for (Entry e : this.allOptions.entrySet()) {
final String optionName = e.getKey();
final CommandLineOption 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 group
*/
private String[]
optionNames(CommandLineOptionGroup optionGroup) {
List result = new ArrayList();
for (Entry e : this.allOptions.entrySet()) {
final String optionName = e.getKey();
final CommandLineOption option = e.getValue();
if (option.group().getAnnotation(CommandLineOptionGroup.class) != optionGroup) continue;
result.add(optionName);
}
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;
CommandLineOption firstOption = this.getOption(firstOptionName, target.getClass());
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.
try {
ss.read();
} catch (UnexpectedElementException e) {
throw new AssertionError();
}
this.applyCommandLineOption(firstOptionName, firstOption, ss, target);
}
for (int i = 0; i < followingOptionLetters.length(); i++) {
String optionName = "-" + followingOptionLetters.charAt(i);
CommandLineOption optionMethod = this.getOption(optionName, target.getClass());
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 {
if (ss.atEnd()) return false;
String optionName;
try {
optionName = ss.peek();
} catch (UnexpectedElementException uee) {
throw new AssertionError(uee);
}
CommandLineOption option = this.getOption(optionName, target.getClass());
if (option == null) return false;
try {
ss.read();
} catch (UnexpectedElementException uee) {
throw new AssertionError(uee);
}
this.applyCommandLineOption(
optionName, // optionName
option, // option
ss, // stringStream
target // target
);
return true;
}
/**
* Determines the method of {@code target.getClass()} that is applicable for the given option. The
* rules for matching are as follows:
*
* - The method must be annotated with {@link CommandLineOption @CommandLineOption}.
* -
* If that annotation has the "{@code name}" element:
*
* -
* If the {@code name} value starts with "-":
*
* -
* The option equals the {@code name} value.
*
*
*
* -
* Otherwise, if the {@code name} value does not start with "-":
*
* -
* The option equals {@code "-"+name} or {@code "--"+name}
*
*
*
*
* Example:
*
* @CommandLineOption(name = { "alpha", "-beta" }) public void method() { ...
*
* matches if option is {@code "-alpha"}, {@code "--alpha"} and {@code "-beta"}.
*
* -
* Otherwise, if that annotation does not have the "{@code name}" element:
*
* -
* The method name, with an optional "set" or "add" prefix removed, then converted to {@link
* Phrase#toLowerCaseHyphenated() lower-case-hyphenated}, then prefixed with "-" and "--", equals the
* option.
*
* Example: Method name "setFooBar()" matches if option is "-foo-bar" or "--foo-bar".
*
*
*
*
*
* @return {@code Null} iff there is no applicable method
*/
@Nullable public CommandLineOption
getOption(String optionName, Class> targetClass) {
return this.allOptions.get(optionName);
}
private Method
methodFor(CommandLineOption option) {
return AssertionUtil.notNull(this.optionToMethod.get(option), option.toString());
}
/**
* 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,
CommandLineOption 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));
}
CommandLineOptionGroup optionGroup = this.optionToGroup.get(option);
if (optionGroup != null) {
if (this.singularOptionGroups.contains(optionGroup) && this.actualOptionGroups.contains(optionGroup)) {
throw new ConflictingOptions(optionGroup, option, optionName);
}
}
Method method = this.methodFor(option);
Class>[] methodParametersTypes = method.getParameterTypes();
Annotation[][] methodParametersAnnotations = method.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 {
method.invoke(target, methodArgs);
} catch (Exception e) {
throw new AssertionError(e);
}
this.actualOptions.add(option);
if (optionGroup != null) 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 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 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
*/
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