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.Method;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.regex.Pattern;
import de.unkrig.commons.lang.AssertionUtil;
import de.unkrig.commons.lang.ObjectUtil;
import de.unkrig.commons.nullanalysis.Nullable;
import de.unkrig.commons.text.Notations;
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.annotation.CommandLineOption;
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(); }
private CommandLineOptions() {}
/**
* Sets the target's properties from the args.
*
* All public methods of the target (including those declared by superclasses) are regarded candidates iff
* they are annotated with the {@link CommandLineOption} annotation.
*
*
* The possible "names" of the command line option are derived from the {@link CommandLineOption#name() name}
* element of the {@link CommandLineOption} annotation, or, if that is missing, from the method name.
*
*
* 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. After that, the method is invoked with the arguments,
* and the elements are removed from args.
*
*
* 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)
*
*
* Example:
*
*
* public
* class MyMain {
*
* // ...
*
* @CommandLineOption
* public static void
* setWidth(double width) {
* System.out.println("width=" + width);
* }
* }
*
* ...
*
* final MyMain main = new MyMain();
*
* String[] args = { "--width", "17.5", "a", "b" };
* System.out.println(Arrays.toString(args); // Prints "[--width, 17.5, a, b]".
*
* args = MainBean.parseCommandLineOptions(args, main); // Prints "width=17.5".
*
* System.out.println(Arrays.toString(args); // Prints "[a, b]".
*
*
* @return The args, less the elements that were parsed as "command line
* options"
* @throws IllegalArgumentException An error occurred with parsing; typically a command-line application would
* print the message of the exception to STDERR and call "{@code System.exit(1)}"
* @throws RuntimeException
*/
public static String[]
parse(String[] args, Object target) {
// Now examine the "args" as far as they can be identified as "command line options".
int idx = 0;
while (idx < args.length) {
String arg = args[idx];
// Special option "--" indicates the end of the command line options.
if ("--".equals(arg)) {
idx++;
break;
}
// "-" is not a command line option, but a normal command line argument (typically means "STDIN" or
// "STDOUT").
if ("-".equals(arg)) {
break;
}
Method method = CommandLineOptions.getMethodForOption(arg, target.getClass());
if (method != null) {
// A "normal" command line option, e.g. "-o foo.txt".
idx = CommandLineOptions.applyCommandLineOption(
arg, // optionName
method, // method
args, // args
idx + 1, // optionaArgumentIndex
target // target
);
} else
if (arg.length() >= 2 && arg.charAt(0) == '-' && arg.charAt(1) != '-') {
// Process "compact single-letter" command line options, e.g. "-lar" for "-l -a -r".
// Event options with arguments are possible, e.g. "-abc AA BB CC" for "-a AA -b BB -c CC".
idx++;
for (int i = 1; i < arg.length(); i++) {
String optionName = "-" + arg.charAt(i);
method = CommandLineOptions.getMethodForOption(optionName, target.getClass());
if (method == null) {
throw new IllegalArgumentException((
"Unrecognized command line option \""
+ arg
+ "\"; try \"--help\""
));
}
idx = CommandLineOptions.applyCommandLineOption(
optionName,
method,
args,
idx, // optionArgumentIndex
target
);
}
} else
{
// See whether "arg" is a normal command line argument (e.g. a file name), or rather an unrecognized
// command line option.
if (args[idx].startsWith("-")) {
throw new IllegalArgumentException((
"Unrecognized command line option \""
+ args[idx]
+ "\"; try \"--help\""
));
}
// "Arg" is probably a normal command line argument (e.g. a file name)
return idx == 0 ? args : Arrays.copyOfRange(args, idx, args.length);
}
}
// "Args" was completely parse as command line options.
return new String[0];
}
/**
* Determines the method of {@code target.getClass()} that is applicable for the given option.
*
* @return {@code Null} iff there is no applicable method
*/
@Nullable public static Method
getMethodForOption(String option, Class> targetClass) {
for (Method m : targetClass.getMethods()) {
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 }
)) {
if (name2.equals(option)) return m;
}
}
}
return null;
}
/**
* Parses the command line option's arguments from the args and invokes the method.
*
* Iff the method is a {@code varargs} method, then all remaining arguments are converted to an array.
*
*
* @param optionArgumentIndex The position of the first option argument in args
* @return The position of the last option argument in args plus one
* @throws IllegalArgumentException There are less option arguments available than the method requires
*/
public static int
applyCommandLineOption(
String optionName,
Method method,
String[] args,
int optionArgumentIndex,
@Nullable Object target
) {
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];
if (methodParametersTypes.length >= 1 && method.isVarArgs()) {
final Class> componentType = methodParametersTypes[methodArgs.length - 1].getComponentType();
// The VARARGS case: Process the first n-1 args as usual...
if (optionArgumentIndex + methodParametersTypes.length - 1 > args.length) {
throw new IllegalArgumentException(
"Command line option \""
+ optionName
+ "\" requires at least "
+ (methodParametersTypes.length - 1)
+ "\" arguments, but only "
+ (args.length - optionArgumentIndex)
+ " are available on the command line"
);
}
for (int i = 0; i < methodArgs.length - 1; i++) {
methodArgs[i] = CommandLineOptions.fromString(
args[optionArgumentIndex++], // text
methodParametersTypes[i], // targetType
methodParametersAnnotations[i] // annotations
);
}
// ... then parse the all remaining args into the array that is the last method args.
int arrayLength = args.length - optionArgumentIndex;
Object array = Array.newInstance(componentType, arrayLength);
for (int i = 0; i < arrayLength; i++) {
Array.set(array, i, CommandLineOptions.fromString(
args[optionArgumentIndex++], // text
componentType, // targetType
methodParametersAnnotations[methodArgs.length - 1] // annotations
));
}
methodArgs[methodArgs.length - 1] = array;
} else {
// This is the non-VARARGS case.
if (optionArgumentIndex + methodParametersTypes.length > args.length) {
throw new IllegalArgumentException(
"Command line option \""
+ optionName
+ "\" requires "
+ methodParametersTypes.length
+ "\" arguments, but only "
+ (args.length - optionArgumentIndex)
+ " are available on the command line"
);
}
for (int i = 0; i < methodArgs.length; i++) {
methodArgs[i] = CommandLineOptions.fromString(
args[optionArgumentIndex++], // text
methodParametersTypes[i], // targetType
methodParametersAnnotations[i] // annotations
);
}
}
// Now that the "methodArgs" array is filled, invoke the method.
try {
method.invoke(target, methodArgs);
} catch (Exception e) {
throw new AssertionError(e);
}
return optionArgumentIndex;
}
/**
* An extended version of {@link ObjectUtil#fromString(String, Class)} that also supports {@link Pattern2} and
* {@link Glob}.
*/
@SuppressWarnings("unchecked") private static T
fromString(String text, Class targetType, Annotation[] annotations) {
// Use "Pattern2", so that the Pattern2.WILDCARD flag can also be used.
if (targetType == Pattern.class) {
return (T) Pattern2.compile(text, CommandLineOptions.getRegexFlags(annotations));
}
if (targetType == Glob.class) {
return (T) Glob.compile(text, CommandLineOptions.getRegexFlags(annotations));
}
return ObjectUtil.fromString(text, targetType);
}
/**
* Checks for a {@link RegexFlags} annotation and returns its value.
*/
private static int
getRegexFlags(Annotation[] annotations) {
for (Annotation a : annotations) {
if (a.annotationType() == RegexFlags.class) {
return ((RegexFlags) a).value();
}
}
return 0;
}
/**
* Reads (and decodes) the contents of a resource, replaces all occurrences of
* "${
system-property-name}
" with the value of the system property, and writes
* the result to a {@code PrintStream}.
*
* The resource is found through the clasS's class loader, and the name {@code
* "}package-name{@code /}simple-class-name{@code .}name{@code "}, where all
* periods in the package-name have been replaced with slashes.
*
*
* To ensure that the resource is decoded with the same charset as it was encoded, you should not use the JVM
* charset (which could be different in the build environment and the runtime environment).
*
*
* @param charset The charset to use for decoding the contents of the resource; {@code null} for the
* JVM default charset
* @throws FileNotFoundException The resource could not be found
*/
public static void
printResource(Class> clasS, String name, @Nullable Charset charset, PrintStream ps) throws IOException {
String resourceName = clasS.getSimpleName() + "." + name;
InputStream is = clasS.getResourceAsStream(resourceName);
if (is == null) throw new FileNotFoundException(resourceName);
try {
Writer w = new OutputStreamWriter(ps);
PatternUtil.replaceSystemProperties(
resourceName,
charset == null ? new InputStreamReader(is) : new InputStreamReader(is, charset),
w
);
w.flush();
} finally {
is.close();
}
}
}