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