1 /* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package vogar; 18 19 import com.google.common.collect.Lists; 20 import java.io.File; 21 import java.io.IOException; 22 import java.lang.reflect.Field; 23 import java.lang.reflect.ParameterizedType; 24 import java.lang.reflect.Type; 25 import java.util.ArrayList; 26 import java.util.Arrays; 27 import java.util.Collection; 28 import java.util.HashMap; 29 import java.util.Iterator; 30 import java.util.List; 31 import java.util.Map; 32 import vogar.util.Strings; 33 34 /** 35 * Parses command line options. 36 * 37 * Strings in the passed-in String[] are parsed left-to-right. Each 38 * String is classified as a short option (such as "-v"), a long 39 * option (such as "--verbose"), an argument to an option (such as 40 * "out.txt" in "-f out.txt"), or a non-option positional argument. 41 * 42 * A simple short option is a "-" followed by a short option 43 * character. If the option requires an argument (which is true of any 44 * non-boolean option), it may be written as a separate parameter, but 45 * need not be. That is, "-f out.txt" and "-fout.txt" are both 46 * acceptable. 47 * 48 * It is possible to specify multiple short options after a single "-" 49 * as long as all (except possibly the last) do not require arguments. 50 * 51 * A long option begins with "--" followed by several characters. If 52 * the option requires an argument, it may be written directly after 53 * the option name, separated by "=", or as the next argument. (That 54 * is, "--file=out.txt" or "--file out.txt".) 55 * 56 * A boolean long option '--name' automatically gets a '--no-name' 57 * companion. Given an option "--flag", then, "--flag", "--no-flag", 58 * "--flag=true" and "--flag=false" are all valid, though neither 59 * "--flag true" nor "--flag false" are allowed (since "--flag" by 60 * itself is sufficient, the following "true" or "false" is 61 * interpreted separately). You can use "yes" and "no" as synonyms for 62 * "true" and "false". 63 * 64 * Each String not starting with a "-" and not a required argument of 65 * a previous option is a non-option positional argument, as are all 66 * successive Strings. Each String after a "--" is a non-option 67 * positional argument. 68 * 69 * Parsing of numeric fields such byte, short, int, long, float, and 70 * double fields is supported. This includes both unboxed and boxed 71 * versions (e.g. int vs Integer). If there is a problem parsing the 72 * argument to match the desired type, a runtime exception is thrown. 73 * 74 * File option fields are supported by simply wrapping the string 75 * argument in a File object without testing for the existance of the 76 * file. 77 * 78 * Parameterized Collection fields such as List<File> and Set<String> 79 * are supported as long as the parameter type is otherwise supported 80 * by the option parser. The collection field should be initialized 81 * with an appropriate collection instance. 82 * 83 * Enum types are supported. Input may be in either CONSTANT_CASE or 84 * lower_case. 85 * 86 * The fields corresponding to options are updated as their options 87 * are processed. Any remaining positional arguments are returned as a 88 * List<String>. 89 * 90 * Here's a simple example: 91 * 92 * // This doesn't need to be a separate class, if your application doesn't warrant it. 93 * // Non-@Option fields will be ignored. 94 * class Options { 95 * @Option(names = { "-q", "--quiet" }) 96 * boolean quiet = false; 97 * 98 * // Boolean options require a long name if it's to be possible to explicitly turn them off. 99 * // Here the user can use --no-color. 100 * @Option(names = { "--color" }) 101 * boolean color = true; 102 * 103 * @Option(names = { "-m", "--mode" }) 104 * String mode = "standard; // Supply a default just by setting the field. 105 * 106 * @Option(names = { "-p", "--port" }) 107 * int portNumber = 8888; 108 * 109 * // There's no need to offer a short name for rarely-used options. 110 * @Option(names = { "--timeout" }) 111 * double timeout = 1.0; 112 * 113 * @Option(names = { "-o", "--output-file" }) 114 * File output; 115 * 116 * // Multiple options are added to the collection. 117 * // The collection field itself must be non-null. 118 * @Option(names = { "-i", "--input-file" }) 119 * List<File> inputs = new ArrayList<File>(); 120 * 121 * } 122 * 123 * class Main { 124 * public static void main(String[] args) { 125 * Options options = new Options(); 126 * List<String> inputFilenames = new OptionParser(options).parse(args); 127 * for (String inputFilename : inputFilenames) { 128 * if (!options.quiet) { 129 * ... 130 * } 131 * ... 132 * } 133 * } 134 * } 135 * 136 * See also: 137 * 138 * the getopt(1) man page 139 * Python's "optparse" module (http://docs.python.org/library/optparse.html) 140 * the POSIX "Utility Syntax Guidelines" (http://www.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap12.html#tag_12_02) 141 * the GNU "Standards for Command Line Interfaces" (http://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces) 142 */ 143 public class OptionParser { 144 private static final HashMap<Class<?>, Handler> handlers = new HashMap<Class<?>, Handler>(); 145 static { handlers.put(boolean.class, new BooleanHandler())146 handlers.put(boolean.class, new BooleanHandler()); handlers.put(Boolean.class, new BooleanHandler())147 handlers.put(Boolean.class, new BooleanHandler()); 148 handlers.put(byte.class, new ByteHandler())149 handlers.put(byte.class, new ByteHandler()); handlers.put(Byte.class, new ByteHandler())150 handlers.put(Byte.class, new ByteHandler()); handlers.put(short.class, new ShortHandler())151 handlers.put(short.class, new ShortHandler()); handlers.put(Short.class, new ShortHandler())152 handlers.put(Short.class, new ShortHandler()); handlers.put(int.class, new IntegerHandler())153 handlers.put(int.class, new IntegerHandler()); handlers.put(Integer.class, new IntegerHandler())154 handlers.put(Integer.class, new IntegerHandler()); handlers.put(long.class, new LongHandler())155 handlers.put(long.class, new LongHandler()); handlers.put(Long.class, new LongHandler())156 handlers.put(Long.class, new LongHandler()); 157 handlers.put(float.class, new FloatHandler())158 handlers.put(float.class, new FloatHandler()); handlers.put(Float.class, new FloatHandler())159 handlers.put(Float.class, new FloatHandler()); handlers.put(double.class, new DoubleHandler())160 handlers.put(double.class, new DoubleHandler()); handlers.put(Double.class, new DoubleHandler())161 handlers.put(Double.class, new DoubleHandler()); 162 handlers.put(String.class, new StringHandler())163 handlers.put(String.class, new StringHandler()); handlers.put(File.class, new FileHandler())164 handlers.put(File.class, new FileHandler()); 165 } getHandler(Type type)166 Handler getHandler(Type type) { 167 if (type instanceof ParameterizedType) { 168 ParameterizedType parameterizedType = (ParameterizedType) type; 169 Class rawClass = (Class<?>) parameterizedType.getRawType(); 170 if (!Collection.class.isAssignableFrom(rawClass)) { 171 throw new RuntimeException("cannot handle non-collection parameterized type " + type); 172 } 173 Type actualType = parameterizedType.getActualTypeArguments()[0]; 174 if (!(actualType instanceof Class)) { 175 throw new RuntimeException("cannot handle nested parameterized type " + type); 176 } 177 return getHandler(actualType); 178 } 179 if (type instanceof Class) { 180 Class<?> classType = (Class) type; 181 if (Collection.class.isAssignableFrom(classType)) { 182 // could handle by just having a default of treating 183 // contents as String but consciously decided this 184 // should be an error 185 throw new RuntimeException( 186 "cannot handle non-parameterized collection " + type + ". " + 187 "use a generic Collection to specify a desired element type"); 188 } 189 if (classType.isEnum()) { 190 return new EnumHandler(classType); 191 } 192 return handlers.get(classType); 193 } 194 throw new RuntimeException("cannot handle unknown field type " + type); 195 } 196 197 private final Object optionSource; 198 private final HashMap<String, Field> optionMap; 199 private final Map<Field, Object> defaultOptionMap; 200 201 /** 202 * Constructs a new OptionParser for setting the @Option fields of 'optionSource'. 203 */ OptionParser(Object optionSource)204 public OptionParser(Object optionSource) { 205 this.optionSource = optionSource; 206 this.optionMap = makeOptionMap(); 207 this.defaultOptionMap = new HashMap<Field, Object>(); 208 } 209 readFile(File configFile)210 public static String[] readFile(File configFile) { 211 if (!configFile.exists()) { 212 return new String[0]; 213 } 214 215 List<String> configFileLines; 216 try { 217 configFileLines = Strings.readFileLines(configFile); 218 } catch (IOException e) { 219 throw new RuntimeException(e); 220 } 221 222 List<String> argsList = Lists.newArrayList(); 223 for (String rawLine : configFileLines) { 224 String line = rawLine.trim(); 225 226 // allow comments and blank lines 227 if (line.startsWith("#") || line.isEmpty()) { 228 continue; 229 } 230 int space = line.indexOf(' '); 231 if (space == -1) { 232 argsList.add(line); 233 } else { 234 argsList.add(line.substring(0, space)); 235 argsList.add(line.substring(space + 1).trim()); 236 } 237 } 238 239 return argsList.toArray(new String[argsList.size()]); 240 } 241 242 /** 243 * Parses the command-line arguments 'args', setting the @Option fields of the 'optionSource' provided to the constructor. 244 * Returns a list of the positional arguments left over after processing all options. 245 */ parse(String[] args)246 public List<String> parse(String[] args) { 247 return parseOptions(Arrays.asList(args).iterator()); 248 } 249 parseOptions(Iterator<String> args)250 private List<String> parseOptions(Iterator<String> args) { 251 final List<String> leftovers = new ArrayList<String>(); 252 253 // Scan 'args'. 254 while (args.hasNext()) { 255 final String arg = args.next(); 256 if (arg.equals("--")) { 257 // "--" marks the end of options and the beginning of positional arguments. 258 break; 259 } else if (arg.startsWith("--")) { 260 // A long option. 261 parseLongOption(arg, args); 262 } else if (arg.startsWith("-")) { 263 // A short option. 264 parseGroupedShortOptions(arg, args); 265 } else { 266 // The first non-option marks the end of options. 267 leftovers.add(arg); 268 break; 269 } 270 } 271 272 // Package up the leftovers. 273 while (args.hasNext()) { 274 leftovers.add(args.next()); 275 } 276 return leftovers; 277 } 278 fieldForArg(String name)279 private Field fieldForArg(String name) { 280 final Field field = optionMap.get(name); 281 if (field == null) { 282 throw new RuntimeException("unrecognized option '" + name + "'"); 283 } 284 return field; 285 } 286 parseLongOption(String arg, Iterator<String> args)287 private void parseLongOption(String arg, Iterator<String> args) { 288 String name = arg.replaceFirst("^--no-", "--"); 289 String value = null; 290 291 // Support "--name=value" as well as "--name value". 292 final int equalsIndex = name.indexOf('='); 293 if (equalsIndex != -1) { 294 value = name.substring(equalsIndex + 1); 295 name = name.substring(0, equalsIndex); 296 } 297 298 final Field field = fieldForArg(name); 299 final Handler handler = getHandler(field.getGenericType()); 300 if (value == null) { 301 if (handler.isBoolean()) { 302 value = arg.startsWith("--no-") ? "false" : "true"; 303 } else { 304 value = grabNextValue(args, name, field); 305 } 306 } 307 setValue(field, arg, handler, value); 308 } 309 310 // Given boolean options a and b, and non-boolean option f, we want to allow: 311 // -ab 312 // -abf out.txt 313 // -abfout.txt 314 // (But not -abf=out.txt --- POSIX doesn't mention that either way, but GNU expressly forbids it.) parseGroupedShortOptions(String arg, Iterator<String> args)315 private void parseGroupedShortOptions(String arg, Iterator<String> args) { 316 for (int i = 1; i < arg.length(); ++i) { 317 final String name = "-" + arg.charAt(i); 318 final Field field = fieldForArg(name); 319 final Handler handler = getHandler(field.getGenericType()); 320 String value; 321 if (handler.isBoolean()) { 322 value = "true"; 323 } else { 324 // We need a value. If there's anything left, we take the rest of this "short option". 325 if (i + 1 < arg.length()) { 326 value = arg.substring(i + 1); 327 i = arg.length() - 1; 328 } else { 329 value = grabNextValue(args, name, field); 330 } 331 } 332 setValue(field, arg, handler, value); 333 } 334 } 335 336 @SuppressWarnings("unchecked") setValue(Field field, String arg, Handler handler, String valueText)337 private void setValue(Field field, String arg, Handler handler, String valueText) { 338 339 Object value = handler.translate(valueText); 340 if (value == null) { 341 final String type = field.getType().getSimpleName().toLowerCase(); 342 throw new RuntimeException("couldn't convert '" + valueText + "' to a " + type + " for option '" + arg + "'"); 343 } 344 try { 345 field.setAccessible(true); 346 // record the original value of the field so it can be reset 347 if (!defaultOptionMap.containsKey(field)) { 348 defaultOptionMap.put(field, field.get(optionSource)); 349 } 350 if (Collection.class.isAssignableFrom(field.getType())) { 351 Collection collection = (Collection) field.get(optionSource); 352 collection.add(value); 353 } else { 354 field.set(optionSource, value); 355 } 356 } catch (IllegalAccessException ex) { 357 throw new RuntimeException("internal error", ex); 358 } 359 } 360 361 /** 362 * Resets optionSource's fields to their defaults 363 */ reset()364 public void reset() { 365 for (Map.Entry<Field, Object> entry : defaultOptionMap.entrySet()) { 366 try { 367 entry.getKey().set(optionSource, entry.getValue()); 368 } catch (IllegalAccessException e) { 369 throw new RuntimeException(e); 370 } 371 } 372 } 373 374 // Returns the next element of 'args' if there is one. Uses 'name' and 'field' to construct a helpful error message. grabNextValue(Iterator<String> args, String name, Field field)375 private String grabNextValue(Iterator<String> args, String name, Field field) { 376 if (!args.hasNext()) { 377 final String type = field.getType().getSimpleName().toLowerCase(); 378 throw new RuntimeException("option '" + name + "' requires a " + type + " argument"); 379 } 380 return args.next(); 381 } 382 383 // Cache the available options and report any problems with the options themselves right away. makeOptionMap()384 private HashMap<String, Field> makeOptionMap() { 385 final HashMap<String, Field> optionMap = new HashMap<String, Field>(); 386 final Class<?> optionClass = optionSource.getClass(); 387 for (Field field : optionClass.getDeclaredFields()) { 388 if (field.isAnnotationPresent(Option.class)) { 389 final Option option = field.getAnnotation(Option.class); 390 final String[] names = option.names(); 391 if (names.length == 0) { 392 throw new RuntimeException("found an @Option with no name!"); 393 } 394 for (String name : names) { 395 if (optionMap.put(name, field) != null) { 396 throw new RuntimeException("found multiple @Options sharing the name '" + name + "'"); 397 } 398 } 399 if (getHandler(field.getGenericType()) == null) { 400 throw new RuntimeException("unsupported @Option field type '" + field.getType() + "'"); 401 } 402 } 403 } 404 return optionMap; 405 } 406 407 static abstract class Handler { 408 // Only BooleanHandler should ever override this. isBoolean()409 boolean isBoolean() { 410 return false; 411 } 412 413 /** 414 * Returns an object of appropriate type for the given Handle, corresponding to 'valueText'. 415 * Returns null on failure. 416 */ translate(String valueText)417 abstract Object translate(String valueText); 418 } 419 420 static class BooleanHandler extends Handler { isBoolean()421 @Override boolean isBoolean() { 422 return true; 423 } 424 translate(String valueText)425 Object translate(String valueText) { 426 if (valueText.equalsIgnoreCase("true") || valueText.equalsIgnoreCase("yes")) { 427 return Boolean.TRUE; 428 } else if (valueText.equalsIgnoreCase("false") || valueText.equalsIgnoreCase("no")) { 429 return Boolean.FALSE; 430 } 431 return null; 432 } 433 } 434 435 static class ByteHandler extends Handler { translate(String valueText)436 Object translate(String valueText) { 437 try { 438 return Byte.parseByte(valueText); 439 } catch (NumberFormatException ex) { 440 return null; 441 } 442 } 443 } 444 445 static class ShortHandler extends Handler { translate(String valueText)446 Object translate(String valueText) { 447 try { 448 return Short.parseShort(valueText); 449 } catch (NumberFormatException ex) { 450 return null; 451 } 452 } 453 } 454 455 static class IntegerHandler extends Handler { translate(String valueText)456 Object translate(String valueText) { 457 try { 458 return Integer.parseInt(valueText); 459 } catch (NumberFormatException ex) { 460 return null; 461 } 462 } 463 } 464 465 static class LongHandler extends Handler { translate(String valueText)466 Object translate(String valueText) { 467 try { 468 return Long.parseLong(valueText); 469 } catch (NumberFormatException ex) { 470 return null; 471 } 472 } 473 } 474 475 static class FloatHandler extends Handler { translate(String valueText)476 Object translate(String valueText) { 477 try { 478 return Float.parseFloat(valueText); 479 } catch (NumberFormatException ex) { 480 return null; 481 } 482 } 483 } 484 485 static class DoubleHandler extends Handler { translate(String valueText)486 Object translate(String valueText) { 487 try { 488 return Double.parseDouble(valueText); 489 } catch (NumberFormatException ex) { 490 return null; 491 } 492 } 493 } 494 495 static class StringHandler extends Handler { translate(String valueText)496 Object translate(String valueText) { 497 return valueText; 498 } 499 } 500 501 @SuppressWarnings("unchecked") // creating an instance with a non-enum type is an error! 502 static class EnumHandler extends Handler { 503 private final Class<?> enumType; 504 EnumHandler(Class<?> enumType)505 public EnumHandler(Class<?> enumType) { 506 this.enumType = enumType; 507 } 508 translate(String valueText)509 Object translate(String valueText) { 510 try { 511 return Enum.valueOf((Class) enumType, valueText.toUpperCase()); 512 } catch (IllegalArgumentException e) { 513 return null; 514 } 515 } 516 } 517 518 static class FileHandler extends Handler { translate(String valueText)519 Object translate(String valueText) { 520 return new File(valueText).getAbsoluteFile(); 521 } 522 } 523 } 524