/* * The Broad Institute * SOFTWARE COPYRIGHT NOTICE AGREEMENT * This software and its documentation are copyright 2008 by the * Broad Institute/Massachusetts Institute of Technology. All rights are reserved. * * This software is supplied without any warranty or guaranteed support whatsoever. Neither * the Broad Institute nor MIT can be responsible for its use, misuse, or functionality. */ package edu.mit.broad.picard.cmdline; import java.io.*; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.*; import edu.mit.broad.picard.util.StringUtil; import edu.mit.broad.picard.PicardException; /** * Annotation-driven utility for parsing command-line arguments, checking for errors, and producing usage message. * * This class supports options of the form KEY=VALUE, plus positional arguments. Positional arguments must not contain * an equal sign lest they be mistaken for a KEY=VALUE pair. * * The caller must supply an object that both defines the command line and has the parsed options set into it. * For each possible KEY=VALUE option, there must be a public data member annotated with @Option. The KEY name is * the name of the data member. An abbreviated name may also be specified with the shortName attribute of @Option. * If the data member is a List, then the option may be specified multiple times. The type of the data member, * or the type of the List element must either have a ctor T(String), or must be an Enum. List options must * be initialized by the caller with some kind of list. Any other option that is non-null is assumed to have the given * value as a default. If an option has no default value, and does not have the optional attribute of @Option set, * is required. For List options, minimum and maximum number of elements may be specified in the @Option annotation. * * A single List data member may be annotated with the @PositionalArguments. This behaves similarly to a Option * with List data member: the caller must initialize the data member, the type must be constructable from String, and * min and max number of elements may be specified. If no @PositionalArguments annotation appears in the object, * then it is an error for the command line to contain positional arguments. * * A single String public data member may be annotated with @Usage. This string, if present, is used to * construct the usage message. Details about the possible options are automatically appended to this string. * If @Usage does not appear, a boilerplate usage message is used. */ public class CommandLineParser { // For formatting option section of usage message. private static final int OPTION_COLUMN_WIDTH = 30; private static final int DESCRIPTION_COLUMN_WIDTH = 50; private static final Boolean[] TRUE_FALSE_VALUES = {Boolean.TRUE, Boolean.FALSE}; // Use these if no @Usage annotation private static final String defaultUsagePreamble = "Usage: program [options...]\n"; private static final String defaultUsagePreambleWithPositionalArguments = "Usage: program [options...] [positional-arguments...]\n"; private static final String OPTIONS_FILE = "OPTIONS_FILE"; /** * A typical command line program will call this to get the beginning of the usage message, * and then append a description of the program, like this: * * \@Usage(programVersion=PROGRAM_VERSION) * public String USAGE = CommandLineParser.getStandardUsagePreamble(getClass()) + "Frobnicates the freebozzle." */ public static String getStandardUsagePreamble(Class mainClass) { return "USAGE: " + mainClass.getName() + " [options]\n\n"; } // This is the object that the caller has provided that contains annotations, // and into which the values will be assigned. private final Object callerOptions; private String usagePreamble; // null if no @PositionalArguments annotation private Field positionalArguments; private int minPositionalArguments; private int maxPositionalArguments; // List of all the data members with @Option annotation private final List optionDefinitions = new ArrayList(); // Maps long name, and short name, if present, to an option definition that is // also in the optionDefinitions list. private final Map optionMap = new HashMap(); // For printing error messages when parsing command line. private PrintStream messageStream; // In case implementation wants to get at arg for some reason. private String[] argv; /** * This attribute is here just to facilitate printing usage for OPTIONS_FILE */ public File IGNORE_THIS_PROPERTY; /** * Prepare for parsing command line arguments, by validating annotations. * @param callerOptions This object contains annotations that define the acceptable command-line options, * and ultimately will receive the settings when a command line is parsed. */ public CommandLineParser(final Object callerOptions) { this.callerOptions = callerOptions; for (final Field field : this.callerOptions.getClass().getFields()) { if (field.getAnnotation(PositionalArguments.class) != null) { handlePositionalArgumentAnnotation(field); } if (field.getAnnotation(Usage.class) != null) { handleUsageAnnotation(field); } if (field.getAnnotation(Option.class) != null) { handleOptionAnnotation(field); } } if (usagePreamble == null) { if (positionalArguments == null) { usagePreamble = defaultUsagePreamble; } else { usagePreamble = defaultUsagePreambleWithPositionalArguments; } } } /** * Print a usage message based on the options object passed to the ctor. * @param stream Where to write the usage message. */ public void usage(final PrintStream stream) { stream.print(usagePreamble); if (!optionDefinitions.isEmpty()) { stream.println("\nOptions:\n"); for (final OptionDefinition optionDefinition : optionDefinitions) { printOptionUsage(stream, optionDefinition); } } final Field fileField; try { fileField = getClass().getField("IGNORE_THIS_PROPERTY"); } catch (NoSuchFieldException e) { throw new PicardException("Should never happen", e); } final OptionDefinition optionsFileOptionDefinition = new OptionDefinition(fileField, OPTIONS_FILE, "", "File of OPTION_NAME=value pairs. No positional parameters allowed. Unlike command-line options, " + "unrecognized options are ignored. " + "A single-valued option set in an options file may be overridden " + "by a subsequent command-line option. " + "A line starting with '#' is considered a comment.", false, true, 0, Integer.MAX_VALUE, null, new String[0]); printOptionUsage(stream, optionsFileOptionDefinition); } /** * Parse command-line options, and store values in callerOptions object passed to ctor. * @param messageStream Where to write error messages. * @param args Command line tokens. * @return true if command line is valid. */ public boolean parseOptions(final PrintStream messageStream, final String[] args) { this.argv = args; this.messageStream = messageStream; for (final String arg: args) { if (arg.equals("-h") || arg.equals("--help")) { usage(messageStream); return false; } final String[] pair = arg.split("=", 2); if (pair.length == 2) { if (pair[0].equals(OPTIONS_FILE)) { if (!parseOptionsFile(pair[1])) { messageStream.println(); usage(messageStream); return false; } } else { if (!parseOption(pair[0], pair[1], false)) { messageStream.println(); usage(messageStream); return false; } } } else if (!parsePositionalArgument(arg)) { messageStream.println(); usage(messageStream); return false; } } if (!checkNumArguments()) { messageStream.println(); usage(messageStream); return false; } return true; } /** * After command line has been parsed, make sure that all required options have values, and that * lists with minimum # of elements have sufficient. * @return true if valid */ private boolean checkNumArguments() { try { for (final OptionDefinition optionDefinition : optionDefinitions) { StringBuilder mutextOptionNames = new StringBuilder(); for (String mutexOption : optionDefinition.mutuallyExclusive) { OptionDefinition mutextOptionDef = optionMap.get(mutexOption); if (mutextOptionDef != null && mutextOptionDef.hasBeenSet) { mutextOptionNames.append(" ").append(mutextOptionDef.name); } } if (optionDefinition.hasBeenSet && mutextOptionNames.length() > 0) { messageStream.println("ERROR: Option '" + optionDefinition.name + "' cannot be used in conjunction with option(s)" + mutextOptionNames.toString()); return false; } if (optionDefinition.isCollection) { final Collection c = (Collection)optionDefinition.field.get(callerOptions); if (c.size() < optionDefinition.minElements) { messageStream.println("ERROR: Option '" + optionDefinition.name + "' must be specified at least " + optionDefinition.minElements + " times."); return false; } } else if (!optionDefinition.optional && !optionDefinition.hasBeenSet && mutextOptionNames.length() == 0) { messageStream.print("ERROR: Option '" + optionDefinition.name + "' is required"); if (optionDefinition.mutuallyExclusive.isEmpty()) { messageStream.println("."); } else { messageStream.println(" unless any of " + optionDefinition.mutuallyExclusive + " are specified."); } return false; } } if (positionalArguments != null) { final Collection c = (Collection)positionalArguments.get(callerOptions); if (c.size() < minPositionalArguments) { messageStream.println("ERROR: At least " + minPositionalArguments + " positional arguments must be specified."); return false; } } return true; } catch (IllegalAccessException e) { // Should never happen because lack of publicness has already been checked. throw new RuntimeException(e); } } private boolean parsePositionalArgument(final String stringValue) { if (positionalArguments == null) { messageStream.println("ERROR: Invalid argument '" + stringValue + "'."); return false; } final Object value; try { value = constructFromString(getUnderlyingType(positionalArguments), stringValue); } catch (CommandLineParseException e) { messageStream.println("ERROR: " + e.getMessage()); return false; } final Collection c; try { c = (Collection)positionalArguments.get(callerOptions); } catch (IllegalAccessException e) { throw new RuntimeException(e); } if (c.size() >= maxPositionalArguments) { messageStream.println("ERROR: No more than " + maxPositionalArguments + " positional arguments may be specified on the command line."); return false; } c.add(value); return true; } private boolean parseOption(String key, final String stringValue, final boolean optionsFile) { key = key.toUpperCase(); final OptionDefinition optionDefinition = optionMap.get(key); if (optionDefinition == null) { if (optionsFile) { // Silently ignore unrecognized option from options file return true; } messageStream.println("ERROR: Unrecognized option: " + key); return false; } if (!optionDefinition.isCollection) { if (optionDefinition.hasBeenSet && !optionDefinition.hasBeenSetFromOptionsFile) { messageStream.println("ERROR: Option '" + key + "' cannot be specified more than once."); return false; } } final Object value; try { value = constructFromString(getUnderlyingType(optionDefinition.field), stringValue); } catch (CommandLineParseException e) { messageStream.println("ERROR: " + e.getMessage()); return false; } try { if (optionDefinition.isCollection) { final Collection c = (Collection)optionDefinition.field.get(callerOptions); if (c.size() >= optionDefinition.maxElements) { messageStream.println("ERROR: Option '" + key + "' cannot be used more than " + optionDefinition.maxElements + " times."); return false; } c.add(value); } else { optionDefinition.field.set(callerOptions, value); optionDefinition.hasBeenSet = true; optionDefinition.hasBeenSetFromOptionsFile = optionsFile; } } catch (IllegalAccessException e) { // Should never happen because we only iterate through public fields. throw new RuntimeException(e); } return true; } /** * Parsing of options from file is looser than normal. Any unrecognized options are * ignored, and a single-valued option that is set in a file may be overridden by a * subsequent appearance of that option. * A line that starts with '#' is ignored. * @param optionsFile * @return false if a fatal error occurred */ private boolean parseOptionsFile(final String optionsFile) { try { final BufferedReader reader = new BufferedReader(new FileReader(optionsFile)); String line; while ((line = reader.readLine()) != null) { if (line.startsWith("#")) { continue; } final String[] pair = line.split("=", 2); if (pair.length == 2) { if (!parseOption(pair[0], pair[1], true)) { messageStream.println(); usage(messageStream); return false; } } else { messageStream.println("Strange line in OPTIONS_FILE " + optionsFile + ": " + line); usage(messageStream); return false; } } reader.close(); return true; } catch (IOException e) { throw new PicardException("I/O error loading OPTIONS_FILE=" + optionsFile, e); } } private void printOptionUsage(final PrintStream stream, final OptionDefinition optionDefinition) { final String type = getUnderlyingType(optionDefinition.field).getSimpleName(); String optionLabel = optionDefinition.name + "=" + type; stream.print(optionLabel); if (optionDefinition.shortName.length() > 0) { stream.println(); } if (optionDefinition.shortName.length() > 0) { optionLabel = optionDefinition.shortName + "=" + type; stream.print(optionLabel); } int numSpaces = OPTION_COLUMN_WIDTH - optionLabel.length(); if (optionLabel.length() > OPTION_COLUMN_WIDTH) { stream.println(); numSpaces = OPTION_COLUMN_WIDTH; } printSpaces(stream, numSpaces); final StringBuilder sb = new StringBuilder(); if (optionDefinition.doc.length() > 0) { sb.append(optionDefinition.doc); sb.append(" "); } if (optionDefinition.optional && !optionDefinition.isCollection) { sb.append("Default value: "); sb.append(optionDefinition.defaultValue); sb.append(". "); } else if (!optionDefinition.isCollection){ sb.append("Required. "); } Object[] enumConstants = getUnderlyingType(optionDefinition.field).getEnumConstants(); if (enumConstants == null && getUnderlyingType(optionDefinition.field) == Boolean.class) { enumConstants = TRUE_FALSE_VALUES; } if (enumConstants != null) { sb.append("Possible values: {"); for (int i = 0; i < enumConstants.length; ++i) { if (i > 0) { sb.append(", "); } sb.append(enumConstants[i].toString()); } sb.append("} "); } if (optionDefinition.isCollection) { if (optionDefinition.minElements == 0) { if (optionDefinition.maxElements == Integer.MAX_VALUE) { sb.append("This option may be specified 0 or more times."); } else { sb.append("This option must be specified no more than " + optionDefinition.maxElements + "times."); } } else if (optionDefinition.maxElements == Integer.MAX_VALUE) { sb.append("This option must be specified at least " + optionDefinition.minElements + " times."); } else { sb.append("This option may be specified between " + optionDefinition.minElements + " and " + optionDefinition.maxElements + " times."); } } if (!optionDefinition.mutuallyExclusive.isEmpty()) { sb.append(" Cannot be used in conjuction with option(s)"); for (String option : optionDefinition.mutuallyExclusive) { OptionDefinition mutextOptionDefinition = optionMap.get(option); sb.append(" ").append(mutextOptionDefinition.name); if (mutextOptionDefinition.shortName.length() > 0) { sb.append(" (").append(mutextOptionDefinition.shortName).append(")"); } } } final String wrappedDescription = StringUtil.wordWrap(sb.toString(), DESCRIPTION_COLUMN_WIDTH); final String[] descriptionLines = wrappedDescription.split("\n"); for (int i = 0; i < descriptionLines.length; ++i) { if (i > 0) { printSpaces(stream, OPTION_COLUMN_WIDTH); } stream.println(descriptionLines[i]); } stream.println(); } private void printSpaces(final PrintStream stream, final int numSpaces) { final StringBuilder sb = new StringBuilder(); for (int i = 0; i < numSpaces; ++i) { sb.append(" "); } stream.print(sb); } private void handleOptionAnnotation(final Field field) { try { final Option optionAnnotation = field.getAnnotation(Option.class); final boolean isCollection = isCollectionField(field); if (isCollection) { if (optionAnnotation.maxElements() == 0) { throw new CommandLineParserDefinitionException("@Option member " + field.getName() + "has maxElements = 0"); } if (optionAnnotation.minElements() > optionAnnotation.maxElements()) { throw new CommandLineParserDefinitionException("In @Option member " + field.getName() + ", minElements cannot be > maxElements"); } } if (!canBeMadeFromString(getUnderlyingType(field))) { throw new CommandLineParserDefinitionException("@Option member " + field.getName() + " must have a String ctor or be an enum"); } final OptionDefinition optionDefinition = new OptionDefinition(field, field.getName(), optionAnnotation.shortName(), optionAnnotation.doc(), optionAnnotation.optional() || (field.get(callerOptions) != null), isCollection, optionAnnotation.minElements(), optionAnnotation.maxElements(), field.get(callerOptions), optionAnnotation.mutex()); for (String option : optionAnnotation.mutex()) { OptionDefinition mutextOptionDef = optionMap.get(option); if (mutextOptionDef != null) { mutextOptionDef.mutuallyExclusive.add(field.getName()); } } if (optionMap.containsKey(optionDefinition.name)) { throw new CommandLineParserDefinitionException(optionDefinition.name + " has already been used"); } optionMap.put(optionDefinition.name, optionDefinition); if (optionDefinition.shortName.length() > 0) { if (optionMap.containsKey(optionDefinition.shortName)) { throw new CommandLineParserDefinitionException(optionDefinition.shortName + " has already been used"); } optionMap.put(optionDefinition.shortName, optionDefinition); } optionDefinitions.add(optionDefinition); } catch (IllegalAccessException e) { throw new CommandLineParserDefinitionException(field.getName() + " must have public visibility to have @Option annotation"); } } private void handleUsageAnnotation(final Field field) { if (usagePreamble != null) { throw new CommandLineParserDefinitionException ("@Usage cannot be used more than once in an option class."); } try { usagePreamble = (String)field.get(callerOptions); final Usage usageAnnotation = field.getAnnotation(Usage.class); if (usageAnnotation.programVersion().length() > 0) { usagePreamble += "Version: " + usageAnnotation.programVersion() + "\n"; } } catch (IllegalAccessException e) { throw new CommandLineParserDefinitionException("@Usage data member must be public"); } catch (ClassCastException e) { throw new CommandLineParserDefinitionException ("@Usage can only be applied to a String data member."); } } private void handlePositionalArgumentAnnotation(final Field field) { if (positionalArguments != null) { throw new CommandLineParserDefinitionException ("@PositionalArguments cannot be used more than once in an option class."); } positionalArguments = field; if (!isCollectionField(field)) { throw new CommandLineParserDefinitionException("@PositionalArguments must be applied to a Collection"); } if (!canBeMadeFromString(getUnderlyingType(field))) { throw new CommandLineParserDefinitionException("@PositionalParameters member " + field.getName() + "does not have a String ctor"); } final PositionalArguments positionalArgumentsAnnotation = field.getAnnotation(PositionalArguments.class); minPositionalArguments = positionalArgumentsAnnotation.minElements(); maxPositionalArguments = positionalArgumentsAnnotation.maxElements(); if (minPositionalArguments > maxPositionalArguments) { throw new CommandLineParserDefinitionException("In @PositionalArguments, minElements cannot be > maxElements"); } } private boolean isCollectionField(final Field field) { try { field.getType().asSubclass(Collection.class); return true; } catch (ClassCastException e) { return false; } } private Class getUnderlyingType(final Field field) { if (isCollectionField(field)) { final ParameterizedType clazz = (ParameterizedType)(field.getGenericType()); final Type[] genericTypes = clazz.getActualTypeArguments(); if (genericTypes.length != 1) { throw new CommandLineParserDefinitionException("Strange collection type for field " + field.getName()); } return (Class)genericTypes[0]; } else { return field.getType(); } } // True if clazz is an enum, or if it has a ctor that takes a single String argument. private boolean canBeMadeFromString(final Class clazz) { if (clazz.isEnum()) { return true; } try { clazz.getConstructor(String.class); return true; } catch (NoSuchMethodException e) { return false; } } private Object constructFromString(final Class clazz, final String s) { try { if (clazz.isEnum()) { try { return Enum.valueOf(clazz, s); } catch (IllegalArgumentException e) { throw new CommandLineParseException("'" + s + "' is not a valid value for " + clazz.getSimpleName() + ".", e); } } final Constructor ctor = clazz.getConstructor(String.class); return ctor.newInstance(s); } catch (NoSuchMethodException e) { // Shouldn't happen because we've checked for presence of ctor throw new CommandLineParseException(e); } catch (InstantiationException e) { throw new CommandLineParseException("Abstract class '" + clazz.getSimpleName() + "'cannot be used for an option value type.", e); } catch (IllegalAccessException e) { throw new CommandLineParseException("String constructor for option value type '" + clazz.getSimpleName() + "' must be public.", e); } catch (InvocationTargetException e) { throw new CommandLineParseException("Problem constructing " + clazz.getSimpleName() + " from the string '" + s + "'.", e.getCause()); } } public String[] getArgv() { return argv; } private class OptionDefinition { final Field field; final String name; final String shortName; final String doc; final boolean optional; final boolean isCollection; final int minElements; final int maxElements; final String defaultValue; boolean hasBeenSet = false; boolean hasBeenSetFromOptionsFile = false; Set mutuallyExclusive; private OptionDefinition(final Field field, final String name, final String shortName, final String doc, final boolean optional, final boolean collection, final int minElements, final int maxElements, final Object defaultValue, String[] mutuallyExclusive) { this.field = field; this.name = name.toUpperCase(); this.shortName = shortName.toUpperCase(); this.doc = doc; this.optional = optional; isCollection = collection; this.minElements = minElements; this.maxElements = maxElements; if (defaultValue != null) { this.defaultValue = defaultValue.toString(); } else { this.defaultValue = "null"; } this.mutuallyExclusive = new HashSet(Arrays.asList(mutuallyExclusive)); } } }