/* * 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 and Set * 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. * * 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 inputs = new ArrayList(); * * } * * class Main { * public static void main(String[] args) { * Options options = new Options(); * List 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, Handler> handlers = new HashMap, 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 optionMap; private final Map 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(); } public static String[] readFile(File configFile) { if (!configFile.exists()) { return new String[0]; } List configFileLines; try { configFileLines = Strings.readFileLines(configFile); } catch (IOException e) { throw new RuntimeException(e); } List 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 parse(String[] args) { return parseOptions(Arrays.asList(args).iterator()); } private List parseOptions(Iterator args) { final List leftovers = new ArrayList(); // 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 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 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 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 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 makeOptionMap() { final HashMap optionMap = new HashMap(); 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(); } } }