blob: 343b1a325094c8aaa0867ef9825e568eda8cd793 [file] [log] [blame]
/*
* Copyright (C) 2010 The Android Open Source Project
*
* 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 vogar;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import vogar.util.Strings;
/**
* 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.
*
* Parsing of numeric fields such byte, short, int, long, float, and
* double fields is supported. This includes both unboxed and boxed
* versions (e.g. int vs Integer). If there is a problem parsing the
* argument to match the desired type, a runtime exception is thrown.
*
* File option fields are supported by simply wrapping the string
* argument in a File object without testing for the existance of the
* file.
*
* Parameterized Collection fields such as List<File> and Set<String>
* are supported as long as the parameter type is otherwise supported
* by the option parser. The collection field should be initialized
* with an appropriate collection instance.
*
* Enum types are supported. Input may be in either CONSTANT_CASE or
* lower_case.
*
* The fields corresponding to options are updated as their options
* are processed. Any remaining positional arguments are returned as a
* List<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" })
* File output;
*
* // Multiple options are added to the collection.
* // The collection field itself must be non-null.
* @Option(names = { "-i", "--input-file" })
* List<File> inputs = new ArrayList<File>();
*
* }
*
* class Main {
* public static void main(String[] args) {
* Options options = new Options();
* List<String> inputFilenames = new OptionParser(options).parse(args);
* for (String inputFilename : inputFilenames) {
* if (!options.quiet) {
* ...
* }
* ...
* }
* }
* }
*
* 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)
*/
public class OptionParser {
private static final HashMap<Class<?>, Handler> handlers = new HashMap<Class<?>, Handler>();
static {
handlers.put(boolean.class, new BooleanHandler());
handlers.put(Boolean.class, new BooleanHandler());
handlers.put(byte.class, new ByteHandler());
handlers.put(Byte.class, new ByteHandler());
handlers.put(short.class, new ShortHandler());
handlers.put(Short.class, new ShortHandler());
handlers.put(int.class, new IntegerHandler());
handlers.put(Integer.class, new IntegerHandler());
handlers.put(long.class, new LongHandler());
handlers.put(Long.class, new LongHandler());
handlers.put(float.class, new FloatHandler());
handlers.put(Float.class, new FloatHandler());
handlers.put(double.class, new DoubleHandler());
handlers.put(Double.class, new DoubleHandler());
handlers.put(String.class, new StringHandler());
handlers.put(File.class, new FileHandler());
}
Handler getHandler(Type type) {
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
Class rawClass = (Class<?>) parameterizedType.getRawType();
if (!Collection.class.isAssignableFrom(rawClass)) {
throw new RuntimeException("cannot handle non-collection parameterized type " + type);
}
Type actualType = parameterizedType.getActualTypeArguments()[0];
if (!(actualType instanceof Class)) {
throw new RuntimeException("cannot handle nested parameterized type " + type);
}
return getHandler(actualType);
}
if (type instanceof Class) {
Class<?> classType = (Class) type;
if (Collection.class.isAssignableFrom(classType)) {
// could handle by just having a default of treating
// contents as String but consciously decided this
// should be an error
throw new RuntimeException(
"cannot handle non-parameterized collection " + type + ". " +
"use a generic Collection to specify a desired element type");
}
if (classType.isEnum()) {
return new EnumHandler(classType);
}
return handlers.get(classType);
}
throw new RuntimeException("cannot handle unknown field type " + type);
}
private final Object optionSource;
private final HashMap<String, Field> optionMap;
private final Map<Field, Object> defaultOptionMap;
/**
* Constructs a new OptionParser for setting the @Option fields of 'optionSource'.
*/
public OptionParser(Object optionSource) {
this.optionSource = optionSource;
this.optionMap = makeOptionMap();
this.defaultOptionMap = new HashMap<Field, Object>();
}
public static String[] readFile(File configFile) {
if (!configFile.exists()) {
return new String[0];
}
List<String> configFileLines;
try {
configFileLines = Strings.readFileLines(configFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
List<String> argsList = Lists.newArrayList();
for (String rawLine : configFileLines) {
String line = rawLine.trim();
// allow comments and blank lines
if (line.startsWith("#") || line.isEmpty()) {
continue;
}
int space = line.indexOf(' ');
if (space == -1) {
argsList.add(line);
} else {
argsList.add(line.substring(0, space));
argsList.add(line.substring(space + 1).trim());
}
}
return argsList.toArray(new String[argsList.size()]);
}
/**
* 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 List<String> parse(String[] args) {
return parseOptions(Arrays.asList(args).iterator());
}
private List<String> parseOptions(Iterator<String> args) {
final List<String> leftovers = new ArrayList<String>();
// Scan 'args'.
while (args.hasNext()) {
final String arg = args.next();
if (arg.equals("--")) {
// "--" marks the end of options and the beginning of positional arguments.
break;
} else if (arg.startsWith("--")) {
// A long option.
parseLongOption(arg, args);
} else if (arg.startsWith("-")) {
// A short option.
parseGroupedShortOptions(arg, args);
} else {
// The first non-option marks the end of options.
leftovers.add(arg);
break;
}
}
// Package up the leftovers.
while (args.hasNext()) {
leftovers.add(args.next());
}
return leftovers;
}
private Field fieldForArg(String name) {
final Field field = optionMap.get(name);
if (field == null) {
throw new RuntimeException("unrecognized option '" + name + "'");
}
return field;
}
private void parseLongOption(String arg, Iterator<String> args) {
String name = arg.replaceFirst("^--no-", "--");
String value = null;
// Support "--name=value" as well as "--name value".
final int equalsIndex = name.indexOf('=');
if (equalsIndex != -1) {
value = name.substring(equalsIndex + 1);
name = name.substring(0, equalsIndex);
}
final Field field = fieldForArg(name);
final Handler handler = getHandler(field.getGenericType());
if (value == null) {
if (handler.isBoolean()) {
value = arg.startsWith("--no-") ? "false" : "true";
} else {
value = grabNextValue(args, name, field);
}
}
setValue(field, arg, handler, 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 parseGroupedShortOptions(String arg, Iterator<String> args) {
for (int i = 1; i < arg.length(); ++i) {
final String name = "-" + arg.charAt(i);
final Field field = fieldForArg(name);
final Handler handler = getHandler(field.getGenericType());
String value;
if (handler.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;
} else {
value = grabNextValue(args, name, field);
}
}
setValue(field, arg, handler, value);
}
}
@SuppressWarnings("unchecked")
private void setValue(Field field, String arg, Handler handler, String valueText) {
Object value = handler.translate(valueText);
if (value == null) {
final String type = field.getType().getSimpleName().toLowerCase();
throw new RuntimeException("couldn't convert '" + valueText + "' to a " + type + " for option '" + arg + "'");
}
try {
field.setAccessible(true);
// record the original value of the field so it can be reset
if (!defaultOptionMap.containsKey(field)) {
defaultOptionMap.put(field, field.get(optionSource));
}
if (Collection.class.isAssignableFrom(field.getType())) {
Collection collection = (Collection) field.get(optionSource);
collection.add(value);
} else {
field.set(optionSource, value);
}
} catch (IllegalAccessException ex) {
throw new RuntimeException("internal error", ex);
}
}
/**
* Resets optionSource's fields to their defaults
*/
public void reset() {
for (Map.Entry<Field, Object> entry : defaultOptionMap.entrySet()) {
try {
entry.getKey().set(optionSource, entry.getValue());
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
// Returns the next element of 'args' if there is one. Uses 'name' and 'field' to construct a helpful error message.
private String grabNextValue(Iterator<String> args, String name, Field field) {
if (!args.hasNext()) {
final String type = field.getType().getSimpleName().toLowerCase();
throw new RuntimeException("option '" + name + "' requires a " + type + " argument");
}
return args.next();
}
// Cache the available options and report any problems with the options themselves right away.
private HashMap<String, Field> makeOptionMap() {
final HashMap<String, Field> optionMap = new HashMap<String, Field>();
final Class<?> optionClass = optionSource.getClass();
for (Field field : optionClass.getDeclaredFields()) {
if (field.isAnnotationPresent(Option.class)) {
final Option option = field.getAnnotation(Option.class);
final String[] names = option.names();
if (names.length == 0) {
throw new RuntimeException("found an @Option with no name!");
}
for (String name : names) {
if (optionMap.put(name, field) != null) {
throw new RuntimeException("found multiple @Options sharing the name '" + name + "'");
}
}
if (getHandler(field.getGenericType()) == null) {
throw new RuntimeException("unsupported @Option field type '" + field.getType() + "'");
}
}
}
return optionMap;
}
static abstract class Handler {
// Only BooleanHandler should ever override this.
boolean isBoolean() {
return false;
}
/**
* Returns an object of appropriate type for the given Handle, corresponding to 'valueText'.
* Returns null on failure.
*/
abstract Object translate(String valueText);
}
static class BooleanHandler extends Handler {
@Override boolean isBoolean() {
return true;
}
Object translate(String valueText) {
if (valueText.equalsIgnoreCase("true") || valueText.equalsIgnoreCase("yes")) {
return Boolean.TRUE;
} else if (valueText.equalsIgnoreCase("false") || valueText.equalsIgnoreCase("no")) {
return Boolean.FALSE;
}
return null;
}
}
static class ByteHandler extends Handler {
Object translate(String valueText) {
try {
return Byte.parseByte(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class ShortHandler extends Handler {
Object translate(String valueText) {
try {
return Short.parseShort(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class IntegerHandler extends Handler {
Object translate(String valueText) {
try {
return Integer.parseInt(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class LongHandler extends Handler {
Object translate(String valueText) {
try {
return Long.parseLong(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class FloatHandler extends Handler {
Object translate(String valueText) {
try {
return Float.parseFloat(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class DoubleHandler extends Handler {
Object translate(String valueText) {
try {
return Double.parseDouble(valueText);
} catch (NumberFormatException ex) {
return null;
}
}
}
static class StringHandler extends Handler {
Object translate(String valueText) {
return valueText;
}
}
@SuppressWarnings("unchecked") // creating an instance with a non-enum type is an error!
static class EnumHandler extends Handler {
private final Class<?> enumType;
public EnumHandler(Class<?> enumType) {
this.enumType = enumType;
}
Object translate(String valueText) {
try {
return Enum.valueOf((Class) enumType, valueText.toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
}
static class FileHandler extends Handler {
Object translate(String valueText) {
return new File(valueText).getAbsoluteFile();
}
}
}