• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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      */
value()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 
forClass(Class<? extends T> c)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    */
CommandLineParser(Class<? extends T> c)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    */
parseAndInject(String[] args, T injectee)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 {
isBoolean()184     abstract boolean isBoolean();
inject(String valueText, Object injectee)185     abstract void inject(String valueText, Object injectee) throws InvalidCommandException;
delayedInjection()186     boolean delayedInjection() {
187       return false;
188     }
189   }
190 
191   private static class InjectionMap {
forClass(Class<?> injectedClass)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 
InjectionMap(ImmutableMap<String, InjectableOption> optionMap, Method leftoversMethod)248     InjectionMap(ImmutableMap<String, InjectableOption> optionMap, Method leftoversMethod) {
249       this.optionMap = optionMap;
250       this.leftoversMethod = leftoversMethod;
251     }
252 
getInjectableOption(String optionName)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 {
create(Field field)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 
FieldOption(Field field, Class<?> c)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 
isBoolean()287     @Override boolean isBoolean() {
288       return isBoolean;
289     }
290 
inject(String valueText, Object injectee)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 {
create(Method method)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 
MethodOption(Method method, Class<?> c)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 
isBoolean()326     @Override boolean isBoolean() {
327       return isBoolean;
328     }
329 
delayedInjection()330     @Override boolean delayedInjection() {
331       return true;
332     }
333 
inject(String valueText, Object injectee)334     @Override void inject(String valueText, Object injectee) throws InvalidCommandException {
335       invokeMethod(injectee, method, convert(parser, valueText));
336     }
337   }
338 
convert(Parser<?> parser, String valueText)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 
parseLongOption(String arg, Iterator<String> args)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 
injectNowOrLater(InjectableOption injectable, String value)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 
PendingInjection(InjectableOption injectableOption, String value)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 
parseShortOptions(String arg, Iterator<String> args)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 
invokeMethod(Object injectee, Method method, Object value)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 
grabNextValue(Iterator<String> args, String name)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 
isStaticOrAbstract(Method method)440   private static boolean isStaticOrAbstract(Method method) {
441     int modifiers = method.getModifiers();
442     return Modifier.isStatic(modifiers) || Modifier.isAbstract(modifiers);
443   }
444 }
445