package org.computoring.gop /** * Groovy Option Parser * * GOP is a command line option parser alternative to CliBuilder. * * An example: *
 *  def parser = new org.computoring.gop.Parser(description: "An example parser.")
 *  parser.with {
 *    required 'f', 'foo-bar', [description: 'The foo-bar option'] 
 *    optional 'b', [
 *      longName: 'bar-baz', 
 *      default: 'xyz', 
 *      description: 'The optional bar-baz option with a default of "xyz"'
 *    ]
 *    flag 'c' 
 *    flag 'd', 'debug', [default: true] 
 *    required 'i', 'count', [
 *      description: 'A required, validated option', 
 *      validate: {
 *        Integer.parseInt it 
 *      }
 *    ] 
 *    remainder {
 *      assert it
 *      it
 *    }
 *  }                                                                                                                                                                                                             
 *
 *  def params = parser.parse("-f foo_value --debug --count 123 -- some other stuff".split())
 *  assert params.'foo-bar' == 'foo_value'
 *  assert params.b == 'xyz'
 *  assert params.c == false
 *  assert params.debug == true
 *  assert params.count instanceof Integer
 *  assert params.i == 123
 *  assert parser.remainder.join(' ') == 'some other stuff'
 * 
* * @author Travis Hume (travis@computoring.org) */ public class Parser { /** A property describing this option parser. Displayed in usage statement. */ def description /** * A property specifying the max width for option defaults when formatting * the usage statement. */ int defaultValueWidth = 30 def options = [:] def parameters = [:] def remainder = [] private Closure remainderValidator private Closure postParseValidator private Throwable remainderError private Throwable postParseError private boolean parseCalled /** * Add a required option to the parser. * Parameters must be supplied for each required option at parsing time. * * @param shortName * A single character name to use with for this option. Pass in null to specify * an option without a shortName. * * @param opts * A map of additional options for this option. Recognized options include: * longName: String -- A long name to use for this option. Long names can be anything, but you * have to follow groovy rules of map key referencing, namely quoting anything * that isn't a simple string (i.e. params.'long-option') * description: String -- A string describing this option. This description will be used to create a * usage statement. * validate: {Closure} -- A closure that will be passed the parameter supplied for this * option. The return value of the closure is the final value of * the parameter. This is useful for conversions and validations. * * @throws Exception */ def required( String shortName, Map opts = [:] ) { if( opts.default ) { throw new Exception( "Default values don't make sense for required options" ) } addOption( shortName, 'required', opts ) } /** * A convienence method for required( shortName, [longName: name] ). * @see #required( String, Map ) */ def required( String shortName, String longName, Map opts = [:] ) { required( shortName, [longName: longName] + opts ) } /** * Add an optional option to the parser. * Parameters are not required to be supplied for optional options at parsing time. Additionally, optional * options may have a default value. * * @param shortName * A single character name to use with for this option. Pass in null to specify * an option without a shortName. * * @param opts * A map of additional options for this option. Recognized options include: * longName: String -- A long name to use for this option. Long names can be anything, but you * have to follow groovy rules of map key referencing, namely quoting anything * that isn't a simple string (i.e. params.'long-option') * default: value -- A default value to return if none is provided. Note that the a default value * is processed by the validate closure is one is specified. * description: String -- A string describing this option. This description will be used to create a * usage statement. * validate: {Closure} -- A closure that will be passed the parameter supplied for this * option. The return value of the closure is the final value of * the parameter. This is useful for conversions and validations. * * @throws Exception */ def optional( String shortName, Map opts = [:] ) { addOption( shortName, 'optional', opts ) } /** * A convienence method for optional( shortName, [longName: name] ). * @see #optional( String, Map ) */ def optional( String shortName, String longName, Map opts = [:] ) { optional( shortName, [longName: longName] + opts ) } /** * Add a flag option to the parser. * Flags are boolean options that do not accept a value during parsing. Flags are false by default and specifying * them during parsing will make them true. Default value can be changed to true, see below. * * @param shortName * A single character name to use with for this option. Pass in null to specify * an option without a shortName. * * @param opts * A map of additional options for this option. Recognized options include: * longName: String -- A long name to use for this option. Long names can be anything, but you * have to follow groovy rules of map key referencing, namely quoting anything * that isn't a simple string (i.e. params.'long-option') * default: value -- The default value will be true or false depending on the * truthiness of the supplied value. * description: String -- A string describing this option. This description will be used to create a * usage statement. * validate: {Closure} -- A closure that will be passed the parameter supplied for this * option. The return value of the closure is evaluated as true or false and assigned * to the parameter. * * @throws Exception */ def flag( String shortName, Map opts = [:] ) { opts.default = ( opts.default ) ? true : false addOption( shortName, 'flag', opts ) } /** * A convienence method for flag( shortName, [longName: name] ). * @see #flag( String, Map ) */ def flag( String shortName, String longName, Map opts = [:] ) { flag( shortName, [longName: longName] + opts ) } /** * Define a validation closure for the remainder. * * @param validator -- A closure that will be passed the remainder after parameter parsing is complete. * The return value of the closure is the final value of the remainder. * This is useful for conversions and validations. */ def remainder( Closure validator ) { this.remainderValidator = validator } /** * Define a post parse validation closure. * After parsing, this Closure will be ran and the parsed parameters passed to it. Useful * for validating inter-option dependencies (e.g. must specify --this or --that) * * @param validator -- A Closure that will be passed the parsed parameters. */ def validate( Closure validator ) { this.postParseValidator = validator } /** * Apply configured options to the supplied args returning a map of parameters. * Each option that is mapped to a parameter is available in the returned map in its * short and optionally its long name. * * @param args * Typically, an array of command line arguments. Can be any Iterable. * * @return Map * A map of parsed parameters. Each option that is mapped to a parameter will have an * entry for its shortName and additionally for its longName if specified. * * @throws Exception */ Map parse( args ) { parseCalled = true def PARAM_NAME = ~/^(-[^-]|--.+)$/ def parameter = null args.each { arg -> if( remainder ) { remainder << arg } else if( parameter ) { addParameter( parameter, arg ) parameter = null } else if( arg =~ PARAM_NAME ) { // options can't look like -foo if( arg =~ ~/^-[^-].+/ ) { throw new Exception( "Illegal parameter [$arg], short options must be a single character" ) } def name = arg.replaceFirst( /--?/, '' ) if( !options.containsKey( name )) { throw new Exception( "unknown parameter $arg" ) } parameter = options[name] if( parameter.type == 'flag' ) { addParameter( parameter, true ) parameter = null } } else { remainder << arg // if( !( arg == '--' )) remainder << arg } } // if we found -- to stop parsing, remove it if( remainder[0] == '--' ) remainder = remainder[1..-1] if( missingOptions ) { throw new Exception( "Required parameters not provided" ) } if( errorOptions ) { throw new Exception( "Validation errors" ) } if( remainderValidator ) { try { remainder = remainderValidator(remainder) } catch( Throwable t ) { remainderError = t throw new Exception( "Remainder validation error", t) } } if( postParseValidator ) { try { postParseValidator( parameters ) } catch( Throwable t ) { postParseError = t throw new Exception( "Post parsing validation failure", t ) } } return parameters } /** * Returns a formatted String describing this parser's options with their default values and * descriptions. * * Note that effort is made to align defaults and descriptinos vertically. This can be a bit * wonky if you supply a large default or description. * * @param message * When supplied, message will be displayed at the beginning of the usage message. * Useful for reporting exceptions during parsing or values that fail option validation. */ String getUsage() { def buffer = new StringWriter() def writer = new PrintWriter( buffer ) def missing = missingOptions def errors = errorOptions if( parseCalled && (missing || errors || remainderError || postParseError)) { if( missing ) { writer.println( "Missing required parameters" ) missing.each { def option = it.shortName ? "-$it.shortName" : "--$it.longName" writer.println( " ${( it.description ) ? "$option $it.description" : "$option"}" ) } } if( errors ) { writer.println( "Validation errors" ) errors.each { def option = it.shortName ? "-$it.shortName" : "--$it.longName" writer.println( " $option : ${it.error.toString()}" ) } } if( remainderError ) { writer.println( "Remainder validation error" ) writer.println( " $remainderError" ) } if( postParseError ) { writer.println( "" ) writer.println( "Post parse validation error" ) writer.println( " $postParseError" ) } writer.println( "" ) } if( description ) writer.println( description ) def longestName = 5 + options.inject( 0 ) { max, option -> option.value.longName ? Math.max( max, option.value.longName.size() ) : max } def longestDefault = 5 + options.inject( 0 ) { max, option -> def x = option.value.default (x && x.metaClass.respondsTo(x, "size")) ? Math.max( max, x.size() ) : max } def pattern = " %s%s%-${longestName}s %-${longestDefault}s %s\n" ['Required': requiredOptions, 'Optional': optionalOptions, 'Flags': flagOptions].each { header, map -> if( map ) { writer.println( header ) map.each { name, opts -> def shortName = opts.shortName ? "-$opts.shortName" : " " def comma = (opts.shortName && opts.longName) ? ", " : " " def longName = opts.longName ? "--$opts.longName" : "" def defaultValue = (opts.default || opts.type == 'flag') ? "[${opts.default.toString()}]" : "" if(defaultValue.size() > defaultValueWidth) { defaultValue = "[${defaultValue[1..defaultValueWidth]}...]" } def description = opts.description ?: "" writer.printf( pattern, shortName, comma, longName, defaultValue, description) } writer.println() } } return buffer.toString() } private def getMissingOptions() { (requiredOptions.keySet() - parameters.keySet()).inject( [] ) {list, it -> list << options[it] list } } private def getErrorOptions() { options.values().findAll { it.error } as Set } private def addParameter( parameter, value ) { if( parameter.validate ) { try { value = parameter.validate( value ) if( parameter.type == 'flag' ) value = value ? true : false } catch( Throwable t ) { def x = options[parameter.shortName] ?: options[parameter.longName] x.error = t value = null } } if( parameter.shortName ) parameters[parameter.shortName] = value if( parameter.longName ) parameters[parameter.longName] = value } private def getRequiredOptions() { findOptions( 'required' ) } private def getOptionalOptions() { findOptions( 'optional' ) } private def getFlagOptions() { findOptions( 'flag' ) } private def findOptions( type ) { options.inject( [:] ) { map, option -> if( option.value.type == type ) { def name = option.value.shortName ?: option.value.longName map[name] = option.value } map } } private def addOption( shortName, type, opts ) { opts = opts ?: [:] opts.type = type ?: 'optional' if( !shortName && !opts.longName ) { throw new Exception( "Not short or long name specified" ) } if( shortName && options.containsKey(shortName)) { throw new Exception( "Dup option specified: $shortName" ) } if( shortName && shortName.size() != 1 ) { throw new Exception( "Invalid option name: $shortName. Option names must be a single character. To set a long name for this option add [longName: 'long-name']" ) } if( opts.longName && options.containsKey(opts.longName)) { throw new Exception( "Dup option specified: $opts.longName" ) } if( opts.validate && !(opts.validate instanceof Closure )) { throw new Exception( "Invalid validate option, must be a Closure" ) } if( shortName ) { opts.shortName = shortName options[shortName] = opts } if( opts.longName ) { options[opts.longName] = opts } // create parameters for options with defaults if(opts.containsKey("default")) { addParameter(opts, opts.default) } } private setOptions( arg ) {} private setUsage( arg ) {} }