/*
|
* 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();
|
}
|
}
|
}
|