Home | History | Annotate | Download | only in options
      1 /*
      2  * Copyright (C) 2011 Google Inc.
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
      5  * in compliance with the License. You may obtain a copy of the License at
      6  *
      7  * http://www.apache.org/licenses/LICENSE-2.0
      8  *
      9  * Unless required by applicable law or agreed to in writing, software distributed under the License
     10  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
     11  * or implied. See the License for the specific language governing permissions and limitations under
     12  * the License.
     13  */
     14 
     15 package com.google.caliper.options;
     16 
     17 import static com.google.common.base.Preconditions.checkArgument;
     18 
     19 import com.google.caliper.util.DisplayUsageException;
     20 import com.google.caliper.util.InvalidCommandException;
     21 import com.google.caliper.util.Parser;
     22 import com.google.caliper.util.Parsers;
     23 import com.google.common.base.Throwables;
     24 import com.google.common.collect.ImmutableList;
     25 import com.google.common.collect.ImmutableMap;
     26 import com.google.common.collect.Iterators;
     27 import com.google.common.collect.Lists;
     28 import com.google.common.primitives.Primitives;
     29 
     30 import java.lang.annotation.ElementType;
     31 import java.lang.annotation.Retention;
     32 import java.lang.annotation.RetentionPolicy;
     33 import java.lang.annotation.Target;
     34 import java.lang.reflect.Field;
     35 import java.lang.reflect.InvocationTargetException;
     36 import java.lang.reflect.Method;
     37 import java.lang.reflect.Modifier;
     38 import java.lang.reflect.Type;
     39 import java.text.ParseException;
     40 import java.util.Iterator;
     41 import java.util.List;
     42 
     43 // based on r135 of OptionParser.java from vogar
     44 // NOTE: this class is still pretty messy but will be cleaned up further and possibly offered to
     45 // Guava.
     46 
     47 /**
     48  * Parses command line options.
     49  *
     50  * Strings in the passed-in String[] are parsed left-to-right. Each String is classified as a short
     51  * option (such as "-v"), a long option (such as "--verbose"), an argument to an option (such as
     52  * "out.txt" in "-f out.txt"), or a non-option positional argument.
     53  *
     54  * A simple short option is a "-" followed by a short option character. If the option requires an
     55  * argument (which is true of any non-boolean option), it may be written as a separate parameter,
     56  * but need not be. That is, "-f out.txt" and "-fout.txt" are both acceptable.
     57  *
     58  * It is possible to specify multiple short options after a single "-" as long as all (except
     59  * possibly the last) do not require arguments.
     60  *
     61  * A long option begins with "--" followed by several characters. If the option requires an
     62  * argument, it may be written directly after the option name, separated by "=", or as the next
     63  * argument. (That is, "--file=out.txt" or "--file out.txt".)
     64  *
     65  * A boolean long option '--name' automatically gets a '--no-name' companion. Given an option
     66  * "--flag", then, "--flag", "--no-flag", "--flag=true" and "--flag=false" are all valid, though
     67  * neither "--flag true" nor "--flag false" are allowed (since "--flag" by itself is sufficient, the
     68  * following "true" or "false" is interpreted separately). You can use "yes" and "no" as synonyms
     69  * for "true" and "false".
     70  *
     71  * Each String not starting with a "-" and not a required argument of a previous option is a
     72  * non-option positional argument, as are all successive Strings. Each String after a "--" is a
     73  * non-option positional argument.
     74  *
     75  * The fields corresponding to options are updated as their options are processed. Any remaining
     76  * positional arguments are returned as an ImmutableList<String>.
     77  *
     78  * Here's a simple example:
     79  *
     80  * // This doesn't need to be a separate class, if your application doesn't warrant it. //
     81  * Non-@Option fields will be ignored. class Options {
     82  *
     83  * @Option(names = { "-q", "--quiet" }) boolean quiet = false;
     84  *
     85  * // Boolean options require a long name if it's to be possible to explicitly turn them off. //
     86  * Here the user can use --no-color.
     87  * @Option(names = { "--color" }) boolean color = true;
     88  * @Option(names = { "-m", "--mode" }) String mode = "standard; // Supply a default just by setting
     89  * the field.
     90  * @Option(names = { "-p", "--port" }) int portNumber = 8888;
     91  *
     92  * // There's no need to offer a short name for rarely-used options.
     93  * @Option(names = { "--timeout" }) double timeout = 1.0;
     94  * @Option(names = { "-o", "--output-file" }) String outputFile;
     95  *
     96  * }
     97  *
     98  * See also:
     99  *
    100  * the getopt(1) man page Python's "optparse" module (http://docs.python.org/library/optparse.html)
    101  * the POSIX "Utility Syntax Guidelines" (http://www.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap12.html#tag_12_02)
    102  * the GNU "Standards for Command Line Interfaces" (http://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces)
    103  */
    104 final class CommandLineParser<T> {
    105   /**
    106    * Annotates a field or method in an options class to signify that parsed values should be
    107    * injected.
    108    */
    109   @Retention(RetentionPolicy.RUNTIME)
    110   @Target({ElementType.FIELD, ElementType.METHOD})
    111   public @interface Option {
    112     /**
    113      * The names for this option, such as { "-h", "--help" }. Names must start with one or two '-'s.
    114      * An option must have at least one name.
    115      */
    116     String[] value();
    117   }
    118 
    119   /**
    120    * Annotates a single method in an options class to receive any "leftover" arguments. The method
    121    * must accept {@code ImmutableList<String>} or a supertype. The method will be invoked even if
    122    * the list is empty.
    123    */
    124   @Retention(RetentionPolicy.RUNTIME)
    125   @Target({ElementType.FIELD, ElementType.METHOD})
    126   public @interface Leftovers {}
    127 
    128   public static <T> CommandLineParser<T> forClass(Class<? extends T> c) {
    129     return new CommandLineParser<T>(c);
    130   }
    131 
    132   private final InjectionMap injectionMap;
    133   private T injectee;
    134 
    135   // TODO(kevinb): make a helper object that can be mutated during processing
    136   private final List<PendingInjection> pendingInjections = Lists.newArrayList();
    137 
    138   /**
    139    * Constructs a new command-line parser that will inject values into {@code injectee}.
    140    *
    141    * @throws IllegalArgumentException if {@code injectee} contains multiple options using the same
    142    *     name
    143    */
    144   private CommandLineParser(Class<? extends T> c) {
    145     this.injectionMap = InjectionMap.forClass(c);
    146   }
    147 
    148   /**
    149    * Parses the command-line arguments 'args', setting the @Option fields of the 'optionSource'
    150    * provided to the constructor. Returns a list of the positional arguments left over after
    151    * processing all options.
    152    */
    153   public void parseAndInject(String[] args, T injectee) throws InvalidCommandException {
    154     this.injectee = injectee;
    155     pendingInjections.clear();
    156     Iterator<String> argsIter = Iterators.forArray(args);
    157     ImmutableList.Builder<String> builder = ImmutableList.builder();
    158 
    159     while (argsIter.hasNext()) {
    160       String arg = argsIter.next();
    161       if (arg.equals("--")) {
    162         break; // "--" marks the end of options and the beginning of positional arguments.
    163       } else if (arg.startsWith("--")) {
    164         parseLongOption(arg, argsIter);
    165       } else if (arg.startsWith("-")) {
    166         parseShortOptions(arg, argsIter);
    167       } else {
    168         builder.add(arg);
    169         // allow positional arguments to mix with options since many linux commands do
    170       }
    171     }
    172 
    173     for (PendingInjection pi : pendingInjections) {
    174       pi.injectableOption.inject(pi.value, injectee);
    175     }
    176 
    177     ImmutableList<String> leftovers = builder.addAll(argsIter).build();
    178     invokeMethod(injectee, injectionMap.leftoversMethod, leftovers);
    179   }
    180 
    181   // Private stuff from here on down
    182 
    183   private abstract static class InjectableOption {
    184     abstract boolean isBoolean();
    185     abstract void inject(String valueText, Object injectee) throws InvalidCommandException;
    186     boolean delayedInjection() {
    187       return false;
    188     }
    189   }
    190 
    191   private static class InjectionMap {
    192     public static InjectionMap forClass(Class<?> injectedClass) {
    193       ImmutableMap.Builder<String, InjectableOption> builder = ImmutableMap.builder();
    194 
    195       InjectableOption helpOption = new InjectableOption() {
    196         @Override boolean isBoolean() {
    197           return true;
    198         }
    199         @Override void inject(String valueText, Object injectee) throws DisplayUsageException {
    200           throw new DisplayUsageException();
    201         }
    202       };
    203       builder.put("-h", helpOption);
    204       builder.put("--help", helpOption);
    205 
    206       Method leftoverMethod = null;
    207 
    208       for (Field field : injectedClass.getDeclaredFields()) {
    209         checkArgument(!field.isAnnotationPresent(Leftovers.class),
    210             "Sorry, @Leftovers only works for methods at present"); // TODO(kevinb)
    211         Option option = field.getAnnotation(Option.class);
    212         if (option != null) {
    213           InjectableOption injectable = FieldOption.create(field);
    214           for (String optionName : option.value()) {
    215             builder.put(optionName, injectable);
    216           }
    217         }
    218       }
    219       for (Method method : injectedClass.getDeclaredMethods()) {
    220         if (method.isAnnotationPresent(Leftovers.class)) {
    221           checkArgument(!isStaticOrAbstract(method),
    222               "@Leftovers method cannot be static or abstract");
    223           checkArgument(!method.isAnnotationPresent(Option.class),
    224               "method has both @Option and @Leftovers");
    225           checkArgument(leftoverMethod == null, "Two methods have @Leftovers");
    226 
    227           method.setAccessible(true);
    228           leftoverMethod = method;
    229 
    230           // TODO: check type is a supertype of ImmutableList<String>
    231         }
    232         Option option = method.getAnnotation(Option.class);
    233         if (option != null) {
    234           InjectableOption injectable = MethodOption.create(method);
    235           for (String optionName : option.value()) {
    236             builder.put(optionName, injectable);
    237           }
    238         }
    239       }
    240 
    241       ImmutableMap<String, InjectableOption> optionMap = builder.build();
    242       return new InjectionMap(optionMap, leftoverMethod);
    243     }
    244 
    245     final ImmutableMap<String, InjectableOption> optionMap;
    246     final Method leftoversMethod;
    247 
    248     InjectionMap(ImmutableMap<String, InjectableOption> optionMap, Method leftoversMethod) {
    249       this.optionMap = optionMap;
    250       this.leftoversMethod = leftoversMethod;
    251     }
    252 
    253     InjectableOption getInjectableOption(String optionName) throws InvalidCommandException {
    254       InjectableOption injectable = optionMap.get(optionName);
    255       if (injectable == null) {
    256         throw new InvalidCommandException("Invalid option: %s", optionName);
    257       }
    258       return injectable;
    259     }
    260   }
    261 
    262   private static class FieldOption extends InjectableOption {
    263     private static InjectableOption create(Field field) {
    264       field.setAccessible(true);
    265       Type type = field.getGenericType();
    266 
    267       if (type instanceof Class) {
    268         return new FieldOption(field, (Class<?>) type);
    269       }
    270       throw new IllegalArgumentException("can't inject parameterized types etc.");
    271     }
    272 
    273     private Field field;
    274     private boolean isBoolean;
    275     private Parser<?> parser;
    276 
    277     private FieldOption(Field field, Class<?> c) {
    278       this.field = field;
    279       this.isBoolean = c == boolean.class || c == Boolean.class;
    280       try {
    281         this.parser = Parsers.conventionalParser(Primitives.wrap(c));
    282       } catch (NoSuchMethodException e) {
    283         throw new IllegalArgumentException("No suitable String-conversion method");
    284       }
    285     }
    286 
    287     @Override boolean isBoolean() {
    288       return isBoolean;
    289     }
    290 
    291     @Override void inject(String valueText, Object injectee) throws InvalidCommandException {
    292       Object value = convert(parser, valueText);
    293       try {
    294         field.set(injectee, value);
    295       } catch (IllegalAccessException impossible) {
    296         throw new AssertionError(impossible);
    297       }
    298     }
    299   }
    300 
    301   private static class MethodOption extends InjectableOption {
    302     private static InjectableOption create(Method method) {
    303       checkArgument(!isStaticOrAbstract(method),
    304           "@Option methods cannot be static or abstract");
    305       Class<?>[] classes = method.getParameterTypes();
    306       checkArgument(classes.length == 1, "Method does not have exactly one argument: " + method);
    307       return new MethodOption(method, classes[0]);
    308     }
    309 
    310     private Method method;
    311     private boolean isBoolean;
    312     private Parser<?> parser;
    313 
    314     private MethodOption(Method method, Class<?> c) {
    315       this.method = method;
    316       this.isBoolean = c == boolean.class || c == Boolean.class;
    317       try {
    318         this.parser = Parsers.conventionalParser(Primitives.wrap(c));
    319       } catch (NoSuchMethodException e) {
    320         throw new IllegalArgumentException("No suitable String-conversion method");
    321       }
    322 
    323       method.setAccessible(true);
    324     }
    325 
    326     @Override boolean isBoolean() {
    327       return isBoolean;
    328     }
    329 
    330     @Override boolean delayedInjection() {
    331       return true;
    332     }
    333 
    334     @Override void inject(String valueText, Object injectee) throws InvalidCommandException {
    335       invokeMethod(injectee, method, convert(parser, valueText));
    336     }
    337   }
    338 
    339   private static Object convert(Parser<?> parser, String valueText) throws InvalidCommandException {
    340     Object value;
    341     try {
    342       value = parser.parse(valueText);
    343     } catch (ParseException e) {
    344       throw new InvalidCommandException("wrong datatype: " + e.getMessage());
    345     }
    346     return value;
    347   }
    348 
    349   private void parseLongOption(String arg, Iterator<String> args) throws InvalidCommandException {
    350     String name = arg.replaceFirst("^--no-", "--");
    351     String value = null;
    352 
    353     // Support "--name=value" as well as "--name value".
    354     int equalsIndex = name.indexOf('=');
    355     if (equalsIndex != -1) {
    356       value = name.substring(equalsIndex + 1);
    357       name = name.substring(0, equalsIndex);
    358     }
    359 
    360     InjectableOption injectable = injectionMap.getInjectableOption(name);
    361 
    362     if (value == null) {
    363       value = injectable.isBoolean()
    364           ? Boolean.toString(!arg.startsWith("--no-"))
    365           : grabNextValue(args, name);
    366     }
    367     injectNowOrLater(injectable, value);
    368   }
    369 
    370   private void injectNowOrLater(InjectableOption injectable, String value)
    371       throws InvalidCommandException {
    372     if (injectable.delayedInjection()) {
    373       pendingInjections.add(new PendingInjection(injectable, value));
    374     } else {
    375       injectable.inject(value, injectee);
    376     }
    377   }
    378 
    379   private static class PendingInjection {
    380     InjectableOption injectableOption;
    381     String value;
    382 
    383     private PendingInjection(InjectableOption injectableOption, String value) {
    384       this.injectableOption = injectableOption;
    385       this.value = value;
    386     }
    387   }
    388 
    389   // Given boolean options a and b, and non-boolean option f, we want to allow:
    390   // -ab
    391   // -abf out.txt
    392   // -abfout.txt
    393   // (But not -abf=out.txt --- POSIX doesn't mention that either way, but GNU expressly forbids it.)
    394 
    395   private void parseShortOptions(String arg, Iterator<String> args) throws InvalidCommandException {
    396     for (int i = 1; i < arg.length(); ++i) {
    397       String name = "-" + arg.charAt(i);
    398       InjectableOption injectable = injectionMap.getInjectableOption(name);
    399 
    400       String value;
    401       if (injectable.isBoolean()) {
    402         value = "true";
    403       } else {
    404         // We need a value. If there's anything left, we take the rest of this "short option".
    405         if (i + 1 < arg.length()) {
    406           value = arg.substring(i + 1);
    407           i = arg.length() - 1; // delayed "break"
    408 
    409         // otherwise the next arg
    410         } else {
    411           value = grabNextValue(args, name);
    412         }
    413       }
    414       injectNowOrLater(injectable, value);
    415     }
    416   }
    417 
    418   private static void invokeMethod(Object injectee, Method method, Object value)
    419       throws InvalidCommandException {
    420     try {
    421       method.invoke(injectee, value);
    422     } catch (IllegalAccessException impossible) {
    423       throw new AssertionError(impossible);
    424     } catch (InvocationTargetException e) {
    425       Throwable cause = e.getCause();
    426       Throwables.propagateIfPossible(cause, InvalidCommandException.class);
    427       throw new RuntimeException(e);
    428     }
    429   }
    430 
    431   private String grabNextValue(Iterator<String> args, String name)
    432       throws InvalidCommandException {
    433     if (args.hasNext()) {
    434       return args.next();
    435     } else {
    436       throw new InvalidCommandException("option '" + name + "' requires an argument");
    437     }
    438   }
    439 
    440   private static boolean isStaticOrAbstract(Method method) {
    441     int modifiers = method.getModifiers();
    442     return Modifier.isStatic(modifiers) || Modifier.isAbstract(modifiers);
    443   }
    444 }
    445