blob: 5e6790488b3af94fd03399b9ddb03dd407503b57 [file] [log] [blame]
/*
* Copyright (C) 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.caliper.options;
import static com.google.common.base.Preconditions.checkArgument;
import com.google.caliper.util.DisplayUsageException;
import com.google.caliper.util.InvalidCommandException;
import com.google.caliper.util.Parser;
import com.google.caliper.util.Parsers;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.primitives.Primitives;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Type;
import java.text.ParseException;
import java.util.Iterator;
import java.util.List;
// based on r135 of OptionParser.java from vogar
// NOTE: this class is still pretty messy but will be cleaned up further and possibly offered to
// Guava.
/**
* Parses command line options.
*
* Strings in the passed-in String[] are parsed left-to-right. Each String is classified as a short
* option (such as "-v"), a long option (such as "--verbose"), an argument to an option (such as
* "out.txt" in "-f out.txt"), or a non-option positional argument.
*
* A simple short option is a "-" followed by a short option character. If the option requires an
* argument (which is true of any non-boolean option), it may be written as a separate parameter,
* but need not be. That is, "-f out.txt" and "-fout.txt" are both acceptable.
*
* It is possible to specify multiple short options after a single "-" as long as all (except
* possibly the last) do not require arguments.
*
* A long option begins with "--" followed by several characters. If the option requires an
* argument, it may be written directly after the option name, separated by "=", or as the next
* argument. (That is, "--file=out.txt" or "--file out.txt".)
*
* A boolean long option '--name' automatically gets a '--no-name' companion. Given an option
* "--flag", then, "--flag", "--no-flag", "--flag=true" and "--flag=false" are all valid, though
* neither "--flag true" nor "--flag false" are allowed (since "--flag" by itself is sufficient, the
* following "true" or "false" is interpreted separately). You can use "yes" and "no" as synonyms
* for "true" and "false".
*
* Each String not starting with a "-" and not a required argument of a previous option is a
* non-option positional argument, as are all successive Strings. Each String after a "--" is a
* non-option positional argument.
*
* The fields corresponding to options are updated as their options are processed. Any remaining
* positional arguments are returned as an ImmutableList<String>.
*
* Here's a simple example:
*
* // This doesn't need to be a separate class, if your application doesn't warrant it. //
* Non-@Option fields will be ignored. class Options {
*
* @Option(names = { "-q", "--quiet" }) boolean quiet = false;
*
* // Boolean options require a long name if it's to be possible to explicitly turn them off. //
* Here the user can use --no-color.
* @Option(names = { "--color" }) boolean color = true;
* @Option(names = { "-m", "--mode" }) String mode = "standard; // Supply a default just by setting
* the field.
* @Option(names = { "-p", "--port" }) int portNumber = 8888;
*
* // There's no need to offer a short name for rarely-used options.
* @Option(names = { "--timeout" }) double timeout = 1.0;
* @Option(names = { "-o", "--output-file" }) String outputFile;
*
* }
*
* See also:
*
* the getopt(1) man page Python's "optparse" module (http://docs.python.org/library/optparse.html)
* the POSIX "Utility Syntax Guidelines" (http://www.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap12.html#tag_12_02)
* the GNU "Standards for Command Line Interfaces" (http://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces)
*/
final class CommandLineParser<T> {
/**
* Annotates a field or method in an options class to signify that parsed values should be
* injected.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface Option {
/**
* The names for this option, such as { "-h", "--help" }. Names must start with one or two '-'s.
* An option must have at least one name.
*/
String[] value();
}
/**
* Annotates a single method in an options class to receive any "leftover" arguments. The method
* must accept {@code ImmutableList<String>} or a supertype. The method will be invoked even if
* the list is empty.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface Leftovers {}
public static <T> CommandLineParser<T> forClass(Class<? extends T> c) {
return new CommandLineParser<T>(c);
}
private final InjectionMap injectionMap;
private T injectee;
// TODO(kevinb): make a helper object that can be mutated during processing
private final List<PendingInjection> pendingInjections = Lists.newArrayList();
/**
* Constructs a new command-line parser that will inject values into {@code injectee}.
*
* @throws IllegalArgumentException if {@code injectee} contains multiple options using the same
* name
*/
private CommandLineParser(Class<? extends T> c) {
this.injectionMap = InjectionMap.forClass(c);
}
/**
* Parses the command-line arguments 'args', setting the @Option fields of the 'optionSource'
* provided to the constructor. Returns a list of the positional arguments left over after
* processing all options.
*/
public void parseAndInject(String[] args, T injectee) throws InvalidCommandException {
this.injectee = injectee;
pendingInjections.clear();
Iterator<String> argsIter = Iterators.forArray(args);
ImmutableList.Builder<String> builder = ImmutableList.builder();
while (argsIter.hasNext()) {
String arg = argsIter.next();
if (arg.equals("--")) {
break; // "--" marks the end of options and the beginning of positional arguments.
} else if (arg.startsWith("--")) {
parseLongOption(arg, argsIter);
} else if (arg.startsWith("-")) {
parseShortOptions(arg, argsIter);
} else {
builder.add(arg);
// allow positional arguments to mix with options since many linux commands do
}
}
for (PendingInjection pi : pendingInjections) {
pi.injectableOption.inject(pi.value, injectee);
}
ImmutableList<String> leftovers = builder.addAll(argsIter).build();
invokeMethod(injectee, injectionMap.leftoversMethod, leftovers);
}
// Private stuff from here on down
private abstract static class InjectableOption {
abstract boolean isBoolean();
abstract void inject(String valueText, Object injectee) throws InvalidCommandException;
boolean delayedInjection() {
return false;
}
}
private static class InjectionMap {
public static InjectionMap forClass(Class<?> injectedClass) {
ImmutableMap.Builder<String, InjectableOption> builder = ImmutableMap.builder();
InjectableOption helpOption = new InjectableOption() {
@Override boolean isBoolean() {
return true;
}
@Override void inject(String valueText, Object injectee) throws DisplayUsageException {
throw new DisplayUsageException();
}
};
builder.put("-h", helpOption);
builder.put("--help", helpOption);
Method leftoverMethod = null;
for (Field field : injectedClass.getDeclaredFields()) {
checkArgument(!field.isAnnotationPresent(Leftovers.class),
"Sorry, @Leftovers only works for methods at present"); // TODO(kevinb)
Option option = field.getAnnotation(Option.class);
if (option != null) {
InjectableOption injectable = FieldOption.create(field);
for (String optionName : option.value()) {
builder.put(optionName, injectable);
}
}
}
for (Method method : injectedClass.getDeclaredMethods()) {
if (method.isAnnotationPresent(Leftovers.class)) {
checkArgument(!isStaticOrAbstract(method),
"@Leftovers method cannot be static or abstract");
checkArgument(!method.isAnnotationPresent(Option.class),
"method has both @Option and @Leftovers");
checkArgument(leftoverMethod == null, "Two methods have @Leftovers");
method.setAccessible(true);
leftoverMethod = method;
// TODO: check type is a supertype of ImmutableList<String>
}
Option option = method.getAnnotation(Option.class);
if (option != null) {
InjectableOption injectable = MethodOption.create(method);
for (String optionName : option.value()) {
builder.put(optionName, injectable);
}
}
}
ImmutableMap<String, InjectableOption> optionMap = builder.build();
return new InjectionMap(optionMap, leftoverMethod);
}
final ImmutableMap<String, InjectableOption> optionMap;
final Method leftoversMethod;
InjectionMap(ImmutableMap<String, InjectableOption> optionMap, Method leftoversMethod) {
this.optionMap = optionMap;
this.leftoversMethod = leftoversMethod;
}
InjectableOption getInjectableOption(String optionName) throws InvalidCommandException {
InjectableOption injectable = optionMap.get(optionName);
if (injectable == null) {
throw new InvalidCommandException("Invalid option: %s", optionName);
}
return injectable;
}
}
private static class FieldOption extends InjectableOption {
private static InjectableOption create(Field field) {
field.setAccessible(true);
Type type = field.getGenericType();
if (type instanceof Class) {
return new FieldOption(field, (Class<?>) type);
}
throw new IllegalArgumentException("can't inject parameterized types etc.");
}
private Field field;
private boolean isBoolean;
private Parser<?> parser;
private FieldOption(Field field, Class<?> c) {
this.field = field;
this.isBoolean = c == boolean.class || c == Boolean.class;
try {
this.parser = Parsers.conventionalParser(Primitives.wrap(c));
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException("No suitable String-conversion method");
}
}
@Override boolean isBoolean() {
return isBoolean;
}
@Override void inject(String valueText, Object injectee) throws InvalidCommandException {
Object value = convert(parser, valueText);
try {
field.set(injectee, value);
} catch (IllegalAccessException impossible) {
throw new AssertionError(impossible);
}
}
}
private static class MethodOption extends InjectableOption {
private static InjectableOption create(Method method) {
checkArgument(!isStaticOrAbstract(method),
"@Option methods cannot be static or abstract");
Class<?>[] classes = method.getParameterTypes();
checkArgument(classes.length == 1, "Method does not have exactly one argument: " + method);
return new MethodOption(method, classes[0]);
}
private Method method;
private boolean isBoolean;
private Parser<?> parser;
private MethodOption(Method method, Class<?> c) {
this.method = method;
this.isBoolean = c == boolean.class || c == Boolean.class;
try {
this.parser = Parsers.conventionalParser(Primitives.wrap(c));
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException("No suitable String-conversion method");
}
method.setAccessible(true);
}
@Override boolean isBoolean() {
return isBoolean;
}
@Override boolean delayedInjection() {
return true;
}
@Override void inject(String valueText, Object injectee) throws InvalidCommandException {
invokeMethod(injectee, method, convert(parser, valueText));
}
}
private static Object convert(Parser<?> parser, String valueText) throws InvalidCommandException {
Object value;
try {
value = parser.parse(valueText);
} catch (ParseException e) {
throw new InvalidCommandException("wrong datatype: " + e.getMessage());
}
return value;
}
private void parseLongOption(String arg, Iterator<String> args) throws InvalidCommandException {
String name = arg.replaceFirst("^--no-", "--");
String value = null;
// Support "--name=value" as well as "--name value".
int equalsIndex = name.indexOf('=');
if (equalsIndex != -1) {
value = name.substring(equalsIndex + 1);
name = name.substring(0, equalsIndex);
}
InjectableOption injectable = injectionMap.getInjectableOption(name);
if (value == null) {
value = injectable.isBoolean()
? Boolean.toString(!arg.startsWith("--no-"))
: grabNextValue(args, name);
}
injectNowOrLater(injectable, value);
}
private void injectNowOrLater(InjectableOption injectable, String value)
throws InvalidCommandException {
if (injectable.delayedInjection()) {
pendingInjections.add(new PendingInjection(injectable, value));
} else {
injectable.inject(value, injectee);
}
}
private static class PendingInjection {
InjectableOption injectableOption;
String value;
private PendingInjection(InjectableOption injectableOption, String value) {
this.injectableOption = injectableOption;
this.value = value;
}
}
// Given boolean options a and b, and non-boolean option f, we want to allow:
// -ab
// -abf out.txt
// -abfout.txt
// (But not -abf=out.txt --- POSIX doesn't mention that either way, but GNU expressly forbids it.)
private void parseShortOptions(String arg, Iterator<String> args) throws InvalidCommandException {
for (int i = 1; i < arg.length(); ++i) {
String name = "-" + arg.charAt(i);
InjectableOption injectable = injectionMap.getInjectableOption(name);
String value;
if (injectable.isBoolean()) {
value = "true";
} else {
// We need a value. If there's anything left, we take the rest of this "short option".
if (i + 1 < arg.length()) {
value = arg.substring(i + 1);
i = arg.length() - 1; // delayed "break"
// otherwise the next arg
} else {
value = grabNextValue(args, name);
}
}
injectNowOrLater(injectable, value);
}
}
private static void invokeMethod(Object injectee, Method method, Object value)
throws InvalidCommandException {
try {
method.invoke(injectee, value);
} catch (IllegalAccessException impossible) {
throw new AssertionError(impossible);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
Throwables.propagateIfPossible(cause, InvalidCommandException.class);
throw new RuntimeException(e);
}
}
private String grabNextValue(Iterator<String> args, String name)
throws InvalidCommandException {
if (args.hasNext()) {
return args.next();
} else {
throw new InvalidCommandException("option '" + name + "' requires an argument");
}
}
private static boolean isStaticOrAbstract(Method method) {
int modifiers = method.getModifiers();
return Modifier.isStatic(modifiers) || Modifier.isAbstract(modifiers);
}
}